From 24991df1504a8d969e119d7023411a828dbf7c25 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 16 Sep 2025 16:22:34 +0200 Subject: [PATCH 1/3] Draft --- .../Templates/TypeScriptType.tstpl | 9 ++ .../TypeScriptTypesPostProcessor.php | 136 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/SchemaProcessor/PostProcessor/Templates/TypeScriptType.tstpl create mode 100644 src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php diff --git a/src/SchemaProcessor/PostProcessor/Templates/TypeScriptType.tstpl b/src/SchemaProcessor/PostProcessor/Templates/TypeScriptType.tstpl new file mode 100644 index 0000000..d41bfe1 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/TypeScriptType.tstpl @@ -0,0 +1,9 @@ +{% foreach imports as import %} +import { {{ import.name }} } from '{{ import.path }}'; +{% endforeach %} + +export type {{ name }} = { + {% foreach properties as property %} + {{ property.getAttribute() }}: {{ typescriptType(property) }}; + {% endforeach %} +} diff --git a/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php b/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php new file mode 100644 index 0000000..a4070aa --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php @@ -0,0 +1,136 @@ +generateModelDirectory($targetDirectory); + + $this->targetDirectory = $targetDirectory; + $this->renderer = new Render(__DIR__ . DIRECTORY_SEPARATOR . 'Templates' . DIRECTORY_SEPARATOR); + } + + public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void + { + $this->generatorConfiguration = $generatorConfiguration; + $this->schemas[] = $schema; + } + + public function postProcess(): void + { + parent::postProcess(); + + foreach ($this->schemas as $schema) { + $result = file_put_contents( + $this->targetDirectory . DIRECTORY_SEPARATOR . $schema->getClassName() . '.ts', + $this->renderer->renderTemplate( + 'TypeScriptType.tstpl', + [ + 'name' => $schema->getClassName(), + 'properties' => array_filter( + $schema->getProperties(), + fn(PropertyInterface $property): bool => !$property->isInternal(), + ), + 'imports' => $this->getTypeScriptImports($schema), + 'typescriptType' => fn(PropertyInterface $property): string => join( + ' | ', + array_map( + fn(string $type): string => match (str_replace('[]', '', $type)) { + 'string' => 'string', + 'int', 'float' => 'number', + 'bool' => 'boolean', + '', 'mixed' => 'any', + default => $property->getType()->getName(), + } . (str_contains($type, '[]') ? '[]' : ''), + explode('|', $property->getTypeHint()), + ), + ), + ], + ) + ); + + if ($result === false) { + // @codeCoverageIgnoreStart + throw new FileSystemException("Can't write TypeScript type {$schema->getClassName()}.",); + // @codeCoverageIgnoreEnd + } + + if ($this->generatorConfiguration->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Rendered TypeScript type {$schema->getClassName()}\n"; + // @codeCoverageIgnoreEnd + } + } + } + + /** + * @return string[] + */ + private function getTypeScriptImports(Schema $schema): array + { + $imports = []; + + foreach ($schema->getProperties() as $property) { + // use typehint instead of type to cover multi-types + foreach (array_unique( + [...explode('|', $property->getTypeHint()), ...explode('|', $property->getTypeHint(true))] + ) as $type) { + // as the typehint only knows the class name but not the fqcn, lookup in the original imports + foreach ($schema->getUsedClasses() as $originalClassImport) { + if (str_ends_with($originalClassImport, "\\$type")) { + $type = $originalClassImport; + } + } + + if (class_exists($type) && in_array(JSONModelInterface::class, class_implements($type))) { + $imports[] = [ + 'name' => basename($type), + 'path' => $this->relativeNamespacePath($schema, $type), + ]; + } + } + } + + return array_filter(array_unique($imports)); + } + + private function relativeNamespacePath(Schema $schema, string $targetNS): string + { + $baseParts = preg_split( + '/\\\\+/', + trim($this->generatorConfiguration->getNamespacePrefix() . '\\' . $schema->getClassPath(), '\\'), + ) ?: []; + $targetParts = preg_split('/\\\\+/', trim($targetNS, '\\')) ?: []; + + $i = 0; + $max = min(count($baseParts), count($targetParts)); + while ($i < $max && $baseParts[$i] === $targetParts[$i]) { + $i++; + } + + $ups = array_fill(0, max(count($baseParts) - $i, 0), '..'); + $downs = array_slice($targetParts, $i); + $parts = array_merge($ups, $downs); + $rel = implode('/', $parts); + + return './' . $rel; + } +} From 8bfe347f5a71903fc0ec7f332a14f705ecbec4ba Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 17 Sep 2025 14:08:32 +0200 Subject: [PATCH 2/3] Draft --- .../PostProcessor/EnumPostProcessor.php | 78 ++-------------- .../PostProcessor/EnumTrait.php | 84 +++++++++++++++++ .../Templates/TypeScriptEnum.tstpl | 6 ++ .../TypeScriptTypesPostProcessor.php | 93 ++++++++++++++++--- 4 files changed, 178 insertions(+), 83 deletions(-) create mode 100644 src/SchemaProcessor/PostProcessor/EnumTrait.php create mode 100644 src/SchemaProcessor/PostProcessor/Templates/TypeScriptEnum.tstpl diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index e3915c8..f638f2d 100644 --- a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php @@ -21,13 +21,14 @@ use PHPModelGenerator\ModelGenerator; use PHPModelGenerator\PropertyProcessor\Filter\FilterProcessor; use PHPModelGenerator\Utils\ArrayHash; -use PHPModelGenerator\Utils\NormalizedName; /** * Generates a PHP enum for enums from JSON schemas which are automatically mapped for properties holding the enum */ class EnumPostProcessor extends PostProcessor { + use EnumTrait; + private array $generatedEnums = []; private string $namespace; @@ -168,63 +169,6 @@ public function postProcess(): void parent::postProcess(); } - /** - * @throws SchemaException - */ - private function validateEnum(PropertyInterface $property): bool - { - $throw = function (string $message) use ($property): void { - throw new SchemaException( - sprintf( - $message, - $property->getName(), - $property->getJsonSchema()->getFile(), - ) - ); - }; - - $json = $property->getJsonSchema()->getJson(); - - $types = $this->getArrayTypes($json['enum']); - - // the enum must contain either only string values or provide a value map to resolve the values - if ($types !== ['string'] && !isset($json['enum-map'])) { - if ($this->skipNonMappedEnums) { - return false; - } - - $throw('Unmapped enum %s in file %s'); - } - - if (isset($json['enum-map'])) { - asort($json['enum']); - if (is_array($json['enum-map'])) { - asort($json['enum-map']); - } - - if (!is_array($json['enum-map']) - || $this->getArrayTypes(array_keys($json['enum-map'])) !== ['string'] - || count(array_uintersect( - $json['enum-map'], - $json['enum'], - fn($a, $b): int => $a === $b ? 0 : 1, - )) !== count($json['enum']) - ) { - $throw('invalid enum map %s in file %s'); - } - } - - return true; - } - - private function getArrayTypes(array $array): array - { - return array_unique(array_map( - static fn($item): string => gettype($item), - $array, - )); - } - private function renderEnum( GeneratorConfiguration $generatorConfiguration, JsonSchema $jsonSchema, @@ -235,20 +179,14 @@ private function renderEnum( $cases = []; foreach ($values as $value) { - $caseName = ucfirst(NormalizedName::from($map ? array_search($value, $map, true) : $value, $jsonSchema)); - - if (preg_match('/^\d/', $caseName) === 1) { - $caseName = "_$caseName"; - } - - $cases[$caseName] = var_export($value, true); + $cases[$this->getCaseName($value, $map, $jsonSchema)] = var_export($value, true); } - $backedType = null; - switch ($this->getArrayTypes($values)) { - case ['string']: $backedType = 'string'; break; - case ['integer']: $backedType = 'int'; break; - } + $backedType = match ($this->getArrayTypes($values)) { + ['string'] => 'string', + ['integer'] => 'int', + default => null, + }; // make sure different enums with an identical name don't overwrite each other while (in_array("$this->namespace\\$name", array_column($this->generatedEnums, 'fqcn'))) { diff --git a/src/SchemaProcessor/PostProcessor/EnumTrait.php b/src/SchemaProcessor/PostProcessor/EnumTrait.php new file mode 100644 index 0000000..8bcefd1 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/EnumTrait.php @@ -0,0 +1,84 @@ +getName(), + $property->getJsonSchema()->getFile(), + ) + ); + }; + + $json = $property->getJsonSchema()->getJson(); + + $types = $this->getArrayTypes($json['enum']); + + // the enum must contain either only string values or provide a value map to resolve the values + if ($types !== ['string'] && !isset($json['enum-map'])) { + if ($skipNonMappedEnums) { + return false; + } + + $throw('Unmapped enum %s in file %s'); + } + + if (isset($json['enum-map'])) { + asort($json['enum']); + if (is_array($json['enum-map'])) { + asort($json['enum-map']); + } + + if (!is_array($json['enum-map']) + || $this->getArrayTypes(array_keys($json['enum-map'])) !== ['string'] + || count(array_uintersect( + $json['enum-map'], + $json['enum'], + fn($a, $b): int => $a === $b ? 0 : 1, + )) !== count($json['enum']) + ) { + $throw('invalid enum map %s in file %s'); + } + } + + return true; + } + + private function getArrayTypes(array $array): array + { + return array_unique(array_map( + static fn($item): string => gettype($item), + $array, + )); + } + + protected function getCaseName(mixed $value, ?array $map, JsonSchema $jsonSchema): string + { + $caseName = ucfirst(NormalizedName::from($map ? array_search($value, $map, true) : $value, $jsonSchema)); + + if (preg_match('/^\d/', $caseName) === 1) { + $caseName = "_$caseName"; + } + + return $caseName; + } +} diff --git a/src/SchemaProcessor/PostProcessor/Templates/TypeScriptEnum.tstpl b/src/SchemaProcessor/PostProcessor/Templates/TypeScriptEnum.tstpl new file mode 100644 index 0000000..26dcd05 --- /dev/null +++ b/src/SchemaProcessor/PostProcessor/Templates/TypeScriptEnum.tstpl @@ -0,0 +1,6 @@ + +export enum {{ name }} { + {% foreach cases as case, value %} + {{ case }} = {{ value }}, + {% endforeach %} +} diff --git a/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php b/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php index a4070aa..c8b81e2 100644 --- a/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/TypeScriptTypesPostProcessor.php @@ -11,26 +11,35 @@ use PHPModelGenerator\Model\Property\PropertyInterface; use PHPModelGenerator\Model\Schema; use PHPModelGenerator\ModelGenerator; +use PHPModelGenerator\Utils\RenderHelper; class TypeScriptTypesPostProcessor extends PostProcessor { + use EnumTrait; + private string $targetDirectory; + private bool $renderEnums; private Render $renderer; private GeneratorConfiguration $generatorConfiguration; + private RenderHelper $renderHelper; + /** @var Schema[] */ private array $schemas = []; - public function __construct(string $targetDirectory) + public function __construct(string $targetDirectory, bool $renderEnums = true) { (new ModelGenerator())->generateModelDirectory($targetDirectory); $this->targetDirectory = $targetDirectory; + $this->renderEnums = $renderEnums; $this->renderer = new Render(__DIR__ . DIRECTORY_SEPARATOR . 'Templates' . DIRECTORY_SEPARATOR); } public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void { - $this->generatorConfiguration = $generatorConfiguration; + $this->generatorConfiguration ??= $generatorConfiguration; + $this->renderHelper ??= new RenderHelper($generatorConfiguration); + $this->schemas[] = $schema; } @@ -39,7 +48,20 @@ public function postProcess(): void parent::postProcess(); foreach ($this->schemas as $schema) { + $enumMap = []; + + foreach ($schema->getProperties() as $property) { + if ($this->renderEnums + && isset($property->getJsonSchema()->getJson()['enum']) + && $this->validateEnum($property) + ) { + // TODO: deduplicate + $enumMap[$property->getName()] = $this->renderEnum($schema, $property); + } + } + $result = file_put_contents( + // TODO nested directory structure from namespaces $this->targetDirectory . DIRECTORY_SEPARATOR . $schema->getClassName() . '.ts', $this->renderer->renderTemplate( 'TypeScriptType.tstpl', @@ -49,18 +71,30 @@ public function postProcess(): void $schema->getProperties(), fn(PropertyInterface $property): bool => !$property->isInternal(), ), - 'imports' => $this->getTypeScriptImports($schema), - 'typescriptType' => fn(PropertyInterface $property): string => join( + 'imports' => [ + ...$this->getTypeScriptImports($schema), + ...array_map(fn (string $enum): array => ['name' => $enum, 'path' => "./$enum"], $enumMap), + ], + 'typescriptType' => fn (PropertyInterface $property): string => join( ' | ', - array_map( - fn(string $type): string => match (str_replace('[]', '', $type)) { - 'string' => 'string', - 'int', 'float' => 'number', - 'bool' => 'boolean', - '', 'mixed' => 'any', - default => $property->getType()->getName(), - } . (str_contains($type, '[]') ? '[]' : ''), - explode('|', $property->getTypeHint()), + array_unique( + array_map( + function (string $type) use ($property, $enumMap): string { + if (isset($enumMap[$property->getName()]) && $type !== 'null') { + return $enumMap[$property->getName()]; + } + + return match (str_replace('[]', '', $type)) { + 'null' => 'null', + 'string' => 'string', + 'int', 'float' => 'number', + 'bool' => 'boolean', + '', 'mixed' => 'any', + default => $property->getType()->getName(), + } . (str_contains($type, '[]') ? '[]' : ''); + }, + explode('|', $this->renderHelper->getTypeHintAnnotation($property)), + ), ), ), ], @@ -133,4 +167,37 @@ private function relativeNamespacePath(Schema $schema, string $targetNS): string return './' . $rel; } + + private function renderEnum(Schema $schema, PropertyInterface $property): string + { + $json = $property->getJsonSchema()->getJson(); + $enumName = $json['$id'] ?? $schema->getClassName() . ucfirst($property->getName()); + $cases = []; + + foreach ($json['enum'] as $value) { + $caseName = $this->getCaseName($value, $json['enum-map'] ?? null, $property->getJsonSchema()); + $cases[$caseName] = var_export($value, true); + } + + $result = file_put_contents( + $this->targetDirectory . DIRECTORY_SEPARATOR . $enumName . '.ts', + $this->renderer->renderTemplate( + 'TypeScriptEnum.tstpl', + ['name' => $enumName, 'cases' => $cases], + ), + ); + + if ($result === false) { + // @codeCoverageIgnoreStart + throw new FileSystemException("Can't write TypeScript enum $enumName.",); + // @codeCoverageIgnoreEnd + } + + if ($this->generatorConfiguration->isOutputEnabled()) { + // @codeCoverageIgnoreStart + echo "Rendered TypeScript enum $enumName\n"; + // @codeCoverageIgnoreEnd + } + return $enumName; + } } From 807369424cc5189bd3753cd5660154baea6fc7fd Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 17 Sep 2025 14:15:55 +0200 Subject: [PATCH 3/3] Draft --- src/SchemaProcessor/PostProcessor/EnumPostProcessor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index f638f2d..6735595 100644 --- a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php @@ -68,7 +68,7 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu foreach ($schema->getProperties() as $property) { $json = $property->getJsonSchema()->getJson(); - if (!isset($json['enum']) || !$this->validateEnum($property)) { + if (!isset($json['enum']) || !$this->validateEnum($property, $this->skipNonMappedEnums)) { continue; }