Skip to content

Commit b5372dd

Browse files
authoredFeb 6, 2025··
feat(openapi): filter x-apiplatform-tags to produce different openapi specifications (#6945)
1 parent 4875510 commit b5372dd

File tree

15 files changed

+252
-10
lines changed

15 files changed

+252
-10
lines changed
 

Diff for: ‎src/OpenApi/Attributes/Webhook.php

+3
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313

1414
namespace ApiPlatform\OpenApi\Attributes;
1515

16+
use ApiPlatform\OpenApi\Model\ExtensionTrait;
1617
use ApiPlatform\OpenApi\Model\PathItem;
1718

1819
class Webhook
1920
{
21+
use ExtensionTrait;
22+
2023
public function __construct(
2124
protected string $name,
2225
protected ?PathItem $pathItem = null,

Diff for: ‎src/OpenApi/Command/OpenApiCommand.php

+7-4
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ protected function configure(): void
4343
->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML')
4444
->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file')
4545
->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'Open API version to use (2 or 3) (2 is deprecated)', '3')
46-
->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode');
46+
->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode')
47+
->addOption('filter-tags', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter only matching x-apiplatform-tag operations', null);
4748
}
4849

4950
/**
@@ -53,9 +54,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5354
{
5455
$filesystem = new Filesystem();
5556
$io = new SymfonyStyle($input, $output);
56-
$data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json', [
57-
'spec_version' => $input->getOption('spec-version'),
58-
]);
57+
$data = $this->normalizer->normalize(
58+
$this->openApiFactory->__invoke(['filter_tags' => $input->getOption('filter-tags')]),
59+
'json',
60+
['spec_version' => $input->getOption('spec-version')]
61+
);
5962

6063
if ($input->getOption('yaml') && !class_exists(Yaml::class)) {
6164
$output->writeln('The "symfony/yaml" component is not installed.');

Diff for: ‎src/OpenApi/Factory/OpenApiFactory.php

+31-3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use ApiPlatform\OpenApi\Model\Response;
5050
use ApiPlatform\OpenApi\Model\SecurityScheme;
5151
use ApiPlatform\OpenApi\Model\Server;
52+
use ApiPlatform\OpenApi\Model\Tag;
5253
use ApiPlatform\OpenApi\OpenApi;
5354
use ApiPlatform\OpenApi\Options;
5455
use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait;
@@ -69,6 +70,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface
6970
use TypeFactoryTrait;
7071

7172
public const BASE_URL = 'base_url';
73+
public const API_PLATFORM_TAG = 'x-apiplatform-tag';
7274
public const OVERRIDE_OPENAPI_RESPONSES = 'open_api_override_responses';
7375
private readonly Options $openApiOptions;
7476
private readonly PaginationOptions $paginationOptions;
@@ -101,6 +103,10 @@ public function __construct(
101103

102104
/**
103105
* {@inheritdoc}
106+
*
107+
* You can filter openapi operations with the `x-apiplatform-tag` on an OpenApi Operation using the `filter_tags`.
108+
*
109+
* @param array{base_url?: string, filter_tags?: string[]}&array<string, mixed> $context
104110
*/
105111
public function __invoke(array $context = []): OpenApi
106112
{
@@ -112,12 +118,13 @@ public function __invoke(array $context = []): OpenApi
112118
$paths = new Paths();
113119
$schemas = new \ArrayObject();
114120
$webhooks = new \ArrayObject();
121+
$tags = [];
115122

116123
foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) {
117124
$resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
118125

119126
foreach ($resourceMetadataCollection as $resourceMetadata) {
120-
$this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $context);
127+
$this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context);
121128
}
122129
}
123130

@@ -128,6 +135,8 @@ public function __invoke(array $context = []): OpenApi
128135
$securityRequirements[] = [$key => []];
129136
}
130137

138+
$globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: [];
139+
131140
return new OpenApi(
132141
$info,
133142
$servers,
@@ -142,19 +151,25 @@ public function __invoke(array $context = []): OpenApi
142151
new \ArrayObject($securitySchemes)
143152
),
144153
$securityRequirements,
145-
[],
154+
$globalTags,
146155
null,
147156
null,
148157
$webhooks
149158
);
150159
}
151160

