Passed
Pull Request — master (#2983)
by Kévin
04:30
created

src/Swagger/Serializer/DocumentationNormalizer.php (2 issues)

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\Core\Swagger\Serializer;
15
16
use ApiPlatform\Core\Api\FilterCollection;
17
use ApiPlatform\Core\Api\FilterLocatorTrait;
18
use ApiPlatform\Core\Api\FormatsProviderInterface;
19
use ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface;
20
use ApiPlatform\Core\Api\OperationMethodResolverInterface;
21
use ApiPlatform\Core\Api\OperationType;
22
use ApiPlatform\Core\Api\ResourceClassResolverInterface;
23
use ApiPlatform\Core\Api\UrlGeneratorInterface;
24
use ApiPlatform\Core\Documentation\Documentation;
25
use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
26
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
27
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
28
use ApiPlatform\Core\Metadata\Property\PropertyMetadata;
29
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
30
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
31
use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface;
32
use ApiPlatform\Core\PathResolver\OperationPathResolverInterface;
33
use Psr\Container\ContainerInterface;
34
use Symfony\Component\PropertyInfo\Type;
35
use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
36
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
37
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
38
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
39
40
/**
41
 * Generates an OpenAPI specification (formerly known as Swagger). OpenAPI v2 and v3 are supported.
42
 *
43
 * @author Amrouche Hamza <[email protected]>
44
 * @author Teoh Han Hui <[email protected]>
45
 * @author Kévin Dunglas <[email protected]>
46
 * @author Anthony GRASSIOT <[email protected]>
47
 */
48
final class DocumentationNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
49
{
50
    use FilterLocatorTrait;
51
52
    public const FORMAT = 'json';
53
    public const BASE_URL = 'base_url';
54
    public const SPEC_VERSION = 'spec_version';
55
    public const OPENAPI_VERSION = '3.0.2';
56
    public const SWAGGER_DEFINITION_NAME = 'swagger_definition_name';
57
    public const SWAGGER_VERSION = '2.0';
58
59
    /**
60
     * @deprecated
61
     */
62
    public const ATTRIBUTE_NAME = 'swagger_context';
63
64
    private $resourceMetadataFactory;
65
    private $propertyNameCollectionFactory;
66
    private $propertyMetadataFactory;
67
    private $resourceClassResolver;
68
    private $operationMethodResolver;
69
    private $operationPathResolver;
70
    private $nameConverter;
71
    private $oauthEnabled;
72
    private $oauthType;
73
    private $oauthFlow;
74
    private $oauthTokenUrl;
75
    private $oauthAuthorizationUrl;
76
    private $oauthScopes;
77
    private $apiKeys;
78
    private $subresourceOperationFactory;
79
    private $paginationEnabled;
80
    private $paginationPageParameterName;
81
    private $clientItemsPerPage;
82
    private $itemsPerPageParameterName;
83
    private $paginationClientEnabled;
84
    private $paginationClientEnabledParameterName;
85
    private $formats;
86
    private $formatsProvider;
87
    private $defaultContext = [
88
        self::BASE_URL => '/',
89
        self::SPEC_VERSION => 2,
90
        ApiGatewayNormalizer::API_GATEWAY => false,
91
    ];
92
93
    /**
94
     * @param ContainerInterface|FilterCollection|null     $filterLocator The new filter locator or the deprecated filter collection
95
     * @param array|OperationAwareFormatsProviderInterface $formats
96
     */
97
    public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, OperationMethodResolverInterface $operationMethodResolver = null, OperationPathResolverInterface $operationPathResolver, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, bool $oauthEnabled = false, string $oauthType = '', string $oauthFlow = '', string $oauthTokenUrl = '', string $oauthAuthorizationUrl = '', array $oauthScopes = [], array $apiKeys = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', $formats = [], bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination', array $defaultContext = [])
98
    {
99
        if ($urlGenerator) {
100
            @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.1 and will be removed in 3.0.', UrlGeneratorInterface::class, __METHOD__), E_USER_DEPRECATED);
101
        }
102
        if ($operationMethodResolver) {
103
            @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.5 and will be removed in 3.0.', OperationMethodResolverInterface::class, __METHOD__), E_USER_DEPRECATED);
104
        }
105
106
        if ($formats instanceof FormatsProviderInterface) {
107
            @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.5 and will be removed in 3.0, pass an array instead.', FormatsProviderInterface::class, __METHOD__), E_USER_DEPRECATED);
108
109
            $this->formatsProvider = $formats;
110
        } else {
111
            $this->formats = $formats;
112
        }
113
114
        $this->setFilterLocator($filterLocator, true);
115
116
        $this->resourceMetadataFactory = $resourceMetadataFactory;
117
        $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
118
        $this->propertyMetadataFactory = $propertyMetadataFactory;
119
        $this->resourceClassResolver = $resourceClassResolver;
120
        $this->operationMethodResolver = $operationMethodResolver;
121
        $this->operationPathResolver = $operationPathResolver;
122
        $this->nameConverter = $nameConverter;
123
        $this->oauthEnabled = $oauthEnabled;
124
        $this->oauthType = $oauthType;
125
        $this->oauthFlow = $oauthFlow;
126
        $this->oauthTokenUrl = $oauthTokenUrl;
127
        $this->oauthAuthorizationUrl = $oauthAuthorizationUrl;
128
        $this->oauthScopes = $oauthScopes;
129
        $this->subresourceOperationFactory = $subresourceOperationFactory;
130
        $this->paginationEnabled = $paginationEnabled;
131
        $this->paginationPageParameterName = $paginationPageParameterName;
132
        $this->apiKeys = $apiKeys;
133
        $this->clientItemsPerPage = $clientItemsPerPage;
134
        $this->itemsPerPageParameterName = $itemsPerPageParameterName;
135
        $this->paginationClientEnabled = $paginationClientEnabled;
136
        $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName;
137
138
        $this->defaultContext = array_merge($this->defaultContext, $defaultContext);
139
    }
140
141
    /**
142
     * {@inheritdoc}
143
     */
144
    public function normalize($object, $format = null, array $context = [])
145
    {
146
        $v3 = 3 === ($context['spec_version'] ?? $this->defaultContext['spec_version']) && !($context['api_gateway'] ?? $this->defaultContext['api_gateway']);
147
148
        $definitions = new \ArrayObject();
149
        $paths = new \ArrayObject();
150
        $links = new \ArrayObject();
151
152
        foreach ($object->getResourceNameCollection() as $resourceClass) {
153
            $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
154
            $resourceShortName = $resourceMetadata->getShortName();
155
156
            // Items needs to be parsed first to be able to reference the lines from the collection operation
157
            $this->addPaths($v3, $paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, OperationType::ITEM, $links);
158
            $this->addPaths($v3, $paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, OperationType::COLLECTION, $links);
159
160
            if (null === $this->subresourceOperationFactory) {
161
                continue;
162
            }
163
164
            foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $subresourceOperation) {
165
                $paths[$this->getPath($subresourceOperation['shortNames'][0], $subresourceOperation['route_name'], $subresourceOperation, OperationType::SUBRESOURCE)] = $this->addSubresourceOperation($v3, $subresourceOperation, $definitions, $operationId, $resourceMetadata);
166
            }
167
        }
168
169
        $definitions->ksort();
170
        $paths->ksort();
171
172
        return $this->computeDoc($v3, $object, $definitions, $paths, $context);
173
    }
