diff --git a/docs/source/complexTypes/object.rst b/docs/source/complexTypes/object.rst index ac965cba..5bf623fa 100644 --- a/docs/source/complexTypes/object.rst +++ b/docs/source/complexTypes/object.rst @@ -93,12 +93,15 @@ Naming Naming of classes ^^^^^^^^^^^^^^^^^ -If the given main object in a JSON-Schema file contains a `$id` the id will be used as class name. Otherwise the name of the file will be used. +If the given main object in a JSON-Schema file contains a `title`, the title will be used as class name. +Otherwise, if an `$id` is present, the basename of the $id and as a last fallback the name of the file will be used. Naming of nested classes ^^^^^^^^^^^^^^^^^^^^^^^^ -For the class name of a nested class the `$id` property of the nested object is used. If the id property isn't present the property key will be prefixed with the parent class. If an object `Person` has a nested object `car` without a `$id` the class for car will be named **Person_Car**. +For the class name of a nested class the `title` property (fallback to `$id`) of the nested object is used. +If neither the title nor the $id property is present the property key will be prefixed with the parent class. +If an object `Person` has a nested object `car` without a `title` and an `$id` the class for car will be named **Person_Car**. Property Name Normalization ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/generic/references.rst b/docs/source/generic/references.rst index 22ac8b1e..0e5c19a8 100644 --- a/docs/source/generic/references.rst +++ b/docs/source/generic/references.rst @@ -11,10 +11,20 @@ Supported reference types * relative reference based on the location on the file system to a complete file (example: `"$ref": "./../modules/myObject.json"`) * relative reference based on the location on the file system to an object by id (example: `"$ref": "./../modules/myObject.json#IdOfMyObject"`) * relative reference based on the location on the file system to an object by path (example: `"$ref": "./../modules/myObject.json#/definitions/myObject"`) +* absolute reference based on the location on the file system to a complete file (example: `"$ref": "/modules/myObject.json"`) +* absolute reference based on the location on the file system to an object by id (example: `"$ref": "/modules/myObject.json#IdOfMyObject"`) +* absolute reference based on the location on the file system to an object by path (example: `"$ref": "/modules/myObject.json#/definitions/myObject"`) * network reference to a complete file (example: `"$ref": "https://my.domain.com/schema/modules/myObject.json"`) * network reference to an object by id (example: `"$ref": "https://my.domain.com/schema/modules/myObject.json#IdOfMyObject"`) * network reference to an object by path (example: `"$ref": "https://my.domain.com/schema/modules/myObject.json#/definitions/myObject"`) +If an `$id` is present in the schema, the `$ref` will be resolved relative to the `$id` (except the `$ref` already is an absolute reference, e.g. a full URL). +The behaviour of `$ref` resolving can be overwritten by implementing a custom **SchemaProviderInterface**, for example when you want to use network references behind an authorization. + +.. note:: + + For absolute local references, the default implementation traverses up the directory tree until it finds a matching file to find the project root + Object reference ---------------- diff --git a/src/Model/Schema.php b/src/Model/Schema.php index 62e057f4..75888e93 100644 --- a/src/Model/Schema.php +++ b/src/Model/Schema.php @@ -65,7 +65,7 @@ public function __construct( protected bool $initialClass = false, ) { $this->jsonSchema = $schema; - $this->schemaDefinitionDictionary = $dictionary ?? new SchemaDefinitionDictionary(''); + $this->schemaDefinitionDictionary = $dictionary ?? new SchemaDefinitionDictionary($schema); $this->description = $schema->getJson()['description'] ?? ''; $this->addInterface(JSONModelInterface::class); diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index 18f3cee6..adc1693d 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -22,7 +22,7 @@ class SchemaDefinitionDictionary extends ArrayObject /** * SchemaDefinitionDictionary constructor. */ - public function __construct(private string $sourceDirectory) + public function __construct(private JsonSchema $schema) { parent::__construct(); } @@ -129,33 +129,25 @@ public function getDefinition(string $key, SchemaProcessor $schemaProcessor, arr /** * @throws SchemaException */ - protected function parseExternalFile( + private function parseExternalFile( string $jsonSchemaFile, string $externalKey, SchemaProcessor $schemaProcessor, array &$path, ): ?SchemaDefinition { - $jsonSchemaFilePath = filter_var($jsonSchemaFile, FILTER_VALIDATE_URL) - ? $jsonSchemaFile - : $this->sourceDirectory . '/' . $jsonSchemaFile; - - if (!filter_var($jsonSchemaFilePath, FILTER_VALIDATE_URL) && !is_file($jsonSchemaFilePath)) { - throw new SchemaException("Reference to non existing JSON-Schema file $jsonSchemaFilePath"); - } - - $jsonSchema = file_get_contents($jsonSchemaFilePath); - - if (!$jsonSchema || !($decodedJsonSchema = json_decode($jsonSchema, true))) { - throw new SchemaException("Invalid JSON-Schema file $jsonSchemaFilePath"); - } + $jsonSchema = $schemaProcessor->getSchemaProvider()->getRef( + $this->schema->getFile(), + $this->schema->getJson()['$id'] ?? null, + $jsonSchemaFile, + ); // set up a dummy schema to fetch the definitions from the external file $schema = new Schema( '', $schemaProcessor->getCurrentClassPath(), 'ExternalSchema', - new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema), - new self(dirname($jsonSchemaFilePath)), + $jsonSchema, + new self($jsonSchema), ); $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); diff --git a/src/ModelGenerator.php b/src/ModelGenerator.php index 6831678a..d6c286a7 100644 --- a/src/ModelGenerator.php +++ b/src/ModelGenerator.php @@ -105,7 +105,7 @@ public function generateModels(SchemaProviderInterface $schemaProvider, string $ $renderQueue = new RenderQueue(); $schemaProcessor = new SchemaProcessor( - $schemaProvider->getBaseDirectory(), + $schemaProvider, $destination, $this->generatorConfiguration, $renderQueue, diff --git a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php index 80b618df..231c6a40 100644 --- a/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php +++ b/src/SchemaProcessor/PostProcessor/EnumPostProcessor.php @@ -75,8 +75,9 @@ public function process(Schema $schema, GeneratorConfiguration $generatorConfigu $this->checkForExistingTransformingFilter($property); $values = $json['enum']; - $enumSignature = ArrayHash::hash($json, ['enum', 'enum-map', '$id']); - $enumName = $json['$id'] ?? $schema->getClassName() . ucfirst($property->getName()); + $enumSignature = ArrayHash::hash($json, ['enum', 'enum-map', 'title', '$id']); + $enumName = $json['title'] + ?? basename($json['$id'] ?? $schema->getClassName() . ucfirst($property->getName())); if (!isset($this->generatedEnums[$enumSignature])) { $this->generatedEnums[$enumSignature] = [ diff --git a/src/SchemaProcessor/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index c1b08adc..f6a6331c 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -20,6 +20,7 @@ use PHPModelGenerator\PropertyProcessor\PropertyMetaDataCollection; use PHPModelGenerator\PropertyProcessor\PropertyFactory; use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; +use PHPModelGenerator\SchemaProvider\SchemaProviderInterface; /** * Class SchemaProcessor @@ -28,23 +29,18 @@ */ class SchemaProcessor { - /** @var string */ - protected $currentClassPath; - /** @var string */ - protected $currentClassName; + protected string $currentClassPath; + protected string $currentClassName; /** @var Schema[] Collect processed schemas to avoid duplicated classes */ - protected $processedSchema = []; + protected array $processedSchema = []; /** @var PropertyInterface[] Collect processed schemas to avoid duplicated classes */ - protected $processedMergedProperties = []; + protected array $processedMergedProperties = []; /** @var string[] */ - protected $generatedFiles = []; + protected array $generatedFiles = []; - /** - * SchemaProcessor constructor. - */ public function __construct( - protected string $baseSource, + protected SchemaProviderInterface $schemaProvider, protected string $destination, protected GeneratorConfiguration $generatorConfiguration, protected RenderQueue $renderQueue, @@ -68,7 +64,7 @@ public function process(JsonSchema $jsonSchema): void $jsonSchema, $this->currentClassPath, $this->currentClassName, - new SchemaDefinitionDictionary(dirname($jsonSchema->getFile())), + new SchemaDefinitionDictionary($jsonSchema), true, ); } @@ -311,7 +307,7 @@ function () use ($property, $schema, $mergedPropertySchema): void { */ protected function setCurrentClassPath(string $jsonSchemaFile): void { - $path = str_replace($this->baseSource, '', dirname($jsonSchemaFile)); + $path = str_replace($this->schemaProvider->getBaseDirectory(), '', dirname($jsonSchemaFile)); $pieces = array_map( static fn(string $directory): string => ucfirst(preg_replace('/\W/', '', $directory)), explode(DIRECTORY_SEPARATOR, $path), @@ -340,6 +336,11 @@ public function getGeneratorConfiguration(): GeneratorConfiguration return $this->generatorConfiguration; } + public function getSchemaProvider(): SchemaProviderInterface + { + return $this->schemaProvider; + } + private function getTargetFileName(string $classPath, string $className): string { return join( diff --git a/src/SchemaProvider/OpenAPIv3Provider.php b/src/SchemaProvider/OpenAPIv3Provider.php index 486d8897..7d7288fa 100644 --- a/src/SchemaProvider/OpenAPIv3Provider.php +++ b/src/SchemaProvider/OpenAPIv3Provider.php @@ -14,6 +14,8 @@ */ class OpenAPIv3Provider implements SchemaProviderInterface { + use RefResolverTrait; + /** @var array */ private $openAPIv3Spec; diff --git a/src/SchemaProvider/RecursiveDirectoryProvider.php b/src/SchemaProvider/RecursiveDirectoryProvider.php index 5b989891..895716c0 100644 --- a/src/SchemaProvider/RecursiveDirectoryProvider.php +++ b/src/SchemaProvider/RecursiveDirectoryProvider.php @@ -18,6 +18,8 @@ */ class RecursiveDirectoryProvider implements SchemaProviderInterface { + use RefResolverTrait; + private string $sourceDirectory; /** diff --git a/src/SchemaProvider/RefResolverTrait.php b/src/SchemaProvider/RefResolverTrait.php new file mode 100644 index 00000000..b038858c --- /dev/null +++ b/src/SchemaProvider/RefResolverTrait.php @@ -0,0 +1,98 @@ +getFullRefURL($id ?? $currentFile, $ref) + ?: $this->getLocalRefPath($currentFile, $ref); + + if ($jsonSchemaFilePath === null || !($jsonSchema = file_get_contents($jsonSchemaFilePath))) { + throw new SchemaException("Reference to non existing JSON-Schema file $ref"); + } + + if (!($decodedJsonSchema = json_decode($jsonSchema, true))) { + throw new SchemaException("Invalid JSON-Schema file $jsonSchemaFilePath"); + } + + return new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema); + } + + /** + * Try to build a full URL to fetch the schema from utilizing the $id field of the schema + */ + private function getFullRefURL(string $id, string $ref): ?string + { + if (filter_var($ref, FILTER_VALIDATE_URL)) { + return $ref; + } + + if (!filter_var($id, FILTER_VALIDATE_URL) || ($idURL = parse_url($id)) === false) { + return null; + } + + $baseURL = $idURL['scheme'] . '://' . $idURL['host'] . (isset($idURL['port']) ? ':' . $idURL['port'] : ''); + + // root relative $ref + if (str_starts_with($ref, '/')) { + return $baseURL . $ref; + } + + // relative $ref against the path of $id + $segments = explode('/', rtrim(dirname($idURL['path'] ?? '/'), '/') . '/' . $ref); + $output = []; + + foreach ($segments as $seg) { + if ($seg === '' || $seg === '.') { + continue; + } + if ($seg === '..') { + array_pop($output); + continue; + } + $output[] = $seg; + } + + return $baseURL . '/' . implode('/', $output); + } + + private function getLocalRefPath(string $currentFile, string $ref): ?string + { + $currentDir = dirname($currentFile); + // windows compatibility + $jsonSchemaFile = str_replace('\\', '/', $ref); + + // relative paths to the current location + if (!str_starts_with($jsonSchemaFile, '/')) { + $candidate = $currentDir . '/' . $jsonSchemaFile; + + return file_exists($candidate) ? $candidate : null; + } + + // absolute paths: traverse up to find the context root directory + $relative = ltrim($jsonSchemaFile, '/'); + + $dir = $currentDir; + while (true) { + $candidate = $dir . '/' . $relative; + if (file_exists($candidate)) { + return $candidate; + } + + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; + } + + return null; + } +} \ No newline at end of file diff --git a/src/SchemaProvider/SchemaProviderInterface.php b/src/SchemaProvider/SchemaProviderInterface.php index c342a915..40ed9c88 100644 --- a/src/SchemaProvider/SchemaProviderInterface.php +++ b/src/SchemaProvider/SchemaProviderInterface.php @@ -25,4 +25,15 @@ public function getSchemas(): iterable; * Get the base directory of the provider */ public function getBaseDirectory(): string; + + /** + * Load the content of a referenced file. You may include the RefResolverTrait which tries local and URL loading. + * If your referenced files are not easily accessible, e.g. behind a login, you need to implement the lookup yourself. + * The JsonSchema object must contain the whole referenced schema. + * + * @param string $currentFile The file containing the reference + * @param string|null $id If present, the $id field of the + * @param string $ref The $ref which should be resolved (without anchor part, anchors are resolved internally) + */ + public function getRef(string $currentFile, ?string $id, string $ref): JsonSchema; } diff --git a/src/Utils/ClassNameGenerator.php b/src/Utils/ClassNameGenerator.php index fd6b4592..8150115c 100644 --- a/src/Utils/ClassNameGenerator.php +++ b/src/Utils/ClassNameGenerator.php @@ -29,11 +29,11 @@ public function getClassName( $className = sprintf( $isMergeClass ? '%s_Merged_%s' : '%s_%s', $currentClassName, - ucfirst( - isset($json['$id']) - ? str_replace('#', '', $json['$id']) - : ($propertyName . ($currentClassName ? md5(json_encode($json)) : '')), - ) + ucfirst(match(true) { + isset($json['title']) => $json['title'], + isset($json['$id']) => basename($json['$id']), + default => ($propertyName . ($currentClassName ? md5(json_encode($json)) : '')), + }), ); return ucfirst(preg_replace('/\W/', '', trim($className, '_'))); diff --git a/tests/AbstractPHPModelGeneratorTestCase.php b/tests/AbstractPHPModelGeneratorTestCase.php index 48ec2ac2..61bd06d8 100644 --- a/tests/AbstractPHPModelGeneratorTestCase.php +++ b/tests/AbstractPHPModelGeneratorTestCase.php @@ -204,7 +204,7 @@ protected function generateClass( $className = $this->getClassName(); if (!$originalClassNames) { - // extend the class name generator to attach a uniqid as multiple test executions use identical $id + // extend the class name generator to attach a uniqid as multiple test executions use identical title // properties which would lead to name collisions $generatorConfiguration->setClassNameGenerator(new class extends ClassNameGenerator { public function getClassName( @@ -221,12 +221,12 @@ public function getClassName( // generate an object ID for valid JSON schema files to avoid class name collisions in the testing process $jsonSchemaArray = json_decode($jsonSchema, true); if ($jsonSchemaArray) { - $jsonSchemaArray['$id'] = $className; + $jsonSchemaArray['title'] = $className; if (isset($jsonSchemaArray['components']['schemas'])) { $counter = 0; foreach ($jsonSchemaArray['components']['schemas'] as &$schema) { - $schema['$id'] = $className . '_' . $counter++; + $schema['title'] = $className . '_' . $counter++; } } diff --git a/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 4a963ada..a5ea94e1 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -10,6 +10,7 @@ use PHPModelGenerator\Exception\RenderException; use PHPModelGenerator\Exception\SchemaException; use PHPModelGenerator\Model\GeneratorConfiguration; +use PHPModelGenerator\Model\Schema; use PHPModelGenerator\Tests\AbstractPHPModelGeneratorTestCase; use stdClass; @@ -24,13 +25,12 @@ class ReferencePropertyTest extends AbstractPHPModelGeneratorTestCase /** * @dataProvider internalReferenceProvider - * @dataProvider notResolvedExternalReferenceProvider * * @throws FileSystemException * @throws RenderException * @throws SchemaException */ - public function testNotResolvedReferenceThrowsAnException(string $reference): void + public function testNotResolvedInternalReferenceThrowsAnException(string $reference): void { $this->expectException(SchemaException::class); $this->expectExceptionMessageMatches( @@ -57,16 +57,6 @@ public function externalReferenceProvider(): array ]; } - public function notResolvedExternalReferenceProvider(): array - { - return [ - 'External non existing file' => ['../ReferencePropertyTest_external/notExisting'], - 'External path reference in non existing file' => ['../ReferencePropertyTest_external/notExisting#person'], - 'External non existing path reference' => ['../ReferencePropertyTest_external/library.json#/definitions/animal'], - 'External non existing direct reference' => ['../ReferencePropertyTest_external/library.json#animal'], - ]; - } - /** * @dataProvider internalReferenceProvider * @dataProvider externalReferenceProvider @@ -395,9 +385,9 @@ public function invalidCombinedReferenceObjectPropertyTypeDataProvider(): array * @throws RenderException * @throws SchemaException */ - public function testNestedExternalReference(string $reference): void + public function testNestedExternalReference(string $id, string $reference): void { - $className = $this->generateClassFromFileTemplate('NestedExternalReference.json', [$reference]); + $className = $this->generateClassFromFileTemplate('NestedExternalReference.json', [$id, $reference]); $object = new $className([ 'family' => [ @@ -426,9 +416,70 @@ public function testNestedExternalReference(string $reference): void public function nestedReferenceProvider(): array { + $baseURL = 'https://raw.githubusercontent.com/wol-soft/php-json-schema-model-generator/master/tests/Schema/'; + return [ - 'Local reference' => ['../ReferencePropertyTest_external/library.json'], - 'Network reference' => ['https://raw.githubusercontent.com/wol-soft/php-json-schema-model-generator/master/tests/Schema/ReferencePropertyTest_external/library.json'], + 'local reference - relative' => [ + 'NestedExternalReference.json', + '../ReferencePropertyTest_external/library.json', + ], + 'local reference - context absolute' => [ + 'NestedExternalReference.json', + '/ReferencePropertyTest_external/library.json', + ], + 'network reference - full URL' => [ + 'NestedExternalReference.json', + $baseURL . 'ReferencePropertyTest_external/library.json', + ], + 'network reference - relative path to full URL $id' => [ + $baseURL . 'ReferencePropertyTest/NestedExternalReference.json', + '../ReferencePropertyTest_external/library.json', + ], + 'network reference - absolute path to full URL $id' => [ + $baseURL . 'ReferencePropertyTest/NestedExternalReference.json', + '/wol-soft/php-json-schema-model-generator/master/tests/Schema/ReferencePropertyTest_external/library.json', + ], + ]; + } + + /** + * @dataProvider nonResolvableExternalReferenceProvider + */ + public function testNonResolvableExternalReference(string $id, string $reference): void + { + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches( + sprintf('/Unresolved Reference %s#\/definitions\/family in file .*\.json/', str_replace('/', '\/', $reference)), + ); + + $this->generateClassFromFileTemplate('NestedExternalReference.json', [$id, $reference]); + } + + public function nonResolvableExternalReferenceProvider(): array + { + $baseURL = 'https://raw.githubusercontent.com/wol-soft/php-json-schema-model-generator/master/tests/Schema/'; + + return [ + 'local reference - relative' => [ + 'NestedExternalReference.json', + '../ReferencePropertyTest_external/nonexistent.json', + ], + 'local reference - context absolute' => [ + 'NestedExternalReference.json', + '/ReferencePropertyTest_external/nonexistent.json', + ], + 'network reference - full URL' => [ + 'NestedExternalReference.json', + $baseURL . 'ReferencePropertyTest_external/nonexistent.json', + ], + 'network reference - relative path to full URL $id' => [ + $baseURL . 'ReferencePropertyTest/NestedExternalReference.json', + '../ReferencePropertyTest_external/nonexistent.json', + ], + 'network reference - absolute path to full URL $id' => [ + $baseURL . 'ReferencePropertyTest/NestedExternalReference.json', + '/wol-soft/php-json-schema-model-generator/master/tests/Schema/ReferencePropertyTest_external/nonexistent.json', + ], ]; } diff --git a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php index f60814e7..438f5694 100644 --- a/tests/PropertyProcessor/PropertyProcessorFactoryTest.php +++ b/tests/PropertyProcessor/PropertyProcessorFactoryTest.php @@ -19,6 +19,7 @@ use PHPModelGenerator\PropertyProcessor\PropertyProcessorFactory; use PHPModelGenerator\SchemaProcessor\RenderQueue; use PHPModelGenerator\SchemaProcessor\SchemaProcessor; +use PHPModelGenerator\SchemaProvider\RecursiveDirectoryProvider; use PHPUnit\Framework\TestCase; /** @@ -40,7 +41,12 @@ public function testGetPropertyProcessor(string $type, string $expectedClass): v $propertyProcessor = $propertyProcessorFactory->getProcessor( $type, new PropertyMetaDataCollection(), - new SchemaProcessor('', '', new GeneratorConfiguration(), new RenderQueue()), + new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ), new Schema('', '', '', new JsonSchema('', [])), ); @@ -76,7 +82,12 @@ public function testGetInvalidPropertyProcessorThrowsAnException(): void $propertyProcessorFactory->getProcessor( 'Hello', new PropertyMetaDataCollection(), - new SchemaProcessor('', '', new GeneratorConfiguration(), new RenderQueue()), + new SchemaProcessor( + new RecursiveDirectoryProvider(__DIR__), + '', + new GeneratorConfiguration(), + new RenderQueue(), + ), new Schema('', '', '', new JsonSchema('', [])), ); } diff --git a/tests/Schema/ReferencePropertyTest/NestedExternalReference.json b/tests/Schema/ReferencePropertyTest/NestedExternalReference.json index 74a06335..17d5aaa3 100644 --- a/tests/Schema/ReferencePropertyTest/NestedExternalReference.json +++ b/tests/Schema/ReferencePropertyTest/NestedExternalReference.json @@ -1,5 +1,6 @@ { "type": "object", + "$id": "%s", "properties": { "family": { "$ref": "%s#/definitions/family"