Skip to content

Commit 804da1b

Browse files
Romaixnsoyuka
andauthored
fix(openapi): compatibility with OpenAPI 3.0 (api-platform#6065)
fixes api-platform#5978 Co-authored-by: soyuka <[email protected]>
1 parent af8726a commit 804da1b

File tree

7 files changed

+144
-7
lines changed

7 files changed

+144
-7
lines changed

features/openapi/docs.feature

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,44 @@ Feature: Documentation support
393393
"$ref": "#\/components\/schemas\/WrappedResponseEntity-read"
394394
}
395395
"""
396+
397+
Scenario: Retrieve the OpenAPI documentation with 3.0 specification
398+
Given I send a "GET" request to "/docs.jsonopenapi?spec_version=3.0.0"
399+
Then the response status code should be 200
400+
And the response should be in JSON
401+
And the JSON node "openapi" should be equal to "3.0.0"
402+
And the JSON node "components.schemas.DummyBoolean" should be equal to:
403+
"""
404+
{
405+
"type": "object",
406+
"description": "",
407+
"deprecated": false,
408+
"properties": {
409+
"id": {
410+
"readOnly": true,
411+
"anyOf": [
412+
{
413+
"type": "integer"
414+
},
415+
{
416+
"type": "null"
417+
}
418+
]
419+
},
420+
"isDummyBoolean": {
421+
"anyOf": [
422+
{
423+
"type": "boolean"
424+
},
425+
{
426+
"type": "null"
427+
}
428+
]
429+
},
430+
"dummyBoolean": {
431+
"readOnly": true,
432+
"type": "boolean"
433+
}
434+
}
435+
}
436+
"""

src/Documentation/Action/DocumentationAction.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
2222
use ApiPlatform\OpenApi\OpenApi;
2323
use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer;
24+
use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer;
2425
use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer;
2526
use ApiPlatform\State\ProcessorInterface;
2627
use ApiPlatform\State\ProviderInterface;
@@ -60,7 +61,11 @@ public function __invoke(Request $request = null)
6061
return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version);
6162
}
6263

63-
$context = ['api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY), 'base_url' => $request->getBaseUrl()];
64+
$context = [
65+
'api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY),
66+
'base_url' => $request->getBaseUrl(),
67+
'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION),
68+
];
6469
$request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context);
6570
$format = $this->getRequestFormat($request, $this->documentationFormats);
6671

@@ -78,7 +83,18 @@ private function getOpenApiDocumentation(array $context, string $format, Request
7883
{
7984
if ($this->provider && $this->processor) {
8085
$context['request'] = $request;
81-
$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);
86+
$operation = new Get(
87+
class: OpenApi::class,
88+
read: true,
89+
serialize: true,
90+
provider: fn () => $this->openApiFactory->__invoke($context),
91+
normalizationContext: [
92+
ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null,
93+
LegacyOpenApiNormalizer::SPEC_VERSION => $context['spec_version'] ?? null,
94+
],
95+
outputFormats: $this->documentationFormats
96+
);
97+
8298
if ('html' === $format) {
8399
$operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true);
84100
}

src/Documentation/Action/EntrypointAction.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Metadata\Get;
1818
use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
1919
use ApiPlatform\Metadata\Resource\ResourceNameCollection;
20+
use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer;
2021
use ApiPlatform\State\ProcessorInterface;
2122
use ApiPlatform\State\ProviderInterface;
2223
use Symfony\Component\HttpFoundation\Request;
@@ -41,7 +42,10 @@ public function __construct(
4142
public function __invoke(Request $request)
4243
{
4344
static::$resourceNameCollection = $this->resourceNameCollectionFactory->create();
44-
$context = ['request' => $request];
45+
$context = [
46+
'request' => $request,
47+
'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION),
48+
];
4549
$request->attributes->set('_api_platform_disable_listeners', true);
4650
$operation = new Get(outputFormats: $this->documentationFormats, read: true, serialize: true, class: Entrypoint::class, provider: [self::class, 'provide']);
4751
$request->attributes->set('_api_operation', $operation);

src/OpenApi/Command/OpenApiCommand.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5353
{
5454
$filesystem = new Filesystem();
5555
$io = new SymfonyStyle($input, $output);
56-
$data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json');
56+
$data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json', [
57+
'spec_version' => $input->getOption('spec-version'),
58+
]);
5759
$content = $input->getOption('yaml')
5860
? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK)
5961
: (json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) ?: '');
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\OpenApi\Serializer;
15+
16+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
17+
18+
final class LegacyOpenApiNormalizer implements NormalizerInterface
19+
{
20+
public const SPEC_VERSION = 'spec_version';
21+
private array $defaultContext = [
22+
self::SPEC_VERSION => '3.1.0',
23+
];
24+
25+
public function __construct(private readonly NormalizerInterface $decorated, $defaultContext = [])
26+
{
27+
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
28+
}
29+
30+
public function normalize(mixed $object, string $format = null, array $context = []): array
31+
{
32+
$openapi = $this->decorated->normalize($object, $format, $context);
33+
34+
if ('3.0.0' !== ($context['spec_version'] ?? null)) {
35+
return $openapi;
36+
}
37+
38+
$schemas = &$openapi['components']['schemas'];
39+
$openapi['openapi'] = '3.0.0';
40+
foreach ($openapi['components']['schemas'] as $name => $component) {
41+
foreach ($component['properties'] ?? [] as $property => $value) {
42+
if (\is_array($value['type'] ?? false)) {
43+
foreach ($value['type'] as $type) {
44+
$schemas[$name]['properties'][$property]['anyOf'][] = ['type' => $type];
45+
}
46+
unset($schemas[$name]['properties'][$property]['type']);
47+
}
48+
unset($schemas[$name]['properties'][$property]['owl:maxCardinality']);
49+
}
50+
}
51+
52+
return $openapi;
53+
}
54+
55+
/**
56+
* {@inheritdoc}
57+
*/
58+
public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
59+
{
60+
return $this->decorated->supportsNormalization($data, $format, $context);
61+
}
62+
63+
public function getSupportedTypes($format): array
64+
{
65+
return $this->decorated->getSupportedTypes($format);
66+
}
67+
}

src/Symfony/Bundle/Resources/config/openapi.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@
5858

5959
<service id="api_platform.openapi.normalizer.api_gateway" class="ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer" public="false" decorates="api_platform.openapi.normalizer" decoration-priority="-1">
6060
<argument type="service" id="api_platform.openapi.normalizer.api_gateway.inner" />
61-
<tag name="serializer.normalizer" priority="-780" />
61+
<tag name="serializer.normalizer" />
62+
</service>
63+
64+
<service id="api_platform.openapi.normalizer.legacy" class="ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer" public="false" decorates="api_platform.openapi.normalizer.api_gateway" decoration-priority="-2">
65+
<argument type="service" id="api_platform.openapi.normalizer.legacy.inner" />
66+
<tag name="serializer.normalizer" />
6267
</service>
6368
<service id="ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface" alias="api_platform.openapi.factory" />
6469

tests/Documentation/Action/DocumentationActionTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ public function testDocumentationAction(): void
5252
$requestProphecy->headers = $this->prophesize(ParameterBagInterface::class)->reveal();
5353
$requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1);
5454
$queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1);
55+
$queryProphecy->get('spec_version')->willReturn('3.1.0')->shouldBeCalledTimes(1);
5556
$attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1);
5657
$attributesProphecy->get('_format')->willReturn(null)->shouldBeCalledTimes(1);
57-
$attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true])->shouldBeCalledTimes(1);
58+
$attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => '3.1.0'])->shouldBeCalledTimes(1);
5859

5960
$documentation = new DocumentationAction($this->prophesize(ResourceNameCollectionFactoryInterface::class)->reveal(), 'my api', '', '1.0.0', $openApiFactoryProphecy->reveal());
6061
$this->assertInstanceOf(OpenApi::class, $documentation($requestProphecy->reveal()));
@@ -75,8 +76,9 @@ public function testDocumentationActionWithoutOpenApiFactory(): void
7576
$requestProphecy->query = $queryProphecy->reveal();
7677
$requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1);
7778
$queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1);
79+
$queryProphecy->get('spec_version')->willReturn('3.1.0')->shouldBeCalledTimes(1);
7880
$attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1);
79-
$attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true])->shouldBeCalledTimes(1);
81+
$attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => '3.1.0'])->shouldBeCalledTimes(1);
8082
$resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class);
8183
$resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies']))->shouldBeCalled();
8284

0 commit comments

Comments
 (0)