174
175
    /**
176
     * Updates the list of entries in the paths collection.
177
     */
178
    private function addPaths(bool $v3, \ArrayObject $paths, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, ResourceMetadata $resourceMetadata, string $operationType, \ArrayObject $links)
179
    {
180
        if (null === $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) {
181
            return;
182
        }
183
184
        foreach ($operations as $operationName => $operation) {
185
            $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType);
186
            if ($this->operationMethodResolver) {
187
                $method = OperationType::ITEM === $operationType ? $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName) : $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName);
188
            } else {
189
                $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET');
190
            }
191
192
            $paths[$path][strtolower($method)] = $this->getPathOperation($v3, $operationName, $operation, $method, $operationType, $resourceClass, $resourceMetadata, $definitions, $links);
193
        }
194
    }
195
196
    /**
197
     * Gets the path for an operation.
198
     *
199
     * If the path ends with the optional _format parameter, it is removed
200
     * as optional path parameters are not yet supported.
201
     *
202
     * @see https://github.com/OAI/OpenAPI-Specification/issues/93
203
     */
204
    private function getPath(string $resourceShortName, string $operationName, array $operation, string $operationType): string
205
    {
206
        $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
207
        if ('.{_format}' === substr($path, -10)) {
208
            $path = substr($path, 0, -10);
209
        }
210
211
        return $path;
212
    }
213
214
    /**
215
     * Gets a path Operation Object.
216
     *
217
     * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operation-object
218
     */
219
    private function getPathOperation(bool $v3, string $operationName, array $operation, string $method, string $operationType, string $resourceClass, ResourceMetadata $resourceMetadata, \ArrayObject $definitions, \ArrayObject $links): \ArrayObject
220
    {
221
        $pathOperation = new \ArrayObject($operation[$v3 ? 'openapi_context' : 'swagger_context'] ?? []);
222
        $resourceShortName = $resourceMetadata->getShortName();
223
        $pathOperation['tags'] ?? $pathOperation['tags'] = [$resourceShortName];
224
        $pathOperation['operationId'] ?? $pathOperation['operationId'] = lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType);
225
        if ($v3 && 'GET' === $method && OperationType::ITEM === $operationType && $link = $this->getLinkObject($resourceClass, $pathOperation['operationId'], $this->getPath($resourceShortName, $operationName, $operation, $operationType))) {
226
            $links[$pathOperation['operationId']] = $link;
227
        }
228
        if ($resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', null, true)) {
229
            $pathOperation['deprecated'] = true;
230
        }
231
232
        if (null === $this->formatsProvider) {
233
            $requestFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_formats', [], true);
234
            $responseFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_formats', [], true);
235
        } else {
236
            $requestFormats = $responseFormats = $this->formatsProvider->getFormatsFromOperation($resourceClass, $operationName, $operationType);
237
        }
