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..dfe8127 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,36 @@ 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) { + if (! str_starts_with($schema->reference, '#')) { + break; + } + + $name = null; + $reference = rawurldecode($schema->reference); + foreach (['#/components/schemas/', '#/definitions/'] as $prefix) { + if (str_starts_with($reference, $prefix)) { + $name = str_replace(['~1', '~0'], ['/', '~'], substr($reference, strlen($prefix))); + break; + } + } + + if ($name === null || isset($visited[$name]) || ! isset($this->schemas[$name])) { + break; + } + $visited[$name] = true; + $schema = $this->schemas[$name]; + } + + return $schema; + } + /** @return list */ public function operations(): array { diff --git a/tests/ParserTest.php b/tests/ParserTest.php index c672658..c55dbc3 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 @@ -153,6 +155,36 @@ 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'], + 'Encoded Name' => ['type' => 'string'], + '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'], + ]], + ]); + + 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['EncodedExternal'], $spec->resolveSchema($spec->schemas['EncodedExternal'])); + 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([