152-
private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks, array $context = []): void
161+
private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks, array &$tags, array $context = []): void
153162
{
154163
if (0 === $resource->getOperations()->count()) {
155164
return;
156165
}
157166

167+
// This filters on our extension x-apiplatform-tag as the openapi operation tag is used for ordering operations
168+
$filteredTags = $context['filter_tags'] ?? [];
169+
if (!\is_array($filteredTags)) {
170+
$filteredTags = [$filteredTags];
171+
}
172+
158173
foreach ($resource->getOperations() as $operationName => $operation) {
159174
$resourceShortName = $operation->getShortName();
160175
// No path to return
@@ -169,6 +184,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
169184
continue;
170185
}
171186

187+
$operationTag = ($openapiAttribute?->getExtensionProperties()[self::API_PLATFORM_TAG] ?? []);
188+
if (!\is_array($operationTag)) {
189+
$operationTag = [$operationTag];
190+
}
191+
192+
if ($filteredTags && $filteredTags !== array_intersect($filteredTags, $operationTag)) {
193+
continue;
194+
}
195+
172196
$resourceClass = $operation->getClass() ?? $resource->getClass();
173197
$routeName = $operation->getRouteName() ?? $operation->getName();
174198

@@ -217,6 +241,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
217241
extensionProperties: $openapiOperation->getExtensionProperties(),
218242
);
219243

244+
foreach ($openapiOperation->getTags() as $v) {
245+
$tags[$v] = new Tag(name: $v, description: $resource->getDescription());
246+
}
247+
220248
[$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation);
221249

222250
if ($path) {

Diff for: ‎src/OpenApi/Factory/OpenApiFactoryInterface.php

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface OpenApiFactoryInterface
1919
{
2020
/**
2121
* Creates an OpenApi class.
22+
*
23+
* @param array<string, mixed> $context
2224
*/
2325
public function __invoke(array $context = []): OpenApi;
2426
}

Diff for: ‎src/OpenApi/Model/Tag.php

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Model;
15+
16+
final class Tag
17+
{
18+
use ExtensionTrait;
19+
20+
public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null)
21+
{
22+
}
23+
24+
public function getName(): string
25+
{
26+
return $this->name;
27+
}
28+
29+
public function getDescription(): ?string
30+
{
31+
return $this->description;
32+
}
33+
34+
public function withName(string $name): self
35+
{
36+
$clone = clone $this;
37+
$clone->name = $name;
38+
39+
return $clone;
40+
}
41+
42+
public function withDescription(string $description): self
43+
{
44+
$clone = clone $this;
45+
$clone->description = $description;
46+
47+
return $clone;
48+
}
49+
50+
public function getExternalDocs(): string
51+
{
52+
return $this->externalDocs;
53+
}
54+
55+
public function withExternalDocs(string $externalDocs): self
56+
{
57+
$clone = clone $this;
58+
$clone->externalDocs = $externalDocs;
59+
60+
return $clone;
61+
}
62+
}

Diff for: ‎src/OpenApi/Options.php

+14
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@
1313

1414
namespace ApiPlatform\OpenApi;
1515

16+
use ApiPlatform\OpenApi\Model\Tag;
17+
1618
final readonly class Options
1719
{
20+
/**
21+
* @param Tag[] $tags
22+
*/
1823
public function __construct(
1924
private string $title,
2025
private string $description = '',
@@ -36,6 +41,7 @@ public function __construct(
3641
private bool $overrideResponses = true,
3742
private bool $persistAuthorization = false,
3843
private array $httpAuth = [],
44+
private array $tags = [],
3945
) {
4046
}
4147

@@ -138,4 +144,12 @@ public function hasPersistAuthorization(): bool
138144
{
139145
return $this->persistAuthorization;
140146
}
147+
148+
/**
149+
* @return Tag[]
150+
*/
151+
public function getTags(): array
152+
{
153+
return $this->tags;
154+
}
141155
}

Diff for: ‎src/Symfony/Action/DocumentationAction.php

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ public function __invoke(?Request $request = null)
6565
'api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY),
6666
'base_url' => $request->getBaseUrl(),
6767
'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION),
68+
'filter_tags' => $request->query->all('filter_tags'),
6869
];
6970
$request->attributes->set('_api_normalization_context', $request->attributes->get('_api_normalization_context', []) + $context);
7071
$this->addRequestFormats($request, $this->documentationFormats);

Diff for: ‎src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

+8
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use ApiPlatform\Metadata\FilterInterface;
3535
use ApiPlatform\Metadata\UriVariableTransformerInterface;
3636
use ApiPlatform\Metadata\UrlGeneratorInterface;
37+
use ApiPlatform\OpenApi\Model\Tag;
3738
use ApiPlatform\RamseyUuid\Serializer\UuidDenormalizer;
3839
use ApiPlatform\State\ApiResource\Error;
3940
use ApiPlatform\State\ParameterProviderInterface;
@@ -853,6 +854,13 @@ private function registerOpenApiConfiguration(ContainerBuilder $container, array
853854
$container->setParameter('api_platform.openapi.license.url', $config['openapi']['license']['url']);
854855
$container->setParameter('api_platform.openapi.overrideResponses', $config['openapi']['overrideResponses']);
855856

857+
$tags = [];
858+
foreach ($config['openapi']['tags'] as $tag) {
859+
$tags[] = new Tag($tag['name'], $tag['description'] ?? null);
860+
}
861+
862+
$container->setParameter('api_platform.openapi.tags', $tags);
863+
856864
$loader->load('json_schema.xml');
857865
}
858866