238
239
        $requestMimeTypes = $this->extractMimeTypes($requestFormats);
240
        $responseMimeTypes = $this->extractMimeTypes($responseFormats);
241
        switch ($method) {
242
            case 'GET':
243
                return $this->updateGetOperation($v3, $pathOperation, $responseMimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions);
244
            case 'POST':
245
                return $this->updatePostOperation($v3, $pathOperation, $requestMimeTypes, $responseMimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions, $links);
246
            case 'PATCH':
247
                $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Updates the %s resource.', $resourceShortName);
248
            // no break
249
            case 'PUT':
250
                return $this->updatePutOperation($v3, $pathOperation, $requestMimeTypes, $responseMimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions);
251
            case 'DELETE':
252
                return $this->updateDeleteOperation($v3, $pathOperation, $resourceShortName, $operationType, $operationName, $resourceMetadata);
253
        }
254
255
        return $pathOperation;
256
    }
257
258
    private function updateGetOperation(bool $v3, \ArrayObject $pathOperation, array $mimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions): \ArrayObject
259
    {
260
        $serializerContext = $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName);
261
262
        $responseDefinitionKey = false;
263
        $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => $resourceClass], true);
264
        if (null !== $outputClass = $outputMetadata['class'] ?? null) {
265
            $responseDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $outputClass, $serializerContext);
266
        }
267
268
        $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200');
269
270
        if (!$v3) {
271
            $pathOperation['produces'] ?? $pathOperation['produces'] = $mimeTypes;
272
        }
273
274
        if (OperationType::COLLECTION === $operationType) {
275
            $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Retrieves the collection of %s resources.', $resourceShortName);
276
277
            $successResponse = ['description' => sprintf('%s collection response', $resourceShortName)];
278
279
            if ($responseDefinitionKey) {
280
                if ($v3) {
281
                    $successResponse['content'] = array_fill_keys($mimeTypes, [
282
                        'schema' => [
283
                            'type' => 'array',
284
                            'items' => ['$ref' => sprintf('#/components/schemas/%s', $responseDefinitionKey)],
285
                        ],
286
                    ]);
287
                } else {
288
                    $successResponse['schema'] = [
289
                        'type' => 'array',
290
                        'items' => ['$ref' => sprintf('#/definitions/%s', $responseDefinitionKey)],
291
                    ];
292
                }
293
            }
294
295
            $pathOperation['responses'] ?? $pathOperation['responses'] = [$successStatus => $successResponse];
296
            $pathOperation['parameters'] ?? $pathOperation['parameters'] = $this->getFiltersParameters($v3, $resourceClass, $operationName, $resourceMetadata, $definitions, $serializerContext);
297
298
            $this->addPaginationParameters($v3, $resourceMetadata, $operationName, $pathOperation);
299
300
            return $pathOperation;
301
        }
302
303
        $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Retrieves a %s resource.', $resourceShortName);
304
305
        $pathOperation = $this->addItemOperationParameters($v3, $pathOperation);
306
307
        $successResponse = ['description' => sprintf('%s resource response', $resourceShortName)];
308
        if ($responseDefinitionKey) {
309
            if ($v3) {
310
                $successResponse['content'] = array_fill_keys($mimeTypes, ['schema' => ['$ref' => sprintf('#/components/schemas/%s', $responseDefinitionKey)]]);
311
            } else {
312
                $successResponse['schema'] = ['$ref' => sprintf('#/definitions/%s', $responseDefinitionKey)];
313
            }
314
        }
315
316
        $pathOperation['responses'] ?? $pathOperation['responses'] = [
317
            $successStatus => $successResponse,
318
            '404' => ['description' => 'Resource not found'],
319
        ];
320
321
        return $pathOperation;
322
    }
323
324
    private function addPaginationParameters(bool $v3, ResourceMetadata $resourceMetadata, string $operationName, \ArrayObject $pathOperation)
325
    {
326
        if ($this->paginationEnabled && $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', true, true)) {
327
            $paginationParameter = [
328
                'name' => $this->paginationPageParameterName,
329
                'in' => 'query',
330
                'required' => false,
331
                'description' => 'The collection page number',
332
            ];
333
            $v3 ? $paginationParameter['schema'] = ['type' => 'integer'] : $paginationParameter['type'] = 'integer';
334
            $pathOperation['parameters'][] = $paginationParameter;
335
336
            if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->clientItemsPerPage, true)) {
337
                $itemPerPageParameter = [
338
                    'name' => $this->itemsPerPageParameterName,
339
                    'in' => 'query',
340
                    'required' => false,
341
                    'description' => 'The number of items per page',
342
                ];
343
                $v3 ? $itemPerPageParameter['schema'] = ['type' => 'integer'] : $itemPerPageParameter['type'] = 'integer';
344
345
                $pathOperation['parameters'][] = $itemPerPageParameter;
346
            }
347
        }
348
349
        if ($this->paginationEnabled && $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->paginationClientEnabled, true)) {
350
            $paginationEnabledParameter = [
351
                'name' => $this->paginationClientEnabledParameterName,
352
                'in' => 'query',
353
                'required' => false,
354
                'description' => 'Enable or disable pagination',
355
            ];
356
            $v3 ? $paginationEnabledParameter['schema'] = ['type' => 'boolean'] : $paginationEnabledParameter['type'] = 'boolean';
357
            $pathOperation['parameters'][] = $paginationEnabledParameter;
358
        }
