From 851cc56e8eea408b38c3bb1c144c0e6e179a7873 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 4 Sep 2026 17:15:08 +0100 Subject: [PATCH 1/4] feat: resolve local schema references --- README.md | 8 +++++++- src/Specification.php | 27 +++++++++++++++++++++++++++ tests/ParserTest.php | 25 +++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4207976..047a2e1 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,13 @@ if ($schema instanceof ReferenceSchema) { } ``` -This makes valid recursive schemas safe to parse. +This makes valid recursive schemas safe to parse. Resolve a schema's local component reference explicitly when its concrete type is needed: + +```php +$resolved = $specification->resolveSchema($schema); +``` + +`resolveSchema()` follows chained local schema references and leaves missing, external, or cyclic references unresolved. OpenAPI 3.1 annotated enumerations (`oneOf` or `anyOf` of `const` + `title`) are mapped onto `StringSchema` fields. The type name is the composite `title`. diff --git a/src/Specification.php b/src/Specification.php index fdb7c0d..e750de0 100644 --- a/src/Specification.php +++ b/src/Specification.php @@ -8,6 +8,7 @@ use Utopia\OpenAPI\Model\Info; use Utopia\OpenAPI\Model\Operation; use Utopia\OpenAPI\Model\PathItem; +use Utopia\OpenAPI\Model\ReferenceSchema; use Utopia\OpenAPI\Model\Schema; use Utopia\OpenAPI\Model\SecurityRequirement; use Utopia\OpenAPI\Model\SecurityScheme; @@ -40,6 +41,32 @@ public function __construct( public ?ExternalDocumentation $externalDocumentation = null, ) {} + public function resolveSchema(Schema $schema): Schema + { + $visited = []; + while ($schema instanceof ReferenceSchema) { + $name = $this->schemaName($schema->reference); + if ($name === null || isset($visited[$name]) || ! isset($this->schemas[$name])) { + break; + } + $visited[$name] = true; + $schema = $this->schemas[$name]; + } + + return $schema; + } + + private function schemaName(string $reference): ?string + { + foreach (['#/components/schemas/', '#/definitions/'] as $prefix) { + if (str_starts_with($reference, $prefix)) { + return str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen($prefix))); + } + } + + return null; + } + /** @return list */ public function operations(): array { diff --git a/tests/ParserTest.php b/tests/ParserTest.php index c672658..62013fa 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -153,6 +153,31 @@ public function test_parses_open_api2_form_data_as_request_body(): void self::assertSame('binary', $body?->content['multipart/form-data']->schema->properties['file']->format); } + public function test_resolves_schema_references_explicitly(): void + { + $spec = Parser::parse([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'References', 'version' => '1'], + 'paths' => [], + 'components' => ['schemas' => [ + 'Binary' => ['type' => 'string', 'format' => 'binary'], + 'Alias' => ['$ref' => '#/components/schemas/Binary'], + 'Chained' => ['$ref' => '#/components/schemas/Alias'], + 'Missing' => ['$ref' => '#/components/schemas/Unknown'], + 'External' => ['$ref' => 'schemas.json#/Binary'], + 'CycleA' => ['$ref' => '#/components/schemas/CycleB'], + 'CycleB' => ['$ref' => '#/components/schemas/CycleA'], + ]], + ]); + + self::assertSame($spec->schemas['Binary'], $spec->resolveSchema($spec->schemas['Binary'])); + self::assertSame($spec->schemas['Binary'], $spec->resolveSchema($spec->schemas['Alias'])); + self::assertSame($spec->schemas['Binary'], $spec->resolveSchema($spec->schemas['Chained'])); + self::assertSame($spec->schemas['Missing'], $spec->resolveSchema($spec->schemas['Missing'])); + self::assertSame($spec->schemas['External'], $spec->resolveSchema($spec->schemas['External'])); + self::assertSame($spec->schemas['CycleA'], $spec->resolveSchema($spec->schemas['CycleA'])); + } + public function test_resolves_escaped_local_json_pointer_and_detects_reference_cycles(): void { $resolver = new LocalResolver([ From 919a8cc1eb9bbcd2b813478a9cff85baf19c9744 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 4 Sep 2026 17:18:49 +0100 Subject: [PATCH 2/4] refactor schema reference resolution --- src/Specification.php | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Specification.php b/src/Specification.php index e750de0..265b7db 100644 --- a/src/Specification.php +++ b/src/Specification.php @@ -41,11 +41,21 @@ public function __construct( public ?ExternalDocumentation $externalDocumentation = null, ) {} + /** + * Resolve chained local component references without expanding recursive schema graphs. + */ public function resolveSchema(Schema $schema): Schema { $visited = []; while ($schema instanceof ReferenceSchema) { - $name = $this->schemaName($schema->reference); + $name = null; + foreach (['#/components/schemas/', '#/definitions/'] as $prefix) { + if (str_starts_with($schema->reference, $prefix)) { + $name = str_replace(['~1', '~0'], ['/', '~'], substr($schema->reference, strlen($prefix))); + break; + } + } + if ($name === null || isset($visited[$name]) || ! isset($this->schemas[$name])) { break; } @@ -56,17 +66,6 @@ public function resolveSchema(Schema $schema): Schema return $schema; } - private function schemaName(string $reference): ?string - { - foreach (['#/components/schemas/', '#/definitions/'] as $prefix) { - if (str_starts_with($reference, $prefix)) { - return str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen($prefix))); - } - } - - return null; - } - /** @return list */ public function operations(): array { From 4d2d592d8a9b3730697207f49d5244c10a6abcea Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 4 Sep 2026 17:33:59 +0100 Subject: [PATCH 3/4] address greptile review feedback (greploop iteration 1) --- src/Specification.php | 5 +++-- tests/ParserTest.php | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Specification.php b/src/Specification.php index 265b7db..d483805 100644 --- a/src/Specification.php +++ b/src/Specification.php @@ -49,9 +49,10 @@ public function resolveSchema(Schema $schema): Schema $visited = []; while ($schema instanceof ReferenceSchema) { $name = null; + $reference = rawurldecode($schema->reference); foreach (['#/components/schemas/', '#/definitions/'] as $prefix) { - if (str_starts_with($schema->reference, $prefix)) { - $name = str_replace(['~1', '~0'], ['/', '~'], substr($schema->reference, strlen($prefix))); + if (str_starts_with($reference, $prefix)) { + $name = str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen($prefix))); break; } } diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 62013fa..939d514 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -129,7 +129,9 @@ public function test_parses_open_api2_directly(): void $operation = $spec->paths['/pets']->operation(HttpMethod::POST); self::assertTrue($operation?->requestBody?->required); self::assertInstanceOf(ReferenceSchema::class, $operation?->requestBody?->content['application/json']->schema); - self::assertInstanceOf(ReferenceSchema::class, $operation?->responses['200']->content['application/json']->schema); + $responseSchema = $operation?->responses['200']->content['application/json']->schema; + self::assertInstanceOf(ReferenceSchema::class, $responseSchema); + self::assertSame($spec->schemas['Pet'], $spec->resolveSchema($responseSchema)); } public function test_parses_open_api2_form_data_as_request_body(): void @@ -163,6 +165,8 @@ public function test_resolves_schema_references_explicitly(): void 'Binary' => ['type' => 'string', 'format' => 'binary'], 'Alias' => ['$ref' => '#/components/schemas/Binary'], 'Chained' => ['$ref' => '#/components/schemas/Alias'], + 'Encoded Name' => ['type' => 'string'], + 'Encoded' => ['$ref' => '#/components/schemas/Encoded%20Name'], 'Missing' => ['$ref' => '#/components/schemas/Unknown'], 'External' => ['$ref' => 'schemas.json#/Binary'], 'CycleA' => ['$ref' => '#/components/schemas/CycleB'], @@ -173,6 +177,7 @@ public function test_resolves_schema_references_explicitly(): void self::assertSame($spec->schemas['Binary'], $spec->resolveSchema($spec->schemas['Binary'])); self::assertSame($spec->schemas['Binary'], $spec->resolveSchema($spec->schemas['Alias'])); self::assertSame($spec->schemas['Binary'], $spec->resolveSchema($spec->schemas['Chained'])); + self::assertSame($spec->schemas['Encoded Name'], $spec->resolveSchema($spec->schemas['Encoded'])); self::assertSame($spec->schemas['Missing'], $spec->resolveSchema($spec->schemas['Missing'])); self::assertSame($spec->schemas['External'], $spec->resolveSchema($spec->schemas['External'])); self::assertSame($spec->schemas['CycleA'], $spec->resolveSchema($spec->schemas['CycleA'])); From cef485193c059a1c274036b2f6078b213545b4ff Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Fri, 4 Sep 2026 17:37:35 +0100 Subject: [PATCH 4/4] address greptile review feedback (greploop iteration 2) --- src/Specification.php | 4 ++++ tests/ParserTest.php | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/Specification.php b/src/Specification.php index d483805..dfe8127 100644 --- a/src/Specification.php +++ b/src/Specification.php @@ -48,6 +48,10 @@ public function resolveSchema(Schema $schema): Schema { $visited = []; while ($schema instanceof ReferenceSchema) { + if (! str_starts_with($schema->reference, '#')) { + break; + } + $name = null; $reference = rawurldecode($schema->reference); foreach (['#/components/schemas/', '#/definitions/'] as $prefix) { diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 939d514..c55dbc3 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -169,6 +169,7 @@ public function test_resolves_schema_references_explicitly(): void 'Encoded' => ['$ref' => '#/components/schemas/Encoded%20Name'], 'Missing' => ['$ref' => '#/components/schemas/Unknown'], 'External' => ['$ref' => 'schemas.json#/Binary'], + 'EncodedExternal' => ['$ref' => '%23/components/schemas/Binary'], 'CycleA' => ['$ref' => '#/components/schemas/CycleB'], 'CycleB' => ['$ref' => '#/components/schemas/CycleA'], ]], @@ -180,6 +181,7 @@ public function test_resolves_schema_references_explicitly(): void self::assertSame($spec->schemas['Encoded Name'], $spec->resolveSchema($spec->schemas['Encoded'])); self::assertSame($spec->schemas['Missing'], $spec->resolveSchema($spec->schemas['Missing'])); self::assertSame($spec->schemas['External'], $spec->resolveSchema($spec->schemas['External'])); + self::assertSame($spec->schemas['EncodedExternal'], $spec->resolveSchema($spec->schemas['EncodedExternal'])); self::assertSame($spec->schemas['CycleA'], $spec->resolveSchema($spec->schemas['CycleA'])); }