Diff for: ‎src/Symfony/Bundle/DependencyInjection/Configuration.php

+10
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
1919
use ApiPlatform\Metadata\Post;
2020
use ApiPlatform\Metadata\Put;
21+
use ApiPlatform\OpenApi\Model\Tag;
2122
use ApiPlatform\Symfony\Controller\MainController;
2223
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
2324
use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle;
@@ -503,6 +504,15 @@ private function addOpenApiSection(ArrayNodeDefinition $rootNode): void
503504
->end()
504505
->end()
505506
->scalarNode('termsOfService')->defaultNull()->info('A URL to the Terms of Service for the API. MUST be in the format of a URL.')->end()
507+
->arrayNode('tags')
508+
->info('Global OpenApi tags overriding the default computed tags if specified.')
509+
->prototype('array')
510+
->children()
511+
->scalarNode('name')->isRequired()->end()
512+
->scalarNode('description')->defaultNull()->end()
513+
->end()
514+
->end()
515+
->end()
506516
->arrayNode('license')
507517
->addDefaultsIfNotSet()
508518
->children()

Diff for: ‎src/Symfony/Bundle/Resources/config/openapi.xml

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
<argument>%api_platform.openapi.overrideResponses%</argument>
6262
<argument>%api_platform.swagger.persist_authorization%</argument>
6363
<argument>%api_platform.swagger.http_auth%</argument>
64+
<argument>%api_platform.openapi.tags%</argument>
6465
</service>
6566
<service id="ApiPlatform\OpenApi\Options" alias="api_platform.openapi.options" />
6667

Diff for: ‎tests/Fixtures/TestBundle/ApiResource/Crud.php

+16-1
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,23 @@
1414
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
1515

1616
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Delete;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\GetCollection;
20+
use ApiPlatform\Metadata\Patch;
21+
use ApiPlatform\Metadata\Post;
22+
use ApiPlatform\OpenApi\Factory\OpenApiFactory;
23+
use ApiPlatform\OpenApi\Model\Operation;
1724

18-
#[ApiResource]
25+
#[ApiResource(
26+
operations: [
27+
new Get(),
28+
new GetCollection(openapi: new Operation(extensionProperties: [OpenApiFactory::API_PLATFORM_TAG => ['internal', 'anotherone']])),
29+
new Post(openapi: new Operation(extensionProperties: [OpenApiFactory::API_PLATFORM_TAG => 'internal'])),
30+
new Patch(),
31+
new Delete(),
32+
]
33+
)]
1934
class Crud
2035
{
2136
public string $id;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Get;
18+
use ApiPlatform\OpenApi\Factory\OpenApiFactory;
19+
use ApiPlatform\OpenApi\Model\Operation;
20+
21+
#[ApiResource(
22+
description: 'Something nice',
23+
operations: [
24+
new Get(openapi: new Operation(extensionProperties: [OpenApiFactory::API_PLATFORM_TAG => ['anotherone']])),
25+
]
26+
)]
27+
class CrudOpenApiApiPlatformTag
28+
{
29+
public string $id;
30+
}

Diff for: ‎tests/Functional/OpenApiTest.php

+31-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
1717
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Crud;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\CrudOpenApiApiPlatformTag;
1819
use ApiPlatform\Tests\SetupClassResourcesTrait;
1920

2021
class OpenApiTest extends ApiTestCase
@@ -26,12 +27,11 @@ class OpenApiTest extends ApiTestCase
2627
*/
2728
public static function getResources(): array
2829
{
29-
return [Crud::class];
30+
return [Crud::class, CrudOpenApiApiPlatformTag::class];
3031
}
3132