359
    }
360
361
    /**
362
     * @throws ResourceClassNotFoundException
363
     */
364
    private function addSubresourceOperation(bool $v3, array $subresourceOperation, \ArrayObject $definitions, string $operationId, ResourceMetadata $resourceMetadata): \ArrayObject
365
    {
366
        $operationName = 'get'; // TODO: we might want to extract that at some point to also support other subresource operations
367
368
        $subResourceMetadata = $this->resourceMetadataFactory->create($subresourceOperation['resource_class']);
369
        $serializerContext = $this->getSerializerContext(OperationType::SUBRESOURCE, false, $subResourceMetadata, $operationName);
370
        $responseDefinitionKey = $this->getDefinition($v3, $definitions, $subResourceMetadata, $subresourceOperation['resource_class'], null, $serializerContext);
371
        $pathOperation = new \ArrayObject([]);
372
        $pathOperation['tags'] = $subresourceOperation['shortNames'];
373
        $pathOperation['operationId'] = $operationId;
374
        $pathOperation['summary'] = sprintf('Retrieves %s%s resource%s.', $subresourceOperation['collection'] ? 'the collection of ' : 'a ', $subresourceOperation['shortNames'][0], $subresourceOperation['collection'] ? 's' : '');
375
376
        if (null === $this->formatsProvider) {
377
            // TODO: Subresource operation metadata aren't available by default, for now we have to fallback on default formats.
378
            // TODO: A better approach would be to always populate the subresource operation array.
379
            $responseFormats = $this
380
                ->resourceMetadataFactory
381
                ->create($subresourceOperation['resource_class'])
382
                ->getTypedOperationAttribute(OperationType::SUBRESOURCE, $operationName, 'output_formats', $this->formats, true);
383
        } else {
384
            $responseFormats = $this->formatsProvider->getFormatsFromOperation($subresourceOperation['resource_class'], $operationName, OperationType::SUBRESOURCE);
385
        }
386
387
        $mimeTypes = $this->extractMimeTypes($responseFormats);
388
389
        if (!$v3) {
390
            $pathOperation['produces'] = $mimeTypes;
391
        }
392
393
        $pathOperation['responses'] = $this->getSubresourceResponse($v3, $mimeTypes, $subresourceOperation['collection'], $subresourceOperation['shortNames'][0], $responseDefinitionKey);
394
        // Avoid duplicates parameters when there is a filter on a subresource identifier
395
        $parametersMemory = [];
396
        $pathOperation['parameters'] = [];
397
        foreach ($subresourceOperation['identifiers'] as list($identifier, , $hasIdentifier)) {
398
            if (true === $hasIdentifier) {
399
                $parameter = ['name' => $identifier, 'in' => 'path', 'required' => true];
400
                $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string';
401
                $pathOperation['parameters'][] = $parameter;
402
                $parametersMemory[] = $identifier;
403
            }
404
        }
405
        if ($parameters = $this->getFiltersParameters($v3, $subresourceOperation['resource_class'], $operationName, $subResourceMetadata, $definitions, $serializerContext)) {
406
            foreach ($parameters as $parameter) {
407
                if (!\in_array($parameter['name'], $parametersMemory, true)) {
408
                    $pathOperation['parameters'][] = $parameter;
409
                }
410
            }
411
        }
412
413
        if ($subresourceOperation['collection']) {
414
            $this->addPaginationParameters($v3, $resourceMetadata, $operationName, $pathOperation);
415
        }
416
417
        return new \ArrayObject(['get' => $pathOperation]);
418
    }
419
420
    private function getSubresourceResponse(bool $v3, $mimeTypes, bool $collection, string $shortName, string $definitionKey): array
421
    {
422
        if ($collection) {
423
            $okResponse = [
424
                'description' => sprintf('%s collection response', $shortName),
425
            ];
426
427
            if ($v3) {
428
                $okResponse['content'] = array_fill_keys($mimeTypes, ['schema' => ['type' => 'array', 'items' => ['$ref' => sprintf('#/components/schemas/%s', $definitionKey)]]]);
429
            } else {
430
                $okResponse['schema'] = ['type' => 'array', 'items' => ['$ref' => sprintf('#/definitions/%s', $definitionKey)]];
431
            }
432
        } else {
433
            $okResponse = [
434
                'description' => sprintf('%s resource response', $shortName),
435
            ];
436
437
            if ($v3) {
438
                $okResponse['content'] = array_fill_keys($mimeTypes, ['schema' => ['$ref' => sprintf('#/components/schemas/%s', $definitionKey)]]);
439
            } else {
440
                $okResponse['schema'] = ['$ref' => sprintf('#/definitions/%s', $definitionKey)];
441
            }
442
        }
443
444
        return ['200' => $okResponse, '404' => ['description' => 'Resource not found']];
445
    }
446
447
    private function updatePostOperation(bool $v3, \ArrayObject $pathOperation, array $requestMimeTypes, array $responseMimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions, \ArrayObject $links): \ArrayObject
