diff --git a/composer.json b/composer.json index d5de7dbb567..006dff9d9a1 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "symfony/http-kernel": "^4.3.7 || ^5.0", "symfony/property-access": "^3.4 || ^4.0 || ^5.0", "symfony/property-info": "^3.4 || ^4.0 || ^5.0", - "symfony/serializer": "^4.3 || ^5.0", + "symfony/serializer": "^4.4 || ^5.0", "symfony/web-link": "^4.1 || ^5.0", "willdurand/negotiation": "^2.0.3" }, diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php new file mode 100644 index 00000000000..6bc69f52a86 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Command; + +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Yaml\Yaml; + +/** + * Dumps Open API documentation. + */ +final class OpenApiCommand extends Command +{ + private $openApiFactory; + private $normalizer; + + public function __construct(OpenApiFactoryInterface $openApiFactory, NormalizerInterface $normalizer) + { + parent::__construct(); + $this->openApiFactory = $openApiFactory; + $this->normalizer = $normalizer; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('api:openapi:export') + ->setDescription('Dump the Open API documentation') + ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') + ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'Open API version to use (2 or 3) (2 is deprecated)', 3) + ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + // Backwards compatibility + if (2 === $specVersion = (int) $input->getOption('spec-version')) { + $command = $this->getApplication()->find('api:swagger:export'); + + return $command->run(new ArrayInput([ + 'command' => 'api:swagger:export', + '--spec-version' => $specVersion, + '--yaml' => $input->getOption('yaml'), + '--output' => $input->getOption('output'), + '--api-gateway' => $input->getOption('api-gateway'), + ]), $output); + } + + $filesystem = new Filesystem(); + $io = new SymfonyStyle($input, $output); + $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $content = $input->getOption('yaml') + ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) + : (json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: ''); + + $filename = $input->getOption('output'); + if ($filename && \is_string($filename)) { + $filesystem->dumpFile($filename, $content); + $io->success(sprintf('Data written to %s.', $filename)); + + return 0; + } + + $output->writeln($content); + + return 0; + } +} diff --git a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php index 9c220501e60..02dfbe45006 100644 --- a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php @@ -67,9 +67,8 @@ public function __construct(NormalizerInterface $normalizer, ResourceNameCollect protected function configure() { $this - ->setName('api:openapi:export') - ->setAliases(['api:swagger:export']) - ->setDescription('Dump the OpenAPI documentation') + ->setName('api:swagger:export') + ->setDescription('Dump the Swagger v2 documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, sprintf('OpenAPI version to use (%s)', implode(' or ', $this->swaggerVersions)), $this->swaggerVersions[0] ?? 2) ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') @@ -90,6 +89,10 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new InvalidOptionException(sprintf('This tool only supports versions %s of the OpenAPI specification ("%s" given).', implode(', ', $this->swaggerVersions), $version)); } + if (3 === (int) $version) { + @trigger_error('The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export".', E_USER_DEPRECATED); + } + $documentation = new Documentation($this->resourceNameCollectionFactory->create(), $this->apiTitle, $this->apiDescription, $this->apiVersion, $this->apiFormats); $data = $this->normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['spec_version' => (int) $version, ApiGatewayNormalizer::API_GATEWAY => $input->getOption('api-gateway')]); $content = $input->getOption('yaml') diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 42d7faf8715..4a33e424ae3 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -365,6 +365,7 @@ private function registerOAuthConfiguration(ContainerBuilder $container, array $ $container->setParameter('api_platform.oauth.flow', $config['oauth']['flow']); $container->setParameter('api_platform.oauth.tokenUrl', $config['oauth']['tokenUrl']); $container->setParameter('api_platform.oauth.authorizationUrl', $config['oauth']['authorizationUrl']); + $container->setParameter('api_platform.oauth.refreshUrl', $config['oauth']['refreshUrl']); $container->setParameter('api_platform.oauth.scopes', $config['oauth']['scopes']); } @@ -381,6 +382,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('json_schema.xml'); $loader->load('swagger.xml'); + $loader->load('openapi.xml'); $loader->load('swagger-ui.xml'); if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 536d15ad886..51ca4471e2b 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -249,6 +249,7 @@ private function addOAuthSection(ArrayNodeDefinition $rootNode): void ->scalarNode('flow')->defaultValue('application')->info('The oauth flow grant type.')->end() ->scalarNode('tokenUrl')->defaultValue('/oauth/v2/token')->info('The oauth token url.')->end() ->scalarNode('authorizationUrl')->defaultValue('/oauth/v2/auth')->info('The oauth authentication url.')->end() + ->scalarNode('refreshUrl')->defaultValue('/oauth/v2/refresh')->info('The oauth refresh url.')->end() ->arrayNode('scopes') ->prototype('scalar')->end() ->end() @@ -296,7 +297,7 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->addDefaultsIfNotSet() ->children() ->arrayNode('versions') - ->info('The active versions of OpenAPI to be exported or used in the swagger_ui. The first value is the default.') + ->info('The active versions of Open API to be exported or used in the swagger_ui. The first value is the default.') ->defaultValue($defaultVersions) ->beforeNormalization() ->always(static function ($v) { diff --git a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml index 471da830649..a838e119689 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml @@ -33,6 +33,17 @@ %api_platform.graphql.collection.pagination% + + + %api_platform.collection.pagination.enabled% + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.client_items_per_page% + %api_platform.collection.pagination.items_per_page_parameter_name% + %api_platform.collection.pagination.client_enabled% + %api_platform.collection.pagination.enabled_parameter_name% + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml new file mode 100644 index 00000000000..03bda2f24da --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + %api_platform.title% + %api_platform.description% + %api_platform.version% + %api_platform.oauth.enabled% + %api_platform.oauth.type% + %api_platform.oauth.flow% + %api_platform.oauth.tokenUrl% + %api_platform.oauth.authorizationUrl% + %api_platform.oauth.refreshUrl% + %api_platform.oauth.scopes% + %api_platform.swagger.api_keys% + + + + + + + + + + + + + + %api_platform.formats% + + + + + + + + + + + + + + + + + + diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php new file mode 100644 index 00000000000..1db7646943f --- /dev/null +++ b/src/DataProvider/PaginationOptions.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\DataProvider; + +final class PaginationOptions +{ + private $paginationEnabled; + private $paginationPageParameterName; + private $clientItemsPerPage; + private $itemsPerPageParameterName; + private $paginationClientEnabled; + private $paginationClientEnabledParameterName; + + public function __construct(bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination') + { + $this->paginationEnabled = $paginationEnabled; + $this->paginationPageParameterName = $paginationPageParameterName; + $this->clientItemsPerPage = $clientItemsPerPage; + $this->itemsPerPageParameterName = $itemsPerPageParameterName; + $this->paginationClientEnabled = $paginationClientEnabled; + $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName; + } + + public function isPaginationEnabled(): bool + { + return $this->paginationEnabled; + } + + public function getPaginationPageParameterName(): string + { + return $this->paginationPageParameterName; + } + + public function getClientItemsPerPage(): bool + { + return $this->clientItemsPerPage; + } + + public function getItemsPerPageParameterName(): string + { + return $this->itemsPerPageParameterName; + } + + public function getPaginationClientEnabled(): bool + { + return $this->paginationClientEnabled; + } + + public function getPaginationClientEnabledParameterName(): string + { + return $this->paginationClientEnabledParameterName; + } +} diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 57fd1698372..754fc013b4f 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -15,7 +15,9 @@ use ApiPlatform\Core\Api\FormatsProviderInterface; use ApiPlatform\Core\Documentation\Documentation; +use ApiPlatform\Core\Documentation\DocumentationInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; @@ -33,18 +35,24 @@ final class DocumentationAction private $formats; private $formatsProvider; private $swaggerVersions; + private $openApiFactory; /** * @param int[] $swaggerVersions * @param mixed|array|FormatsProviderInterface $formatsProvider */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', $formatsProvider = null, array $swaggerVersions = [2, 3]) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', $formatsProvider = null, array $swaggerVersions = [2, 3], OpenApiFactoryInterface $openApiFactory = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->title = $title; $this->description = $description; $this->version = $version; $this->swaggerVersions = $swaggerVersions; + $this->openApiFactory = $openApiFactory; + + if (null === $openApiFactory) { + @trigger_error(sprintf('Not passing an instance of "%s" as 7th parameter of the constructor of "%s" is deprecated since API Platform 2.6', OpenApiFactoryInterface::class, __CLASS__), E_USER_DEPRECATED); + } if (null === $formatsProvider) { return; @@ -56,13 +64,14 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName return; } + $this->formatsProvider = $formatsProvider; } - public function __invoke(Request $request = null): Documentation + public function __invoke(Request $request = null): DocumentationInterface { if (null !== $request) { - $context = ['base_url' => $request->getBaseUrl(), 'spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 2)]; + $context = ['base_url' => $request->getBaseUrl(), 'spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 3)]; if ($request->query->getBoolean('api_gateway')) { $context['api_gateway'] = true; } @@ -70,11 +79,16 @@ public function __invoke(Request $request = null): Documentation $attributes = RequestAttributesExtractor::extractAttributes($request); } + // BC check to be removed in 3.0 if (null !== $this->formatsProvider) { $this->formats = $this->formatsProvider->getFormatsFromAttributes($attributes ?? []); } + if (null !== $this->openApiFactory && isset($context) && 3 === $context['spec_version']) { + return $this->openApiFactory->__invoke($context ?? []); + } + return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats); } } diff --git a/src/Documentation/Documentation.php b/src/Documentation/Documentation.php index 53f5372df28..67228a77c5e 100644 --- a/src/Documentation/Documentation.php +++ b/src/Documentation/Documentation.php @@ -20,7 +20,7 @@ * * @author Amrouche Hamza */ -final class Documentation +final class Documentation implements DocumentationInterface { private $resourceNameCollection; private $title; diff --git a/src/Documentation/DocumentationInterface.php b/src/Documentation/DocumentationInterface.php new file mode 100644 index 00000000000..0bbea27c30c --- /dev/null +++ b/src/Documentation/DocumentationInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Documentation; + +/** + * An API documentation. + */ +interface DocumentationInterface +{ +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php new file mode 100644 index 00000000000..7ce8cc4d137 --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -0,0 +1,448 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Factory; + +use ApiPlatform\Core\Api\FilterLocatorTrait; +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Core\JsonSchema\TypeFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\Model\ExternalDocumentation; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; + +/** + * Generates an Open API v3 specification. + */ +final class OpenApiFactory implements OpenApiFactoryInterface +{ + use FilterLocatorTrait; + + public const BASE_URL = 'base_url'; + + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $operationPathResolver; + private $subresourceOperationFactory; + private $formats; + private $jsonSchemaFactory; + private $jsonSchemaTypeFactory; + private $openApiOptions; + private $paginationOptions; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, SchemaFactoryInterface $jsonSchemaFactory, TypeFactoryInterface $jsonSchemaTypeFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $filterLocator, SubresourceOperationFactoryInterface $subresourceOperationFactory, array $formats = [], Options $openApiOptions, PaginationOptions $paginationOptions) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->jsonSchemaFactory = $jsonSchemaFactory; + $this->jsonSchemaTypeFactory = $jsonSchemaTypeFactory; + $this->formats = $formats; + $this->setFilterLocator($filterLocator, true); + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->operationPathResolver = $operationPathResolver; + $this->openApiOptions = $openApiOptions; + $this->paginationOptions = $paginationOptions; + $this->subresourceOperationFactory = $subresourceOperationFactory; + } + + /** + * {@inheritdoc} + */ + public function __invoke(array $context = []): OpenApi + { + $baseUrl = $context[self::BASE_URL] ?? '/'; + $info = new Model\Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription())); + $servers = '/' === $baseUrl || '' === $baseUrl ? [] : [new Model\Server($baseUrl)]; + $paths = new Model\Paths(); + $links = []; + $schemas = []; + + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $resourceShortName = $resourceMetadata->getShortName(); + + // Items needs to be parsed first to be able to reference the lines from the collection operation + list($itemOperationLinks, $itemOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::ITEM, $context, $paths, $links, $schemas); + $schemas += $itemOperationSchemas; + list($collectionOperationLinks, $collectionOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::COLLECTION, $context, $paths, $links, $schemas); + + list($subresourceOperationLinks, $subresourceOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::SUBRESOURCE, $context, $paths, $links, $schemas); + $schemas += $collectionOperationSchemas; + } + + $securitySchemes = $this->getSecuritySchemes(); + $securityRequirements = []; + + foreach (array_keys($securitySchemes) as $key) { + $securityRequirements[$key] = []; + } + + return new OpenApi($info, $servers, $paths, new Model\Components(new \ArrayObject($schemas), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject($securitySchemes)), $securityRequirements); + } + + /** + * @return array | array + */ + private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array &$links, array $schemas = []): array + { + $resourceShortName = $resourceMetadata->getShortName(); + $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : (OperationType::ITEM === $operationType ? $resourceMetadata->getItemOperations() : $this->subresourceOperationFactory->create($resourceClass)); + if (!$operations) { + return [$links, $schemas]; + } + + foreach ($operations as $operationName => $operation) { + $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType); + $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET'); + list($requestMimeTypes, $responseMimeTypes) = $this->getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata); + $operationId = $operation['openapi_context']['operationId'] ?? lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType); + $linkedOperationId = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM); + $pathItem = $paths->getPath($path) ?: new Model\PathItem(); + + $operationOutputSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), $context); + $schemas += $operationOutputSchema->getDefinitions()->getArrayCopy(); + $operationOutputSchemas[$operationFormat] = $operationOutputSchema; + } + + $parameters = []; + $responses = []; + + // Set up parameters + if (OperationType::ITEM === $operationType) { + $parameters[] = new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string']); + $links[$operationId] = $this->getLink($resourceClass, $operationId, $path); + } elseif (OperationType::COLLECTION === $operationType && 'GET' === $method) { + $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); + } elseif (OperationType::SUBRESOURCE === $operationType) { + // FIXME: In SubresourceOperationFactory identifiers may happen twice + $added = []; + foreach ($operation['identifiers'] as $identifier) { + if (\in_array($identifier[0], $added, true)) { + continue; + } + $added[] = $identifier[0]; + $parameterShortname = $this->resourceMetadataFactory->create($identifier[1])->getShortName(); + $parameters[] = new Model\Parameter($identifier[0], 'path', $parameterShortname.' identifier', true, false, false, ['type' => 'string']); + } + + if ($operation['collection']) { + $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); + } + } + + // Create responses + switch ($method) { + case 'GET': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $responses[$successStatus] = new Model\Response(sprintf('%s %s', $resourceShortName, OperationType::COLLECTION === $operationType ? 'collection' : 'resource'), $responseContent); + break; + case 'POST': + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201'); + $responses[$successStatus] = new Model\Response(sprintf('%s resource created', $resourceShortName), $responseContent, null, $responseLinks); + $responses['400'] = new Model\Response('Invalid input'); + break; + case 'PATCH': + case 'PUT': + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $responses[$successStatus] = new Model\Response(sprintf('%s resource updated', $resourceShortName), $responseContent, null, $responseLinks); + $responses['400'] = new Model\Response('Invalid input'); + break; + case 'DELETE': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '204'); + $responses[$successStatus] = new Model\Response(sprintf('%s resource deleted', $resourceShortName)); + break; + } + + if (OperationType::ITEM === $operationType) { + $responses['404'] = new Model\Response('Resource not found'); + } + + if (!$responses) { + $responses['default'] = new Model\Response('Unexpected error'); + } + + $requestBody = null; + if ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), $context); + $schemas += $operationInputSchema->getDefinitions()->getArrayCopy(); + $operationInputSchemas[$operationFormat] = $operationInputSchema; + } + + $requestBody = new Model\RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true); + } + + $pathItem = $pathItem->{'with'.ucfirst($method)}(new Model\Operation( + $operationId, + $operation['openapi_context']['tags'] ?? (OperationType::SUBRESOURCE === $operationType ? $operation['shortNames'] : [$resourceShortName]), + $responses, + $operation['openapi_context']['summary'] ?? '', + $operation['openapi_context']['description'] ?? $this->getPathDescription($resourceShortName, $method, $operationType), + isset($operation['openapi_context']['externalDocs']) ? new ExternalDocumentation($operation['openapi_context']['externalDocs']['description'] ?? null, $operation['openapi_context']['externalDocs']['url']) : null, + $parameters, + $requestBody, + isset($operation['openapi_context']['callbacks']) ? new \ArrayObject($operation['openapi_context']['callbacks']) : null, + $operation['openapi_context']['deprecated'] ?? (bool) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', false, true), + $operation['openapi_context']['security'] ?? [], + $operation['openapi_context']['servers'] ?? [] + )); + + $paths->addPath($path, $pathItem); + } + + return [$links, $schemas]; + } + + private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject + { + $content = new \ArrayObject(); + + foreach ($responseMimeTypes as $mimeType => $format) { + $content[$mimeType] = new Model\MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); + } + + return $content; + } + + private function getMimeTypes(string $resourceClass, string $operationName, string $operationType, ResourceMetadata $resourceMetadata = null): array + { + $requestFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_formats', $this->formats, true); + $responseFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_formats', $this->formats, true); + + $requestMimeTypes = $this->flattenMimeTypes($requestFormats); + $responseMimeTypes = $this->flattenMimeTypes($responseFormats); + + return [$requestMimeTypes, $responseMimeTypes]; + } + + private function flattenMimeTypes(array $responseFormats): array + { + $responseMimeTypes = []; + foreach ($responseFormats as $responseFormat => $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $responseMimeTypes[$mimeType] = $responseFormat; + } + } + + return $responseMimeTypes; + } + + /** + * Gets the path for an operation. + * + * If the path ends with the optional _format parameter, it is removed + * as optional path parameters are not yet supported. + * + * @see https://github.com/OAI/OpenAPI-Specification/issues/93 + */ + private function getPath(string $resourceShortName, string $operationName, array $operation, string $operationType): string + { + if ($operation['path'] ?? null) { + return 0 === strpos($operation['path'], '/') ? $operation['path'] : '/'.$operation['path']; + } + $path = $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); + if ('.{_format}' === substr($path, -10)) { + $path = substr($path, 0, -10); + } + + return $path; + } + + private function getPathDescription(string $resourceShortName, string $method, string $operationType): string + { + switch ($method) { + case 'GET': + $pathSummary = OperationType::COLLECTION === $operationType ? 'Retrieves the collection of %s resources.' : 'Retrieves a %s resource.'; + break; + case 'POST': + $pathSummary = 'Creates a %s resource.'; + break; + case 'PATCH': + $pathSummary = 'Updates the %s resource.'; + break; + case 'PUT': + $pathSummary = 'Replaces the %s resource.'; + break; + case 'DELETE': + $pathSummary = 'Removes the %s resource.'; + break; + default: + return $resourceShortName; + } + + return sprintf($pathSummary, $resourceShortName); + } + + /** + * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. + */ + private function getLink(string $resourceClass, string $operationId, string $path): Model\Link + { + $parameters = []; + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); + if (!$propertyMetadata->isIdentifier()) { + continue; + } + + $parameters[$propertyName] = sprintf('$response.body#/%s', $propertyName); + } + + return new Model\Link( + $operationId, + new \ArrayObject($parameters), + [], + 1 === \count($parameters) ? sprintf('The `%1$s` value returned in the response can be used as the `%1$s` parameter in `GET %2$s`.', key($parameters), $path) : sprintf('The values returned in the response can be used in `GET %s`.', $path) + ); + } + + /** + * Gets parameters corresponding to enabled filters. + */ + private function getFiltersParameters(ResourceMetadata $resourceMetadata, string $operationName, string $resourceClass): array + { + $parameters = []; + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + foreach ($resourceFilters as $filterId) { + if (!$filter = $this->getFilter($filterId)) { + continue; + } + + foreach ($filter->getDescription($resourceClass) as $name => $data) { + $schema = $data['schema'] ?? \in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']; + + $parameters[] = new Model\Parameter( + $name, + 'query', + $data['description'] ?? '', + $data['required'] ?? false, + $data['openapi']['deprecated'] ?? false, + $data['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array($data['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], true) ? 'deepObject' : 'form', + 'array' === $schema['type'], + $data['openapi']['allowReserved'] ?? false, + $data['openapi']['example'] ?? null, + isset($data['openapi']['examples'] + ) ? new \ArrayObject($data['openapi']['examples']) : null); + } + } + + return $parameters; + } + + private function getPaginationParameters(ResourceMetadata $resourceMetadata, string $operationName): array + { + if (!$this->paginationOptions->isPaginationEnabled()) { + return []; + } + + $parameters = []; + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', true, true)) { + $parameters[] = new Model\Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]); + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->paginationOptions->getClientItemsPerPage(), true)) { + $schema = [ + 'type' => 'integer', + 'default' => $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_items_per_page', 30, true), + 'minimum' => 0, + ]; + + if (null !== $maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_maximum_items_per_page', null, true)) { + $schema['maximum'] = $maxItemsPerPage; + } + + $parameters[] = new Model\Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema); + } + } + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->paginationOptions->getPaginationClientEnabled(), true)) { + $parameters[] = new Model\Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); + } + + return $parameters; + } + + private function getOauthSecurityScheme(): Model\SecurityScheme + { + $oauthFlow = new Model\OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl(), $this->openApiOptions->getOAuthRefreshUrl(), new \ArrayObject($this->openApiOptions->getOAuthScopes())); + $description = sprintf( + 'OAuth 2.0 %s Grant', + strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) + ); + $implicit = $password = $clientCredentials = $authorizationCode = null; + + switch ($this->openApiOptions->getOAuthFlow()) { + case 'implicit': + $implicit = $oauthFlow; + break; + case 'password': + $password = $oauthFlow; + break; + case 'application': + case 'clientCredentials': + $clientCredentials = $oauthFlow; + break; + case 'accessCode': + case 'authorizationCode': + $authorizationCode = $oauthFlow; + break; + default: + throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode'); + } + + return new Model\SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, 'oauth2', null, new Model\OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null); + } + + private function getSecuritySchemes(): array + { + $securitySchemes = []; + + if ($this->openApiOptions->getOAuthEnabled()) { + $securitySchemes['oauth'] = $this->getOauthSecurityScheme(); + } + + foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) { + $description = sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']); + $securitySchemes[$key] = new Model\SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type'], 'bearer'); + } + + return $securitySchemes; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactoryInterface.php b/src/OpenApi/Factory/OpenApiFactoryInterface.php new file mode 100644 index 00000000000..d4b04bb190f --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactoryInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Factory; + +use ApiPlatform\Core\OpenApi\OpenApi; + +interface OpenApiFactoryInterface +{ + /** + * Creates an OpenApi class. + */ + public function __invoke(array $context = []): OpenApi; +} diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php new file mode 100644 index 00000000000..1035ab1d7f0 --- /dev/null +++ b/src/OpenApi/Model/Components.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Components +{ + use ExtensionTrait; + + private $schemas; + private $responses; + private $parameters; + private $examples; + private $requestBodies; + private $headers; + private $securitySchemes; + private $links; + private $callbacks; + + public function __construct(\ArrayObject $schemas = null, \ArrayObject $responses = null, \ArrayObject $parameters = null, \ArrayObject $examples = null, \ArrayObject $requestBodies = null, \ArrayObject $headers = null, \ArrayObject $securitySchemes = null, \ArrayObject $links = null, \ArrayObject $callbacks = null) + { + $this->schemas = $schemas; + $this->responses = $responses; + $this->parameters = $parameters; + $this->examples = $examples; + $this->requestBodies = $requestBodies; + $this->headers = $headers; + $this->securitySchemes = $securitySchemes; + $this->links = $links; + $this->callbacks = $callbacks; + } + + public function getSchemas(): ?\ArrayObject + { + return $this->schemas; + } + + public function getResponses(): ?\ArrayObject + { + return $this->responses; + } + + public function getParameters(): ?\ArrayObject + { + return $this->parameters; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getRequestBodies(): ?\ArrayObject + { + return $this->requestBodies; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getSecuritySchemes(): ?\ArrayObject + { + return $this->securitySchemes; + } + + public function getLinks(): ?\ArrayObject + { + return $this->links; + } + + public function getCallbacks(): ?\ArrayObject + { + return $this->callbacks; + } + + public function withSchemas(\ArrayObject $schemas): self + { + $clone = clone $this; + $clone->schemas = $schemas; + + return $clone; + } + + public function withResponses(\ArrayObject $responses): self + { + $clone = clone $this; + $clone->responses = $responses; + + return $clone; + } + + public function withParameters(\ArrayObject $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withRequestBodies(\ArrayObject $requestBodies): self + { + $clone = clone $this; + $clone->requestBodies = $requestBodies; + + return $clone; + } + + public function withHeaders(\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withSecuritySchemes(\ArrayObject $securitySchemes): self + { + $clone = clone $this; + $clone->securitySchemes = $securitySchemes; + + return $clone; + } + + public function withLinks(\ArrayObject $links): self + { + $clone = clone $this; + $clone->links = $links; + + return $clone; + } + + public function withCallbacks(\ArrayObject $callbacks): self + { + $clone = clone $this; + $clone->callbacks = $callbacks; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Contact.php b/src/OpenApi/Model/Contact.php new file mode 100644 index 00000000000..713de0ff65e --- /dev/null +++ b/src/OpenApi/Model/Contact.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Contact +{ + use ExtensionTrait; + + private $name; + private $url; + private $email; + + public function __construct(string $name = '', string $url = '', string $email = '') + { + $this->name = $name; + $this->url = $url; + $this->email = $email; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getEmail(): string + { + return $this->email; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } + + public function withEmail(string $email): self + { + $clone = clone $this; + $clone->email = $email; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Encoding.php b/src/OpenApi/Model/Encoding.php new file mode 100644 index 00000000000..12b8d91a1d2 --- /dev/null +++ b/src/OpenApi/Model/Encoding.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Encoding +{ + use ExtensionTrait; + + private $contentType; + private $headers; + private $style; + private $explode; + private $allowReserved; + + public function __construct(string $contentType = '', \ArrayObject $headers = null, string $style = '', bool $explode = false, bool $allowReserved = false) + { + $this->contentType = $contentType; + $this->headers = $headers; + $this->style = $style; + $this->explode = $explode; + $this->allowReserved = $allowReserved; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getStyle(): string + { + return $this->style; + } + + public function canExplode(): bool + { + return $this->explode; + } + + public function canAllowReserved(): bool + { + return $this->allowReserved; + } + + public function withContentType(string $contentType): self + { + $clone = clone $this; + $clone->contentType = $contentType; + + return $clone; + } + + public function withHeaders(?\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withStyle(string $style): self + { + $clone = clone $this; + $clone->style = $style; + + return $clone; + } + + public function withExplode(bool $explode): self + { + $clone = clone $this; + $clone->explode = $explode; + + return $clone; + } + + public function withAllowReserved(bool $allowReserved): self + { + $clone = clone $this; + $clone->allowReserved = $allowReserved; + + return $clone; + } +} diff --git a/src/OpenApi/Model/ExtensionTrait.php b/src/OpenApi/Model/ExtensionTrait.php new file mode 100644 index 00000000000..62f97f2151d --- /dev/null +++ b/src/OpenApi/Model/ExtensionTrait.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +trait ExtensionTrait +{ + private $extensionProperties = []; + + public function withExtensionProperty(string $key, string $value) + { + if (0 !== strpos($key, 'x-')) { + $key = 'x-'.$key; + } + + $clone = clone $this; + $clone->extensionProperties[$key] = $value; + + return $clone; + } + + public function getExtensionProperties(): array + { + return $this->extensionProperties; + } +} diff --git a/src/OpenApi/Model/ExternalDocumentation.php b/src/OpenApi/Model/ExternalDocumentation.php new file mode 100644 index 00000000000..f606a26b4d7 --- /dev/null +++ b/src/OpenApi/Model/ExternalDocumentation.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class ExternalDocumentation +{ + use ExtensionTrait; + + private $description; + private $url; + + public function __construct(string $description = '', string $url = '') + { + $this->description = $description; + $this->url = $url; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getUrl(): string + { + return $this->url; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Info.php b/src/OpenApi/Model/Info.php new file mode 100644 index 00000000000..9b16e9e8909 --- /dev/null +++ b/src/OpenApi/Model/Info.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Info +{ + use ExtensionTrait; + + private $title; + private $description; + private $termsOfService; + private $contact; + private $license; + private $version; + + public function __construct(string $title, string $version, string $description = '', string $termsOfService = null, Contact $contact = null, License $license = null) + { + $this->title = $title; + $this->version = $version; + $this->description = $description; + $this->termsOfService = $termsOfService; + $this->contact = $contact; + $this->license = $license; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getTermsOfService(): ?string + { + return $this->termsOfService; + } + + public function getContact(): ?Contact + { + return $this->contact; + } + + public function getLicense(): ?License + { + return $this->license; + } + + public function getVersion(): string + { + return $this->version; + } + + public function withTitle(string $title): self + { + $info = clone $this; + $info->title = $title; + + return $info; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withTermsOfService(string $termsOfService): self + { + $clone = clone $this; + $clone->termsOfService = $termsOfService; + + return $clone; + } + + public function withContact(Contact $contact): self + { + $clone = clone $this; + $clone->contact = $contact; + + return $clone; + } + + public function withLicense(License $license): self + { + $clone = clone $this; + $clone->license = $license; + + return $clone; + } + + public function withVersion(string $version): self + { + $clone = clone $this; + $clone->version = $version; + + return $clone; + } +} diff --git a/src/OpenApi/Model/License.php b/src/OpenApi/Model/License.php new file mode 100644 index 00000000000..ebc183c9aa0 --- /dev/null +++ b/src/OpenApi/Model/License.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class License +{ + use ExtensionTrait; + + private $name; + private $url; + + public function __construct(string $name, string $url) + { + $this->name = $name; + $this->url = $url; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): string + { + return $this->url; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Link.php b/src/OpenApi/Model/Link.php new file mode 100644 index 00000000000..a0feb9511d6 --- /dev/null +++ b/src/OpenApi/Model/Link.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Link +{ + use ExtensionTrait; + + private $operationId; + private $parameters; + private $requestBody; + private $description; + private $server; + + public function __construct(string $operationId, \ArrayObject $parameters = null, $requestBody = null, string $description = '', Server $server = null) + { + $this->operationId = $operationId; + $this->parameters = $parameters; + $this->requestBody = $requestBody; + $this->description = $description; + $this->server = $server; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getParameters(): \ArrayObject + { + return $this->parameters; + } + + public function getRequestBody() + { + return $this->requestBody; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getServer(): ?Server + { + return $this->server; + } + + public function withOperationId(string $operationId): self + { + $clone = clone $this; + $clone->operationId = $operationId; + + return $clone; + } + + public function withParameters(\ArrayObject $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody($requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withServer(Server $server): self + { + $clone = clone $this; + $clone->server = $server; + + return $clone; + } +} diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php new file mode 100644 index 00000000000..8f232bb4b4b --- /dev/null +++ b/src/OpenApi/Model/MediaType.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class MediaType +{ + use ExtensionTrait; + + private $schema; + private $example; + private $examples; + private $encoding; + + public function __construct(\ArrayObject $schema = null, $example = null, \ArrayObject $examples = null, Encoding $encoding = null) + { + $this->schema = $schema; + $this->example = $example; + $this->examples = $examples; + $this->encoding = $encoding; + } + + public function getSchema(): ?\ArrayObject + { + return $this->schema; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getEncoding(): ?Encoding + { + return $this->encoding; + } + + public function withSchema(\ArrayObject $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withEncoding(Encoding $encoding): self + { + $clone = clone $this; + $clone->encoding = $encoding; + + return $clone; + } +} diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php new file mode 100644 index 00000000000..1b3dfd4849b --- /dev/null +++ b/src/OpenApi/Model/OAuthFlow.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class OAuthFlow +{ + use ExtensionTrait; + + private $authorizationUrl; + private $tokenUrl; + private $refreshUrl; + private $scopes; + + public function __construct(string $authorizationUrl = null, string $tokenUrl = null, string $refreshUrl = null, \ArrayObject $scopes = null) + { + $this->authorizationUrl = $authorizationUrl; + $this->tokenUrl = $tokenUrl; + $this->refreshUrl = $refreshUrl; + $this->scopes = $scopes; + } + + public function getAuthorizationUrl(): ?string + { + return $this->authorizationUrl; + } + + public function getTokenUrl(): ?string + { + return $this->tokenUrl; + } + + public function getRefreshUrl(): ?string + { + return $this->refreshUrl; + } + + public function getScopes(): \ArrayObject + { + return $this->scopes; + } + + public function withAuthorizationUrl(string $authorizationUrl): self + { + $clone = clone $this; + $clone->authorizationUrl = $authorizationUrl; + + return $clone; + } + + public function withTokenUrl(string $tokenUrl): self + { + $clone = clone $this; + $clone->tokenUrl = $tokenUrl; + + return $clone; + } + + public function withRefreshUrl(string $refreshUrl): self + { + $clone = clone $this; + $clone->refreshUrl = $refreshUrl; + + return $clone; + } + + public function withScopes(\ArrayObject $scopes): self + { + $clone = clone $this; + $clone->scopes = $scopes; + + return $clone; + } +} diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php new file mode 100644 index 00000000000..afe06ad4824 --- /dev/null +++ b/src/OpenApi/Model/OAuthFlows.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class OAuthFlows +{ + use ExtensionTrait; + + private $implicit; + private $password; + private $clientCredentials; + private $authorizationCode; + + public function __construct(OAuthFlow $implicit = null, OAuthFlow $password = null, OAuthFlow $clientCredentials = null, OAuthFlow $authorizationCode = null) + { + $this->implicit = $implicit; + $this->password = $password; + $this->clientCredentials = $clientCredentials; + $this->authorizationCode = $authorizationCode; + } + + public function getImplicit(): ?OAuthFlow + { + return $this->implicit; + } + + public function getPassword(): ?OAuthFlow + { + return $this->password; + } + + public function getClientCredentials(): ?OAuthFlow + { + return $this->clientCredentials; + } + + public function getAuthorizationCode(): ?OAuthFlow + { + return $this->authorizationCode; + } + + public function withImplicit(OAuthFlow $implicit): self + { + $clone = clone $this; + $clone->implicit = $implicit; + + return $clone; + } + + public function withPassword(OAuthFlow $password): self + { + $clone = clone $this; + $clone->password = $password; + + return $clone; + } + + public function withClientCredentials(OAuthFlow $clientCredentials): self + { + $clone = clone $this; + $clone->clientCredentials = $clientCredentials; + + return $clone; + } + + public function withAuthorizationCode(OAuthFlow $authorizationCode): self + { + $clone = clone $this; + $clone->authorizationCode = $authorizationCode; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php new file mode 100644 index 00000000000..c5b7fd76faf --- /dev/null +++ b/src/OpenApi/Model/Operation.php @@ -0,0 +1,211 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Operation +{ + use ExtensionTrait; + + private $tags; + private $summary; + private $description; + private $operationId; + private $parameters; + private $requestBody; + private $responses; + private $callbacks; + private $deprecated; + private $security; + private $servers; + private $externalDocs; + + public function __construct(string $operationId = null, array $tags = [], array $responses = [], string $summary = '', string $description = '', ExternalDocumentation $externalDocs = null, array $parameters = [], RequestBody $requestBody = null, \ArrayObject $callbacks = null, bool $deprecated = false, array $security = [], array $servers = []) + { + $this->tags = $tags; + $this->summary = $summary; + $this->description = $description; + $this->operationId = $operationId; + $this->parameters = $parameters; + $this->requestBody = $requestBody; + $this->responses = $responses; + $this->callbacks = $callbacks; + $this->deprecated = $deprecated; + $this->security = $security; + $this->servers = $servers; + $this->externalDocs = $externalDocs; + } + + public function addResponse(Response $response, $status = 'default'): self + { + $this->responses[$status] = $response; + + return $this; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getTags(): array + { + return $this->tags; + } + + public function getResponses(): array + { + return $this->responses; + } + + public function getSummary(): string + { + return $this->summary; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getExternalDocs(): ?ExternalDocumentation + { + return $this->externalDocs; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getRequestBody(): ?RequestBody + { + return $this->requestBody; + } + + public function getCallbacks(): ?\ArrayObject + { + return $this->callbacks; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function getSecurity(): array + { + return $this->security; + } + + public function getServers(): array + { + return $this->servers; + } + + public function withOperationId(string $operationId): self + { + $clone = clone $this; + $clone->operationId = $operationId; + + return $clone; + } + + public function withTags(array $tags): self + { + $clone = clone $this; + $clone->tags = $tags; + + return $clone; + } + + public function withResponses(array $responses): self + { + $clone = clone $this; + $clone->responses = $responses; + + return $clone; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withExternalDocs(ExternalDocumentation $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } + + public function withParameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody(RequestBody $requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withCallbacks(\ArrayObject $callbacks): self + { + $clone = clone $this; + $clone->callbacks = $callbacks; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } + + public function withSecurity(array $security): self + { + $clone = clone $this; + $clone->security = $security; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Parameter.php b/src/OpenApi/Model/Parameter.php new file mode 100644 index 00000000000..f68f0cbd231 --- /dev/null +++ b/src/OpenApi/Model/Parameter.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Parameter +{ + use ExtensionTrait; + + private $name; + private $in; + private $description; + private $required; + private $deprecated; + private $allowEmptyValue; + private $schema; + private $explode; + private $allowReserved; + private $style; + private $example; + private $examples; + private $content; + + public function __construct(string $name, string $in, string $description = '', bool $required = false, bool $deprecated = false, bool $allowEmptyValue = false, array $schema = [], string $style = null, bool $explode = false, bool $allowReserved = false, $example = null, \ArrayObject $examples = null, \ArrayObject $content = null) + { + $this->name = $name; + $this->in = $in; + $this->description = $description; + $this->required = $required; + $this->deprecated = $deprecated; + $this->allowEmptyValue = $allowEmptyValue; + $this->schema = $schema; + $this->explode = $explode; + $this->allowReserved = $allowReserved; + $this->example = $example; + $this->examples = $examples; + $this->content = $content; + $this->style = $style; + + if (null === $style) { + if ('query' === $in || 'cookie' === $in) { + $this->style = 'form'; + } elseif ('path' === $in || 'header' === $in) { + $this->style = 'simple'; + } + } + } + + public function getName(): ?string + { + return $this->name; + } + + public function getIn(): ?string + { + return $this->in; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function canAllowEmptyValue(): bool + { + return $this->allowEmptyValue; + } + + public function getSchema(): array + { + return $this->schema; + } + + public function getStyle(): string + { + return $this->style; + } + + public function canExplode(): bool + { + return $this->explode; + } + + public function canAllowReserved(): bool + { + return $this->allowReserved; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getContent(): ?\ArrayObject + { + return $this->content; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withIn(string $in): self + { + $clone = clone $this; + $clone->in = $in; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withRequired(bool $required): self + { + $clone = clone $this; + $clone->required = $required; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } + + public function withAllowEmptyValue(bool $allowEmptyValue): self + { + $clone = clone $this; + $clone->allowEmptyValue = $allowEmptyValue; + + return $clone; + } + + public function withSchema(array $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withStyle(string $style): self + { + $clone = clone $this; + $clone->style = $style; + + return $clone; + } + + public function withExplode(bool $explode): self + { + $clone = clone $this; + $clone->explode = $explode; + + return $clone; + } + + public function withAllowReserved(bool $allowReserved): self + { + $clone = clone $this; + $clone->allowReserved = $allowReserved; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } +} diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php new file mode 100644 index 00000000000..1285336fa74 --- /dev/null +++ b/src/OpenApi/Model/PathItem.php @@ -0,0 +1,220 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class PathItem +{ + use ExtensionTrait; + + private static $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; + private $ref; + private $summary; + private $description; + private $get; + private $put; + private $post; + private $delete; + private $options; + private $head; + private $patch; + private $trace; + private $servers; + private $parameters; + + public function __construct(string $ref = null, string $summary = null, string $description = null, Operation $get = null, Operation $put = null, Operation $post = null, Operation $delete = null, Operation $options = null, Operation $head = null, Operation $patch = null, Operation $trace = null, array $servers = [], array $parameters = []) + { + $this->ref = $ref; + $this->summary = $summary; + $this->description = $description; + $this->get = $get; + $this->put = $put; + $this->post = $post; + $this->delete = $delete; + $this->options = $options; + $this->head = $head; + $this->patch = $patch; + $this->trace = $trace; + $this->servers = $servers; + $this->parameters = $parameters; + } + + public function getRef(): ?string + { + return $this->ref; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getGet(): ?Operation + { + return $this->get; + } + + public function getPut(): ?Operation + { + return $this->put; + } + + public function getPost(): ?Operation + { + return $this->post; + } + + public function getDelete(): ?Operation + { + return $this->delete; + } + + public function getOptions(): ?Operation + { + return $this->options; + } + + public function getHead(): ?Operation + { + return $this->head; + } + + public function getPatch(): ?Operation + { + return $this->patch; + } + + public function getTrace(): ?Operation + { + return $this->trace; + } + + public function getServers(): array + { + return $this->servers; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function withRef(string $ref): self + { + $clone = clone $this; + $clone->ref = $ref; + + return $clone; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withGet(Operation $get): self + { + $clone = clone $this; + $clone->get = $get; + + return $clone; + } + + public function withPut(Operation $put): self + { + $clone = clone $this; + $clone->put = $put; + + return $clone; + } + + public function withPost(Operation $post): self + { + $clone = clone $this; + $clone->post = $post; + + return $clone; + } + + public function withDelete(Operation $delete): self + { + $clone = clone $this; + $clone->delete = $delete; + + return $clone; + } + + public function withOptions(Operation $options): self + { + $clone = clone $this; + $clone->options = $options; + + return $clone; + } + + public function withHead(Operation $head): self + { + $clone = clone $this; + $clone->head = $head; + + return $clone; + } + + public function withPatch(Operation $patch): self + { + $clone = clone $this; + $clone->patch = $patch; + + return $clone; + } + + public function withTrace(Operation $trace): self + { + $clone = clone $this; + $clone->trace = $trace; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } + + public function withParameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Paths.php b/src/OpenApi/Model/Paths.php new file mode 100644 index 00000000000..c0b30a9a64c --- /dev/null +++ b/src/OpenApi/Model/Paths.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Paths +{ + private $paths; + + public function addPath(string $path, PathItem $pathItem) + { + $this->paths[$path] = $pathItem; + } + + public function getPath(string $path): ?PathItem + { + return $this->paths[$path] ?? null; + } + + public function getPaths(): array + { + return $this->paths; + } +} diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php new file mode 100644 index 00000000000..04235777a5e --- /dev/null +++ b/src/OpenApi/Model/RequestBody.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class RequestBody +{ + use ExtensionTrait; + + private $description; + private $content; + private $required; + + public function __construct(string $description = '', \ArrayObject $content = null, bool $required = false) + { + $this->description = $description; + $this->content = $content; + $this->required = $required; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): \ArrayObject + { + return $this->content; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withRequired(bool $required): self + { + $clone = clone $this; + $clone->required = $required; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php new file mode 100644 index 00000000000..9bb2f84d353 --- /dev/null +++ b/src/OpenApi/Model/Response.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Response +{ + use ExtensionTrait; + + private $description; + private $content; + private $headers; + private $links; + + public function __construct(string $description = '', \ArrayObject $content = null, \ArrayObject $headers = null, \ArrayObject $links = null) + { + $this->description = $description; + $this->content = $content; + $this->headers = $headers; + $this->links = $links; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): ?\ArrayObject + { + return $this->content; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getLinks(): ?\ArrayObject + { + return $this->links; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withHeaders(\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withLinks(\ArrayObject $links): self + { + $clone = clone $this; + $clone->links = $links; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Schema.php b/src/OpenApi/Model/Schema.php new file mode 100644 index 00000000000..5b9060238f7 --- /dev/null +++ b/src/OpenApi/Model/Schema.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +use ApiPlatform\Core\JsonSchema\Schema as JsonSchema; + +final class Schema extends \ArrayObject +{ + use ExtensionTrait; + + private $nullable; + private $discriminator; + private $readOnly; + private $writeOnly; + private $xml; + private $externalDocs; + private $example; + private $deprecated; + private $schema; + + public function __construct(bool $nullable = false, $discriminator = null, bool $readOnly = false, bool $writeOnly = false, string $xml = null, $externalDocs = null, $example = null, bool $deprecated = false) + { + $this->nullable = $nullable; + $this->discriminator = $discriminator; + $this->readOnly = $readOnly; + $this->writeOnly = $writeOnly; + $this->xml = $xml; + $this->externalDocs = $externalDocs; + $this->example = $example; + $this->deprecated = $deprecated; + $this->schema = new JsonSchema(); + + parent::__construct([]); + } + + public function setDefinitions(array $definitions) + { + $this->schema->setDefinitions(new \ArrayObject($definitions)); + } + + /** + * {@inheritdoc} + */ + public function getArrayCopy(): array + { + $schema = parent::getArrayCopy(); + unset($schema['schema']); + + return $schema; + } + + public function getDefinitions(): \ArrayObject + { + return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); + } + + public function getNullable(): bool + { + return $this->nullable; + } + + public function getDiscriminator() + { + return $this->discriminator; + } + + public function getReadOnly(): bool + { + return $this->readOnly; + } + + public function getWriteOnly(): bool + { + return $this->writeOnly; + } + + public function getXml(): string + { + return $this->xml; + } + + public function getExternalDocs() + { + return $this->externalDocs; + } + + public function getExample() + { + return $this->example; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function withNullable(bool $nullable): self + { + $clone = clone $this; + $clone->nullable = $nullable; + + return $clone; + } + + public function withDiscriminator($discriminator): self + { + $clone = clone $this; + $clone->discriminator = $discriminator; + + return $clone; + } + + public function withReadOnly(bool $readOnly): self + { + $clone = clone $this; + $clone->readOnly = $readOnly; + + return $clone; + } + + public function withWriteOnly(bool $writeOnly): self + { + $clone = clone $this; + $clone->writeOnly = $writeOnly; + + return $clone; + } + + public function withXml(string $xml): self + { + $clone = clone $this; + $clone->xml = $xml; + + return $clone; + } + + public function withExternalDocs($externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } +} diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php new file mode 100644 index 00000000000..385e4e028c5 --- /dev/null +++ b/src/OpenApi/Model/SecurityScheme.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class SecurityScheme +{ + use ExtensionTrait; + + private $type; + private $description; + private $name; + private $in; + private $scheme; + private $bearerFormat; + private $flows; + private $openIdConnectUrl; + + public function __construct(string $type = null, string $description = '', string $name = null, string $in = null, string $scheme = null, string $bearerFormat = null, OAuthFlows $flows = null, string $openIdConnectUrl = null) + { + $this->type = $type; + $this->description = $description; + $this->name = $name; + $this->in = $in; + $this->scheme = $scheme; + $this->bearerFormat = $bearerFormat; + $this->flows = $flows; + $this->openIdConnectUrl = $openIdConnectUrl; + } + + public function getType(): string + { + return $this->type; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getIn(): ?string + { + return $this->in; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getBearerFormat(): ?string + { + return $this->bearerFormat; + } + + public function getFlows(): ?OAuthFlows + { + return $this->flows; + } + + public function getOpenIdConnectUrl(): ?string + { + return $this->openIdConnectUrl; + } + + public function withType(string $type): self + { + $clone = clone $this; + $clone->type = $type; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withIn(string $in): self + { + $clone = clone $this; + $clone->in = $in; + + return $clone; + } + + public function withScheme(string $scheme): self + { + $clone = clone $this; + $clone->scheme = $scheme; + + return $clone; + } + + public function withBearerFormat(string $bearerFormat): self + { + $clone = clone $this; + $clone->bearerFormat = $bearerFormat; + + return $clone; + } + + public function withFlows(OAuthFlows $flows): self + { + $clone = clone $this; + $clone->flows = $flows; + + return $clone; + } + + public function withOpenIdConnectUrl(string $openIdConnectUrl): self + { + $clone = clone $this; + $clone->openIdConnectUrl = $openIdConnectUrl; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php new file mode 100644 index 00000000000..852afd5c9ad --- /dev/null +++ b/src/OpenApi/Model/Server.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Server +{ + use ExtensionTrait; + + private $url; + private $description; + private $variables; + + public function __construct(string $url, string $description = '', \ArrayObject $variables = null) + { + $this->url = $url; + $this->description = $description; + $this->variables = $variables; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getVariables(): ?\ArrayObject + { + return $this->variables; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withVariables(\ArrayObject $variables): self + { + $clone = clone $this; + $clone->variables = $variables; + + return $clone; + } +} diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php new file mode 100644 index 00000000000..94c50377671 --- /dev/null +++ b/src/OpenApi/OpenApi.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi; + +use ApiPlatform\Core\Documentation\DocumentationInterface; +use ApiPlatform\Core\OpenApi\Model\Components; +use ApiPlatform\Core\OpenApi\Model\ExtensionTrait; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; + +final class OpenApi implements DocumentationInterface +{ + use ExtensionTrait; + + public const VERSION = '3.0.3'; + + private $openapi; + private $info; + private $servers; + private $paths; + private $components; + private $security; + private $tags; + private $externalDocs; + + public function __construct(Info $info, array $servers = [], Paths $paths, Components $components = null, array $security = [], array $tags = [], $externalDocs = null) + { + $this->openapi = self::VERSION; + $this->info = $info; + $this->servers = $servers; + $this->paths = $paths; + $this->components = $components; + $this->security = $security; + $this->tags = $tags; + $this->externalDocs = $externalDocs; + } + + public function getOpenapi(): string + { + return $this->openapi; + } + + public function getInfo(): Info + { + return $this->info; + } + + public function getServers(): array + { + return $this->servers; + } + + public function getPaths() + { + return $this->paths; + } + + public function getComponents(): Components + { + return $this->components; + } + + public function getSecurity(): array + { + return $this->security; + } + + public function getTags(): array + { + return $this->tags; + } + + public function getExternalDocs(): ?array + { + return $this->externalDocs; + } + + public function withOpenapi(string $openapi): self + { + $clone = clone $this; + $clone->openapi = $openapi; + + return $clone; + } + + public function withInfo(Info $info): self + { + $clone = clone $this; + $clone->info = $info; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } + + public function withPaths(Paths $paths): self + { + $clone = clone $this; + $clone->paths = $paths; + + return $clone; + } + + public function withComponents(Components $components): self + { + $clone = clone $this; + $clone->components = $components; + + return $clone; + } + + public function withSecurity(array $security): self + { + $clone = clone $this; + $clone->security = $security; + + return $clone; + } + + public function withTags(array $tags): self + { + $clone = clone $this; + $clone->tags = $tags; + + return $clone; + } + + public function withExternalDocs(array $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } +} diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php new file mode 100644 index 00000000000..c1b35e2e719 --- /dev/null +++ b/src/OpenApi/Options.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi; + +final class Options +{ + private $title; + private $description; + private $version; + private $oAuthEnabled; + private $oAuthType; + private $oAuthFlow; + private $oAuthTokenUrl; + private $oAuthAuthorizationUrl; + private $oAuthRefreshUrl; + private $oAuthScopes; + private $apiKeys; + + public function __construct(string $title, string $description = '', string $version = '', bool $oAuthEnabled = false, string $oAuthType = '', string $oAuthFlow = '', string $oAuthTokenUrl = '', string $oAuthAuthorizationUrl = '', string $oAuthRefreshUrl = '', array $oAuthScopes = [], array $apiKeys = []) + { + $this->title = $title; + $this->description = $description; + $this->version = $version; + $this->oAuthEnabled = $oAuthEnabled; + $this->oAuthType = $oAuthType; + $this->oAuthFlow = $oAuthFlow; + $this->oAuthTokenUrl = $oAuthTokenUrl; + $this->oAuthAuthorizationUrl = $oAuthAuthorizationUrl; + $this->oAuthRefreshUrl = $oAuthRefreshUrl; + $this->oAuthScopes = $oAuthScopes; + $this->apiKeys = $apiKeys; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getOAuthEnabled(): bool + { + return $this->oAuthEnabled; + } + + public function getOAuthType(): string + { + return $this->oAuthType; + } + + public function getOAuthFlow(): string + { + return $this->oAuthFlow; + } + + public function getOAuthTokenUrl(): string + { + return $this->oAuthTokenUrl; + } + + public function getOAuthAuthorizationUrl(): string + { + return $this->oAuthAuthorizationUrl; + } + + public function getOAuthRefreshUrl(): string + { + return $this->oAuthRefreshUrl; + } + + public function getOAuthScopes(): array + { + return $this->oAuthScopes; + } + + public function getApiKeys(): array + { + return $this->apiKeys; + } +} diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php new file mode 100644 index 00000000000..975e340c14c --- /dev/null +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Serializer; + +use ApiPlatform\Core\OpenApi\OpenApi; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Generates an OpenAPI v3 specification. + */ +final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + public const FORMAT = 'json'; + public const OPEN_API_PRESERVE_EMPTY_OBJECTS = 'open_api_preserve_empty_objects'; + private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; + + private $decorated; + + public function __construct(NormalizerInterface $decorated) + { + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; + $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; + + return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); + } + + private function recursiveClean($data): array + { + foreach ($data as $key => $value) { + if (self::EXTENSION_PROPERTIES_KEY === $key) { + foreach ($data[self::EXTENSION_PROPERTIES_KEY] as $extensionPropertyKey => $extensionPropertyValue) { + $data[$extensionPropertyKey] = $extensionPropertyValue; + } + continue; + } + + // Side effect of using getPaths(): Paths which itself contains the array + if ('paths' === $key) { + $value = $data['paths'] = $data['paths']['paths']; + unset($data['paths']['paths']); + } + + if (\is_array($value)) { + $data[$key] = $this->recursiveClean($value); + // arrays must stay even if empty + continue; + } + } + + unset($data[self::EXTENSION_PROPERTIES_KEY]); + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return self::FORMAT === $format && $data instanceof OpenApi; + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 835fe75a740..8c88ad034ad 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -85,6 +85,7 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup private $paginationClientEnabledParameterName; private $formats; private $formatsProvider; + /** * @var SchemaFactoryInterface */ diff --git a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php new file mode 100644 index 00000000000..a9df5225a0e --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\Command; + +use ApiPlatform\Core\OpenApi\OpenApi; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Console\Tester\ApplicationTester; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * @author Amrouche Hamza + */ +class OpenApiCommandTest extends KernelTestCase +{ + /** + * @var ApplicationTester + */ + private $tester; + + protected function setUp(): void + { + self::bootKernel(); + + $application = new Application(static::$kernel); + $application->setCatchExceptions(false); + $application->setAutoExit(false); + + $this->tester = new ApplicationTester($application); + } + + public function testExecute() + { + $this->tester->run(['command' => 'api:openapi:export']); + + $this->assertJson($this->tester->getDisplay()); + } + + public function testExecuteWithYaml() + { + $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); + + $result = $this->tester->getDisplay(); + $this->assertYaml($result); + + $expected = <<assertStringContainsString($expected, $result, 'nested object should be present.'); + + $expected = <<assertStringContainsString($expected, $result, 'arrays should be correctly formatted.'); + $this->assertStringContainsString('openapi: '.OpenApi::VERSION, $result); + + $expected = <<assertStringContainsString($expected, $result, 'multiline formatting must be preserved (using literal style).'); + } + + public function testWriteToFile() + { + /** @var string $tmpFile */ + $tmpFile = tempnam(sys_get_temp_dir(), 'test_write_to_file'); + + $this->tester->run(['command' => 'api:openapi:export', '--output' => $tmpFile]); + + $this->assertJson((string) @file_get_contents($tmpFile)); + @unlink($tmpFile); + } + + /** + * @param string $data + */ + private function assertYaml($data) + { + try { + Yaml::parse($data); + } catch (ParseException $exception) { + $this->fail('Is not valid YAML: '.$exception->getMessage()); + } + $this->addToAssertionCount(1); + } +} diff --git a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php index e290d87768c..69ba05ee03d 100644 --- a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php +++ b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php @@ -41,6 +41,10 @@ protected function setUp(): void $this->tester = new ApplicationTester($application); } + /** + * @group legacy + * @expectedDeprecation The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export". + */ public function testExecuteWithAliasVersion3() { $this->tester->run(['command' => 'api:swagger:export', '--spec-version' => 3]); @@ -48,13 +52,10 @@ public function testExecuteWithAliasVersion3() $this->assertJson($this->tester->getDisplay()); } - public function testExecuteOpenApiVersion2() - { - $this->tester->run(['command' => 'api:openapi:export']); - - $this->assertJson($this->tester->getDisplay()); - } - + /** + * @group legacy + * @expectedDeprecation The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export". + */ public function testExecuteWithYamlVersion3() { $this->tester->run(['command' => 'api:swagger:export', '--yaml' => true, '--spec-version' => 3]); @@ -93,20 +94,11 @@ public function testExecuteWithYamlVersion3() $this->assertStringContainsString($expected, $result, 'multiline formatting must be preserved (using literal style).'); } - public function testExecuteOpenApiVersion2WithYaml() - { - $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); - - $result = $this->tester->getDisplay(); - $this->assertYaml($result); - $this->assertStringContainsString("swagger: '2.0'", $result); - } - public function testExecuteWithBadArguments() { $this->expectException(InvalidOptionException::class); $this->expectExceptionMessage('This tool only supports versions 2, 3 of the OpenAPI specification ("foo" given).'); - $this->tester->run(['command' => 'api:openapi:export', '--spec-version' => 'foo', '--yaml' => true]); + $this->tester->run(['command' => 'api:swagger:export', '--spec-version' => 'foo', '--yaml' => true]); } public function testWriteToFile() diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index fd3ad6ee7fe..2271044208c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -61,6 +61,7 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\Pagination; +use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\FilterValidationException; @@ -77,6 +78,9 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\Filter\GroupFilter; use ApiPlatform\Core\Serializer\Filter\PropertyFilter; @@ -960,6 +964,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.operation_path_resolver.generator', 'api_platform.operation_path_resolver.underscore', 'api_platform.pagination', + 'api_platform.pagination_options', 'api_platform.path_segment_name_generator.underscore', 'api_platform.path_segment_name_generator.dash', 'api_platform.resource_class_resolver', @@ -1019,6 +1024,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) SerializerContextBuilderInterface::class => 'api_platform.serializer.context_builder', SubresourceDataProviderInterface::class => 'api_platform.subresource_data_provider', UrlGeneratorInterface::class => 'api_platform.router', + PaginationOptions::class => 'api_platform.pagination_options', ]; foreach ($aliases as $alias => $service) { @@ -1143,6 +1149,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.oauth.flow' => 'application', 'api_platform.oauth.tokenUrl' => '/oauth/v2/token', 'api_platform.oauth.authorizationUrl' => '/oauth/v2/auth', + 'api_platform.oauth.refreshUrl' => '/oauth/v2/refresh', 'api_platform.oauth.scopes' => [], 'api_platform.enable_swagger_ui' => true, 'api_platform.enable_re_doc' => true, @@ -1319,6 +1326,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions[] = 'api_platform.json_schema.type_factory'; $definitions[] = 'api_platform.json_schema.schema_factory'; $definitions[] = 'api_platform.json_schema.json_schema_generate_command'; + $definitions[] = 'api_platform.openapi.options'; + $definitions[] = 'api_platform.openapi.normalizer'; + $definitions[] = 'api_platform.openapi.normalizer.api_gateway'; + $definitions[] = 'api_platform.openapi.factory'; + $definitions[] = 'api_platform.openapi.command'; } // has jsonld @@ -1390,6 +1402,9 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $aliases += [ TypeFactoryInterface::class => 'api_platform.json_schema.type_factory', SchemaFactoryInterface::class => 'api_platform.json_schema.schema_factory', + Options::class => 'api_platform.openapi.options', + OpenApiNormalizer::class => 'api_platform.openapi.normalizer', + OpenApiFactoryInterface::class => 'api_platform.openapi.factory', ]; } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 57510a68ca1..2276679a860 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -144,6 +144,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'flow' => 'application', 'tokenUrl' => '/oauth/v2/token', 'authorizationUrl' => '/oauth/v2/auth', + 'refreshUrl' => '/oauth/v2/refresh', 'scopes' => [], ], 'swagger' => [ diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index 3d7bbd3efe7..728fd06a92e 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -18,6 +18,10 @@ use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; +use ApiPlatform\Core\OpenApi\OpenApi; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -29,7 +33,11 @@ */ class DocumentationActionTest extends TestCase { - public function testyDocumentationAction(): void + /** + * @group legacy + * @expectedDeprecation Not passing an instance of "ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface" as 7th parameter of the constructor of "ApiPlatform\Core\Documentation\Action\DocumentationAction" is deprecated since API Platform 2.6 + */ + public function testDocumentationAction(): void { $requestProphecy = $this->prophesize(Request::class); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); @@ -81,4 +89,48 @@ public function testDocumentationActionFormatDeprecation() $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), '', '', '', ['formats' => ['jsonld' => 'application/ld+json']]); } + + public function testDocumentationActionV2(): void + { + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $requestProphecy = $this->prophesize(Request::class); + $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $queryProphecy = $this->prophesize(ParameterBag::class); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); + $requestProphecy->attributes = $attributesProphecy->reveal(); + $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); + $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->getInt('spec_version', 2)->willReturn(2)->shouldBeCalledTimes(1); + $attributesProphecy->all()->willReturn(['_api_normalization_context' => ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 2]])->shouldBeCalledTimes(1); + $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 2])->shouldBeCalledTimes(1); + + $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'My happy hippie api', 'lots of chocolate', '1.0.0', null, [2, 3], $openApiFactoryProphecy->reveal()); + $this->assertEquals(new Documentation(new ResourceNameCollection(['dummies']), 'My happy hippie api', 'lots of chocolate', '1.0.0'), $documentation($requestProphecy->reveal())); + } + + public function testDocumentationActionV3(): void + { + $openApi = new OpenApi(new Info('my api', '1.0.0'), [], new Paths()); + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::any())->shouldBeCalled()->willReturn($openApi); + $requestProphecy = $this->prophesize(Request::class); + $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $queryProphecy = $this->prophesize(ParameterBag::class); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); + $requestProphecy->attributes = $attributesProphecy->reveal(); + $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); + $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->getInt('spec_version', 2)->willReturn(3)->shouldBeCalledTimes(1); + $attributesProphecy->all()->willReturn(['_api_normalization_context' => ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 3]])->shouldBeCalledTimes(1); + $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 3])->shouldBeCalledTimes(1); + + $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'My happy hippie api', 'lots of chocolate', '1.0.0', null, [2, 3], $openApiFactoryProphecy->reveal()); + $this->assertInstanceOf(OpenApi::class, $documentation($requestProphecy->reveal())); + } } diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php new file mode 100644 index 00000000000..f960dc3a113 --- /dev/null +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -0,0 +1,672 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\OpenApi\Factory; + +use ApiPlatform\Core\Bridge\Symfony\Routing\RouterOperationPathResolver; +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +use ApiPlatform\Core\Tests\Fixtures\DummyFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +class OpenApiFactoryTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testInvoke(): void + { + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + 'custom' => ['method' => 'HEAD', 'path' => '/foo/{id}', 'openapi_context' => ['description' => 'Custom description']] + self::OPERATION_FORMATS, + 'formats' => ['method' => 'PUT', 'path' => '/formatted/{id}', 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]], + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + 'filtered' => ['method' => 'GET', 'filters' => ['f1', 'f2', 'f3', 'f4', 'f5'], 'path' => '/filtered'] + self::OPERATION_FORMATS, + 'paginated' => ['method' => 'GET', 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, 'pagination_items_per_page' => 20, 'pagination_maximum_items_per_page' => 80, 'path' => '/paginated'] + self::OPERATION_FORMATS, + ], + ['pagination_client_items_per_page' => true] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an enum.', true, true, true, true, false, false, null, null, ['openapi_context' => ['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $filters = [ + 'f1' => new DummyFilter(['name' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => true, + 'strategy' => 'exact', + 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true], + ]]), + 'f2' => new DummyFilter(['ha' => [ + 'property' => 'foo', + 'type' => 'int', + 'required' => false, + 'strategy' => 'partial', + ]]), + 'f3' => new DummyFilter(['toto' => [ + 'property' => 'name', + 'type' => 'array', + 'is_collection' => true, + 'required' => true, + 'strategy' => 'exact', + ]]), + 'f4' => new DummyFilter(['order[name]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['asc', 'desc'], + ], + ]]), + ]; + + foreach ($filters as $filterId => $filter) { + $filterLocatorProphecy->has($filterId)->willReturn(true)->shouldBeCalled(); + $filterLocatorProphecy->get($filterId)->willReturn($filter)->shouldBeCalled(); + } + + $filterLocatorProphecy->has('f5')->willReturn(false)->shouldBeCalled(); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $dummySchema = new Schema('openapi'); + // $dummySchema = new Model\Schema(false, null, false, false, null, ['url' => 'http://schema.example.com/Dummy']); + $dummySchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is a dummy.', + 'properties' => [ + 'id' => new \ArrayObject([ + 'type' => 'integer', + 'description' => 'This is an id.', + 'readOnly' => true, + 'minLength' => 3, + 'maxLength' => 20, + 'pattern' => '^dummyPattern$', + ]), + 'name' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is a name.', + ]), + 'description' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an initializable but not writable property.', + ]), + 'dummy_date' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is a \DateTimeInterface object.', + 'format' => 'date-time', + 'nullable' => true, + ]), + 'enum' => new \ArrayObject([ + 'type' => 'string', + 'enum' => ['one', 'two'], + 'example' => 'one', + 'description' => 'This is an enum.', + ]), + ], + 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], + ])); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $this->assertInstanceOf(OpenApi::class, $openApi); + $this->assertEquals($openApi->getInfo(), new Model\Info('Test API', '1.2.3', 'This is a test API.')); + $this->assertEquals($openApi->getServers(), [new Model\Server('/app_dev.php/')]); + + $components = $openApi->getComponents(); + $this->assertInstanceOf(Model\Components::class, $components); + + $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions()])); + + $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ + 'oauth' => new Model\SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, 'oauth2', null, new Model\OAuthFlows(null, null, null, new Model\OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), + 'header' => new Model\SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header', 'bearer'), + 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query', 'bearer'), + ])); + + $paths = $openApi->getPaths(); + $dummiesPath = $paths->getPath('/dummies'); + $this->assertNotNull($dummiesPath); + foreach (['Put', 'Head', 'Trace', 'Delete', 'Options', 'Patch'] as $method) { + $this->assertNull($dummiesPath->{'get'.$method}()); + } + + $this->assertEquals($dummiesPath->getGet(), new Model\Operation( + 'getDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ]))), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ] + )); + + $this->assertEquals($dummiesPath->getPost(), new Model\Operation( + 'postDummyCollection', + ['Dummy'], + [ + '201' => new Model\Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + null, + new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) + ), + '400' => new Model\Response('Invalid input'), + ], + '', + 'Creates a Dummy resource.', + null, + [], + new Model\RequestBody( + 'The new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + true + ) + )); + + $dummyPath = $paths->getPath('/dummies/{id}'); + $this->assertNotNull($dummyPath); + foreach (['Post', 'Head', 'Trace', 'Options', 'Patch'] as $method) { + $this->assertNull($dummyPath->{'get'.$method}()); + } + + $this->assertEquals($dummyPath->getGet(), new Model\Operation( + 'getDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]) + ), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Retrieves a Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $this->assertEquals($dummyPath->getPut(), new Model\Operation( + 'putDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource updated', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + null, + new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) + ), + '400' => new Model\Response('Invalid input'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Replaces the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + true + ) + )); + + $this->assertEquals($dummyPath->getDelete(), new Model\Operation( + 'deleteDummyItem', + ['Dummy'], + [ + '204' => new Model\Response('Dummy resource deleted'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Removes the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $customPath = $paths->getPath('/foo/{id}'); + $this->assertEquals($customPath->getHead(), new Model\Operation( + 'customDummyItem', + ['Dummy'], + [ + '404' => new Model\Response('Resource not found'), + ], + '', + 'Custom description', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $formattedPath = $paths->getPath('/formatted/{id}'); + $this->assertEquals($formattedPath->getPut(), new Model\Operation( + 'formatsDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource updated', + new \ArrayObject([ + 'application/json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + null, + new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) + ), + '400' => new Model\Response('Invalid input'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Replaces the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + new \ArrayObject([ + 'application/json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + true + ) + )); + + $filteredPath = $paths->getPath('/filtered'); + $this->assertEquals($filteredPath->getGet(), new Model\Operation( + 'filteredDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ])), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + new Model\Parameter('name', 'query', '', true, true, true, [ + 'type' => 'string', + ], 'form', false, true, 'bar'), + new Model\Parameter('ha', 'query', '', false, false, true, [ + 'type' => 'integer', + ]), + new Model\Parameter('toto', 'query', '', true, false, true, [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], 'deepObject', true), + new Model\Parameter('order[name]', 'query', '', false, false, true, [ + 'type' => 'string', + ]), + ] + )); + + $paginatedPath = $paths->getPath('/paginated'); + $this->assertEquals($paginatedPath->getGet(), new Model\Operation( + 'paginatedDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ])), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 20, + 'minimum' => 0, + 'maximum' => 80, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ] + )); + } + + public function testOverrideDocumentation() + { + $defaultContext = ['base_url' => '/app_dev.php/']; + + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + ], + [] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + $this->assertEquals($openApi->getInfo()->getExtensionProperties(), ['x-info-key' => 'Info value']); + $this->assertEquals($openApi->getExtensionProperties(), ['x-key' => 'Custom x-key value', 'x-value' => 'Custom x-value value']); + } + + public function testSubresourceDocumentation() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Question::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['answer'])); + $propertyNameCollectionFactoryProphecy->create(Answer::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['content'])); + + $questionMetadata = new ResourceMetadata( + 'Question', + 'This is a question.', + 'http://schema.example.com/Question', + ['get' => ['method' => 'GET', 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]]] + ); + $answerMetadata = new ResourceMetadata( + 'Answer', + 'This is an answer.', + 'http://schema.example.com/Answer', + [], + ['get' => ['method' => 'GET'] + self::OPERATION_FORMATS], + [], + ['get' => ['method' => 'GET', 'input_formats' => ['xml' => ['text/xml']], 'output_formats' => ['xml' => ['text/xml']]]] + ); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Question::class)->shouldBeCalled()->willReturn($questionMetadata); + $resourceMetadataFactoryProphecy->create(Answer::class)->shouldBeCalled()->willReturn($answerMetadata); + + $subresourceMetadata = new SubresourceMetadata(Answer::class, false); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Question::class, 'answer', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [], $subresourceMetadata)); + + $propertyMetadataFactoryProphecy->create(Answer::class, 'content', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [])); + + $routeCollection = new RouteCollection(); + $routeCollection->add('api_answers_get_collection', new Route('/api/answers.{_format}')); + $routeCollection->add('api_questions_answer_get_subresource', new Route('/api/questions/{id}/answer.{_format}')); + $routeCollection->add('api_questions_get_item', new Route('/api/questions/{id}.{_format}')); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->getRouteCollection()->shouldBeCalled()->willReturn($routeCollection); + + $operationPathResolver = new RouterOperationPathResolver($routerProphecy->reveal(), new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator()))); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Question::class, Answer::class])); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactory, + ['jsonld' => ['application/ld+json']], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url', '/app_dev.php/']); + + $paths = $openApi->getPaths(); + $pathItem = $paths->getPath('/questions/{id}/answer.{_format}'); + + $this->assertEquals($pathItem->getGet(), new Model\Operation( + 'api_questions_answer_get_subresourceQuestionSubresource', + ['Answer', 'Question'], + [ + '200' => new Model\Response( + 'Question resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Question']))), + ]) + ), + ], + '', + 'Retrieves a Question resource.', + null, + [new Model\Parameter('id', 'path', 'Question identifier', true, false, false, ['type' => 'string'])] + )); + } +} diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php new file mode 100644 index 00000000000..30444db0e4c --- /dev/null +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\OpenApi\Serializer; + +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\SchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +class OpenApiNormalizerTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testNormalize() + { + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $defaultContext = ['base_url' => '/app_dev.php/']; + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + ], + [] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any(), Argument::any(), Argument::any())->willReturn([]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer()]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $openApiAsArray = $normalizer->normalize($openApi); + + // Just testing normalization specifics + $this->assertEquals($openApiAsArray['x-key'], 'Custom x-key value'); + $this->assertEquals($openApiAsArray['x-value'], 'Custom x-value value'); + $this->assertEquals($openApiAsArray['info']['x-info-key'], 'Info value'); + $this->assertArrayNotHasKey('extensionProperties', $openApiAsArray); + // this key is null, should not be in the output + $this->assertArrayNotHasKey('termsOfService', $openApiAsArray['info']); + $this->assertArrayNotHasKey('paths', $openApiAsArray['paths']); + $this->assertArrayHasKey('/dummies/{id}', $openApiAsArray['paths']); + } +}