From 2f464e05dfa785d13ce2a5ff01033f470a955c44 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 2 Dec 2025 16:28:19 +0100 Subject: [PATCH 1/5] Improve the resolving of references for correct usage of URLs in $id and absolute paths (compare #94) --- src/Model/Schema.php | 2 +- .../SchemaDefinitionDictionary.php | 108 ++++++++++++++++-- .../PostProcessor/EnumPostProcessor.php | 5 +- src/SchemaProcessor/SchemaProcessor.php | 2 +- src/Utils/ClassNameGenerator.php | 10 +- tests/AbstractPHPModelGeneratorTestCase.php | 6 +- tests/Objects/ReferencePropertyTest.php | 28 ++++- .../NestedExternalReference.json | 1 + 8 files changed, 137 insertions(+), 25 deletions(-) diff --git a/src/Model/Schema.php b/src/Model/Schema.php index 62e057f4..6801f4c9 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(); $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..d45e522a 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 = null) { parent::__construct(); } @@ -129,18 +129,16 @@ 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; + $jsonSchemaFilePath = $this->getFullRefURL($jsonSchemaFile) ?: $this->getLocalRefPath($jsonSchemaFile); - if (!filter_var($jsonSchemaFilePath, FILTER_VALIDATE_URL) && !is_file($jsonSchemaFilePath)) { - throw new SchemaException("Reference to non existing JSON-Schema file $jsonSchemaFilePath"); + if ($jsonSchemaFilePath === null) { + throw new SchemaException("Reference to non existing JSON-Schema file $jsonSchemaFile"); } $jsonSchema = file_get_contents($jsonSchemaFilePath); @@ -154,8 +152,8 @@ protected function parseExternalFile( '', $schemaProcessor->getCurrentClassPath(), 'ExternalSchema', - new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema), - new self(dirname($jsonSchemaFilePath)), + $externalSchema = new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema), + new self($externalSchema), ); $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); @@ -163,4 +161,96 @@ protected function parseExternalFile( return $schema->getSchemaDictionary()->getDefinition($externalKey, $schemaProcessor, $path); } + + /** + * Try to build a full URL to fetch the schema from utilizing the $id field of the schema + */ + private function getFullRefURL(string $jsonSchemaFile): ?string + { + if (filter_var($jsonSchemaFile, FILTER_VALIDATE_URL)) { + return $jsonSchemaFile; + } + + if ($this->schema === null + || !filter_var($this->schema->getJson()['$id'] ?? $this->schema->getFile(), FILTER_VALIDATE_URL) + || ($idURL = parse_url($this->schema->getJson()['$id'] ?? $this->schema->getFile())) === false + ) { + return null; + } + + $baseURL = $idURL['scheme'] . '://' . $idURL['host'] . (isset($idURL['port']) ? ':' . $idURL['port'] : ''); + + // root relative $ref + if (str_starts_with($jsonSchemaFile, '/')) { + return $baseURL . $jsonSchemaFile; + } + + // relative $ref against the path of $id + $segments = explode('/', rtrim(dirname($idURL['path'] ?? '/'), '/') . '/' . $jsonSchemaFile); + $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 $jsonSchemaFile): ?string + { + $currentDir = dirname($this->schema->getFile()); + // windows compatibility + $jsonSchemaFile = str_replace('\\', '/', $jsonSchemaFile); + + // relative paths to the current location + if (!str_starts_with($jsonSchemaFile, '/')) { + $candidate = $this->normalizePath($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 = $this->normalizePath($dir . '/' . $relative); + if (file_exists($candidate)) { + return $candidate; + } + + $parent = dirname($dir); + if ($parent === $dir) { + break; + } + $dir = $parent; + } + + return null; + } + + private function normalizePath(string $path): string + { + $segments = explode('/', str_replace('\\', '/', $path)); + $output = []; + + foreach ($segments as $seg) { + if ($seg === '' || $seg === '.') { + continue; + } + if ($seg === '..') { + array_pop($output); + continue; + } + $output[] = $seg; + } + + return str_replace('/', DIRECTORY_SEPARATOR, implode('/', $output)); + } } 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..f6afd195 100644 --- a/src/SchemaProcessor/SchemaProcessor.php +++ b/src/SchemaProcessor/SchemaProcessor.php @@ -68,7 +68,7 @@ public function process(JsonSchema $jsonSchema): void $jsonSchema, $this->currentClassPath, $this->currentClassName, - new SchemaDefinitionDictionary(dirname($jsonSchema->getFile())), + new SchemaDefinitionDictionary($jsonSchema), true, ); } diff --git a/src/Utils/ClassNameGenerator.php b/src/Utils/ClassNameGenerator.php index 789b3049..d89fc8d7 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 ? uniqid() : '')), - ) + ucfirst(match(true) { + isset($json['title']) => $json['title'], + isset($json['$id']) => basename($json['$id']), + default => ($propertyName . ($currentClassName ? uniqid() : '')), + }), ); 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..56481c04 100644 --- a/tests/Objects/ReferencePropertyTest.php +++ b/tests/Objects/ReferencePropertyTest.php @@ -395,9 +395,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 +426,29 @@ 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', + ], ]; } 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" From 6b6234cbc64b6a302b59d374f7d8ca2a30237ce4 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Wed, 3 Dec 2025 20:24:31 +0100 Subject: [PATCH 2/5] cross platform path compatibility --- src/Model/SchemaDefinition/SchemaDefinitionDictionary.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index d45e522a..cb8e239e 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -210,17 +210,18 @@ private function getLocalRefPath(string $jsonSchemaFile): ?string $jsonSchemaFile = str_replace('\\', '/', $jsonSchemaFile); // relative paths to the current location - if (!str_starts_with($jsonSchemaFile, '/')) { + if (!preg_match('#^(?:[A-Za-z]:/|/)#', $jsonSchemaFile, $match)) { $candidate = $this->normalizePath($currentDir . '/' . $jsonSchemaFile); return file_exists($candidate) ? $candidate : null; } + $absolutePathPrefix = $match[0]; // absolute paths: traverse up to find the context root directory - $relative = ltrim($jsonSchemaFile, '/'); + $relative = substr($jsonSchemaFile, strlen($absolutePathPrefix)); $dir = $currentDir; while (true) { - $candidate = $this->normalizePath($dir . '/' . $relative); + $candidate = $absolutePathPrefix . $this->normalizePath($dir . '/' . $relative); if (file_exists($candidate)) { return $candidate; } From df1abfc039d098b1bba791ec3d14461014d07304 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 4 Dec 2025 16:52:57 +0100 Subject: [PATCH 3/5] cross platform path compatibility --- .../SchemaDefinitionDictionary.php | 29 ++++--------------- 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php index cb8e239e..ae15ef9a 100644 --- a/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php +++ b/src/Model/SchemaDefinition/SchemaDefinitionDictionary.php @@ -210,18 +210,18 @@ private function getLocalRefPath(string $jsonSchemaFile): ?string $jsonSchemaFile = str_replace('\\', '/', $jsonSchemaFile); // relative paths to the current location - if (!preg_match('#^(?:[A-Za-z]:/|/)#', $jsonSchemaFile, $match)) { - $candidate = $this->normalizePath($currentDir . '/' . $jsonSchemaFile); + if (!str_starts_with($jsonSchemaFile, '/')) { + $candidate = $currentDir . '/' . $jsonSchemaFile; + return file_exists($candidate) ? $candidate : null; } - $absolutePathPrefix = $match[0]; // absolute paths: traverse up to find the context root directory - $relative = substr($jsonSchemaFile, strlen($absolutePathPrefix)); + $relative = ltrim($jsonSchemaFile, '/'); $dir = $currentDir; while (true) { - $candidate = $absolutePathPrefix . $this->normalizePath($dir . '/' . $relative); + $candidate = $dir . '/' . $relative; if (file_exists($candidate)) { return $candidate; } @@ -235,23 +235,4 @@ private function getLocalRefPath(string $jsonSchemaFile): ?string return null; } - - private function normalizePath(string $path): string - { - $segments = explode('/', str_replace('\\', '/', $path)); - $output = []; - - foreach ($segments as $seg) { - if ($seg === '' || $seg === '.') { - continue; - } - if ($seg === '..') { - array_pop($output); - continue; - } - $output[] = $seg; - } - - return str_replace('/', DIRECTORY_SEPARATOR, implode('/', $output)); - } } From f698d45cb61a5df21a952c4d3beceb12e15def64 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Thu, 4 Dec 2025 17:40:06 +0100 Subject: [PATCH 4/5] #95: Expose a $ref resolve method for the SchemaProvider to be able to load schemas from inaccessible locations (e.g. login, weird folder structure etc.) --- src/Model/Schema.php | 2 +- .../SchemaDefinitionDictionary.php | 96 ++---------------- src/ModelGenerator.php | 2 +- src/SchemaProcessor/SchemaProcessor.php | 25 ++--- src/SchemaProvider/OpenAPIv3Provider.php | 2 + .../RecursiveDirectoryProvider.php | 2 + src/SchemaProvider/RefResolverTrait.php | 98 +++++++++++++++++++ .../SchemaProviderInterface.php | 11 +++ .../PropertyProcessorFactoryTest.php | 15 ++- 9 files changed, 149 insertions(+), 104 deletions(-) create mode 100644 src/SchemaProvider/RefResolverTrait.php diff --git a/src/Model/Schema.php b/src/Model/Schema.php index 6801f4c9..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 ae15ef9a..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 ?JsonSchema $schema = null) + public function __construct(private JsonSchema $schema) { parent::__construct(); } @@ -135,25 +135,19 @@ private function parseExternalFile( SchemaProcessor $schemaProcessor, array &$path, ): ?SchemaDefinition { - $jsonSchemaFilePath = $this->getFullRefURL($jsonSchemaFile) ?: $this->getLocalRefPath($jsonSchemaFile); - - if ($jsonSchemaFilePath === null) { - throw new SchemaException("Reference to non existing JSON-Schema file $jsonSchemaFile"); - } - - $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', - $externalSchema = new JsonSchema($jsonSchemaFilePath, $decodedJsonSchema), - new self($externalSchema), + $jsonSchema, + new self($jsonSchema), ); $schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema); @@ -161,78 +155,4 @@ private function parseExternalFile( return $schema->getSchemaDictionary()->getDefinition($externalKey, $schemaProcessor, $path); } - - /** - * Try to build a full URL to fetch the schema from utilizing the $id field of the schema - */ - private function getFullRefURL(string $jsonSchemaFile): ?string - { - if (filter_var($jsonSchemaFile, FILTER_VALIDATE_URL)) { - return $jsonSchemaFile; - } - - if ($this->schema === null - || !filter_var($this->schema->getJson()['$id'] ?? $this->schema->getFile(), FILTER_VALIDATE_URL) - || ($idURL = parse_url($this->schema->getJson()['$id'] ?? $this->schema->getFile())) === false - ) { - return null; - } - - $baseURL = $idURL['scheme'] . '://' . $idURL['host'] . (isset($idURL['port']) ? ':' . $idURL['port'] : ''); - - // root relative $ref - if (str_starts_with($jsonSchemaFile, '/')) { - return $baseURL . $jsonSchemaFile; - } - - // relative $ref against the path of $id - $segments = explode('/', rtrim(dirname($idURL['path'] ?? '/'), '/') . '/' . $jsonSchemaFile); - $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 $jsonSchemaFile): ?string - { - $currentDir = dirname($this->schema->getFile()); - // windows compatibility - $jsonSchemaFile = str_replace('\\', '/', $jsonSchemaFile); - - // 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; - } } 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/SchemaProcessor.php b/src/SchemaProcessor/SchemaProcessor.php index f6afd195..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, @@ -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/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('', [])), ); } From 74541f0760e6427b94e7241cdc8bd2d143206214 Mon Sep 17 00:00:00 2001 From: Enno Woortmann Date: Tue, 9 Dec 2025 12:52:59 +0100 Subject: [PATCH 5/5] Update docs, add test cases --- docs/source/complexTypes/object.rst | 7 +++- docs/source/generic/references.rst | 10 +++++ tests/Objects/ReferencePropertyTest.php | 55 +++++++++++++++++++------ 3 files changed, 58 insertions(+), 14 deletions(-) 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/tests/Objects/ReferencePropertyTest.php b/tests/Objects/ReferencePropertyTest.php index 56481c04..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 @@ -452,6 +442,47 @@ public function nestedReferenceProvider(): array ]; } + /** + * @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', + ], + ]; + } + public function testInvalidBaseReferenceThrowsAnException(): void { $this->expectException(SchemaException::class);