448
    {
449
        if (!$v3) {
450
            $pathOperation['consumes'] ?? $pathOperation['consumes'] = $requestMimeTypes;
451
            $pathOperation['produces'] ?? $pathOperation['produces'] = $responseMimeTypes;
452
        }
453
454
        $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Creates a %s resource.', $resourceShortName);
455
456
        $userDefinedParameters = $pathOperation['parameters'] ?? null;
457
        if (OperationType::ITEM === $operationType) {
458
            $pathOperation = $this->addItemOperationParameters($v3, $pathOperation);
459
        }
460
461
        $responseDefinitionKey = false;
462
        $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => $resourceClass], true);
463
        if (null !== $outputClass = $outputMetadata['class'] ?? null) {
464
            $responseDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $outputClass, $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName));
465
        }
466
467
        $successResponse = ['description' => sprintf('%s resource created', $resourceShortName)];
468
        if ($responseDefinitionKey) {
469
            if ($v3) {
470
                $successResponse['content'] = array_fill_keys($responseMimeTypes, ['schema' => ['$ref' => sprintf('#/components/schemas/%s', $responseDefinitionKey)]]);
471
                if ($links[$key = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM)] ?? null) {
472
                    $successResponse['links'] = [ucfirst($key) => $links[$key]];
473
                }
474
            } else {
475
                $successResponse['schema'] = ['$ref' => sprintf('#/definitions/%s', $responseDefinitionKey)];
476
            }
477
        }
478
479
        $pathOperation['responses'] ?? $pathOperation['responses'] = [
480
            (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201') => $successResponse,
481
            '400' => ['description' => 'Invalid input'],
482
            '404' => ['description' => 'Resource not found'],
483
        ];
484
485
        $inputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input', ['class' => $resourceClass], true);
486
        if (null === $inputClass = $inputMetadata['class'] ?? null) {
487
            return $pathOperation;
488
        }
489
490
        $requestDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $inputClass, $this->getSerializerContext($operationType, true, $resourceMetadata, $operationName));
491
        if ($v3) {
492
            $pathOperation['requestBody'] ?? $pathOperation['requestBody'] = [
493
                'content' => array_fill_keys($requestMimeTypes, ['schema' => ['$ref' => sprintf('#/components/schemas/%s', $requestDefinitionKey)]]),
494
                'description' => sprintf('The new %s resource', $resourceShortName),
495
            ];
496
        } else {
497
            $userDefinedParameters ?? $pathOperation['parameters'][] = [
498
                'name' => lcfirst($resourceShortName),
499
                'in' => 'body',
500
                'description' => sprintf('The new %s resource', $resourceShortName),
501
                'schema' => ['$ref' => sprintf('#/definitions/%s', $requestDefinitionKey)],
502
            ];
503
        }
504
505
        return $pathOperation;
506
    }
507
508
    private function updatePutOperation(bool $v3, \ArrayObject $pathOperation, array $requestMimeTypes, array $responseMimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions): \ArrayObject
509
    {
510
        if (!$v3) {
511
            $pathOperation['consumes'] ?? $pathOperation['consumes'] = $requestMimeTypes;
512
            $pathOperation['produces'] ?? $pathOperation['produces'] = $responseMimeTypes;
513
        }
514
515
        $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Replaces the %s resource.', $resourceShortName);
516
517
        $pathOperation = $this->addItemOperationParameters($v3, $pathOperation);
518
519
        $responseDefinitionKey = false;
520
        $outputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output', ['class' => $resourceClass], true);
521
        if (null !== $outputClass = $outputMetadata['class'] ?? null) {
522
            $responseDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $outputClass, $this->getSerializerContext($operationType, false, $resourceMetadata, $operationName));
523
        }
524
525
        $successResponse = ['description' => sprintf('%s resource updated', $resourceShortName)];
526
        if ($responseDefinitionKey) {
527
            if ($v3) {
528
                $successResponse['content'] = array_fill_keys($responseMimeTypes, ['schema' => ['$ref' => sprintf('#/components/schemas/%s', $responseDefinitionKey)]]);
529
            } else {
530
                $successResponse['schema'] = ['$ref' => sprintf('#/definitions/%s', $responseDefinitionKey)];
531
            }
532
        }
533
534
        $pathOperation['responses'] ?? $pathOperation['responses'] = [
535
            (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200') => $successResponse,
536
            '400' => ['description' => 'Invalid input'],
537
            '404' => ['description' => 'Resource not found'],
538
        ];
539
540
        $inputMetadata = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input', ['class' => $resourceClass], true);
541
        if (null === $inputClass = $inputMetadata['class'] ?? null) {
542
            return $pathOperation;
543
        }
544
545
        $requestDefinitionKey = $this->getDefinition($v3, $definitions, $resourceMetadata, $resourceClass, $inputClass, $this->getSerializerContext($operationType, true, $resourceMetadata, $operationName));
546
        if ($v3) {
547
            $pathOperation['requestBody'] ?? $pathOperation['requestBody'] = [
548
                'content' => array_fill_keys($requestMimeTypes, ['schema' => ['$ref' => sprintf('#/components/schemas/%s', $requestDefinitionKey)]]),
549
                'description' => sprintf('The updated %s resource', $resourceShortName),
550
            ];
551
        } else {
552
            $pathOperation['parameters'][] = [
553
                'name' => lcfirst($resourceShortName),
554
                'in' => 'body',
555
                'description' => sprintf('The updated %s resource', $resourceShortName),
556
                'schema' => ['$ref' => sprintf('#/definitions/%s', $requestDefinitionKey)],
557
            ];
558
        }
559
560
        return $pathOperation;
561
    }