3233
public function testErrorsAreDocumented(): void
3334
{
34-
$container = static::getContainer();
3535
$response = self::createClient()->request('GET', '/docs', [
3636
'headers' => ['Accept' => 'application/vnd.openapi+json'],
3737
]);
@@ -67,4 +67,33 @@ public function testErrorsAreDocumented(): void
6767
$this->assertArrayHasKey($key, $res['components']['schemas']['Error.jsonapi-jsonapi']['properties']['errors']['properties']);
6868
}
6969
}
70+
71+
public function testFilterExtensionTags(): void
72+
{
73+
$response = self::createClient()->request('GET', '/docs?filter_tags[]=internal', [
74+
'headers' => ['Accept' => 'application/vnd.openapi+json'],
75+
]);
76+
77+
$res = $response->toArray();
78+
$this->assertArrayNotHasKey('CrudOpenApiApiPlatformTag', $res['components']['schemas']);
79+
$this->assertArrayNotHasKey('/cruds/{id}', $res['paths']);
80+
$this->assertArrayHasKey('/cruds', $res['paths']);
81+
$this->assertArrayHasKey('post', $res['paths']['/cruds']);
82+
$this->assertArrayHasKey('get', $res['paths']['/cruds']);
83+
$this->assertEquals([['name' => 'Crud']], $res['tags']);
84+
85+
$response = self::createClient()->request('GET', '/docs?filter_tags[]=anotherone', [
86+
'headers' => ['Accept' => 'application/vnd.openapi+json'],
87+
]);
88+
89+
$res = $response->toArray();
90+
$this->assertArrayHasKey('CrudOpenApiApiPlatformTag', $res['components']['schemas']);
91+
$this->assertArrayHasKey('Crud', $res['components']['schemas']);
92+
$this->assertArrayNotHasKey('/cruds/{id}', $res['paths']);
93+
$this->assertArrayHasKey('/cruds', $res['paths']);
94+
$this->assertArrayNotHasKey('post', $res['paths']['/cruds']);
95+
$this->assertArrayHasKey('get', $res['paths']['/cruds']);
96+
$this->assertArrayHasKey('/crud_open_api_api_platform_tags/{id}', $res['paths']);
97+
$this->assertEquals([['name' => 'Crud'], ['name' => 'CrudOpenApiApiPlatformTag', 'description' => 'Something nice']], $res['tags']);
98+
}
7099
}

Diff for: ‎tests/OpenApi/Command/OpenApiCommandTest.php

+16
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\DummyCar;
1717
use ApiPlatform\OpenApi\OpenApi;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Crud;
1819
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6317\Issue6317;
1920
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5625\Currency;
2021
use ApiPlatform\Tests\SetupClassResourcesTrait;
@@ -52,6 +53,7 @@ public static function getResources(): array
5253
DummyCar::class,
5354
Issue6317::class,
5455
Currency::class,
56+
Crud::class,
5557
];
5658
}
5759

@@ -161,4 +163,18 @@ private function assertYaml(string $data): void
161163
}
162164
$this->addToAssertionCount(1);
163165
}
166+
167+
public function testFilterXApiPlatformTag(): void
168+
{
169+
$this->tester->run(['command' => 'api:openapi:export', '--filter-tags' => 'anotherone']);
170+
$result = $this->tester->getDisplay();
171+
$res = json_decode($result, true, 512, \JSON_THROW_ON_ERROR);
172+
173+
$this->assertArrayHasKey('Crud', $res['components']['schemas']);
174+
$this->assertArrayNotHasKey('/cruds/{id}', $res['paths']);
175+
$this->assertArrayHasKey('/cruds', $res['paths']);
176+
$this->assertArrayNotHasKey('post', $res['paths']['/cruds']);
177+
$this->assertArrayHasKey('get', $res['paths']['/cruds']);
178+
$this->assertEquals([['name' => 'Crud']], $res['tags']);
179+
}
164180
}

Diff for: ‎tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php

+20
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm
219219
],
220220
'swagger_ui_extra_configuration' => [],
221221
'overrideResponses' => true,
222+
'tags' => [],
222223
],
223224
'maker' => [
224225
'enabled' => true,
@@ -415,4 +416,23 @@ public function testHttpAuth(): void
415416
$this->assertArrayHasKey('http_auth', $config['swagger']);
416417
$this->assertSame(['scheme' => 'bearer', 'bearerFormat' => 'JWT'], $config['swagger']['http_auth']['PAT']);
417418
}
419+
420+
/**
421+
* Test openapi tags.
422+
*/
423+
public function testOpenApiTags(): void
424+
{
425+
$config = $this->processor->processConfiguration($this->configuration, [
426+
'api_platform' => [
427+
'openapi' => [
428+
'tags' => [
429+
['name' => 'test', 'description' => 'test2'],
430+
['name' => 'test3'],
431+
],
432+
],
433+
],
434+
]);
435+
436+
$this->assertEquals(['name' => 'test3', 'description' => null], $config['openapi']['tags'][1]);
437+
}
418438
}

0 commit comments

Comments
 (0)
Please sign in to comment.