From 804da1be73991e7c5efffb495345499943802102 Mon Sep 17 00:00:00 2001 From: Romain Herault Date: Wed, 10 Jan 2024 12:03:41 +0100 Subject: [PATCH] fix(openapi): compatibility with OpenAPI 3.0 (#6065) fixes #5978 Co-authored-by: soyuka --- features/openapi/docs.feature | 41 ++++++++++++ .../Action/DocumentationAction.php | 20 +++++- src/Documentation/Action/EntrypointAction.php | 6 +- src/OpenApi/Command/OpenApiCommand.php | 4 +- .../Serializer/LegacyOpenApiNormalizer.php | 67 +++++++++++++++++++ .../Bundle/Resources/config/openapi.xml | 7 +- .../Action/DocumentationActionTest.php | 6 +- 7 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 src/OpenApi/Serializer/LegacyOpenApiNormalizer.php diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 5d55d1ab07e..9fcae506c6c 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -393,3 +393,44 @@ Feature: Documentation support "$ref": "#\/components\/schemas\/WrappedResponseEntity-read" } """ + + Scenario: Retrieve the OpenAPI documentation with 3.0 specification + Given I send a "GET" request to "/docs.jsonopenapi?spec_version=3.0.0" + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "openapi" should be equal to "3.0.0" + And the JSON node "components.schemas.DummyBoolean" should be equal to: + """ + { + "type": "object", + "description": "", + "deprecated": false, + "properties": { + "id": { + "readOnly": true, + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "isDummyBoolean": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "dummyBoolean": { + "readOnly": true, + "type": "boolean" + } + } + } + """ diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 533f84b3455..9ca5c2ddb94 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -21,6 +21,7 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\OpenApi; use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer; +use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; @@ -60,7 +61,11 @@ public function __invoke(Request $request = null) return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version); } - $context = ['api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY), 'base_url' => $request->getBaseUrl()]; + $context = [ + 'api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY), + 'base_url' => $request->getBaseUrl(), + 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), + ]; $request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context); $format = $this->getRequestFormat($request, $this->documentationFormats); @@ -78,7 +83,18 @@ private function getOpenApiDocumentation(array $context, string $format, Request { if ($this->provider && $this->processor) { $context['request'] = $request; - $operation = new Get(class: OpenApi::class, read: true, serialize: true, provider: fn () => $this->openApiFactory->__invoke($context), normalizationContext: [ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null], outputFormats: $this->documentationFormats); + $operation = new Get( + class: OpenApi::class, + read: true, + serialize: true, + provider: fn () => $this->openApiFactory->__invoke($context), + normalizationContext: [ + ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null, + LegacyOpenApiNormalizer::SPEC_VERSION => $context['spec_version'] ?? null, + ], + outputFormats: $this->documentationFormats + ); + if ('html' === $format) { $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); } diff --git a/src/Documentation/Action/EntrypointAction.php b/src/Documentation/Action/EntrypointAction.php index ce619e4f52e..d146720a3df 100644 --- a/src/Documentation/Action/EntrypointAction.php +++ b/src/Documentation/Action/EntrypointAction.php @@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; use Symfony\Component\HttpFoundation\Request; @@ -41,7 +42,10 @@ public function __construct( public function __invoke(Request $request) { static::$resourceNameCollection = $this->resourceNameCollectionFactory->create(); - $context = ['request' => $request]; + $context = [ + 'request' => $request, + 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), + ]; $request->attributes->set('_api_platform_disable_listeners', true); $operation = new Get(outputFormats: $this->documentationFormats, read: true, serialize: true, class: Entrypoint::class, provider: [self::class, 'provide']); $request->attributes->set('_api_operation', $operation); diff --git a/src/OpenApi/Command/OpenApiCommand.php b/src/OpenApi/Command/OpenApiCommand.php index 88d3c0c29f3..a7d1c43641b 100644 --- a/src/OpenApi/Command/OpenApiCommand.php +++ b/src/OpenApi/Command/OpenApiCommand.php @@ -53,7 +53,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); - $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json', [ + 'spec_version' => $input->getOption('spec-version'), + ]); $content = $input->getOption('yaml') ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) : (json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) ?: ''); diff --git a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php new file mode 100644 index 00000000000..a7d7381fc12 --- /dev/null +++ b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php @@ -0,0 +1,67 @@ + + * + * 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\OpenApi\Serializer; + +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class LegacyOpenApiNormalizer implements NormalizerInterface +{ + public const SPEC_VERSION = 'spec_version'; + private array $defaultContext = [ + self::SPEC_VERSION => '3.1.0', + ]; + + public function __construct(private readonly NormalizerInterface $decorated, $defaultContext = []) + { + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + } + + public function normalize(mixed $object, string $format = null, array $context = []): array + { + $openapi = $this->decorated->normalize($object, $format, $context); + + if ('3.0.0' !== ($context['spec_version'] ?? null)) { + return $openapi; + } + + $schemas = &$openapi['components']['schemas']; + $openapi['openapi'] = '3.0.0'; + foreach ($openapi['components']['schemas'] as $name => $component) { + foreach ($component['properties'] ?? [] as $property => $value) { + if (\is_array($value['type'] ?? false)) { + foreach ($value['type'] as $type) { + $schemas[$name]['properties'][$property]['anyOf'][] = ['type' => $type]; + } + unset($schemas[$name]['properties'][$property]['type']); + } + unset($schemas[$name]['properties'][$property]['owl:maxCardinality']); + } + } + + return $openapi; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format, $context); + } + + public function getSupportedTypes($format): array + { + return $this->decorated->getSupportedTypes($format); + } +} diff --git a/src/Symfony/Bundle/Resources/config/openapi.xml b/src/Symfony/Bundle/Resources/config/openapi.xml index 358c48fe5c8..3c53659a5cb 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Symfony/Bundle/Resources/config/openapi.xml @@ -58,7 +58,12 @@ - + + + + + + diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index 260e200d677..4914c90cb06 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -52,9 +52,10 @@ public function testDocumentationAction(): void $requestProphecy->headers = $this->prophesize(ParameterBagInterface::class)->reveal(); $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->get('spec_version')->willReturn('3.1.0')->shouldBeCalledTimes(1); $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); $attributesProphecy->get('_format')->willReturn(null)->shouldBeCalledTimes(1); - $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => '3.1.0'])->shouldBeCalledTimes(1); $documentation = new DocumentationAction($this->prophesize(ResourceNameCollectionFactoryInterface::class)->reveal(), 'my api', '', '1.0.0', $openApiFactoryProphecy->reveal()); $this->assertInstanceOf(OpenApi::class, $documentation($requestProphecy->reveal())); @@ -75,8 +76,9 @@ public function testDocumentationActionWithoutOpenApiFactory(): void $requestProphecy->query = $queryProphecy->reveal(); $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->get('spec_version')->willReturn('3.1.0')->shouldBeCalledTimes(1); $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); - $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => '3.1.0'])->shouldBeCalledTimes(1); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies']))->shouldBeCalled();