562
563
    private function updateDeleteOperation(bool $v3, \ArrayObject $pathOperation, string $resourceShortName, string $operationType, string $operationName, ResourceMetadata $resourceMetadata): \ArrayObject
564
    {
565
        $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Removes the %s resource.', $resourceShortName);
566
        $pathOperation['responses'] ?? $pathOperation['responses'] = [
567
            (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '204') => ['description' => sprintf('%s resource deleted', $resourceShortName)],
568
            '404' => ['description' => 'Resource not found'],
569
        ];
570
571
        return $this->addItemOperationParameters($v3, $pathOperation);
572
    }
573
574
    private function addItemOperationParameters(bool $v3, \ArrayObject $pathOperation): \ArrayObject
575
    {
576
        $parameter = [
577
            'name' => 'id',
578
            'in' => 'path',
579
            'required' => true,
580
        ];
581
        $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string';
582
        $pathOperation['parameters'] ?? $pathOperation['parameters'] = [$parameter];
583
584
        return $pathOperation;
585
    }
586
587
    private function getDefinition(bool $v3, \ArrayObject $definitions, ResourceMetadata $resourceMetadata, string $resourceClass, ?string $publicClass, array $serializerContext = null): string
588
    {
589
        $keyPrefix = $resourceMetadata->getShortName();
590
        if (null !== $publicClass && $resourceClass !== $publicClass) {
591
            $keyPrefix .= ':'.md5($publicClass);
592
        }
593
594
        if (isset($serializerContext[self::SWAGGER_DEFINITION_NAME])) {
595
            $definitionKey = sprintf('%s-%s', $keyPrefix, $serializerContext[self::SWAGGER_DEFINITION_NAME]);
596
        } else {
597
            $definitionKey = $this->getDefinitionKey($keyPrefix, (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []));
0 ignored issues
show
It seems like $keyPrefix can also be of type null; however, parameter $resourceShortName of ApiPlatform\Core\Swagger...zer::getDefinitionKey() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

597
            $definitionKey = $this->getDefinitionKey(/** @scrutinizer ignore-type */ $keyPrefix, (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []));
Loading history...
598
        }
599
600
        if (!isset($definitions[$definitionKey])) {
601
            $definitions[$definitionKey] = [];  // Initialize first to prevent infinite loop
602
            $definitions[$definitionKey] = $this->getDefinitionSchema($v3, $publicClass ?? $resourceClass, $resourceMetadata, $definitions, $serializerContext);
603
        }
604
605
        return $definitionKey;
606
    }
607
608
    private function getDefinitionKey(string $resourceShortName, array $groups): string
609
    {
610
        return $groups ? sprintf('%s-%s', $resourceShortName, implode('_', $groups)) : $resourceShortName;
611
    }
612
613
    /**
614
     * Gets a definition Schema Object.
615
     *
616
     * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject
617
     */
618
    private function getDefinitionSchema(bool $v3, string $resourceClass, ResourceMetadata $resourceMetadata, \ArrayObject $definitions, array $serializerContext = null): \ArrayObject
619
    {
620
        $definitionSchema = new \ArrayObject(['type' => 'object']);
621
622
        if (null !== $description = $resourceMetadata->getDescription()) {
623
            $definitionSchema['description'] = $description;
624
        }
625
626
        if (null !== $iri = $resourceMetadata->getIri()) {
627
            $definitionSchema['externalDocs'] = ['url' => $iri];
628
        }
629
630
        $options = isset($serializerContext[AbstractNormalizer::GROUPS]) ? ['serializer_groups' => $serializerContext[AbstractNormalizer::GROUPS]] : [];
631
        foreach ($this->propertyNameCollectionFactory->create($resourceClass, $options) as $propertyName) {
632
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
633
            if (!$propertyMetadata->isReadable() && !$propertyMetadata->isWritable()) {
634
                continue;
635
            }
636
637
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->normalize($propertyName, $resourceClass, self::FORMAT, $serializerContext ?? []) : $propertyName;
0 ignored issues
show
The call to Symfony\Component\Serial...rInterface::normalize() has too many arguments starting with $resourceClass. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

637
            $normalizedPropertyName = $this->nameConverter ? $this->nameConverter->/** @scrutinizer ignore-call */ normalize($propertyName, $resourceClass, self::FORMAT, $serializerContext ?? []) : $propertyName;

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
638
            if ($propertyMetadata->isRequired()) {
639
                $definitionSchema['required'][] = $normalizedPropertyName;
640
            }
641
642
            $definitionSchema['properties'][$normalizedPropertyName] = $this->getPropertySchema($v3, $propertyMetadata, $definitions, $serializerContext);
643
        }
644
645
        return $definitionSchema;
646
    }
