From bf415dd6379129b10667a33b68f0c2811698195c Mon Sep 17 00:00:00 2001 From: simivar Date: Mon, 4 Nov 2024 23:33:19 +0100 Subject: [PATCH 1/8] feat: support multiple reference lookup classes --- src/Generator/GeneratorRequest.php | 35 ++++++++++++++++++++++----- tests/Generator/SchemaToClassTest.php | 13 +++++++++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/Generator/GeneratorRequest.php b/src/Generator/GeneratorRequest.php index ea3496fc..fa079b39 100644 --- a/src/Generator/GeneratorRequest.php +++ b/src/Generator/GeneratorRequest.php @@ -17,7 +17,8 @@ class GeneratorRequest private array $schema; private ValidatedSpecificationFilesItem $spec; private SpecificationOptions $opts; - private ?ReferenceLookup $referenceLookup = null; + /** @var ReferenceLookup[] */ + private array $referenceLookup = []; use GeneratorHookRunner; @@ -46,7 +47,15 @@ private static function semversifyVersionNumber(string|int $versionNumber): stri public function withReferenceLookup(ReferenceLookup $referenceLookup): self { $clone = clone $this; - $clone->referenceLookup = $referenceLookup; + $clone->referenceLookup = [$referenceLookup]; + + return $clone; + } + + public function withAdditionalReferenceLookup(ReferenceLookup $referenceLookup): self + { + $clone = clone $this; + $clone->referenceLookup[] = $referenceLookup; return $clone; } @@ -182,19 +191,33 @@ public function getOptions(): SpecificationOptions public function lookupReference(string $ref): ReferencedType { - if ($this->referenceLookup === null) { + if (empty($this->referenceLookup)) { return new ReferencedTypeUnknown(); } - return $this->referenceLookup->lookupReference($ref); + foreach ($this->referenceLookup as $referenceLookup) { + $reference = $referenceLookup->lookupReference($ref); + if (!$reference instanceof ReferencedTypeUnknown) { + return $reference; + } + } + + return new ReferencedTypeUnknown(); } public function lookupSchema(string $ref): array { - if ($this->referenceLookup === null) { + if (empty($this->referenceLookup)) { return []; } - return $this->referenceLookup->lookupSchema($ref); + foreach ($this->referenceLookup as $referenceLookup) { + $schema = $referenceLookup->lookupSchema($ref); + if (!empty($schema)) { + return $schema; + } + } + + return []; } } diff --git a/tests/Generator/SchemaToClassTest.php b/tests/Generator/SchemaToClassTest.php index e9636d3d..fb964db6 100644 --- a/tests/Generator/SchemaToClassTest.php +++ b/tests/Generator/SchemaToClassTest.php @@ -75,7 +75,18 @@ public function testCodeGeneration(string $name, array $schema, array $expectedO $opts, ); - $req = $req->withReferenceLookup(new class ($schema) implements ReferenceLookup { + $req = $req->withReferenceLookup(new class implements ReferenceLookup { + public function lookupReference(string $reference): ReferencedType + { + return new ReferencedTypeUnknown(); + } + + public function lookupSchema(string $reference): array + { + return []; + } + }); + $req = $req->withAdditionalReferenceLookup(new class ($schema) implements ReferenceLookup { public function __construct(private readonly array $schema) { } From e79928b3e19403ed2fde6af5648014aec68f5018 Mon Sep 17 00:00:00 2001 From: simivar Date: Tue, 5 Nov 2024 21:44:43 +0100 Subject: [PATCH 2/8] feat: support definitions and $defs lookup out of the box --- src/Generator/Definitions/Definition.php | 18 ++ .../Definitions/DefinitionsCollector.php | 55 +++++ .../Definitions/DefinitionsGenerator.php | 33 +++ .../DefinitionsReferenceLookup.php | 38 ++++ src/Generator/GeneratorRequest.php | 35 ++- src/Generator/SchemaToClass.php | 22 ++ src/Spec/ValidatedSpecificationFilesItem.php | 16 ++ .../Output/Definitions/Address.php | 183 ++++++++++++++++ .../Output/Definitions/Address/Defs/Name.php | 139 ++++++++++++ .../Fixtures/Definitions/Output/Foo.php | 203 ++++++++++++++++++ .../Fixtures/Definitions/schema.json | 44 ++++ .../SchemaDefinitionsCollectorTest.php | 53 +++++ tests/Generator/SchemaToClassTest.php | 43 ++-- 13 files changed, 861 insertions(+), 21 deletions(-) create mode 100644 src/Generator/Definitions/Definition.php create mode 100644 src/Generator/Definitions/DefinitionsCollector.php create mode 100644 src/Generator/Definitions/DefinitionsGenerator.php create mode 100644 src/Generator/Definitions/DefinitionsReferenceLookup.php create mode 100644 tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php create mode 100644 tests/Generator/Fixtures/Definitions/Output/Definitions/Address/Defs/Name.php create mode 100644 tests/Generator/Fixtures/Definitions/Output/Foo.php create mode 100644 tests/Generator/Fixtures/Definitions/schema.json create mode 100644 tests/Generator/SchemaDefinitionsCollectorTest.php diff --git a/src/Generator/Definitions/Definition.php b/src/Generator/Definitions/Definition.php new file mode 100644 index 00000000..1874a9b7 --- /dev/null +++ b/src/Generator/Definitions/Definition.php @@ -0,0 +1,18 @@ + + */ + public function collect(array $schema, string $path = ''): \Generator { + if (isset($schema['definitions'])) { + yield from $this->findNestedDefinitions($schema['definitions'], ($path ?: '#') . '/definitions'); + } + + if (isset($schema['$defs'])) { + yield from $this->findNestedDefinitions($schema['$defs'], ($path ?: '#') . '/$defs'); + } + } + + private function findNestedDefinitions(array $definitions, string $path): \Generator { + foreach ($definitions as $key => $value) { + $newPath = $path . '/' . $key; + yield $newPath => $this->pathToClassName($newPath, $value); + + if (is_array($value)) { + yield from $this->collect($value, $newPath); + } + } + } + + private function pathToClassName(string $path, array $schema): Definition { + $path = str_replace('$defs', 'Defs', $path); + // Strips out the `#` symbol and splits the path into parts + $parts = explode('/', ltrim($path, '#/')); + + // Maps each part of the path to a StudlyCase (or PascalCase) conversion + $classNameParts = array_map(function ($part) { + return str_replace(' ', '', ucwords(str_replace('_', ' ', $part))); + }, $parts); + + $classFQN = implode('\\', $classNameParts); + $className = array_pop($classNameParts); + + // Joins the parts back into a string with slashes (to represent namespace hierarchy) + return new Definition( + namespace: implode('\\', $classNameParts), + directory: implode('/', $classNameParts), + classFQN: $classFQN, + className: $className, + schema: $schema, + ); + } +} \ No newline at end of file diff --git a/src/Generator/Definitions/DefinitionsGenerator.php b/src/Generator/Definitions/DefinitionsGenerator.php new file mode 100644 index 00000000..ae6d308c --- /dev/null +++ b/src/Generator/Definitions/DefinitionsGenerator.php @@ -0,0 +1,33 @@ + $definitions + */ + public function generate(array $definitions, GeneratorRequest $generatorRequest): void + { + foreach ($definitions as $definition) { + $newRequest = $generatorRequest->withClass($definition->className) + ->withSchema($definition->schema) + ->withNamespace(join('\\', [$generatorRequest->getTargetNamespace(), $definition->namespace])) + ->withDirectory(join('/', [$generatorRequest->getTargetDirectory(), $definition->directory])) + ; + + $this->schemaToClass->schemaToClass($newRequest); + } + } +} \ No newline at end of file diff --git a/src/Generator/Definitions/DefinitionsReferenceLookup.php b/src/Generator/Definitions/DefinitionsReferenceLookup.php new file mode 100644 index 00000000..716c39ee --- /dev/null +++ b/src/Generator/Definitions/DefinitionsReferenceLookup.php @@ -0,0 +1,38 @@ + $definitions + */ + public function __construct( + private array $definitions + ) + { + } + + public function lookupReference(string $reference): ReferencedType + { + if (isset($this->definitions[$reference])) { + return new ReferencedTypeClass($this->definitions[$reference]->classFQN); + } + return new ReferencedTypeUnknown(); + } + + public function lookupSchema(string $reference): array + { + if (isset($this->definitions[$reference])) { + return $this->definitions[$reference]->schema; + } + return []; + } +} \ No newline at end of file diff --git a/src/Generator/GeneratorRequest.php b/src/Generator/GeneratorRequest.php index fa079b39..549f45f6 100644 --- a/src/Generator/GeneratorRequest.php +++ b/src/Generator/GeneratorRequest.php @@ -17,7 +17,7 @@ class GeneratorRequest private array $schema; private ValidatedSpecificationFilesItem $spec; private SpecificationOptions $opts; - /** @var ReferenceLookup[] */ + /** @var array */ private array $referenceLookup = []; use GeneratorHookRunner; @@ -47,7 +47,8 @@ private static function semversifyVersionNumber(string|int $versionNumber): stri public function withReferenceLookup(ReferenceLookup $referenceLookup): self { $clone = clone $this; - $clone->referenceLookup = [$referenceLookup]; + $clone->referenceLookup = []; + $clone = $clone->withAdditionalReferenceLookup($referenceLookup); return $clone; } @@ -55,11 +56,19 @@ public function withReferenceLookup(ReferenceLookup $referenceLookup): self public function withAdditionalReferenceLookup(ReferenceLookup $referenceLookup): self { $clone = clone $this; - $clone->referenceLookup[] = $referenceLookup; + $clone->referenceLookup[$referenceLookup::class] = $referenceLookup; return $clone; } + /** + * @param class-string $referenceLookup + */ + public function hasReferenceLookup(string $referenceLookup): bool + { + return isset($this->referenceLookup[$referenceLookup]); + } + public function withSchema(array $schema): self { $clone = clone $this; @@ -78,6 +87,26 @@ public function withClass(string $targetClass): self return $clone; } + public function withNamespace(string $targetNamespace): self + { + $clone = clone $this; + $clone->spec = $this->spec->withTargetNamespace($targetNamespace); + + $clone->clearNonPropagatingHooks(); + + return $clone; + } + + public function withDirectory(string $targetDirectory): self + { + $clone = clone $this; + $clone->spec = $this->spec->withTargetDirectory($targetDirectory); + + $clone->clearNonPropagatingHooks(); + + return $clone; + } + public function withPHPVersion(string $targetPHPVersion): self { $clone = clone $this; diff --git a/src/Generator/SchemaToClass.php b/src/Generator/SchemaToClass.php index 946092e3..625af6f4 100644 --- a/src/Generator/SchemaToClass.php +++ b/src/Generator/SchemaToClass.php @@ -4,6 +4,9 @@ namespace Helmich\Schema2Class\Generator; use Helmich\Schema2Class\Codegen\PropertyGenerator; +use Helmich\Schema2Class\Generator\Definitions\DefinitionsCollector; +use Helmich\Schema2Class\Generator\Definitions\DefinitionsGenerator; +use Helmich\Schema2Class\Generator\Definitions\DefinitionsReferenceLookup; use Helmich\Schema2Class\Generator\Property\IntersectProperty; use Helmich\Schema2Class\Generator\Property\NestedObjectProperty; use Helmich\Schema2Class\Generator\Property\PropertyCollection; @@ -35,6 +38,8 @@ public function schemaToClass(GeneratorRequest $req): void { $schema = $req->getSchema(); + $this->definitionsToSchemas($req); + if (isset($schema["enum"])) { $this->schemaToEnum($req); return; @@ -240,4 +245,21 @@ private function schemaToEnum(GeneratorRequest $req): void $this->writer->writeFile($filename, $content); } + private function definitionsToSchemas(GeneratorRequest $req): void + { + if ($req->hasReferenceLookup(DefinitionsReferenceLookup::class)) { + return; + } + + $collector = new DefinitionsCollector(); + $collectedDefinitions = iterator_to_array($collector->collect($req->getSchema())); + + $req = $req->withReferenceLookup(new DefinitionsReferenceLookup( + $collectedDefinitions, + )); + + $generator = new DefinitionsGenerator($this); + $generator->generate($collectedDefinitions, $req); + } + } diff --git a/src/Spec/ValidatedSpecificationFilesItem.php b/src/Spec/ValidatedSpecificationFilesItem.php index f9cf4102..fc3bd589 100644 --- a/src/Spec/ValidatedSpecificationFilesItem.php +++ b/src/Spec/ValidatedSpecificationFilesItem.php @@ -62,4 +62,20 @@ public function withTargetClass(string $targetClass): self return $c; } + public function withTargetNamespace(string $targetNamespace): self + { + $c = clone $this; + $c->targetNamespace = $targetNamespace; + + return $c; + } + + public function withTargetDirectory(string $targetDirectory): self + { + $c = clone $this; + $c->targetDirectory = $targetDirectory; + + return $c; + } + } \ No newline at end of file diff --git a/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php b/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php new file mode 100644 index 00000000..8ad7deed --- /dev/null +++ b/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php @@ -0,0 +1,183 @@ + 'object', + 'properties' => [ + 'name' => [ + '$ref' => '#/definitions/address/$defs/name', + ], + 'city' => [ + 'type' => 'string', + ], + ], + 'required' => [ + 'city', + ], + '$defs' => [ + 'name' => [ + 'type' => 'object', + 'properties' => [ + 'first' => [ + 'type' => 'string', + ], + ], + ], + ], + ]; + + /** + * @var \Definitions\Address\Defs\Name|null + */ + private ?\Definitions\Address\Defs\Name $name = null; + + /** + * @var string + */ + private string $city; + + /** + * @param string $city + */ + public function __construct(string $city) + { + $this->city = $city; + } + + /** + * @return \Definitions\Address\Defs\Name|null + */ + public function getName() : ?\Definitions\Address\Defs\Name + { + return $this->name ?? null; + } + + /** + * @return string + */ + public function getCity() : string + { + return $this->city; + } + + /** + * @param \Definitions\Address\Defs\Name $name + * @return self + */ + public function withName(\Definitions\Address\Defs\Name $name) : self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + /** + * @return self + */ + public function withoutName() : self + { + $clone = clone $this; + unset($clone->name); + + return $clone; + } + + /** + * @param string $city + * @return self + */ + public function withCity(string $city) : self + { + $validator = new \JsonSchema\Validator(); + $validator->validate($city, static::$schema['properties']['city']); + if (!$validator->isValid()) { + throw new \InvalidArgumentException($validator->getErrors()[0]['message']); + } + + $clone = clone $this; + $clone->city = $city; + + return $clone; + } + + /** + * Builds a new instance from an input array + * + * @param array|object $input Input data + * @param bool $validate Set this to false to skip validation; use at own risk + * @return Address Created instance + * @throws \InvalidArgumentException + */ + public static function buildFromInput(array|object $input, bool $validate = true) : Address + { + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + if ($validate) { + static::validateInput($input); + } + + $name = null; + if (isset($input->{'name'})) { + $name = \Definitions\Address\Defs\Name::buildFromInput($input->{'name'}, validate: $validate); + } + $city = $input->{'city'}; + + $obj = new self($city); + $obj->name = $name; + return $obj; + } + + /** + * Converts this object back to a simple array that can be JSON-serialized + * + * @return array Converted array + */ + public function toJson() : array + { + $output = []; + if (isset($this->name)) { + $output['name'] = $this->name->toJson(); + } + $output['city'] = $this->city; + + return $output; + } + + /** + * Validates an input array + * + * @param array|object $input Input data + * @param bool $return Return instead of throwing errors + * @return bool Validation result + * @throws \InvalidArgumentException + */ + public static function validateInput(array|object $input, bool $return = false) : bool + { + $validator = new \JsonSchema\Validator(); + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + $validator->validate($input, static::$schema); + + if (!$validator->isValid() && !$return) { + $errors = array_map(function(array $e): string { + return $e["property"] . ": " . $e["message"]; + }, $validator->getErrors()); + throw new \InvalidArgumentException(join(", ", $errors)); + } + + return $validator->isValid(); + } + + public function __clone() + { + } +} \ No newline at end of file diff --git a/tests/Generator/Fixtures/Definitions/Output/Definitions/Address/Defs/Name.php b/tests/Generator/Fixtures/Definitions/Output/Definitions/Address/Defs/Name.php new file mode 100644 index 00000000..98f0df2f --- /dev/null +++ b/tests/Generator/Fixtures/Definitions/Output/Definitions/Address/Defs/Name.php @@ -0,0 +1,139 @@ + 'object', + 'properties' => [ + 'first' => [ + 'type' => 'string', + ], + ], + ]; + + /** + * @var string|null + */ + private ?string $first = null; + + /** + * + */ + public function __construct() + { + } + + /** + * @return string|null + */ + public function getFirst() : ?string + { + return $this->first ?? null; + } + + /** + * @param string $first + * @return self + */ + public function withFirst(string $first) : self + { + $validator = new \JsonSchema\Validator(); + $validator->validate($first, static::$schema['properties']['first']); + if (!$validator->isValid()) { + throw new \InvalidArgumentException($validator->getErrors()[0]['message']); + } + + $clone = clone $this; + $clone->first = $first; + + return $clone; + } + + /** + * @return self + */ + public function withoutFirst() : self + { + $clone = clone $this; + unset($clone->first); + + return $clone; + } + + /** + * Builds a new instance from an input array + * + * @param array|object $input Input data + * @param bool $validate Set this to false to skip validation; use at own risk + * @return Name Created instance + * @throws \InvalidArgumentException + */ + public static function buildFromInput(array|object $input, bool $validate = true) : Name + { + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + if ($validate) { + static::validateInput($input); + } + + $first = null; + if (isset($input->{'first'})) { + $first = $input->{'first'}; + } + + $obj = new self(); + $obj->first = $first; + return $obj; + } + + /** + * Converts this object back to a simple array that can be JSON-serialized + * + * @return array Converted array + */ + public function toJson() : array + { + $output = []; + if (isset($this->first)) { + $output['first'] = $this->first; + } + + return $output; + } + + /** + * Validates an input array + * + * @param array|object $input Input data + * @param bool $return Return instead of throwing errors + * @return bool Validation result + * @throws \InvalidArgumentException + */ + public static function validateInput(array|object $input, bool $return = false) : bool + { + $validator = new \JsonSchema\Validator(); + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + $validator->validate($input, static::$schema); + + if (!$validator->isValid() && !$return) { + $errors = array_map(function(array $e): string { + return $e["property"] . ": " . $e["message"]; + }, $validator->getErrors()); + throw new \InvalidArgumentException(join(", ", $errors)); + } + + return $validator->isValid(); + } + + public function __clone() + { + } +} \ No newline at end of file diff --git a/tests/Generator/Fixtures/Definitions/Output/Foo.php b/tests/Generator/Fixtures/Definitions/Output/Foo.php new file mode 100644 index 00000000..07f63f4c --- /dev/null +++ b/tests/Generator/Fixtures/Definitions/Output/Foo.php @@ -0,0 +1,203 @@ + 'http://json-schema.org/draft-07/schema#', + '$id' => 'http://json-schema.org/draft-07/schema#', + 'title' => 'definitions test', + 'type' => 'object', + 'additionalProperties' => false, + 'definitions' => [ + 'address' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + '$ref' => '#/definitions/address/$defs/name', + ], + 'city' => [ + 'type' => 'string', + ], + ], + 'required' => [ + 'city', + ], + '$defs' => [ + 'name' => [ + 'type' => 'object', + 'properties' => [ + 'first' => [ + 'type' => 'string', + ], + ], + ], + ], + ], + ], + 'properties' => [ + 'id' => [ + 'type' => 'integer', + ], + 'address' => [ + '$ref' => '#/definitions/address', + ], + ], + 'required' => [ + 'id', + ], + ]; + + /** + * @var int + */ + private int $id; + + /** + * @var mixed|null + */ + private mixed $address = null; + + /** + * @param int $id + */ + public function __construct(int $id) + { + $this->id = $id; + } + + /** + * @return int + */ + public function getId() : int + { + return $this->id; + } + + /** + * @return mixed|null + */ + public function getAddress() : mixed + { + return $this->address; + } + + /** + * @param int $id + * @return self + */ + public function withId(int $id) : self + { + $validator = new \JsonSchema\Validator(); + $validator->validate($id, static::$schema['properties']['id']); + if (!$validator->isValid()) { + throw new \InvalidArgumentException($validator->getErrors()[0]['message']); + } + + $clone = clone $this; + $clone->id = $id; + + return $clone; + } + + /** + * @param mixed $address + * @return self + */ + public function withAddress(mixed $address) : self + { + $clone = clone $this; + $clone->address = $address; + + return $clone; + } + + /** + * @return self + */ + public function withoutAddress() : self + { + $clone = clone $this; + unset($clone->address); + + return $clone; + } + + /** + * Builds a new instance from an input array + * + * @param array|object $input Input data + * @param bool $validate Set this to false to skip validation; use at own risk + * @return Foo Created instance + * @throws \InvalidArgumentException + */ + public static function buildFromInput(array|object $input, bool $validate = true) : Foo + { + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + if ($validate) { + static::validateInput($input); + } + + $id = (int)($input->{'id'}); + $address = null; + if (isset($input->{'address'})) { + $address = $input->{'address'}; + } + + $obj = new self($id); + $obj->address = $address; + return $obj; + } + + /** + * Converts this object back to a simple array that can be JSON-serialized + * + * @return array Converted array + */ + public function toJson() : array + { + $output = []; + $output['id'] = $this->id; + if (isset($this->address)) { + $output['address'] = $this->address; + } + + return $output; + } + + /** + * Validates an input array + * + * @param array|object $input Input data + * @param bool $return Return instead of throwing errors + * @return bool Validation result + * @throws \InvalidArgumentException + */ + public static function validateInput(array|object $input, bool $return = false) : bool + { + $validator = new \JsonSchema\Validator(); + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + $validator->validate($input, static::$schema); + + if (!$validator->isValid() && !$return) { + $errors = array_map(function(array $e): string { + return $e["property"] . ": " . $e["message"]; + }, $validator->getErrors()); + throw new \InvalidArgumentException(join(", ", $errors)); + } + + return $validator->isValid(); + } + + public function __clone() + { + } +} \ No newline at end of file diff --git a/tests/Generator/Fixtures/Definitions/schema.json b/tests/Generator/Fixtures/Definitions/schema.json new file mode 100644 index 00000000..ae30ab9a --- /dev/null +++ b/tests/Generator/Fixtures/Definitions/schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://json-schema.org/draft-07/schema#", + "title": "definitions test", + "type": "object", + "additionalProperties": false, + "definitions": { + "address": { + "type": "object", + "properties": { + "name": { + "$ref": "#/definitions/address/$defs/name" + }, + "city": { + "type": "string" + } + }, + "required": [ + "city" + ], + "$defs": { + "name": { + "type": "object", + "properties": { + "first": { + "type": "string" + } + } + } + } + } + }, + "properties": { + "id": { + "type": "integer" + }, + "address": { + "$ref": "#/definitions/address" + } + }, + "required": [ + "id" + ] +} \ No newline at end of file diff --git a/tests/Generator/SchemaDefinitionsCollectorTest.php b/tests/Generator/SchemaDefinitionsCollectorTest.php new file mode 100644 index 00000000..2d9bc3ea --- /dev/null +++ b/tests/Generator/SchemaDefinitionsCollectorTest.php @@ -0,0 +1,53 @@ + 'http://json-schema.org/draft-07/schema#', + '$id' => 'http://json-schema.org/draft-07/schema#', + 'title' => 'definitions test', + 'type' => 'object', + 'additionalProperties' => false, + 'definitions' => [ + 'address' => [ + 'type' => 'object', + 'properties' => [ + 'city' => [ + 'type' => 'string' + ] + ], + '$defs' => [ + 'name' => [ + 'type' => 'string' + ] + ] + ], + ], + '$defs' => [ + 'address' => [ + 'type' => 'object', + 'properties' => [ + 'city' => [ + 'type' => 'string' + ] + ] + ] + ] + ]; + + $definitionsGenerator = new DefinitionsCollector(); + $definitions = $definitionsGenerator->collect($schema); + + $this->assertSame([], []); + //$this->assertSame([], iterator_to_array($definitions)); + } +} \ No newline at end of file diff --git a/tests/Generator/SchemaToClassTest.php b/tests/Generator/SchemaToClassTest.php index fb964db6..788dcf02 100644 --- a/tests/Generator/SchemaToClassTest.php +++ b/tests/Generator/SchemaToClassTest.php @@ -3,12 +3,16 @@ namespace Helmich\Schema2Class\Generator; +use FilesystemIterator; use Helmich\Schema2Class\Example\CustomerAddress; +use Helmich\Schema2Class\Loader\SchemaLoader; use Helmich\Schema2Class\Spec\SpecificationOptions; use Helmich\Schema2Class\Spec\ValidatedSpecificationFilesItem; use Helmich\Schema2Class\Writer\DebugWriter; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Yaml\Yaml; use function PHPUnit\Framework\assertThat; @@ -33,16 +37,22 @@ public static function loadCodeGenerationTestCases(): array } $schemaFile = join(DIRECTORY_SEPARATOR, [$testCaseDir, $entry, "schema.yaml"]); + if (!file_exists($schemaFile)) { + $schemaFile = join(DIRECTORY_SEPARATOR, [$testCaseDir, $entry, "schema.json"]); + } + $optionsFile = join(DIRECTORY_SEPARATOR, [$testCaseDir, $entry, "options.yaml"]); $outputDir = join(DIRECTORY_SEPARATOR, [$testCaseDir, $entry, "Output"]); - $output = @opendir($outputDir); - if ($output === false) { + try { + $outputDirectoryIterator = new RecursiveDirectoryIterator($outputDir, FilesystemIterator::SKIP_DOTS); + $outputIterator = new RecursiveIteratorIterator($outputDirectoryIterator); + } catch (\UnexpectedValueException) { throw new \Exception("Could not open output directory for test case '{$entry}'"); } $expectedFiles = []; - $schema = Yaml::parseFile($schemaFile); + $schema = (new SchemaLoader())->loadSchema($schemaFile); $opts = (new SpecificationOptions) ->withTargetPHPVersion("8.2") @@ -52,12 +62,20 @@ public static function loadCodeGenerationTestCases(): array $opts = SpecificationOptions::buildFromInput($optsYaml); } - while ($outputEntry = readdir($output)) { - if (substr($outputEntry, -4) !== ".php") { + /** @var \SplFileInfo $fileInfo */ + foreach ($outputIterator as $fileInfo) { + if ($fileInfo->getExtension() !== 'php') { continue; } - $expectedFiles[$outputEntry] = trim(file_get_contents(join(DIRECTORY_SEPARATOR, [$outputDir, $outputEntry]))); + $outputEntry = $fileInfo->getBasename(); + + /** @var RecursiveDirectoryIterator $directoryIterator */ + $directoryIterator = $outputIterator->getInnerIterator(); + if ($directoryIterator->getSubPath()) { + $outputEntry = join([$directoryIterator->getSubPath(), DIRECTORY_SEPARATOR, $outputEntry]); + } + $expectedFiles[$outputEntry] = trim(file_get_contents($fileInfo->getPathname())); } $testCases[$entry] = [$entry, $schema, $expectedFiles, $opts]; @@ -75,18 +93,7 @@ public function testCodeGeneration(string $name, array $schema, array $expectedO $opts, ); - $req = $req->withReferenceLookup(new class implements ReferenceLookup { - public function lookupReference(string $reference): ReferencedType - { - return new ReferencedTypeUnknown(); - } - - public function lookupSchema(string $reference): array - { - return []; - } - }); - $req = $req->withAdditionalReferenceLookup(new class ($schema) implements ReferenceLookup { + $req = $req->withReferenceLookup(new class ($schema) implements ReferenceLookup { public function __construct(private readonly array $schema) { } From 59bbac7c5efbcc3500ca7267bd187658642961e1 Mon Sep 17 00:00:00 2001 From: simivar Date: Tue, 5 Nov 2024 21:46:43 +0100 Subject: [PATCH 3/8] feat: change composer name done so we can release it on packagist until changes are merged upstream --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e7186a48..89ca7edb 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "helmich/schema2class", + "name": "simivar/schema2class", "description": "Build PHP classes from JSON schema definitions", "type": "project", "license": "MIT", From 2c33a07194a7e2e70af9f7667e7a5303a333407e Mon Sep 17 00:00:00 2001 From: simivar Date: Tue, 5 Nov 2024 21:47:26 +0100 Subject: [PATCH 4/8] revert: change composer name will be done in separate branch --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 89ca7edb..e7186a48 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "simivar/schema2class", + "name": "helmich/schema2class", "description": "Build PHP classes from JSON schema definitions", "type": "project", "license": "MIT", From 3f311f8d968c2e3ab55fb6e2da0b1050cea42802 Mon Sep 17 00:00:00 2001 From: simivar Date: Mon, 11 Nov 2024 21:48:32 +0100 Subject: [PATCH 5/8] fix: the namespace of definition class --- src/Generator/Definitions/DefinitionsCollector.php | 12 +++++++++--- src/Generator/Definitions/DefinitionsGenerator.php | 4 ++-- src/Generator/SchemaToClass.php | 2 +- .../Definitions/Output/Definitions/Address.php | 14 +++++++------- tests/Generator/SchemaDefinitionsCollectorTest.php | 8 +++++++- 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/Generator/Definitions/DefinitionsCollector.php b/src/Generator/Definitions/DefinitionsCollector.php index fc935391..3f507d5b 100644 --- a/src/Generator/Definitions/DefinitionsCollector.php +++ b/src/Generator/Definitions/DefinitionsCollector.php @@ -4,8 +4,14 @@ namespace Helmich\Schema2Class\Generator\Definitions; +use Helmich\Schema2Class\Generator\GeneratorRequest; + class DefinitionsCollector { + public function __construct(protected readonly GeneratorRequest $generatorRequest) + { + } + /** * @return \Generator */ @@ -40,13 +46,13 @@ private function pathToClassName(string $path, array $schema): Definition { return str_replace(' ', '', ucwords(str_replace('_', ' ', $part))); }, $parts); - $classFQN = implode('\\', $classNameParts); + $classFQN = $this->generatorRequest->getTargetNamespace() . '\\' . implode('\\', $classNameParts); $className = array_pop($classNameParts); // Joins the parts back into a string with slashes (to represent namespace hierarchy) return new Definition( - namespace: implode('\\', $classNameParts), - directory: implode('/', $classNameParts), + namespace: $this->generatorRequest->getTargetNamespace() . '\\' . implode('\\', $classNameParts), + directory: $this->generatorRequest->getTargetDirectory() . '/' . implode('/', $classNameParts), classFQN: $classFQN, className: $className, schema: $schema, diff --git a/src/Generator/Definitions/DefinitionsGenerator.php b/src/Generator/Definitions/DefinitionsGenerator.php index ae6d308c..2a35d938 100644 --- a/src/Generator/Definitions/DefinitionsGenerator.php +++ b/src/Generator/Definitions/DefinitionsGenerator.php @@ -23,8 +23,8 @@ public function generate(array $definitions, GeneratorRequest $generatorRequest) foreach ($definitions as $definition) { $newRequest = $generatorRequest->withClass($definition->className) ->withSchema($definition->schema) - ->withNamespace(join('\\', [$generatorRequest->getTargetNamespace(), $definition->namespace])) - ->withDirectory(join('/', [$generatorRequest->getTargetDirectory(), $definition->directory])) + ->withNamespace($definition->namespace) + ->withDirectory($definition->directory) ; $this->schemaToClass->schemaToClass($newRequest); diff --git a/src/Generator/SchemaToClass.php b/src/Generator/SchemaToClass.php index 625af6f4..27dab0ee 100644 --- a/src/Generator/SchemaToClass.php +++ b/src/Generator/SchemaToClass.php @@ -251,7 +251,7 @@ private function definitionsToSchemas(GeneratorRequest $req): void return; } - $collector = new DefinitionsCollector(); + $collector = new DefinitionsCollector($req); $collectedDefinitions = iterator_to_array($collector->collect($req->getSchema())); $req = $req->withReferenceLookup(new DefinitionsReferenceLookup( diff --git a/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php b/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php index 8ad7deed..a6e01bf5 100644 --- a/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php +++ b/tests/Generator/Fixtures/Definitions/Output/Definitions/Address.php @@ -37,9 +37,9 @@ class Address ]; /** - * @var \Definitions\Address\Defs\Name|null + * @var Address\Defs\Name|null */ - private ?\Definitions\Address\Defs\Name $name = null; + private ?Address\Defs\Name $name = null; /** * @var string @@ -55,9 +55,9 @@ public function __construct(string $city) } /** - * @return \Definitions\Address\Defs\Name|null + * @return Address\Defs\Name|null */ - public function getName() : ?\Definitions\Address\Defs\Name + public function getName() : ?Address\Defs\Name { return $this->name ?? null; } @@ -71,10 +71,10 @@ public function getCity() : string } /** - * @param \Definitions\Address\Defs\Name $name + * @param Address\Defs\Name $name * @return self */ - public function withName(\Definitions\Address\Defs\Name $name) : self + public function withName(Address\Defs\Name $name) : self { $clone = clone $this; $clone->name = $name; @@ -128,7 +128,7 @@ public static function buildFromInput(array|object $input, bool $validate = true $name = null; if (isset($input->{'name'})) { - $name = \Definitions\Address\Defs\Name::buildFromInput($input->{'name'}, validate: $validate); + $name = Address\Defs\Name::buildFromInput($input->{'name'}, validate: $validate); } $city = $input->{'city'}; diff --git a/tests/Generator/SchemaDefinitionsCollectorTest.php b/tests/Generator/SchemaDefinitionsCollectorTest.php index 2d9bc3ea..65c18140 100644 --- a/tests/Generator/SchemaDefinitionsCollectorTest.php +++ b/tests/Generator/SchemaDefinitionsCollectorTest.php @@ -5,6 +5,8 @@ namespace Helmich\Schema2Class\Generator; use Helmich\Schema2Class\Generator\Definitions\DefinitionsCollector; +use Helmich\Schema2Class\Spec\SpecificationOptions; +use Helmich\Schema2Class\Spec\ValidatedSpecificationFilesItem; use PHPUnit\Framework\TestCase; final class SchemaDefinitionsCollectorTest extends TestCase @@ -44,7 +46,11 @@ public function testCollectsAllDefinitions(): void ] ]; - $definitionsGenerator = new DefinitionsCollector(); + $definitionsGenerator = new DefinitionsCollector(new GeneratorRequest( + schema: $schema, + spec: new ValidatedSpecificationFilesItem('TargetNamespace', 'TargetClass', 'targetDirectory'), + opts: new SpecificationOptions(), + )); $definitions = $definitionsGenerator->collect($schema); $this->assertSame([], []); From 7ad1e99de61f1c61d17f19c062434508c761025a Mon Sep 17 00:00:00 2001 From: simivar Date: Mon, 11 Nov 2024 21:56:40 +0100 Subject: [PATCH 6/8] fix: the namespace of definition class --- src/Generator/GeneratorRequest.php | 2 +- src/Generator/SchemaToClass.php | 2 +- .../Fixtures/Definitions/Output/Foo.php | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Generator/GeneratorRequest.php b/src/Generator/GeneratorRequest.php index 549f45f6..d702066b 100644 --- a/src/Generator/GeneratorRequest.php +++ b/src/Generator/GeneratorRequest.php @@ -48,7 +48,7 @@ public function withReferenceLookup(ReferenceLookup $referenceLookup): self { $clone = clone $this; $clone->referenceLookup = []; - $clone = $clone->withAdditionalReferenceLookup($referenceLookup); + $clone->referenceLookup[$referenceLookup::class] = $referenceLookup; return $clone; } diff --git a/src/Generator/SchemaToClass.php b/src/Generator/SchemaToClass.php index 27dab0ee..d72929f6 100644 --- a/src/Generator/SchemaToClass.php +++ b/src/Generator/SchemaToClass.php @@ -245,7 +245,7 @@ private function schemaToEnum(GeneratorRequest $req): void $this->writer->writeFile($filename, $content); } - private function definitionsToSchemas(GeneratorRequest $req): void + private function definitionsToSchemas(GeneratorRequest &$req): void { if ($req->hasReferenceLookup(DefinitionsReferenceLookup::class)) { return; diff --git a/tests/Generator/Fixtures/Definitions/Output/Foo.php b/tests/Generator/Fixtures/Definitions/Output/Foo.php index 07f63f4c..227a5f15 100644 --- a/tests/Generator/Fixtures/Definitions/Output/Foo.php +++ b/tests/Generator/Fixtures/Definitions/Output/Foo.php @@ -62,9 +62,9 @@ class Foo private int $id; /** - * @var mixed|null + * @var Definitions\Address|null */ - private mixed $address = null; + private ?Definitions\Address $address = null; /** * @param int $id @@ -83,11 +83,11 @@ public function getId() : int } /** - * @return mixed|null + * @return Definitions\Address|null */ - public function getAddress() : mixed + public function getAddress() : ?Definitions\Address { - return $this->address; + return $this->address ?? null; } /** @@ -109,10 +109,10 @@ public function withId(int $id) : self } /** - * @param mixed $address + * @param Definitions\Address $address * @return self */ - public function withAddress(mixed $address) : self + public function withAddress(Definitions\Address $address) : self { $clone = clone $this; $clone->address = $address; @@ -149,7 +149,7 @@ public static function buildFromInput(array|object $input, bool $validate = true $id = (int)($input->{'id'}); $address = null; if (isset($input->{'address'})) { - $address = $input->{'address'}; + $address = Definitions\Address::buildFromInput($input->{'address'}, validate: $validate); } $obj = new self($id); @@ -167,7 +167,7 @@ public function toJson() : array $output = []; $output['id'] = $this->id; if (isset($this->address)) { - $output['address'] = $this->address; + $output['address'] = $this->address->toJson(); } return $output; From 9027cc4f596b920a6bc736862e784731cf985ae9 Mon Sep 17 00:00:00 2001 From: simivar Date: Mon, 11 Nov 2024 22:04:25 +0100 Subject: [PATCH 7/8] test: add collector --- .../SchemaDefinitionsCollectorTest.php | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/tests/Generator/SchemaDefinitionsCollectorTest.php b/tests/Generator/SchemaDefinitionsCollectorTest.php index 65c18140..a5893ae2 100644 --- a/tests/Generator/SchemaDefinitionsCollectorTest.php +++ b/tests/Generator/SchemaDefinitionsCollectorTest.php @@ -4,6 +4,7 @@ namespace Helmich\Schema2Class\Generator; +use Helmich\Schema2Class\Generator\Definitions\Definition; use Helmich\Schema2Class\Generator\Definitions\DefinitionsCollector; use Helmich\Schema2Class\Spec\SpecificationOptions; use Helmich\Schema2Class\Spec\ValidatedSpecificationFilesItem; @@ -52,8 +53,32 @@ public function testCollectsAllDefinitions(): void opts: new SpecificationOptions(), )); $definitions = $definitionsGenerator->collect($schema); + $definitionsArray = iterator_to_array($definitions); - $this->assertSame([], []); - //$this->assertSame([], iterator_to_array($definitions)); + $this->assertCount(3, $definitionsArray); + + $this->assertArrayHasKey('#/$defs/address', $definitionsArray); + $this->assertArrayHasKey('#/definitions/address', $definitionsArray); + $this->assertArrayHasKey('#/definitions/address/$defs/name', $definitionsArray); + + $this->assertInstanceOf(Definition::class, $definitionsArray['#/$defs/address']); + $this->assertInstanceOf(Definition::class, $definitionsArray['#/definitions/address']); + $this->assertInstanceOf(Definition::class, $definitionsArray['#/definitions/address/$defs/name']); + + $this->assertSame('TargetNamespace\Defs', $definitionsArray['#/$defs/address']->namespace); + $this->assertSame('TargetNamespace\Definitions', $definitionsArray['#/definitions/address']->namespace); + $this->assertSame('TargetNamespace\Definitions\Address\Defs', $definitionsArray['#/definitions/address/$defs/name']->namespace); + + $this->assertSame('targetDirectory/Defs', $definitionsArray['#/$defs/address']->directory); + $this->assertSame('targetDirectory/Definitions', $definitionsArray['#/definitions/address']->directory); + $this->assertSame('targetDirectory/Definitions/Address/Defs', $definitionsArray['#/definitions/address/$defs/name']->directory); + + $this->assertSame('TargetNamespace\Defs\Address', $definitionsArray['#/$defs/address']->classFQN); + $this->assertSame('TargetNamespace\Definitions\Address', $definitionsArray['#/definitions/address']->classFQN); + $this->assertSame('TargetNamespace\Definitions\Address\Defs\Name', $definitionsArray['#/definitions/address/$defs/name']->classFQN); + + $this->assertSame('Address', $definitionsArray['#/$defs/address']->className); + $this->assertSame('Address', $definitionsArray['#/definitions/address']->className); + $this->assertSame('Name', $definitionsArray['#/definitions/address/$defs/name']->className); } } \ No newline at end of file From c9b11590e4f6b360cc62289da281722cdc55100f Mon Sep 17 00:00:00 2001 From: simivar Date: Mon, 11 Nov 2024 22:09:19 +0100 Subject: [PATCH 8/8] feat: simplify code --- .../Definitions/DefinitionsCollector.php | 24 ++++++++----------- src/Generator/SchemaToClass.php | 2 +- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Generator/Definitions/DefinitionsCollector.php b/src/Generator/Definitions/DefinitionsCollector.php index 3f507d5b..9a3f2c83 100644 --- a/src/Generator/Definitions/DefinitionsCollector.php +++ b/src/Generator/Definitions/DefinitionsCollector.php @@ -37,23 +37,19 @@ private function findNestedDefinitions(array $definitions, string $path): \Gener } private function pathToClassName(string $path, array $schema): Definition { - $path = str_replace('$defs', 'Defs', $path); - // Strips out the `#` symbol and splits the path into parts - $parts = explode('/', ltrim($path, '#/')); - - // Maps each part of the path to a StudlyCase (or PascalCase) conversion - $classNameParts = array_map(function ($part) { - return str_replace(' ', '', ucwords(str_replace('_', ' ', $part))); - }, $parts); + $parts = array_map( + fn ($part) => str_replace(' ', '', ucwords(str_replace('_', ' ', $part))), + explode('/', ltrim(str_replace('$defs', 'Defs', $path), '#/')) + ); - $classFQN = $this->generatorRequest->getTargetNamespace() . '\\' . implode('\\', $classNameParts); - $className = array_pop($classNameParts); + $className = array_pop($parts); + $namespace = $this->generatorRequest->getTargetNamespace() . '\\' . implode('\\', $parts); + $directory = $this->generatorRequest->getTargetDirectory() . '/' . implode('/', $parts); - // Joins the parts back into a string with slashes (to represent namespace hierarchy) return new Definition( - namespace: $this->generatorRequest->getTargetNamespace() . '\\' . implode('\\', $classNameParts), - directory: $this->generatorRequest->getTargetDirectory() . '/' . implode('/', $classNameParts), - classFQN: $classFQN, + namespace: $namespace, + directory: $directory, + classFQN: $namespace . '\\' . $className, className: $className, schema: $schema, ); diff --git a/src/Generator/SchemaToClass.php b/src/Generator/SchemaToClass.php index d72929f6..7666ef8a 100644 --- a/src/Generator/SchemaToClass.php +++ b/src/Generator/SchemaToClass.php @@ -254,7 +254,7 @@ private function definitionsToSchemas(GeneratorRequest &$req): void $collector = new DefinitionsCollector($req); $collectedDefinitions = iterator_to_array($collector->collect($req->getSchema())); - $req = $req->withReferenceLookup(new DefinitionsReferenceLookup( + $req = $req->withAdditionalReferenceLookup(new DefinitionsReferenceLookup( $collectedDefinitions, ));