vendor/api-platform/core/src/Core/Swagger/Serializer/DocumentationNormalizer.php line 125

Open in your IDE?
  1. <?php
  2. /*
  3. * This file is part of the API Platform project.
  4. *
  5. * (c) Kévin Dunglas <dunglas@gmail.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. declare(strict_types=1);
  11. namespace ApiPlatform\Core\Swagger\Serializer;
  12. use ApiPlatform\Core\Api\FilterCollection;
  13. use ApiPlatform\Core\Api\FilterLocatorTrait;
  14. use ApiPlatform\Core\Api\FormatsProviderInterface;
  15. use ApiPlatform\Core\Api\IdentifiersExtractorInterface;
  16. use ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface;
  17. use ApiPlatform\Core\Api\OperationMethodResolverInterface;
  18. use ApiPlatform\Core\Api\OperationType;
  19. use ApiPlatform\Core\Api\ResourceClassResolverInterface;
  20. use ApiPlatform\Core\Api\UrlGeneratorInterface;
  21. use ApiPlatform\Core\JsonSchema\SchemaFactory as LegacySchemaFactory;
  22. use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface as LegacySchemaFactoryInterface;
  23. use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
  24. use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
  25. use ApiPlatform\Core\Metadata\Resource\ApiResourceToLegacyResourceMetadataTrait;
  26. use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
  27. use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
  28. use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface;
  29. use ApiPlatform\Documentation\Documentation;
  30. use ApiPlatform\Exception\ResourceClassNotFoundException;
  31. use ApiPlatform\Exception\RuntimeException;
  32. use ApiPlatform\JsonSchema\Schema;
  33. use ApiPlatform\JsonSchema\SchemaFactory;
  34. use ApiPlatform\JsonSchema\SchemaFactoryInterface;
  35. use ApiPlatform\JsonSchema\TypeFactory;
  36. use ApiPlatform\JsonSchema\TypeFactoryInterface;
  37. use ApiPlatform\Metadata\HttpOperation;
  38. use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
  39. use ApiPlatform\OpenApi\OpenApi;
  40. use ApiPlatform\OpenApi\Serializer\ApiGatewayNormalizer;
  41. use ApiPlatform\PathResolver\OperationPathResolverInterface;
  42. use Psr\Container\ContainerInterface;
  43. use Symfony\Component\PropertyInfo\Type;
  44. use Symfony\Component\Serializer\NameConverter\NameConverterInterface;
  45. use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
  46. use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
  47. /**
  48. * Generates an OpenAPI specification (formerly known as Swagger). OpenAPI v2 and v3 are supported.
  49. *
  50. * @author Amrouche Hamza <hamza.simperfit@gmail.com>
  51. * @author Teoh Han Hui <teohhanhui@gmail.com>
  52. * @author Kévin Dunglas <dunglas@gmail.com>
  53. * @author Anthony GRASSIOT <antograssiot@free.fr>
  54. */
  55. final class DocumentationNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
  56. {
  57. use ApiResourceToLegacyResourceMetadataTrait;
  58. use FilterLocatorTrait;
  59. public const FORMAT = 'json';
  60. public const BASE_URL = 'base_url';
  61. public const SPEC_VERSION = 'spec_version';
  62. public const OPENAPI_VERSION = '3.0.2';
  63. public const SWAGGER_DEFINITION_NAME = 'swagger_definition_name';
  64. public const SWAGGER_VERSION = '2.0';
  65. /**
  66. * @deprecated
  67. */
  68. public const ATTRIBUTE_NAME = 'swagger_context';
  69. private $resourceMetadataFactory;
  70. private $propertyNameCollectionFactory;
  71. private $propertyMetadataFactory;
  72. private $operationMethodResolver;
  73. private $operationPathResolver;
  74. private $oauthEnabled;
  75. private $oauthType;
  76. private $oauthFlow;
  77. private $oauthTokenUrl;
  78. private $oauthAuthorizationUrl;
  79. private $oauthScopes;
  80. private $apiKeys;
  81. private $subresourceOperationFactory;
  82. private $paginationEnabled;
  83. private $paginationPageParameterName;
  84. private $clientItemsPerPage;
  85. private $itemsPerPageParameterName;
  86. private $paginationClientEnabled;
  87. private $paginationClientEnabledParameterName;
  88. private $formats;
  89. private $formatsProvider;
  90. /**
  91. * @var SchemaFactoryInterface|LegacySchemaFactoryInterface
  92. */
  93. private $jsonSchemaFactory;
  94. /**
  95. * @var TypeFactoryInterface
  96. */
  97. private $jsonSchemaTypeFactory;
  98. private $defaultContext = [
  99. self::BASE_URL => '/',
  100. ApiGatewayNormalizer::API_GATEWAY => false,
  101. ];
  102. private $identifiersExtractor;
  103. private $openApiNormalizer;
  104. private $legacyMode;
  105. /**
  106. * @param LegacySchemaFactoryInterface|SchemaFactoryInterface|ResourceClassResolverInterface|null $jsonSchemaFactory
  107. * @param ContainerInterface|FilterCollection|null $filterLocator
  108. * @param array|OperationAwareFormatsProviderInterface $formats
  109. * @param mixed|null $jsonSchemaTypeFactory
  110. * @param int[] $swaggerVersions
  111. * @param mixed $resourceMetadataFactory
  112. */
  113. public function __construct($resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, $jsonSchemaFactory = null, $jsonSchemaTypeFactory = null, OperationPathResolverInterface $operationPathResolver = null, 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 = [], array $swaggerVersions = [2, 3], IdentifiersExtractorInterface $identifiersExtractor = null, NormalizerInterface $openApiNormalizer = null, bool $legacyMode = false)
  114. {
  115. if ($jsonSchemaTypeFactory instanceof OperationMethodResolverInterface) {
  116. @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);
  117. $this->operationMethodResolver = $jsonSchemaTypeFactory;
  118. $this->jsonSchemaTypeFactory = new TypeFactory();
  119. } else {
  120. $this->jsonSchemaTypeFactory = $jsonSchemaTypeFactory ?? new TypeFactory();
  121. }
  122. if ($jsonSchemaFactory instanceof ResourceClassResolverInterface) {
  123. @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.5 and will be removed in 3.0.', ResourceClassResolverInterface::class, __METHOD__), \E_USER_DEPRECATED);
  124. }
  125. if (null === $jsonSchemaFactory || $jsonSchemaFactory instanceof ResourceClassResolverInterface) {
  126. if ($resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
  127. $jsonSchemaFactory = new LegacySchemaFactory($this->jsonSchemaTypeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, $nameConverter);
  128. } else {
  129. $jsonSchemaFactory = new SchemaFactory($this->jsonSchemaTypeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, $nameConverter);
  130. }
  131. $this->jsonSchemaTypeFactory->setSchemaFactory($jsonSchemaFactory);
  132. }
  133. $this->jsonSchemaFactory = $jsonSchemaFactory;
  134. if ($nameConverter) {
  135. @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.5 and will be removed in 3.0.', NameConverterInterface::class, __METHOD__), \E_USER_DEPRECATED);
  136. }
  137. if ($urlGenerator) {
  138. @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);
  139. }
  140. if ($formats instanceof FormatsProviderInterface) {
  141. @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);
  142. $this->formatsProvider = $formats;
  143. } else {
  144. $this->formats = $formats;
  145. }
  146. $this->setFilterLocator($filterLocator, true);
  147. if ($resourceMetadataFactory instanceof ResourceMetadataFactoryInterface) {
  148. trigger_deprecation('api-platform/core', '2.7', sprintf('Use "%s" instead of "%s".', ResourceMetadataCollectionFactoryInterface::class, ResourceMetadataFactoryInterface::class));
  149. }
  150. $this->resourceMetadataFactory = $resourceMetadataFactory;
  151. $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
  152. $this->propertyMetadataFactory = $propertyMetadataFactory;
  153. $this->operationPathResolver = $operationPathResolver;
  154. $this->oauthEnabled = $oauthEnabled;
  155. $this->oauthType = $oauthType;
  156. $this->oauthFlow = $oauthFlow;
  157. $this->oauthTokenUrl = $oauthTokenUrl;
  158. $this->oauthAuthorizationUrl = $oauthAuthorizationUrl;
  159. $this->oauthScopes = $oauthScopes;
  160. $this->subresourceOperationFactory = $subresourceOperationFactory;
  161. $this->paginationEnabled = $paginationEnabled;
  162. $this->paginationPageParameterName = $paginationPageParameterName;
  163. $this->apiKeys = $apiKeys;
  164. $this->clientItemsPerPage = $clientItemsPerPage;
  165. $this->itemsPerPageParameterName = $itemsPerPageParameterName;
  166. $this->paginationClientEnabled = $paginationClientEnabled;
  167. $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName;
  168. $this->defaultContext[self::SPEC_VERSION] = $swaggerVersions[0] ?? 2;
  169. $this->defaultContext = array_merge($this->defaultContext, $defaultContext);
  170. $this->identifiersExtractor = $identifiersExtractor;
  171. $this->openApiNormalizer = $openApiNormalizer;
  172. $this->legacyMode = $legacyMode;
  173. }
  174. /**
  175. * {@inheritdoc}
  176. *
  177. * @return array|string|int|float|bool|\ArrayObject|null
  178. */
  179. public function normalize($object, $format = null, array $context = [])
  180. {
  181. if ($object instanceof OpenApi) {
  182. @trigger_error('Using the swagger DocumentationNormalizer is deprecated in favor of decorating the OpenApiFactory, use the "openapi.backward_compatibility_layer" configuration to change this behavior.', \E_USER_DEPRECATED);
  183. return $this->openApiNormalizer->normalize($object, $format, $context);
  184. }
  185. $v3 = 3 === ($context['spec_version'] ?? $this->defaultContext['spec_version']) && !($context['api_gateway'] ?? $this->defaultContext['api_gateway']);
  186. $definitions = new \ArrayObject();
  187. $paths = new \ArrayObject();
  188. $links = new \ArrayObject();
  189. if ($this->resourceMetadataFactory instanceof ResourceMetadataCollectionFactoryInterface) {
  190. foreach ($object->getResourceNameCollection() as $resourceClass) {
  191. $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass);
  192. foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
  193. $resourceMetadata = $this->transformResourceToResourceMetadata($resourceMetadata);
  194. // Items needs to be parsed first to be able to reference the lines from the collection operation
  195. $this->addPaths($v3, $paths, $definitions, $resourceClass, $resourceMetadata->getShortName(), $resourceMetadata, OperationType::ITEM, $links);
  196. $this->addPaths($v3, $paths, $definitions, $resourceClass, $resourceMetadata->getShortName(), $resourceMetadata, OperationType::COLLECTION, $links);
  197. }
  198. }
  199. $definitions->ksort();
  200. $paths->ksort();
  201. return $this->computeDoc($v3, $object, $definitions, $paths, $context);
  202. }
  203. foreach ($object->getResourceNameCollection() as $resourceClass) {
  204. $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass);
  205. if ($this->identifiersExtractor) {
  206. $identifiers = [];
  207. if ($resourceMetadata->getItemOperations()) {
  208. $identifiers = $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass);
  209. }
  210. $resourceMetadata = $resourceMetadata->withAttributes(($resourceMetadata->getAttributes() ?: []) + ['identifiers' => $identifiers]);
  211. }
  212. $resourceShortName = $resourceMetadata->getShortName();
  213. // Items needs to be parsed first to be able to reference the lines from the collection operation
  214. $this->addPaths($v3, $paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, OperationType::ITEM, $links);
  215. $this->addPaths($v3, $paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, OperationType::COLLECTION, $links);
  216. if (null === $this->subresourceOperationFactory) {
  217. continue;
  218. }
  219. foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $subresourceOperation) {
  220. $method = $resourceMetadata->getTypedOperationAttribute(OperationType::SUBRESOURCE, $subresourceOperation['operation_name'], 'method', 'GET');
  221. $paths[$this->getPath($subresourceOperation['shortNames'][0], $subresourceOperation['route_name'], $subresourceOperation, OperationType::SUBRESOURCE)][strtolower($method)] = $this->addSubresourceOperation($v3, $subresourceOperation, $definitions, $operationId, $resourceMetadata);
  222. }
  223. }
  224. $definitions->ksort();
  225. $paths->ksort();
  226. return $this->computeDoc($v3, $object, $definitions, $paths, $context);
  227. }
  228. /**
  229. * Updates the list of entries in the paths collection.
  230. */
  231. private function addPaths(bool $v3, \ArrayObject $paths, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, ResourceMetadata $resourceMetadata, string $operationType, \ArrayObject $links)
  232. {
  233. if (null === $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) {
  234. return;
  235. }
  236. foreach ($operations as $operationName => $operation) {
  237. if (false === ($operation['openapi'] ?? null)) {
  238. continue;
  239. }
  240. // Skolem IRI
  241. if ('api_genid' === ($operation['route_name'] ?? null)) {
  242. continue;
  243. }
  244. if (isset($operation['uri_template'])) {
  245. $path = str_replace('.{_format}', '', $operation['uri_template']);
  246. if (0 !== strpos($path, '/')) {
  247. $path = '/'.$path;
  248. }
  249. } else {
  250. $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType);
  251. }
  252. if ($this->operationMethodResolver) {
  253. $method = OperationType::ITEM === $operationType ? $this->operationMethodResolver->getItemOperationMethod($resourceClass, $operationName) : $this->operationMethodResolver->getCollectionOperationMethod($resourceClass, $operationName);
  254. } else {
  255. $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET');
  256. }
  257. $paths[$path][strtolower($method)] = $this->getPathOperation($v3, $operationName, $operation, $method, $operationType, $resourceClass, $resourceMetadata, $definitions, $links);
  258. }
  259. }
  260. /**
  261. * Gets the path for an operation.
  262. *
  263. * If the path ends with the optional _format parameter, it is removed
  264. * as optional path parameters are not yet supported.
  265. *
  266. * @see https://github.com/OAI/OpenAPI-Specification/issues/93
  267. */
  268. private function getPath(string $resourceShortName, string $operationName, array $operation, string $operationType): string
  269. {
  270. $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
  271. if ('.{_format}' === substr($path, -10)) {
  272. $path = substr($path, 0, -10);
  273. }
  274. return $path;
  275. }
  276. /**
  277. * Gets a path Operation Object.
  278. *
  279. * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operation-object
  280. */
  281. private function getPathOperation(bool $v3, string $operationName, array $operation, string $method, string $operationType, string $resourceClass, ResourceMetadata $resourceMetadata, \ArrayObject $definitions, \ArrayObject $links): \ArrayObject
  282. {
  283. $pathOperation = new \ArrayObject($operation[$v3 ? 'openapi_context' : 'swagger_context'] ?? []);
  284. $resourceShortName = $resourceMetadata->getShortName();
  285. $pathOperation['tags'] ?? $pathOperation['tags'] = [$resourceShortName];
  286. $pathOperation['operationId'] ?? $pathOperation['operationId'] = lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType);
  287. if ($v3 && 'GET' === $method && OperationType::ITEM === $operationType && $link = $this->getLinkObject($resourceClass, $pathOperation['operationId'], $this->getPath($resourceShortName, $operationName, $operation, $operationType))) {
  288. $links[$pathOperation['operationId']] = $link;
  289. }
  290. if ($resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', null, true)) {
  291. $pathOperation['deprecated'] = true;
  292. }
  293. if (null === $this->formatsProvider) {
  294. $requestFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_formats', [], true);
  295. $responseFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_formats', [], true);
  296. } else {
  297. $requestFormats = $responseFormats = $this->formatsProvider->getFormatsFromOperation($resourceClass, $operationName, $operationType);
  298. }
  299. $requestMimeTypes = $this->flattenMimeTypes($requestFormats);
  300. $responseMimeTypes = $this->flattenMimeTypes($responseFormats);
  301. switch ($method) {
  302. case 'GET':
  303. return $this->updateGetOperation($v3, $pathOperation, $responseMimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions);
  304. case 'POST':
  305. return $this->updatePostOperation($v3, $pathOperation, $requestMimeTypes, $responseMimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions, $links);
  306. case 'PATCH':
  307. $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Updates the %s resource.', $resourceShortName);
  308. // no break
  309. case 'PUT':
  310. return $this->updatePutOperation($v3, $pathOperation, $requestMimeTypes, $responseMimeTypes, $operationType, $resourceMetadata, $resourceClass, $resourceShortName, $operationName, $definitions);
  311. case 'DELETE':
  312. return $this->updateDeleteOperation($v3, $pathOperation, $resourceShortName, $operationType, $operationName, $resourceMetadata, $resourceClass);
  313. }
  314. return $pathOperation;
  315. }
  316. /**
  317. * @return array the update message as first value, and if the schema is defined as second
  318. */
  319. private function addSchemas(bool $v3, array $message, \ArrayObject $definitions, string $resourceClass, string $operationType, string $operationName, array $mimeTypes, string $type = Schema::TYPE_OUTPUT, bool $forceCollection = false): array
  320. {
  321. if (!$v3) {
  322. $jsonSchema = $this->getJsonSchema($v3, $definitions, $resourceClass, $type, $operationType, $operationName, 'json', null, $forceCollection);
  323. if (!$jsonSchema->isDefined()) {
  324. return [$message, false];
  325. }
  326. $message['schema'] = $jsonSchema->getArrayCopy(false);
  327. return [$message, true];
  328. }
  329. foreach ($mimeTypes as $mimeType => $format) {
  330. $jsonSchema = $this->getJsonSchema($v3, $definitions, $resourceClass, $type, $operationType, $operationName, $format, null, $forceCollection);
  331. if (!$jsonSchema->isDefined()) {
  332. return [$message, false];
  333. }
  334. $message['content'][$mimeType] = ['schema' => $jsonSchema->getArrayCopy(false)];
  335. }
  336. return [$message, true];
  337. }
  338. private function updateGetOperation(bool $v3, \ArrayObject $pathOperation, array $mimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions): \ArrayObject
  339. {
  340. $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200');
  341. if (!$v3) {
  342. $pathOperation['produces'] ?? $pathOperation['produces'] = array_keys($mimeTypes);
  343. }
  344. if (OperationType::COLLECTION === $operationType) {
  345. $outputResourseShortName = $resourceMetadata->getCollectionOperations()[$operationName]['output']['name'] ?? $resourceShortName;
  346. $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Retrieves the collection of %s resources.', $outputResourseShortName);
  347. $successResponse = ['description' => sprintf('%s collection response', $outputResourseShortName)];
  348. [$successResponse] = $this->addSchemas($v3, $successResponse, $definitions, $resourceClass, $operationType, $operationName, $mimeTypes);
  349. $pathOperation['responses'] ?? $pathOperation['responses'] = [$successStatus => $successResponse];
  350. if (
  351. ($resourceMetadata->getAttributes()['extra_properties']['is_legacy_subresource'] ?? false) ||
  352. ($resourceMetadata->getAttributes()['extra_properties']['is_alternate_resource_metadata'] ?? false)) {
  353. // Avoid duplicates parameters when there is a filter on a subresource identifier
  354. $parametersMemory = [];
  355. $pathOperation['parameters'] = [];
  356. foreach ($resourceMetadata->getCollectionOperations()[$operationName]['identifiers'] as $parameterName => [$class, $identifier]) {
  357. $parameter = ['name' => $parameterName, 'in' => 'path', 'required' => true];
  358. $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string';
  359. $pathOperation['parameters'][] = $parameter;
  360. $parametersMemory[] = $parameterName;
  361. }
  362. if ($parameters = $this->getFiltersParameters($v3, $resourceClass, $operationName, $resourceMetadata)) {
  363. foreach ($parameters as $parameter) {
  364. if (!\in_array($parameter['name'], $parametersMemory, true)) {
  365. $pathOperation['parameters'][] = $parameter;
  366. }
  367. }
  368. }
  369. } else {
  370. $pathOperation['parameters'] ?? $pathOperation['parameters'] = $this->getFiltersParameters($v3, $resourceClass, $operationName, $resourceMetadata);
  371. }
  372. $this->addPaginationParameters($v3, $resourceMetadata, OperationType::COLLECTION, $operationName, $pathOperation);
  373. return $pathOperation;
  374. }
  375. $outputResourseShortName = $resourceMetadata->getItemOperations()[$operationName]['output']['name'] ?? $resourceShortName;
  376. $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Retrieves a %s resource.', $outputResourseShortName);
  377. $pathOperation = $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata, $resourceClass);
  378. $successResponse = ['description' => sprintf('%s resource response', $outputResourseShortName)];
  379. [$successResponse] = $this->addSchemas($v3, $successResponse, $definitions, $resourceClass, $operationType, $operationName, $mimeTypes);
  380. $pathOperation['responses'] ?? $pathOperation['responses'] = [
  381. $successStatus => $successResponse,
  382. '404' => ['description' => 'Resource not found'],
  383. ];
  384. return $pathOperation;
  385. }
  386. private function addPaginationParameters(bool $v3, ResourceMetadata $resourceMetadata, string $operationType, string $operationName, \ArrayObject $pathOperation)
  387. {
  388. if ($this->paginationEnabled && $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'pagination_enabled', true, true)) {
  389. $paginationParameter = [
  390. 'name' => $this->paginationPageParameterName,
  391. 'in' => 'query',
  392. 'required' => false,
  393. 'description' => 'The collection page number',
  394. ];
  395. $v3 ? $paginationParameter['schema'] = [
  396. 'type' => 'integer',
  397. 'default' => 1,
  398. ] : $paginationParameter['type'] = 'integer';
  399. $pathOperation['parameters'][] = $paginationParameter;
  400. if ($resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'pagination_client_items_per_page', $this->clientItemsPerPage, true)) {
  401. $itemPerPageParameter = [
  402. 'name' => $this->itemsPerPageParameterName,
  403. 'in' => 'query',
  404. 'required' => false,
  405. 'description' => 'The number of items per page',
  406. ];
  407. if ($v3) {
  408. $itemPerPageParameter['schema'] = [
  409. 'type' => 'integer',
  410. 'default' => $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'pagination_items_per_page', 30, true),
  411. 'minimum' => 0,
  412. ];
  413. $maxItemsPerPage = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'maximum_items_per_page', null, true);
  414. if (null !== $maxItemsPerPage) {
  415. @trigger_error('The "maximum_items_per_page" option has been deprecated since API Platform 2.5 in favor of "pagination_maximum_items_per_page" and will be removed in API Platform 3.', \E_USER_DEPRECATED);
  416. }
  417. $maxItemsPerPage = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'pagination_maximum_items_per_page', $maxItemsPerPage, true);
  418. if (null !== $maxItemsPerPage) {
  419. $itemPerPageParameter['schema']['maximum'] = $maxItemsPerPage;
  420. }
  421. } else {
  422. $itemPerPageParameter['type'] = 'integer';
  423. }
  424. $pathOperation['parameters'][] = $itemPerPageParameter;
  425. }
  426. }
  427. if ($this->paginationEnabled && $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'pagination_client_enabled', $this->paginationClientEnabled, true)) {
  428. $paginationEnabledParameter = [
  429. 'name' => $this->paginationClientEnabledParameterName,
  430. 'in' => 'query',
  431. 'required' => false,
  432. 'description' => 'Enable or disable pagination',
  433. ];
  434. $v3 ? $paginationEnabledParameter['schema'] = ['type' => 'boolean'] : $paginationEnabledParameter['type'] = 'boolean';
  435. $pathOperation['parameters'][] = $paginationEnabledParameter;
  436. }
  437. }
  438. /**
  439. * @throws ResourceClassNotFoundException
  440. */
  441. private function addSubresourceOperation(bool $v3, array $subresourceOperation, \ArrayObject $definitions, string $operationId, ResourceMetadata $resourceMetadata): \ArrayObject
  442. {
  443. $operationName = 'get'; // TODO: we might want to extract that at some point to also support other subresource operations
  444. $collection = $subresourceOperation['collection'] ?? false;
  445. $subResourceMetadata = $this->resourceMetadataFactory->create($subresourceOperation['resource_class']);
  446. $pathOperation = new \ArrayObject([]);
  447. $pathOperation['tags'] = $subresourceOperation['shortNames'];
  448. $pathOperation['operationId'] = $operationId;
  449. $pathOperation['summary'] = sprintf('Retrieves %s%s resource%s.', $subresourceOperation['collection'] ? 'the collection of ' : 'a ', $subresourceOperation['shortNames'][0], $subresourceOperation['collection'] ? 's' : '');
  450. if (null === $this->formatsProvider) {
  451. // TODO: Subresource operation metadata aren't available by default, for now we have to fallback on default formats.
  452. // TODO: A better approach would be to always populate the subresource operation array.
  453. $subResourceMetadata = $this
  454. ->resourceMetadataFactory
  455. ->create($subresourceOperation['resource_class']);
  456. if ($this->resourceMetadataFactory instanceof ResourceMetadataCollectionFactoryInterface) {
  457. $subResourceMetadata = $this->transformResourceToResourceMetadata($subResourceMetadata[0]);
  458. }
  459. $responseFormats = $subResourceMetadata->getTypedOperationAttribute(OperationType::SUBRESOURCE, $operationName, 'output_formats', $this->formats, true);
  460. } else {
  461. $responseFormats = $this->formatsProvider->getFormatsFromOperation($subresourceOperation['resource_class'], $operationName, OperationType::SUBRESOURCE);
  462. }
  463. $mimeTypes = $this->flattenMimeTypes($responseFormats);
  464. if (!$v3) {
  465. $pathOperation['produces'] = array_keys($mimeTypes);
  466. }
  467. $successResponse = [
  468. 'description' => sprintf('%s %s response', $subresourceOperation['shortNames'][0], $collection ? 'collection' : 'resource'),
  469. ];
  470. [$successResponse] = $this->addSchemas($v3, $successResponse, $definitions, $subresourceOperation['resource_class'], OperationType::SUBRESOURCE, $operationName, $mimeTypes, Schema::TYPE_OUTPUT, $collection);
  471. $pathOperation['responses'] = ['200' => $successResponse, '404' => ['description' => 'Resource not found']];
  472. // Avoid duplicates parameters when there is a filter on a subresource identifier
  473. $parametersMemory = [];
  474. $pathOperation['parameters'] = [];
  475. foreach ($subresourceOperation['identifiers'] as $parameterName => [$class, $identifier, $hasIdentifier]) {
  476. if (false === strpos($subresourceOperation['path'], sprintf('{%s}', $parameterName))) {
  477. continue;
  478. }
  479. $parameter = ['name' => $parameterName, 'in' => 'path', 'required' => true];
  480. $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string';
  481. $pathOperation['parameters'][] = $parameter;
  482. $parametersMemory[] = $parameterName;
  483. }
  484. if ($parameters = $this->getFiltersParameters($v3, $subresourceOperation['resource_class'], $operationName, $subResourceMetadata)) {
  485. foreach ($parameters as $parameter) {
  486. if (!\in_array($parameter['name'], $parametersMemory, true)) {
  487. $pathOperation['parameters'][] = $parameter;
  488. }
  489. }
  490. }
  491. if ($subresourceOperation['collection']) {
  492. $this->addPaginationParameters($v3, $subResourceMetadata, OperationType::SUBRESOURCE, $subresourceOperation['operation_name'], $pathOperation);
  493. }
  494. return $pathOperation;
  495. }
  496. 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
  497. {
  498. if (!$v3) {
  499. $pathOperation['consumes'] ?? $pathOperation['consumes'] = array_keys($requestMimeTypes);
  500. $pathOperation['produces'] ?? $pathOperation['produces'] = array_keys($responseMimeTypes);
  501. }
  502. $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Creates a %s resource.', $resourceShortName);
  503. $identifiers = (array) $resourceMetadata
  504. ->getTypedOperationAttribute($operationType, $operationName, 'identifiers', [], false);
  505. $pathOperation = $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata, $resourceClass, OperationType::ITEM === $operationType ? false : true);
  506. $successResponse = ['description' => sprintf('%s resource created', $resourceShortName)];
  507. [$successResponse, $defined] = $this->addSchemas($v3, $successResponse, $definitions, $resourceClass, $operationType, $operationName, $responseMimeTypes);
  508. if ($defined && $v3 && ($links[$key = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM)] ?? null)) {
  509. $successResponse['links'] = [ucfirst($key) => $links[$key]];
  510. }
  511. $pathOperation['responses'] ?? $pathOperation['responses'] = [
  512. (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201') => $successResponse,
  513. '400' => ['description' => 'Invalid input'],
  514. '404' => ['description' => 'Resource not found'],
  515. '422' => ['description' => 'Unprocessable entity'],
  516. ];
  517. return $this->addRequestBody($v3, $pathOperation, $definitions, $resourceClass, $resourceShortName, $operationType, $operationName, $requestMimeTypes);
  518. }
  519. private function updatePutOperation(bool $v3, \ArrayObject $pathOperation, array $requestMimeTypes, array $responseMimeTypes, string $operationType, ResourceMetadata $resourceMetadata, string $resourceClass, string $resourceShortName, string $operationName, \ArrayObject $definitions): \ArrayObject
  520. {
  521. if (!$v3) {
  522. $pathOperation['consumes'] ?? $pathOperation['consumes'] = array_keys($requestMimeTypes);
  523. $pathOperation['produces'] ?? $pathOperation['produces'] = array_keys($responseMimeTypes);
  524. }
  525. $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Replaces the %s resource.', $resourceShortName);
  526. $pathOperation = $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata, $resourceClass);
  527. $successResponse = ['description' => sprintf('%s resource updated', $resourceShortName)];
  528. [$successResponse] = $this->addSchemas($v3, $successResponse, $definitions, $resourceClass, $operationType, $operationName, $responseMimeTypes);
  529. $pathOperation['responses'] ?? $pathOperation['responses'] = [
  530. (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200') => $successResponse,
  531. '400' => ['description' => 'Invalid input'],
  532. '404' => ['description' => 'Resource not found'],
  533. '422' => ['description' => 'Unprocessable entity'],
  534. ];
  535. return $this->addRequestBody($v3, $pathOperation, $definitions, $resourceClass, $resourceShortName, $operationType, $operationName, $requestMimeTypes, true);
  536. }
  537. private function addRequestBody(bool $v3, \ArrayObject $pathOperation, \ArrayObject $definitions, string $resourceClass, string $resourceShortName, string $operationType, string $operationName, array $requestMimeTypes, bool $put = false)
  538. {
  539. if (isset($pathOperation['requestBody'])) {
  540. return $pathOperation;
  541. }
  542. [$message, $defined] = $this->addSchemas($v3, [], $definitions, $resourceClass, $operationType, $operationName, $requestMimeTypes, Schema::TYPE_INPUT);
  543. if (!$defined) {
  544. return $pathOperation;
  545. }
  546. $description = sprintf('The %s %s resource', $put ? 'updated' : 'new', $resourceShortName);
  547. if ($v3) {
  548. $pathOperation['requestBody'] = $message + ['description' => $description];
  549. return $pathOperation;
  550. }
  551. if (!$this->hasBodyParameter($pathOperation['parameters'] ?? [])) {
  552. $pathOperation['parameters'][] = [
  553. 'name' => lcfirst($resourceShortName),
  554. 'in' => 'body',
  555. 'description' => $description,
  556. ] + $message;
  557. }
  558. return $pathOperation;
  559. }
  560. private function hasBodyParameter(array $parameters): bool
  561. {
  562. foreach ($parameters as $parameter) {
  563. if (\array_key_exists('in', $parameter) && 'body' === $parameter['in']) {
  564. return true;
  565. }
  566. }
  567. return false;
  568. }
  569. private function updateDeleteOperation(bool $v3, \ArrayObject $pathOperation, string $resourceShortName, string $operationType, string $operationName, ResourceMetadata $resourceMetadata, string $resourceClass): \ArrayObject
  570. {
  571. $pathOperation['summary'] ?? $pathOperation['summary'] = sprintf('Removes the %s resource.', $resourceShortName);
  572. $pathOperation['responses'] ?? $pathOperation['responses'] = [
  573. (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '204') => ['description' => sprintf('%s resource deleted', $resourceShortName)],
  574. '404' => ['description' => 'Resource not found'],
  575. ];
  576. return $this->addItemOperationParameters($v3, $pathOperation, $operationType, $operationName, $resourceMetadata, $resourceClass);
  577. }
  578. private function addItemOperationParameters(bool $v3, \ArrayObject $pathOperation, string $operationType, string $operationName, ResourceMetadata $resourceMetadata, string $resourceClass, bool $isPost = false): \ArrayObject
  579. {
  580. $identifiers = (array) $resourceMetadata
  581. ->getTypedOperationAttribute($operationType, $operationName, 'identifiers', [], false);
  582. // Auto-generated routes in API Platform < 2.7 are considered as collection, hotfix this as the OpenApi Factory supports new operations anyways.
  583. // this also fixes a bug where we could not create POST item operations in API P 2.6
  584. if (OperationType::ITEM === $operationType && $isPost) {
  585. $operationType = OperationType::COLLECTION;
  586. }
  587. if (!$identifiers && OperationType::COLLECTION !== $operationType) {
  588. try {
  589. $identifiers = $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass);
  590. } catch (RuntimeException $e) {
  591. // Ignore exception here
  592. } catch (ResourceClassNotFoundException $e) {
  593. if (false === $this->legacyMode) {
  594. // Skipping these, swagger is not compatible with post 2.7 resource metadata
  595. return $pathOperation;
  596. }
  597. throw $e;
  598. }
  599. }
  600. if (\count($identifiers) > 1 ? $resourceMetadata->getItemOperationAttribute($operationName, 'composite_identifier', true, true) : false) {
  601. $identifiers = ['id'];
  602. }
  603. if (!$identifiers && OperationType::COLLECTION === $operationType) {
  604. return $pathOperation;
  605. }
  606. if (!isset($pathOperation['parameters'])) {
  607. $pathOperation['parameters'] = [];
  608. }
  609. foreach ($identifiers as $parameterName => $identifier) {
  610. $parameter = [
  611. 'name' => \is_string($parameterName) ? $parameterName : $identifier,
  612. 'in' => 'path',
  613. 'required' => true,
  614. ];
  615. $v3 ? $parameter['schema'] = ['type' => 'string'] : $parameter['type'] = 'string';
  616. $pathOperation['parameters'][] = $parameter;
  617. }
  618. return $pathOperation;
  619. }
  620. private function getJsonSchema(bool $v3, \ArrayObject $definitions, string $resourceClass, string $type, ?string $operationType, ?string $operationName, string $format = 'json', ?array $serializerContext = null, bool $forceCollection = false): Schema
  621. {
  622. $schema = new Schema($v3 ? Schema::VERSION_OPENAPI : Schema::VERSION_SWAGGER);
  623. $schema->setDefinitions($definitions);
  624. if ($this->jsonSchemaFactory instanceof SchemaFactoryInterface) {
  625. $operation = $operationName ? (new class() extends HttpOperation {})->withName($operationName) : null;
  626. return $this->jsonSchemaFactory->buildSchema($resourceClass, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
  627. }
  628. return $this->jsonSchemaFactory->buildSchema($resourceClass, $format, $type, $operationType, $operationName, $schema, $serializerContext, $forceCollection);
  629. }
  630. private function computeDoc(bool $v3, Documentation $documentation, \ArrayObject $definitions, \ArrayObject $paths, array $context): array
  631. {
  632. $baseUrl = $context[self::BASE_URL] ?? $this->defaultContext[self::BASE_URL];
  633. if ($v3) {
  634. $docs = ['openapi' => self::OPENAPI_VERSION];
  635. if ('/' !== $baseUrl && '' !== $baseUrl) {
  636. $docs['servers'] = [['url' => $baseUrl]];
  637. }
  638. } else {
  639. $docs = [
  640. 'swagger' => self::SWAGGER_VERSION,
  641. 'basePath' => $baseUrl,
  642. ];
  643. }
  644. $docs += [
  645. 'info' => [
  646. 'title' => $documentation->getTitle(),
  647. 'version' => $documentation->getVersion(),
  648. ],
  649. 'paths' => $paths,
  650. ];
  651. if ('' !== $description = $documentation->getDescription()) {
  652. $docs['info']['description'] = $description;
  653. }
  654. $securityDefinitions = [];
  655. $security = [];
  656. if ($this->oauthEnabled) {
  657. $oauthAttributes = [
  658. 'authorizationUrl' => $this->oauthAuthorizationUrl,
  659. 'scopes' => new \ArrayObject($this->oauthScopes),
  660. ];
  661. if ($this->oauthTokenUrl) {
  662. $oauthAttributes['tokenUrl'] = $this->oauthTokenUrl;
  663. }
  664. $securityDefinitions['oauth'] = [
  665. 'type' => $this->oauthType,
  666. 'description' => sprintf(
  667. 'OAuth 2.0 %s Grant',
  668. strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->oauthFlow)))
  669. ),
  670. ];
  671. if ($v3) {
  672. $securityDefinitions['oauth']['flows'] = [
  673. $this->oauthFlow => $oauthAttributes,
  674. ];
  675. } else {
  676. $securityDefinitions['oauth']['flow'] = $this->oauthFlow;
  677. $securityDefinitions['oauth'] = array_merge($securityDefinitions['oauth'], $oauthAttributes);
  678. }
  679. $security[] = ['oauth' => []];
  680. }
  681. foreach ($this->apiKeys as $key => $apiKey) {
  682. $name = $apiKey['name'];
  683. $type = $apiKey['type'];
  684. $securityDefinitions[$key] = [
  685. 'type' => 'apiKey',
  686. 'in' => $type,
  687. 'description' => sprintf('Value for the %s %s', $name, 'query' === $type ? sprintf('%s parameter', $type) : $type),
  688. 'name' => $name,
  689. ];
  690. $security[] = [$key => []];
  691. }
  692. if ($securityDefinitions && $security) { // @phpstan-ignore-line false positive
  693. $docs['security'] = $security;
  694. if (!$v3) {
  695. $docs['securityDefinitions'] = $securityDefinitions;
  696. }
  697. }
  698. if ($v3) {
  699. if (\count($definitions) + \count($securityDefinitions)) {
  700. $docs['components'] = [];
  701. if (\count($definitions)) {
  702. $docs['components']['schemas'] = $definitions;
  703. }
  704. if (\count($securityDefinitions)) {
  705. $docs['components']['securitySchemes'] = $securityDefinitions;
  706. }
  707. }
  708. } elseif (\count($definitions) > 0) {
  709. $docs['definitions'] = $definitions;
  710. }
  711. return $docs;
  712. }
  713. /**
  714. * Gets parameters corresponding to enabled filters.
  715. */
  716. private function getFiltersParameters(bool $v3, string $resourceClass, string $operationName, ResourceMetadata $resourceMetadata): array
  717. {
  718. if (null === $this->filterLocator) {
  719. return [];
  720. }
  721. $parameters = [];
  722. $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true);
  723. foreach ($resourceFilters as $filterId) {
  724. if (!$filter = $this->getFilter($filterId)) {
  725. continue;
  726. }
  727. foreach ($filter->getDescription($resourceClass) as $name => $data) {
  728. $parameter = [
  729. 'name' => $name,
  730. 'in' => 'query',
  731. 'required' => $data['required'],
  732. ];
  733. $type = \in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string'];
  734. $v3 ? $parameter['schema'] = $type : $parameter += $type;
  735. if ($v3 && isset($data['schema'])) {
  736. $parameter['schema'] = $data['schema'];
  737. }
  738. if ('array' === ($type['type'] ?? '')) {
  739. $deepObject = \in_array($data['type'], [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], true);
  740. if ($v3) {
  741. $parameter['style'] = $deepObject ? 'deepObject' : 'form';
  742. $parameter['explode'] = true;
  743. } else {
  744. $parameter['collectionFormat'] = $deepObject ? 'csv' : 'multi';
  745. }
  746. }
  747. $key = $v3 ? 'openapi' : 'swagger';
  748. if (isset($data[$key])) {
  749. $parameter = $data[$key] + $parameter;
  750. }
  751. $parameters[] = $parameter;
  752. }
  753. }
  754. return $parameters;
  755. }
  756. /**
  757. * {@inheritdoc}
  758. */
  759. public function supportsNormalization($data, $format = null, array $context = []): bool
  760. {
  761. return self::FORMAT === $format && ($data instanceof Documentation || $this->openApiNormalizer && $data instanceof OpenApi);
  762. }
  763. /**
  764. * {@inheritdoc}
  765. */
  766. public function hasCacheableSupportsMethod(): bool
  767. {
  768. return true;
  769. }
  770. private function flattenMimeTypes(array $responseFormats): array
  771. {
  772. $responseMimeTypes = [];
  773. foreach ($responseFormats as $responseFormat => $mimeTypes) {
  774. foreach ($mimeTypes as $mimeType) {
  775. $responseMimeTypes[$mimeType] = $responseFormat;
  776. }
  777. }
  778. return $responseMimeTypes;
  779. }
  780. /**
  781. * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject.
  782. */
  783. private function getLinkObject(string $resourceClass, string $operationId, string $path): array
  784. {
  785. $linkObject = $identifiers = [];
  786. foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) {
  787. $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName);
  788. if (!$propertyMetadata->isIdentifier()) {
  789. continue;
  790. }
  791. $linkObject['parameters'][$propertyName] = sprintf('$response.body#/%s', $propertyName);
  792. $identifiers[] = $propertyName;
  793. }
  794. if (!$linkObject) {
  795. return [];
  796. }
  797. $linkObject['operationId'] = $operationId;
  798. $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);
  799. return $linkObject;
  800. }
  801. }