647
648
    /**
649
     * Gets a property Schema Object.
650
     *
651
     * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#schemaObject
652
     */
653
    private function getPropertySchema(bool $v3, PropertyMetadata $propertyMetadata, \ArrayObject $definitions, array $serializerContext = null): \ArrayObject
654
    {
655
        $propertySchema = new \ArrayObject($propertyMetadata->getAttributes()[$v3 ? 'openapi_context' : 'swagger_context'] ?? []);
656
657
        if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) {
658
            $propertySchema['readOnly'] = true;
659
        }
660
661
        if (null !== $description = $propertyMetadata->getDescription()) {
662
            $propertySchema['description'] = $description;
663
        }
664
665
        if (null === $type = $propertyMetadata->getType()) {
666
            return $propertySchema;
667
        }
668
669
        $isCollection = $type->isCollection();
670
        if (null === $valueType = $isCollection ? $type->getCollectionValueType() : $type) {
671
            $builtinType = 'string';
672
            $className = null;
673
        } else {
674
            $builtinType = $valueType->getBuiltinType();
675
            $className = $valueType->getClassName();
676
        }
677
678
        $valueSchema = $this->getType($v3, $builtinType, $isCollection, $className, $propertyMetadata->isReadableLink(), $definitions, $serializerContext);
679
680
        return new \ArrayObject((array) $propertySchema + $valueSchema);
681
    }
682
683
    /**
684
     * Gets the Swagger's type corresponding to the given PHP's type.
685
     */
686
    private function getType(bool $v3, string $type, bool $isCollection, ?string $className, ?bool $readableLink, \ArrayObject $definitions, array $serializerContext = null): array
687
    {
688
        if ($isCollection) {
689
            return ['type' => 'array', 'items' => $this->getType($v3, $type, false, $className, $readableLink, $definitions, $serializerContext)];
690
        }
691
692
        if (Type::BUILTIN_TYPE_STRING === $type) {
693
            return ['type' => 'string'];
694
        }
695
696
        if (Type::BUILTIN_TYPE_INT === $type) {
697
            return ['type' => 'integer'];
698
        }
699
700
        if (Type::BUILTIN_TYPE_FLOAT === $type) {
701
            return ['type' => 'number'];
702
        }
703
704
        if (Type::BUILTIN_TYPE_BOOL === $type) {
705
            return ['type' => 'boolean'];
706
        }
707
708
        if (Type::BUILTIN_TYPE_OBJECT === $type) {
709
            if (null === $className) {
710
                return ['type' => 'string'];
711
            }
712
713
            if (is_subclass_of($className, \DateTimeInterface::class)) {
714
                return ['type' => 'string', 'format' => 'date-time'];
715
            }
716
717
            if (!$this->resourceClassResolver->isResourceClass($className)) {
718
                return ['type' => 'string'];
719
            }
720
721
            if (true === $readableLink) {
722
                return [
723
                    '$ref' => sprintf(
724
                        $v3 ? '#/components/schemas/%s' : '#/definitions/%s',
725
                        $this->getDefinition($v3, $definitions, $resourceMetadata = $this->resourceMetadataFactory->create($className), $className, $resourceMetadata->getAttribute('output')['class'] ?? $className, $serializerContext)
726
                    ),
727
                ];
728
            }
729
        }
730
731
        return ['type' => 'string'];
732
    }
733
734
    private function computeDoc(bool $v3, Documentation $documentation, \ArrayObject $definitions, \ArrayObject $paths, array $context): array
735
    {
736
        $baseUrl = $context[self::BASE_URL] ?? $this->defaultContext[self::BASE_URL];
737
738
        if ($v3) {
739
            $docs = ['openapi' => self::OPENAPI_VERSION];
740
            if ('/' !== $baseUrl && '' !== $baseUrl) {
741
                $docs['servers'] = [['url' => $baseUrl]];
742
            }
743
        } else {
744
            $docs = [
745
                'swagger' => self::SWAGGER_VERSION,
746
                'basePath' => $baseUrl,
747
            ];
748
        }
749
750
        $docs += [
751
            'info' => [
752
                'title' => $documentation->getTitle(),
753
                'version' => $documentation->getVersion(),
754
            ],
755
            'paths' => $paths,
756
        ];
757
758
        if ('' !== $description = $documentation->getDescription()) {
759
            $docs['info']['description'] = $description;
760
        }
761
762
        $securityDefinitions = [];
763
        $security = [];
764
765
        if ($this->oauthEnabled) {
766
            $securityDefinitions['oauth'] = [
767
                'type' => $this->oauthType,
768
                'description' => 'OAuth client_credentials Grant',
769
                'flow' => $this->oauthFlow,
770
                'tokenUrl' => $this->oauthTokenUrl,
771
                'authorizationUrl' => $this->oauthAuthorizationUrl,
772
                'scopes' => $this->oauthScopes,
773
            ];
774
775
            $security[] = ['oauth' => []];
776
        }
777
778
        foreach ($this->apiKeys as $key => $apiKey) {
779
            $name = $apiKey['name'];
780
            $type = $apiKey['type'];
781
782
            $securityDefinitions[$key] = [
783
                'type' => 'apiKey',
784
                'in' => $type,
785
                'description' => sprintf('Value for the %s %s', $name, 'query' === $type ? sprintf('%s parameter', $type) : $type),
786
                'name' => $name,
787
            ];
788
789
            $security[] = [$key => []];
790
        }
791
792
        if ($v3) {
793
            if ($securityDefinitions && $security) {
794
                $docs['security'] = $security;
795
            }
796
        } elseif ($securityDefinitions && $security) {
797
            $docs['securityDefinitions'] = $securityDefinitions;
798
            $docs['security'] = $security;
799
        }
800
801
        if ($v3) {
802
            if (\count($definitions) + \count($securityDefinitions)) {
803
                $docs['components'] = [];
804
                if (\count($definitions)) {
805
                    $docs['components']['schemas'] = $definitions;
806
                }
807
                if (\count($securityDefinitions)) {
808
                    $docs['components']['securitySchemes'] = $securityDefinitions;
809
                }
810
            }
811
        } elseif (\count($definitions) > 0) {
812
            $docs['definitions'] = $definitions;
813
        }
814
815
        return $docs;
816
    }
