From 8365def3c28edc3cd6e7aadf1c0b7cc1a0ccdacc Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 16 Jun 2026 10:55:03 +0200 Subject: [PATCH 1/2] fix(openapi): serialize Reference objects with $ref in the generated document The openapi serializer's inline ObjectNormalizer was built without a class metadata factory or a metadata-aware name converter, so Reference::getRef()'s #[SerializedName('$ref')] was ignored and emitted as "ref". Wire the standard class_metadata_factory and metadata-aware name converter so the model's own serialization metadata is honored. Fixes #8143 --- src/OpenApi/Model/Components.php | 2 +- src/OpenApi/Model/Operation.php | 4 +-- .../Serializer/OpenApiNormalizerTest.php | 31 ++++++++++++++++ .../Bundle/Resources/config/openapi.php | 2 +- .../Issue8143/ReferenceResponse.php | 35 +++++++++++++++++++ tests/Functional/OpenApiTest.php | 17 +++++++++ 6 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue8143/ReferenceResponse.php diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php index cc420cf1f3e..04c6ef14023 100644 --- a/src/OpenApi/Model/Components.php +++ b/src/OpenApi/Model/Components.php @@ -21,7 +21,7 @@ final class Components /** * @param \ArrayObject|\ArrayObject $schemas - * @param \ArrayObject|\ArrayObject $responses + * @param \ArrayObject|\ArrayObject $responses * @param \ArrayObject|\ArrayObject $parameters * @param \ArrayObject|\ArrayObject $examples * @param \ArrayObject|\ArrayObject $requestBodies diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php index 20c5557bdda..e2716ae2658 100644 --- a/src/OpenApi/Model/Operation.php +++ b/src/OpenApi/Model/Operation.php @@ -18,8 +18,8 @@ final class Operation use ExtensionTrait; /** - * @param ?string[] $tags - * @param ?Response[] $responses + * @param ?string[] $tags + * @param array|null $responses */ public function __construct(private ?string $operationId = null, private ?array $tags = null, private ?array $responses = null, private ?string $summary = null, private ?string $description = null, private ?ExternalDocumentation $externalDocs = null, private ?array $parameters = null, private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private ?bool $deprecated = null, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) { diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index be36a20f288..efe1f25df25 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -37,6 +37,7 @@ use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\Paths; +use ApiPlatform\OpenApi\Model\Reference; use ApiPlatform\OpenApi\Model\Schema; use ApiPlatform\OpenApi\Model\Server; use ApiPlatform\OpenApi\OpenApi; @@ -51,6 +52,9 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\TypeInfo\Type; @@ -95,6 +99,33 @@ public function testNormalizeWithEmptySchemas(): void $this->assertCount(0, $array['components']['schemas']); } + public function testNormalizeReferenceUsesDollarRef(): void + { + $openApi = new OpenApi( + new Info('My API', '1.0.0', 'An amazing API'), + [new Server('https://example.com')], + new Paths(), + new Components(responses: new \ArrayObject([ + '401' => new Reference(ref: '#/components/responses/401'), + ])) + ); + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory))]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $array = $normalizer->normalize($openApi); + + $this->assertArrayHasKey('$ref', $array['components']['responses']['401']); + $this->assertSame('#/components/responses/401', $array['components']['responses']['401']['$ref']); + $this->assertArrayNotHasKey('ref', $array['components']['responses']['401']); + } + public function testNormalize(): void { $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); diff --git a/src/Symfony/Bundle/Resources/config/openapi.php b/src/Symfony/Bundle/Resources/config/openapi.php index b68eb55ed4b..8e51f6418a4 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.php +++ b/src/Symfony/Bundle/Resources/config/openapi.php @@ -30,7 +30,7 @@ $services = $container->services(); $services->set('api_platform.openapi.normalizer', OpenApiNormalizer::class) - ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, null)->arg(1, null)->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) + ->args([inline_service(Serializer::class)->arg(0, [inline_service(ObjectNormalizer::class)->arg(0, service('serializer.mapping.class_metadata_factory'))->arg(1, service('serializer.name_converter.metadata_aware'))->arg(2, service('api_platform.property_accessor'))->arg(3, service('api_platform.property_info'))])->arg(1, [service('serializer.encoder.json')])]) ->tag('serializer.normalizer', ['priority' => -795]); $services->alias(OpenApiNormalizer::class, 'api_platform.openapi.normalizer'); diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue8143/ReferenceResponse.php b/tests/Fixtures/TestBundle/ApiResource/Issue8143/ReferenceResponse.php new file mode 100644 index 00000000000..fa15781e86d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue8143/ReferenceResponse.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8143; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\Reference; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/issue8143_reference_response', + openapi: new Operation( + responses: [ + '401' => new Reference(ref: '#/components/responses/401'), + ], + ), + ), + ], +)] +final class ReferenceResponse +{ +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index a5af602e2c0..8cb8120b63d 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6151\OverrideOpenApiResponses; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7064\DeprecatedPutUser; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7064\DeprecatedPutUserAction; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue8143\ReferenceResponse; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ParentAttribute; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbstractDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; @@ -110,9 +111,25 @@ public static function getResources(): array ChildAttribute::class, DeprecatedPutUser::class, DeprecatedPutUserAction::class, + ReferenceResponse::class, ]; } + public function testOpenApiReferenceInResponsesUsesDollarRef(): void + { + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $responses = $json['paths']['/issue8143_reference_response']['post']['responses']; + $this->assertArrayHasKey('$ref', $responses['401']); + $this->assertSame('#/components/responses/401', $responses['401']['$ref']); + $this->assertArrayNotHasKey('ref', $responses['401']); + } + public function testDeprecatedPutDoesNotLeakIntoNestedResourceSchema(): void { $response = self::createClient()->request('GET', '/docs', [ From 46caf7db1484f7efe77acf0c1986b511ade31ca9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 16 Jun 2026 11:29:11 +0200 Subject: [PATCH 2/2] test(openapi): define referenced 401 response component for redocly lint The Issue8143 ReferenceResponse fixture emits a $ref to #/components/responses/401, but the generated document defined no such component, so the Redocly no-unresolved-refs rule failed. Register a test-only OpenApiFactory decorator that declares the reusable 401 response component so the $ref resolves, without weakening the assertion that $ref is emitted. --- .../Issue8143ResponseComponentFactory.php | 42 +++++++++++++++++++ tests/Fixtures/app/config/config_common.yml | 4 ++ 2 files changed, 46 insertions(+) create mode 100644 tests/Fixtures/TestBundle/OpenApi/Issue8143ResponseComponentFactory.php diff --git a/tests/Fixtures/TestBundle/OpenApi/Issue8143ResponseComponentFactory.php b/tests/Fixtures/TestBundle/OpenApi/Issue8143ResponseComponentFactory.php new file mode 100644 index 00000000000..c90959b822f --- /dev/null +++ b/tests/Fixtures/TestBundle/OpenApi/Issue8143ResponseComponentFactory.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\OpenApi; + +use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\OpenApi\Model\Response; +use ApiPlatform\OpenApi\OpenApi; + +/** + * Registers the reusable "401" response component referenced by the + * Issue8143\ReferenceResponse fixture so the emitted $ref resolves. + */ +final class Issue8143ResponseComponentFactory implements OpenApiFactoryInterface +{ + public function __construct(private readonly OpenApiFactoryInterface $decorated) + { + } + + public function __invoke(array $context = []): OpenApi + { + $openApi = ($this->decorated)($context); + $components = $openApi->getComponents(); + $responses = $components->getResponses() ?? new \ArrayObject(); + + if (!isset($responses['401'])) { + $responses['401'] = new Response('Unauthorized'); + } + + return $openApi->withComponents($components->withResponses($responses)); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 0e7cbcba1c3..d03cbd48b35 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -420,6 +420,10 @@ services: decorates: api_platform.metadata.resource.metadata_collection_factory arguments: ['@ApiPlatform\Tests\Fixtures\TestBundle\Metadata\ProviderResourceMetadatatCollectionFactory.inner'] + ApiPlatform\Tests\Fixtures\TestBundle\OpenApi\Issue8143ResponseComponentFactory: + decorates: api_platform.openapi.factory + arguments: ['@ApiPlatform\Tests\Fixtures\TestBundle\OpenApi\Issue8143ResponseComponentFactory.inner'] + app.related_dummy_resource.complex_sub_query_filter: class: ApiPlatform\Tests\Fixtures\TestBundle\Filter\ComplexSubQueryFilter arguments: ['@doctrine']