817
818
    /**
819
     * Gets parameters corresponding to enabled filters.
820
     */
821
    private function getFiltersParameters(bool $v3, string $resourceClass, string $operationName, ResourceMetadata $resourceMetadata, \ArrayObject $definitions, array $serializerContext = null): array
822
    {
823
        if (null === $this->filterLocator) {
824
            return [];
825
        }
826
827
        $parameters = [];
828
        $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true);
829
        foreach ($resourceFilters as $filterId) {
830
            if (!$filter = $this->getFilter($filterId)) {
831
                continue;
832
            }
833
834
            foreach ($filter->getDescription($resourceClass) as $name => $data) {
835
                $parameter = [
836
                    'name' => $name,
837
                    'in' => 'query',
838
                    'required' => $data['required'],
839
                ];
840
841
                $type = $this->getType($v3, $data['type'], $data['is_collection'] ?? false, null, null, $definitions, $serializerContext);
842
                $v3 ? $parameter['schema'] = $type : $parameter += $type;
843
844
                if ('array' === $type['type'] ?? '') {
845
                    $deepObject = \in_array($data['type'], [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], true);
846
847
                    if ($v3) {
848
                        $parameter['style'] = $deepObject ? 'deepObject' : 'form';
849
                        $parameter['explode'] = true;
850
                    } else {
851
                        $parameter['collectionFormat'] = $deepObject ? 'csv' : 'multi';
852
                    }
853
                }
854
855
                $key = $v3 ? 'openapi' : 'swagger';
856
                if (isset($data[$key])) {
857
                    $parameter = $data[$key] + $parameter;
858
                }
859
860
                $parameters[] = $parameter;
861
            }
862
        }
863
864
        return $parameters;
865
    }
866
867
    /**
868
     * {@inheritdoc}
869
     */
870
    public function supportsNormalization($data, $format = null)
871
    {
872
        return self::FORMAT === $format && $data instanceof Documentation;
873
    }
874
875
    /**
876
     * {@inheritdoc}
877
     */
878
    public function hasCacheableSupportsMethod(): bool
879
    {
880
        return true;
881
    }
882
883
    private function getSerializerContext(string $operationType, bool $denormalization, ResourceMetadata $resourceMetadata, string $operationName): ?array
884
    {
885
        $contextKey = $denormalization ? 'denormalization_context' : 'normalization_context';
886
887
        if (OperationType::COLLECTION === $operationType) {
888
            return $resourceMetadata->getCollectionOperationAttribute($operationName, $contextKey, null, true);
889
        }
890
891
        return $resourceMetadata->getItemOperationAttribute($operationName, $contextKey, null, true);
892
    }
893
894
    private function extractMimeTypes(array $responseFormats): array
895
    {
896
        $responseMimeTypes = [];
897
        foreach ($responseFormats as $mimeTypes) {
898
            foreach ($mimeTypes as $mimeType) {
899
                $responseMimeTypes[] = $mimeType;
900
            }
901
        }
902
903
        return $responseMimeTypes;
904
    }
905
906
    /**
907
     * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject.
908
     */
909
    private function getLinkObject(string $resourceClass, string $operationId, string $path): array
910
    {
911
        $linkObject = $identifiers = [];
912
        foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) {
913
            $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
914
            if (!$propertyMetadata->isIdentifier()) {
915
                continue;
916
            }
917
918
            $linkObject['parameters'][$propertyName] = sprintf('$response.body#/%s', $propertyName);
919
            $identifiers[] = $propertyName;
920
        }
921
922
        if (!$linkObject) {
923
            return [];
924
        }
925
        $linkObject['operationId'] = $operationId;
926
        $linkObject['description'] = 1 === \count($identifiers) ? sprintf('The `%1$s` value returned in the response can be used as the `%1$s` parameter in `GET %2$s`.', $identifiers[0], $path) : sprintf('The values returned in the response can be used in `GET %s`.', $path);
927
928
        return $linkObject;
929
    }
930
}
931