From 78bc6af22b482ddb953fbba409cade534ace191e Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 6 Jan 2020 15:37:24 +0100 Subject: [PATCH 01/17] Open API v3 refactor --- .../Symfony/Bundle/Command/OpenApiCommand.php | 73 ++ .../Symfony/Bundle/Command/SwaggerCommand.php | 9 +- .../ApiPlatformExtension.php | 1 + .../Bundle/Resources/config/data_provider.xml | 11 + .../Bundle/Resources/config/openapi.xml | 54 ++ src/DataProvider/PaginationOptions.php | 64 ++ .../Action/DocumentationAction.php | 10 +- src/OpenApi/Factory/OpenApiFactory.php | 409 ++++++++++ .../Factory/OpenApiFactoryInterface.php | 24 + src/OpenApi/Model/Components.php | 87 +++ src/OpenApi/Model/Contact.php | 69 ++ src/OpenApi/Model/ExtensionTrait.php | 29 + src/OpenApi/Model/Info.php | 114 +++ src/OpenApi/Model/License.php | 54 ++ src/OpenApi/Model/Link.php | 102 +++ src/OpenApi/Model/MediaType.php | 84 ++ src/OpenApi/Model/OAuthFlow.php | 84 ++ src/OpenApi/Model/OAuthFlows.php | 84 ++ src/OpenApi/Model/Operation.php | 211 +++++ src/OpenApi/Model/Parameter.php | 228 ++++++ src/OpenApi/Model/PathItem.php | 220 ++++++ src/OpenApi/Model/Paths.php | 34 + src/OpenApi/Model/RequestBody.php | 69 ++ src/OpenApi/Model/Response.php | 84 ++ src/OpenApi/Model/Schema.php | 158 ++++ src/OpenApi/Model/SecurityScheme.php | 144 ++++ src/OpenApi/Model/Server.php | 69 ++ src/OpenApi/OpenApi.php | 154 ++++ src/OpenApi/Options.php | 99 +++ src/OpenApi/Serializer/OpenApiNormalizer.php | 74 ++ .../Serializer/DocumentationNormalizer.php | 1 + .../Bundle/Command/OpenApiCommandTest.php | 111 +++ .../Bundle/Command/SwaggerCommandTest.php | 26 +- .../ApiPlatformExtensionTest.php | 14 + tests/OpenApi/Factory/OpenApiFactoryTest.php | 726 ++++++++++++++++++ 35 files changed, 3762 insertions(+), 22 deletions(-) create mode 100644 src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php create mode 100644 src/Bridge/Symfony/Bundle/Resources/config/openapi.xml create mode 100644 src/DataProvider/PaginationOptions.php create mode 100644 src/OpenApi/Factory/OpenApiFactory.php create mode 100644 src/OpenApi/Factory/OpenApiFactoryInterface.php create mode 100644 src/OpenApi/Model/Components.php create mode 100644 src/OpenApi/Model/Contact.php create mode 100644 src/OpenApi/Model/ExtensionTrait.php create mode 100644 src/OpenApi/Model/Info.php create mode 100644 src/OpenApi/Model/License.php create mode 100644 src/OpenApi/Model/Link.php create mode 100644 src/OpenApi/Model/MediaType.php create mode 100644 src/OpenApi/Model/OAuthFlow.php create mode 100644 src/OpenApi/Model/OAuthFlows.php create mode 100644 src/OpenApi/Model/Operation.php create mode 100644 src/OpenApi/Model/Parameter.php create mode 100644 src/OpenApi/Model/PathItem.php create mode 100644 src/OpenApi/Model/Paths.php create mode 100644 src/OpenApi/Model/RequestBody.php create mode 100644 src/OpenApi/Model/Response.php create mode 100644 src/OpenApi/Model/Schema.php create mode 100644 src/OpenApi/Model/SecurityScheme.php create mode 100644 src/OpenApi/Model/Server.php create mode 100644 src/OpenApi/OpenApi.php create mode 100644 src/OpenApi/Options.php create mode 100644 src/OpenApi/Serializer/OpenApiNormalizer.php create mode 100644 tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php create mode 100644 tests/OpenApi/Factory/OpenApiFactoryTest.php diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php new file mode 100644 index 00000000000..ecb7fcfa062 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -0,0 +1,73 @@ + + * + * 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\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Yaml\Yaml; + +/** + * Console command to dump OpenApi documentations. + */ +final class OpenApiCommand extends Command +{ + private $openApiFactory; + private $normalizer; + + public function __construct(OpenApiFactoryInterface $openApiFactory, NormalizerInterface $normalizer) + { + $this->openApiFactory = $openApiFactory; + $this->normalizer = $normalizer; + parent::__construct(); + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setName('api:openapi:export') + ->setDescription('Dump the OpenAPI documentation') + ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $io = new SymfonyStyle($input, $output); + + $data = $this->normalizer->normalize($this->openApiFactory->create(), '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) ?: ''); + + if (!empty($filename = $input->getOption('output')) && \is_string($filename)) { + file_put_contents($filename, $content); + $io->success(sprintf('Data written to %s.', $filename)); + } else { + $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..6b3a06173e6 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -381,6 +381,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/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..0a332450e85 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + %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.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..408ed642928 --- /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; + +class PaginationOptions +{ + private $paginationEnabled; + private $paginationPageParameterName; + private $clientItemsPerPage; + private $itemsPerPageParameterName; + private $paginationClientEnabled; + private $paginationClientEnabledParameterName; + + public function __construct($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() + { + return $this->paginationEnabled; + } + + public function getPaginationPageParameterName() + { + return $this->paginationPageParameterName; + } + + public function getClientItemsPerPage() + { + return $this->clientItemsPerPage; + } + + public function getItemsPerPageParameterName() + { + return $this->itemsPerPageParameterName; + } + + public function getPaginationClientEnabled() + { + return $this->paginationClientEnabled; + } + + public function getPaginationClientEnabledParameterName() + { + return $this->paginationClientEnabledParameterName; + } +} diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 57fd1698372..15845e2fe48 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -38,13 +38,14 @@ final class DocumentationAction * @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 === $formatsProvider) { return; @@ -56,13 +57,14 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName return; } + $this->formatsProvider = $formatsProvider; } public function __invoke(Request $request = null): Documentation { 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; } @@ -75,6 +77,10 @@ public function __invoke(Request $request = null): Documentation $this->formats = $this->formatsProvider->getFormatsFromAttributes($attributes ?? []); } + if (3 === $context['spec_version']) { + return $this->openApiFactory->create($context); + } + return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats); } } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php new file mode 100644 index 00000000000..c7847bff82c --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -0,0 +1,409 @@ + + * + * 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\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 OpenAPI 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 create(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); + $links = array_merge($links, $itemOperationLinks); + $schemas += $itemOperationSchemas; + list($collectionOperationLinks, $collectionOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::COLLECTION, $context, $paths, $links, $schemas); + $links = array_merge($links, $collectionOperationLinks); + $schemas += $collectionOperationSchemas; + } + + $securitySchemes = $this->getSecuritySchemes(); + $securityRequirements = []; + + foreach (array_keys($securitySchemes) as $key) { + $securityRequirements[$key] = []; + } + + return new OpenApi($info, $servers, $paths, new Model\Components($schemas, [], [], [], [], [], $securitySchemes), $securityRequirements); + } + + private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array $links, array $schemas = []): array + { + $resourceShortName = $resourceMetadata->getShortName(); + if (null === $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) { + 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(); + + /** @TODO support multiple formats for schemas? */ + $operationFormat = array_values($responseMimeTypes)[0]; + $operationSchemaOutput = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), $context); + + $schemas += $operationSchemaOutput->getDefinitions()->getArrayCopy(); + $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)); + } + + // Create responses + switch ($method) { + case 'GET': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); + $responses[$successStatus] = new Model\Response(sprintf('%s %s', $resourceShortName, OperationType::COLLECTION === $operationType ? 'collection' : 'resource'), $responseContent); + break; + case 'POST': + $responseLinks = isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []; + $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201'); + $responses[$successStatus] = new Model\Response(sprintf('%s resource created', $resourceShortName), $responseContent, [], $responseLinks); + $responses['400'] = new Model\Response('Invalid input'); + break; + case 'PATCH': + case 'PUT': + $responseLinks = isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []; + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); + $responses[$successStatus] = new Model\Response(sprintf('%s resource updated', $resourceShortName), $responseContent, [], $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) { + /** @TODO support multiple input formats */ + $operationFormatInput = array_values($requestMimeTypes)[0]; + $operationSchemaInput = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormatInput, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), $context); + $schemas += $operationSchemaInput->getDefinitions()->getArrayCopy(); + $requestBody = new Model\RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationSchemaInput), true); + } + + $pathItem = $pathItem->{'with'.ucfirst($method)}(new Model\Operation( + $operationId, + $operation['openapi_context']['tags'] ?? [$resourceShortName], + $responses, + $operation['openapi_context']['summary'] ?? '', + $operation['openapi_context']['description'] ?? $this->getPathDescription($resourceShortName, $method, $operationType), + $operation['openapi_context']['externalDocs'] ?? [], + $parameters, + $requestBody, + $operation['openapi_context']['callbacks'] ?? [], + $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]; + } + + /** + * @TODO: support multiple format schemas + */ + private function buildContent(array $responseMimeTypes, $operationSchema) + { + $content = []; + + foreach ($responseMimeTypes as $mimeType => $format) { + $content[$mimeType] = new Model\MediaType($operationSchema->getArrayCopy(false)); + } + + return $content; + } + + private function getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata = null) + { + $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 + { + $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); + } + + /** + * 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, + $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, $data['openapi']['examples'] ?? []); + } + } + + return $parameters; + } + + private function getPaginationParameters(ResourceMetadata $resourceMetadata, string $operationName) + { + 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() + { + $oauthFlow = new Model\OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl(), $this->openApiOptions->getOAuthRefreshUrl(), $this->openApiOptions->getOAuthScopes()); + $description = sprintf( + 'OAuth 2.0 %s Grant', + strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) + ); + list($implicit, $password, $clientCredentials, $authorizationCode) = [null, null, null, 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, null, null, new Model\OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode)); + } + + private function getSecuritySchemes() + { + $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']); + } + + return $securitySchemes; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactoryInterface.php b/src/OpenApi/Factory/OpenApiFactoryInterface.php new file mode 100644 index 00000000000..9bbfb752b87 --- /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 create(array $context = []): OpenApi; +} diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php new file mode 100644 index 00000000000..427a588c8d7 --- /dev/null +++ b/src/OpenApi/Model/Components.php @@ -0,0 +1,87 @@ + + * + * 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; + +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(array $schemas = [], array $responses = [], array $parameters = [], array $examples = [], array $requestBodies = [], array $headers = [], array $securitySchemes = [], array $links = [], array $callbacks = []) + { + $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() + { + return $this->schemas; + } + + public function getResponses() + { + return $this->responses; + } + + public function getParameters() + { + return $this->parameters; + } + + public function getExamples() + { + return $this->examples; + } + + public function getRequestBodies() + { + return $this->requestBodies; + } + + public function getHeaders() + { + return $this->headers; + } + + public function getSecuritySchemes() + { + return $this->securitySchemes; + } + + public function getLinks() + { + return $this->links; + } + + public function getCallbacks() + { + return $this->callbacks; + } +} diff --git a/src/OpenApi/Model/Contact.php b/src/OpenApi/Model/Contact.php new file mode 100644 index 00000000000..00139bed3cc --- /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; + +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/ExtensionTrait.php b/src/OpenApi/Model/ExtensionTrait.php new file mode 100644 index 00000000000..d3cf4ff6059 --- /dev/null +++ b/src/OpenApi/Model/ExtensionTrait.php @@ -0,0 +1,29 @@ + + * + * 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 +{ + public function __call(string $name, array $arguments) + { + if (0 !== strpos($name, 'withX')) { + throw new \BadMethodCallException('Specification extensions must start with x!'); + } + + $clone = clone $this; + $clone->{str_replace('withX', 'x-', $name)} = $arguments[0]; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Info.php b/src/OpenApi/Model/Info.php new file mode 100644 index 00000000000..2565fcb8113 --- /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; + +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..686db58ea79 --- /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; + +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..f22ceaa37c3 --- /dev/null +++ b/src/OpenApi/Model/Link.php @@ -0,0 +1,102 @@ + + * + * 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; + +class Link +{ + use ExtensionTrait; + + // public $operationRef; + private $operationId; + private $parameters; + private $requestBody; + private $description; + private $server; + + // string $operationRef, ? + public function __construct(string $operationId, array $parameters = [], array $requestBody = [], string $description = '', Server $server = null) + { + // $this->operationRef = $operationRef; + $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(): array + { + return $this->parameters; + } + + public function getRequestBody(): array + { + 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(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody(array $requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withDescription(array $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..ac55a682ef3 --- /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; + +class MediaType +{ + use ExtensionTrait; + + private $schema; + private $example; + private $examples; + private $encoding; + + public function __construct(array $schema = [], $example = null, array $examples = [], array $encoding = []) + { + $this->schema = $schema; + $this->example = $example; + $this->examples = $examples; + $this->encoding = $encoding; + } + + public function getSchema(): array + { + return $this->schema; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): array + { + return $this->examples; + } + + public function getEncoding(): array + { + return $this->encoding; + } + + public function withSchema(array $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withExample() + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(array $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withEncoding(array $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..5b8c727f68a --- /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; + +class OAuthFlow +{ + use ExtensionTrait; + + private $authorizationUrl; + private $tokenUrl; + private $refreshUrl; + private $scopes; + + public function __construct(string $authorizationUrl = null, string $tokenUrl = null, string $refreshUrl = null, array $scopes = []) + { + $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(): array + { + 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(array $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..f8f150299c6 --- /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; + +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..5f9485a292a --- /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; + +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 = '', $externalDocs = [], array $parameters = [], RequestBody $requestBody = null, $callbacks = [], bool $deprecated = false, $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') + { + $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(): array + { + return $this->externalDocs; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getRequestBody(): ?RequestBody + { + return $this->requestBody; + } + + public function getCallbacks(): array + { + 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(array $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(array $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..66beecea5e3 --- /dev/null +++ b/src/OpenApi/Model/Parameter.php @@ -0,0 +1,228 @@ + + * + * 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; + +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, $examples = [], array $content = []) + { + $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; + + if (null === $style) { + if ('query' === $in || 'cookie' === $in) { + $this->style = 'form'; + } elseif ('path' === $in || 'header' === $in) { + $this->style = 'simple'; + } + } else { + $this->style = $style; + } + } + + 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 getAllowEmptyValue(): bool + { + return $this->allowEmptyValue; + } + + public function getSchema(): array + { + return $this->schema; + } + + public function getStyle(): string + { + return $this->style; + } + + public function getExplode(): bool + { + return $this->explode; + } + + public function getAllowReserved(): bool + { + return $this->allowReserved; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): array + { + return $this->examples; + } + + public function getContent(): array + { + 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(array $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withContent(array $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..ea7d7e1f584 --- /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; + +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 = '', string $description = '', 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..a9347579166 --- /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; + +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() + { + return $this->paths; + } +} diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php new file mode 100644 index 00000000000..be1b74a89eb --- /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; + +class RequestBody +{ + use ExtensionTrait; + + private $description; + private $content; + private $required; + + public function __construct(string $description = '', array $content = [], bool $required = false) + { + $this->description = $description; + $this->content = $content; + $this->required = $required; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): array + { + 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(array $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..c471d9ea37a --- /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; + +class Response +{ + use ExtensionTrait; + + private $description; + private $content; + private $headers; + private $links; + + public function __construct(string $description = '', array $content = [], array $headers = [], array $links = []) + { + $this->description = $description; + $this->content = $content; + $this->headers = $headers; + $this->links = $links; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): array + { + return $this->content; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getLinks(): array + { + return $this->links; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(array $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withHeaders(array $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withLinks(array $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..c7f757e4b03 --- /dev/null +++ b/src/OpenApi/Model/Schema.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\OpenApi\Model; + +use ApiPlatform\Core\JsonSchema\Schema as JsonSchema; + +class Schema +{ + 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(); + } + + public function setDefinitions(array $definitions) + { + $this->schema->setDefinitions(new \ArrayObject($definitions)); + } + + public function getDefinitions(): \ArrayObject + { + return new \ArrayObject(array_merge((array) $this->schema->getDefinitions(), (array) $this)); + } + + 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..d01e6d262ee --- /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; + +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..a1cbcab5f03 --- /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; + +class Server +{ + use ExtensionTrait; + + private $url; + private $description; + private $variables; + + public function __construct(string $url, string $description = '', array $variables = []) + { + $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(): array + { + 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(array $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..c31e39176ad --- /dev/null +++ b/src/OpenApi/OpenApi.php @@ -0,0 +1,154 @@ + + * + * 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\OpenApi\Model\Components; +use ApiPlatform\Core\OpenApi\Model\ExtensionTrait; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; + +class OpenApi +{ + use ExtensionTrait; + + public const VERSION = '3.0.2'; + + 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; + } + + /** + * @param bool $asArray for proper normalization + */ + public function getPaths($asArray = true) + { + return $asArray ? $this->paths->getPaths() : $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..fbfb4c23394 --- /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() + { + return $this->title; + } + + public function getDescription() + { + return $this->description; + } + + public function getVersion() + { + return $this->version; + } + + public function getOAuthEnabled() + { + return $this->oAuthEnabled; + } + + public function getOAuthType() + { + return $this->oAuthType; + } + + public function getOAuthFlow() + { + return $this->oAuthFlow; + } + + public function getOAuthTokenUrl() + { + return $this->oAuthTokenUrl; + } + + public function getOAuthAuthorizationUrl() + { + return $this->oAuthAuthorizationUrl; + } + + public function getOAuthRefreshUrl() + { + return $this->oAuthRefreshUrl; + } + + public function getOAuthScopes() + { + return $this->oAuthScopes; + } + + public function getApiKeys() + { + return $this->apiKeys; + } +} diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php new file mode 100644 index 00000000000..3e5409d4116 --- /dev/null +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -0,0 +1,74 @@ + + * + * 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\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Generates an OpenAPI v3 specification. + */ +final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + public const FORMAT = 'json'; + + private $decorated; + + public function __construct(NormalizerInterface $decorated) + { + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []) + { + return $this->unsetEmptyScalar($this->decorated->normalize($object, $format, $context)); + } + + private function unsetEmptyScalar($data) + { + foreach ($data as $key => $value) { + if (\is_array($value)) { + $data[$key] = $this->unsetEmptyScalar($value); + // arrays must stay even if empty + continue; + } + + if (empty($value)) { + unset($data[$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..a203ab65bff --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -0,0 +1,111 @@ + + * + * 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 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: 3.0.2', $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..0439dd070e9 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,10 @@ 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\Serializer\Options; +use ApiPlatform\Core\OpenApi\Serializer\DocumentationNormalizer; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +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 +965,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 +1025,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) { @@ -1319,6 +1326,10 @@ 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.factory'; + $definitions[] = 'api_platform.openapi.command'; } // has jsonld @@ -1390,6 +1401,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/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php new file mode 100644 index 00000000000..1c2b4dd8198 --- /dev/null +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -0,0 +1,726 @@ + + * + * 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\DataProvider\PaginationOptions; +use ApiPlatform\Core\Documentation\Documentation; +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\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\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\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class OpenApiFactoryTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testCreate(): void + { + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + + $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); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->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')->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')->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')->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')->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->create(['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(), ['Dummy' => $dummySchema->getDefinitions()]); + + $this->assertEquals($components->getSecuritySchemes(), [ + 'oauth' => new Model\SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, null, null, new Model\OAuthFlows(null, null, null, new Model\OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', ['scope param']))), + 'header' => new Model\SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header'), + 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query'), + ]); + + $paths = $openApi->getPaths(false); + $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', [ + 'application/ld+json' => new Model\MediaType([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ]), + ], + '', + 'Retrieves the collection of Dummy resources.', + [], + [ + 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', + [ + 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + ], + [], + ['GetDummyItem' => new Model\Link('getDummyItem', ['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.', + [], + [], + new Model\RequestBody( + 'The new Dummy resource', + [ + 'application/ld+json' => new Model\MediaType(['$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', + [ + 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + ] + ), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Retrieves a Dummy resource.', + [], + [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', + [ + 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + ], + [], + ['GetDummyItem' => new Model\Link('getDummyItem', ['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.', + [], + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + [ + 'application/ld+json' => new Model\MediaType(['$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.', + [], + [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', + [], + [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', + [ + 'application/json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + 'text/csv' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + ], + [], + ['GetDummyItem' => new Model\Link('getDummyItem', ['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.', + [], + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + [ + 'application/json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + 'text/csv' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), + ], + true + ) + )); + + $filteredPath = $paths->getPath('/filtered'); + $this->assertEquals($filteredPath->getGet(), new Model\Operation( + 'filteredDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', [ + 'application/ld+json' => new Model\MediaType([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ]), + ], + '', + 'Retrieves the collection of Dummy resources.', + [], + [ + 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', [ + 'application/ld+json' => new Model\MediaType([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ]), + ], + '', + 'Retrieves the collection of Dummy resources.', + [], + [ + 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() + // { + // $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Test API', 'This is a test API.', '1.2.3'); + // $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); + // $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + // $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + // + // $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + // $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->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')->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')->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')->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); + // + // $noopNormalizer = $this->prophesize(NormalizerInterface::class); + // $noopNormalizer->normalize(Argument::any())->will(function ($args) { + // return $args[0]; + // }); + // + // $normalizer = new DocumentationNormalizer( + // $resourceMetadataFactory, + // $propertyNameCollectionFactory, + // $propertyMetadataFactory, + // $schemaFactory, + // $typeFactory, + // $operationPathResolver, + // $filterLocatorProphecy->reveal(), + // $subresourceOperationFactoryProphecy->reveal(), + // [], + // ['spec_version' => 3], + // new AuthorizationOptions(), + // new PaginationOptions(), + // $noopNormalizer->reveal() + // ); + // + // /** + // * @var OpenApi\OpenApi + // */ + // $openApi = $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, $defaultContext); + // $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')); + // $openApi = $openApi->withXInternalId('Custom X value'); + // var_dump($openApi); + // } + + // public function testSubresourceDocumentation() { + // $documentation = new Documentation(new ResourceNameCollection([Question::class]), 'Test API', 'This is a test API.', '1.2.3'); + // + // $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')->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')->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_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()); + // + // $normalizer = new DocumentationNormalizer( + // $resourceMetadataFactory, + // $propertyNameCollectionFactory, + // $propertyMetadataFactory, + // null, + // null, + // $operationPathResolver, + // null, + // null, + // null, + // false, + // '', + // '', + // '', + // '', + // [], + // [], + // $subresourceOperationFactory, + // true, + // 'page', + // false, + // 'itemsPerPage', + // $formatProvider ?? [], + // false, + // 'pagination', + // ['spec_version' => 3] + // ); + // + // $expected = [ + // 'openapi' => '3.0.2', + // 'info' => [ + // 'title' => 'Test API', + // 'description' => 'This is a test API.', + // 'version' => '1.2.3', + // ], + // 'paths' => new \ArrayObject([ + // '/api/questions/{id}' => [ + // 'get' => new \ArrayObject([ + // 'tags' => ['Question'], + // 'operationId' => 'getQuestionItem', + // 'summary' => 'Retrieves a Question resource.', + // 'parameters' => [ + // [ + // 'name' => 'id', + // 'in' => 'path', + // 'schema' => ['type' => 'string'], + // 'required' => true, + // ], + // ], + // 'responses' => [ + // '200' => [ + // 'description' => 'Question resource response', + // 'content' => [ + // 'application/json' => [ + // 'schema' => ['$ref' => '#/components/schemas/Question'], + // ], + // 'text/csv' => [ + // 'schema' => ['$ref' => '#/components/schemas/Question'], + // ], + // ], + // ], + // '404' => ['description' => 'Resource not found'], + // ], + // ]), + // ], + // '/api/questions/{id}/answer' => new \ArrayObject([ + // 'get' => new \ArrayObject([ + // 'tags' => ['Answer', 'Question'], + // 'operationId' => 'api_questions_answer_get_subresource', + // 'summary' => 'Retrieves a Answer resource.', + // 'responses' => [ + // '200' => [ + // 'description' => 'Answer resource response', + // 'content' => [ + // 'text/xml' => [ + // 'schema' => ['$ref' => '#/components/schemas/Answer'], + // ], + // ], + // ], + // '404' => ['description' => 'Resource not found'], + // ], + // 'parameters' => [ + // [ + // 'name' => 'id', + // 'in' => 'path', + // 'schema' => ['type' => 'string'], + // 'required' => true, + // ], + // ], + // ]), + // ]), + // ]), + // 'components' => [ + // 'schemas' => new \ArrayObject([ + // 'Question' => new \ArrayObject([ + // 'type' => 'object', + // 'description' => 'This is a question.', + // 'externalDocs' => ['url' => 'http://schema.example.com/Question'], + // 'properties' => [ + // 'answer' => new \ArrayObject([ + // 'type' => 'array', + // 'description' => 'This is a name.', + // 'items' => ['$ref' => '#/components/schemas/Answer'], + // ]), + // ], + // ]), + // 'Answer' => new \ArrayObject([ + // 'type' => 'object', + // 'description' => 'This is an answer.', + // 'externalDocs' => ['url' => 'http://schema.example.com/Answer'], + // 'properties' => [ + // 'content' => new \ArrayObject([ + // 'type' => 'array', + // 'description' => 'This is a name.', + // 'items' => ['$ref' => '#/components/schemas/Answer'], + // ]), + // ], + // ]), + // ]), + // ], + // ]; + // + // $this->assertEquals($expected, $normalizer->normalize($documentation)); + // } +} From 2271b57bf2780b81827d4a041060b3908b08cfbb Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 16 Apr 2020 22:14:39 +0200 Subject: [PATCH 02/17] Fix external properties --- src/OpenApi/Factory/OpenApiFactory.php | 4 +- src/OpenApi/Model/ExtensionTrait.php | 15 +- src/OpenApi/Model/OAuthFlows.php | 8 +- src/OpenApi/Model/SecurityScheme.php | 10 +- src/OpenApi/OpenApi.php | 3 - src/OpenApi/Serializer/OpenApiNormalizer.php | 16 +- tests/OpenApi/Factory/OpenApiFactoryTest.php | 173 +++++++++--------- .../Normalizer/OpenApiNormalizerTest.php | 154 ++++++++++++++++ 8 files changed, 277 insertions(+), 106 deletions(-) create mode 100644 tests/OpenApi/Normalizer/OpenApiNormalizerTest.php diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index c7847bff82c..b2adbc8eeed 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -388,7 +388,7 @@ private function getOauthSecurityScheme() throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode'); } - return new Model\SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, null, null, new Model\OAuthFlows($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() @@ -401,7 +401,7 @@ private function getSecuritySchemes() 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']); + $securitySchemes[$key] = new Model\SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type'], 'bearer'); } return $securitySchemes; diff --git a/src/OpenApi/Model/ExtensionTrait.php b/src/OpenApi/Model/ExtensionTrait.php index d3cf4ff6059..55d2a2ba5d2 100644 --- a/src/OpenApi/Model/ExtensionTrait.php +++ b/src/OpenApi/Model/ExtensionTrait.php @@ -15,15 +15,22 @@ trait ExtensionTrait { - public function __call(string $name, array $arguments) + private $extensionProperties = []; + + public function withExtensionProperty(string $key, string $value) { - if (0 !== strpos($name, 'withX')) { - throw new \BadMethodCallException('Specification extensions must start with x!'); + if (0 !== strpos($key, 'x-')) { + $key = 'x-'.$key; } $clone = clone $this; - $clone->{str_replace('withX', 'x-', $name)} = $arguments[0]; + $clone->extensionProperties[$key] = $value; return $clone; } + + public function getExtensionProperties() + { + return $this->extensionProperties; + } } diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php index f8f150299c6..020bd34e342 100644 --- a/src/OpenApi/Model/OAuthFlows.php +++ b/src/OpenApi/Model/OAuthFlows.php @@ -30,22 +30,22 @@ public function __construct(OAuthFlow $implicit = null, OAuthFlow $password = nu $this->authorizationCode = $authorizationCode; } - public function getImplicit(): OAuthFlow + public function getImplicit(): ?OAuthFlow { return $this->implicit; } - public function getPassword(): OAuthFlow + public function getPassword(): ?OAuthFlow { return $this->password; } - public function getClientCredentials(): OAuthFlow + public function getClientCredentials(): ?OAuthFlow { return $this->clientCredentials; } - public function getAuthorizationCode(): OAuthFlow + public function getAuthorizationCode(): ?OAuthFlow { return $this->authorizationCode; } diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php index d01e6d262ee..ce695f03517 100644 --- a/src/OpenApi/Model/SecurityScheme.php +++ b/src/OpenApi/Model/SecurityScheme.php @@ -48,12 +48,12 @@ public function getDescription(): string return $this->description; } - public function getName(): string + public function getName(): ?string { return $this->name; } - public function getIn(): string + public function getIn(): ?string { return $this->in; } @@ -63,17 +63,17 @@ public function getScheme(): string return $this->scheme; } - public function getBearerFormat(): string + public function getBearerFormat(): ?string { return $this->bearerFormat; } - public function getFlows(): OAuthFlows + public function getFlows(): ?OAuthFlows { return $this->flows; } - public function getOpenIdConnectUrl(): string + public function getOpenIdConnectUrl(): ?string { return $this->openIdConnectUrl; } diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index c31e39176ad..2e28a28c1d1 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -60,9 +60,6 @@ public function getServers(): array return $this->servers; } - /** - * @param bool $asArray for proper normalization - */ public function getPaths($asArray = true) { return $asArray ? $this->paths->getPaths() : $this->paths; diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index 3e5409d4116..b4571bb78ac 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -24,6 +24,7 @@ final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsM { public const FORMAT = 'json'; + private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; private $decorated; public function __construct(NormalizerInterface $decorated) @@ -36,14 +37,21 @@ public function __construct(NormalizerInterface $decorated) */ public function normalize($object, $format = null, array $context = []) { - return $this->unsetEmptyScalar($this->decorated->normalize($object, $format, $context)); + return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); } - private function unsetEmptyScalar($data) + private function recursiveClean($data) { foreach ($data as $key => $value) { + if (self::EXTENSION_PROPERTIES_KEY === $key) { + foreach ($data[self::EXTENSION_PROPERTIES_KEY] as $extensionPropertyKey => $extensionPropertyValue) { + $data[$extensionPropertyKey] = $extensionPropertyValue; + } + continue; + } + if (\is_array($value)) { - $data[$key] = $this->unsetEmptyScalar($value); + $data[$key] = $this->recursiveClean($value); // arrays must stay even if empty continue; } @@ -53,6 +61,8 @@ private function unsetEmptyScalar($data) } } + unset($data[self::EXTENSION_PROPERTIES_KEY]); + return $data; } diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 1c2b4dd8198..3e8bcec4002 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Core\Tests\OpenApi\Factory; use ApiPlatform\Core\DataProvider\PaginationOptions; -use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\JsonSchema\Schema; use ApiPlatform\Core\JsonSchema\SchemaFactory; use ApiPlatform\Core\JsonSchema\TypeFactory; @@ -41,7 +40,6 @@ use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; -use Symfony\Component\Serializer\Normalizer\NormalizerInterface; class OpenApiFactoryTest extends TestCase { @@ -190,7 +188,7 @@ public function testCreate(): void 'type' => 'string', 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time', - 'nullable' => true + 'nullable' => true, ]), 'enum' => new \ArrayObject([ 'type' => 'string', @@ -469,88 +467,93 @@ public function testCreate(): void )); } - // public function testOverrideDocumentation() - // { - // $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Test API', 'This is a test API.', '1.2.3'); - // $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); - // $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - // $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); - // - // $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->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')->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')->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')->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); - // - // $noopNormalizer = $this->prophesize(NormalizerInterface::class); - // $noopNormalizer->normalize(Argument::any())->will(function ($args) { - // return $args[0]; - // }); - // - // $normalizer = new DocumentationNormalizer( - // $resourceMetadataFactory, - // $propertyNameCollectionFactory, - // $propertyMetadataFactory, - // $schemaFactory, - // $typeFactory, - // $operationPathResolver, - // $filterLocatorProphecy->reveal(), - // $subresourceOperationFactoryProphecy->reveal(), - // [], - // ['spec_version' => 3], - // new AuthorizationOptions(), - // new PaginationOptions(), - // $noopNormalizer->reveal() - // ); - // - // /** - // * @var OpenApi\OpenApi - // */ - // $openApi = $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, $defaultContext); - // $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')); - // $openApi = $openApi->withXInternalId('Custom X value'); - // var_dump($openApi); - // } + public function testOverrideDocumentation() + { + $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); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->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')->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')->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')->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->create(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths(false)->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths(false)->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() { // $documentation = new Documentation(new ResourceNameCollection([Question::class]), 'Test API', 'This is a test API.', '1.2.3'); diff --git a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php new file mode 100644 index 00000000000..101b06717f2 --- /dev/null +++ b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php @@ -0,0 +1,154 @@ + + * + * 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\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); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->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')->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')->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')->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->create(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths(false)->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths(false)->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']); + } +} From aa5051932cf2de9256ded890b482351bc76021d9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 16 Apr 2020 22:20:36 +0200 Subject: [PATCH 03/17] Fix paths api --- src/OpenApi/OpenApi.php | 4 ++-- src/OpenApi/Serializer/OpenApiNormalizer.php | 6 ++++++ tests/OpenApi/Factory/OpenApiFactoryTest.php | 6 +++--- tests/OpenApi/Normalizer/OpenApiNormalizerTest.php | 6 ++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index 2e28a28c1d1..484893f3d34 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -60,9 +60,9 @@ public function getServers(): array return $this->servers; } - public function getPaths($asArray = true) + public function getPaths() { - return $asArray ? $this->paths->getPaths() : $this->paths; + return $this->paths; } public function getComponents(): Components diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index b4571bb78ac..cb1cae64078 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -50,6 +50,12 @@ private function recursiveClean($data) 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 diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 3e8bcec4002..758f94bc1b4 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -217,7 +217,7 @@ public function testCreate(): void 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query'), ]); - $paths = $openApi->getPaths(false); + $paths = $openApi->getPaths(); $dummiesPath = $paths->getPath('/dummies'); $this->assertNotNull($dummiesPath); foreach (['Put', 'Head', 'Trace', 'Delete', 'Options', 'Patch'] as $method) { @@ -537,10 +537,10 @@ public function testOverrideDocumentation() $openApi = $factory->create(['base_url' => '/app_dev.php/']); - $pathItem = $openApi->getPaths(false)->getPath('/dummies/{id}'); + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); $operation = $pathItem->getGet(); - $openApi->getPaths(false)->addPath('/dummies/{id}', $pathItem->withGet( + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( $operation->withParameters(array_merge( $operation->getParameters(), [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] diff --git a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php index 101b06717f2..649408a335d 100644 --- a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php @@ -119,10 +119,10 @@ public function testNormalize() $openApi = $factory->create(['base_url' => '/app_dev.php/']); - $pathItem = $openApi->getPaths(false)->getPath('/dummies/{id}'); + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); $operation = $pathItem->getGet(); - $openApi->getPaths(false)->addPath('/dummies/{id}', $pathItem->withGet( + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( $operation->withParameters(array_merge( $operation->getParameters(), [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] @@ -150,5 +150,7 @@ public function testNormalize() $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']); } } From 41c9f21edc1bab95d96b44d6687a37e1ae9b7a6a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 17 Apr 2020 10:16:38 +0200 Subject: [PATCH 04/17] Opti --- src/OpenApi/Factory/OpenApiFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index b2adbc8eeed..42b3fa9551a 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -367,7 +367,7 @@ private function getOauthSecurityScheme() 'OAuth 2.0 %s Grant', strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) ); - list($implicit, $password, $clientCredentials, $authorizationCode) = [null, null, null, null]; + $implicit = $password = $clientCredentials = $authorizationCode = null; switch ($this->openApiOptions->getOAuthFlow()) { case 'implicit': From 7e7969b98ec164c65096ac615db1a2c21f130f42 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 17 Apr 2020 18:08:37 +0200 Subject: [PATCH 05/17] @flug's review Co-Authored-By: Flug --- src/DataProvider/PaginationOptions.php | 14 ++++++------- src/OpenApi/Model/Components.php | 18 ++++++++-------- src/OpenApi/Model/ExtensionTrait.php | 2 +- src/OpenApi/Model/Operation.php | 2 +- src/OpenApi/Model/Paths.php | 2 +- src/OpenApi/Options.php | 22 ++++++++++---------- src/OpenApi/Serializer/OpenApiNormalizer.php | 4 ++-- 7 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php index 408ed642928..5271e6a5c66 100644 --- a/src/DataProvider/PaginationOptions.php +++ b/src/DataProvider/PaginationOptions.php @@ -22,7 +22,7 @@ class PaginationOptions private $paginationClientEnabled; private $paginationClientEnabledParameterName; - public function __construct($paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination') + 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; @@ -32,32 +32,32 @@ public function __construct($paginationEnabled = true, string $paginationPagePar $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName; } - public function isPaginationEnabled() + public function isPaginationEnabled(): bool { return $this->paginationEnabled; } - public function getPaginationPageParameterName() + public function getPaginationPageParameterName(): string { return $this->paginationPageParameterName; } - public function getClientItemsPerPage() + public function getClientItemsPerPage(): bool { return $this->clientItemsPerPage; } - public function getItemsPerPageParameterName() + public function getItemsPerPageParameterName(): string { return $this->itemsPerPageParameterName; } - public function getPaginationClientEnabled() + public function getPaginationClientEnabled(): bool { return $this->paginationClientEnabled; } - public function getPaginationClientEnabledParameterName() + public function getPaginationClientEnabledParameterName(): string { return $this->paginationClientEnabledParameterName; } diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php index 427a588c8d7..dd8a8f32326 100644 --- a/src/OpenApi/Model/Components.php +++ b/src/OpenApi/Model/Components.php @@ -40,47 +40,47 @@ public function __construct(array $schemas = [], array $responses = [], array $p $this->callbacks = $callbacks; } - public function getSchemas() + public function getSchemas(): array { return $this->schemas; } - public function getResponses() + public function getResponses(): array { return $this->responses; } - public function getParameters() + public function getParameters(): array { return $this->parameters; } - public function getExamples() + public function getExamples(): array { return $this->examples; } - public function getRequestBodies() + public function getRequestBodies(): array { return $this->requestBodies; } - public function getHeaders() + public function getHeaders(): array { return $this->headers; } - public function getSecuritySchemes() + public function getSecuritySchemes(): array { return $this->securitySchemes; } - public function getLinks() + public function getLinks(): array { return $this->links; } - public function getCallbacks() + public function getCallbacks(): array { return $this->callbacks; } diff --git a/src/OpenApi/Model/ExtensionTrait.php b/src/OpenApi/Model/ExtensionTrait.php index 55d2a2ba5d2..62f97f2151d 100644 --- a/src/OpenApi/Model/ExtensionTrait.php +++ b/src/OpenApi/Model/ExtensionTrait.php @@ -29,7 +29,7 @@ public function withExtensionProperty(string $key, string $value) return $clone; } - public function getExtensionProperties() + public function getExtensionProperties(): array { return $this->extensionProperties; } diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php index 5f9485a292a..cd97ea6cd7e 100644 --- a/src/OpenApi/Model/Operation.php +++ b/src/OpenApi/Model/Operation.php @@ -46,7 +46,7 @@ public function __construct(string $operationId = null, array $tags = [], array $this->externalDocs = $externalDocs; } - public function addResponse(Response $response, $status = 'default') + public function addResponse(Response $response, $status = 'default'): self { $this->responses[$status] = $response; diff --git a/src/OpenApi/Model/Paths.php b/src/OpenApi/Model/Paths.php index a9347579166..61d2415fc03 100644 --- a/src/OpenApi/Model/Paths.php +++ b/src/OpenApi/Model/Paths.php @@ -27,7 +27,7 @@ public function getPath(string $path): ?PathItem return $this->paths[$path] ?? null; } - public function getPaths() + public function getPaths(): array { return $this->paths; } diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index fbfb4c23394..2b176b97719 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -42,57 +42,57 @@ public function __construct(string $title, string $description = '', string $ver $this->apiKeys = $apiKeys; } - public function getTitle() + public function getTitle(): string { return $this->title; } - public function getDescription() + public function getDescription(): string { return $this->description; } - public function getVersion() + public function getVersion(): string { return $this->version; } - public function getOAuthEnabled() + public function getOAuthEnabled(): bool { return $this->oAuthEnabled; } - public function getOAuthType() + public function getOAuthType(): string { return $this->oAuthType; } - public function getOAuthFlow() + public function getOAuthFlow(): string { return $this->oAuthFlow; } - public function getOAuthTokenUrl() + public function getOAuthTokenUrl(): string { return $this->oAuthTokenUrl; } - public function getOAuthAuthorizationUrl() + public function getOAuthAuthorizationUrl(): string { return $this->oAuthAuthorizationUrl; } - public function getOAuthRefreshUrl() + public function getOAuthRefreshUrl(): string { return $this->oAuthRefreshUrl; } - public function getOAuthScopes() + public function getOAuthScopes(): string { return $this->oAuthScopes; } - public function getApiKeys() + public function getApiKeys(): string { return $this->apiKeys; } diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index cb1cae64078..029921fcf65 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -35,12 +35,12 @@ public function __construct(NormalizerInterface $decorated) /** * {@inheritdoc} */ - public function normalize($object, $format = null, array $context = []) + public function normalize($object, $format = null, array $context = []): array { return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); } - private function recursiveClean($data) + private function recursiveClean($data): array { foreach ($data as $key => $value) { if (self::EXTENSION_PROPERTIES_KEY === $key) { From e17f7018dffbc62f85005ed04716983939fa89c2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 24 Apr 2020 15:35:06 +0200 Subject: [PATCH 06/17] Fix tests --- .../Bundle/Resources/config/openapi.xml | 2 +- .../Action/DocumentationAction.php | 15 ++++-- src/Documentation/Documentation.php | 2 +- src/Documentation/DocumentationInterface.php | 21 ++++++++ src/OpenApi/Model/Link.php | 2 +- src/OpenApi/Model/MediaType.php | 2 +- src/OpenApi/OpenApi.php | 3 +- src/OpenApi/Options.php | 4 +- src/OpenApi/Serializer/OpenApiNormalizer.php | 4 +- .../ApiPlatformExtensionTest.php | 3 +- .../Action/DocumentationActionTest.php | 52 ++++++++++++++++++- tests/OpenApi/Factory/OpenApiFactoryTest.php | 47 +++++++++-------- .../Normalizer/OpenApiNormalizerTest.php | 10 ++-- 13 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 src/Documentation/DocumentationInterface.php diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index 0a332450e85..12b767eec3a 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -25,7 +25,7 @@ %api_platform.oauth.scopes% %api_platform.swagger.api_keys% - + diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 15845e2fe48..9e2ffd18285 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,8 +35,10 @@ final class DocumentationAction private $formats; private $formatsProvider; private $swaggerVersions; + private $openApiFactory; /** + * @param OpenApiFactoryInterface $openApiFactory * @param int[] $swaggerVersions * @param mixed|array|FormatsProviderInterface $formatsProvider */ @@ -47,6 +51,10 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->swaggerVersions = $swaggerVersions; $this->openApiFactory = $openApiFactory; + if (null === $openApiFactory) { + @trigger_error(sprintf('Not passing an array or 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; } @@ -61,7 +69,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $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] ?? 3)]; @@ -72,13 +80,14 @@ 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 (3 === $context['spec_version']) { - return $this->openApiFactory->create($context); + if (null !== $this->openApiFactory && isset($context) && 3 === $context['spec_version']) { + return $this->openApiFactory->create($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/Model/Link.php b/src/OpenApi/Model/Link.php index f22ceaa37c3..918dbd700ef 100644 --- a/src/OpenApi/Model/Link.php +++ b/src/OpenApi/Model/Link.php @@ -84,7 +84,7 @@ public function withRequestBody(array $requestBody): self return $clone; } - public function withDescription(array $description): self + public function withDescription(string $description): self { $clone = clone $this; $clone->description = $description; diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php index ac55a682ef3..47cbaeb64b0 100644 --- a/src/OpenApi/Model/MediaType.php +++ b/src/OpenApi/Model/MediaType.php @@ -58,7 +58,7 @@ public function withSchema(array $schema): self return $clone; } - public function withExample() + public function withExample($example): self { $clone = clone $this; $clone->example = $example; diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index 484893f3d34..e1ec04c0608 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -13,12 +13,13 @@ 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; -class OpenApi +class OpenApi implements DocumentationInterface { use ExtensionTrait; diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index 2b176b97719..c1b35e2e719 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -87,12 +87,12 @@ public function getOAuthRefreshUrl(): string return $this->oAuthRefreshUrl; } - public function getOAuthScopes(): string + public function getOAuthScopes(): array { return $this->oAuthScopes; } - public function getApiKeys(): string + public function getApiKeys(): array { return $this->apiKeys; } diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index 029921fcf65..82bc74ebc19 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -52,8 +52,8 @@ private function recursiveClean($data): array // Side effect of using getPaths(): Paths which itself contains the array if ('paths' === $key) { - $value = $data['paths'] = $data['paths']['paths']; - unset($data['paths']['paths']); + $value = $data['paths'] = $data['paths']['paths']; + unset($data['paths']['paths']); } if (\is_array($value)) { diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 0439dd070e9..1501cbb8f08 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -78,9 +78,8 @@ 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\Serializer\Options; -use ApiPlatform\Core\OpenApi\Serializer\DocumentationNormalizer; 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; diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index 3d7bbd3efe7..463191e5ed5 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -18,6 +18,8 @@ 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\OpenApi; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; @@ -29,7 +31,11 @@ */ class DocumentationActionTest extends TestCase { - public function testyDocumentationAction(): void + /** + * @group legacy + * @expectedDeprecation Not passing an array or 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 +87,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 = $this->prophesize(OpenApi::class)->reveal(); + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->create(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 index 758f94bc1b4..8c13508ca13 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -50,12 +50,6 @@ class OpenApiFactoryTest extends TestCase public function testCreate(): void { - $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); - $dummyMetadata = new ResourceMetadata( 'Dummy', 'This is a dummy.', @@ -78,15 +72,21 @@ public function testCreate(): void $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $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')->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')->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')->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')->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')->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']])); + $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())); @@ -132,6 +132,7 @@ public function testCreate(): void $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); $typeFactory = new TypeFactory(); @@ -212,9 +213,9 @@ public function testCreate(): void $this->assertEquals($components->getSchemas(), ['Dummy' => $dummySchema->getDefinitions()]); $this->assertEquals($components->getSecuritySchemes(), [ - 'oauth' => new Model\SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, null, null, new Model\OAuthFlows(null, null, null, new Model\OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', ['scope param']))), - 'header' => new Model\SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header'), - 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query'), + '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', ['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(); @@ -469,11 +470,7 @@ public function testCreate(): void public function testOverrideDocumentation() { - $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', @@ -492,14 +489,20 @@ public function testOverrideDocumentation() ); $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + + $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')->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')->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')->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')->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, '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); diff --git a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php index 649408a335d..f35d369a14d 100644 --- a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\OpenApi\Factory; +namespace ApiPlatform\Core\Tests\OpenApi\Normalizer; use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\JsonSchema\SchemaFactory; @@ -78,10 +78,10 @@ public function testNormalize() $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id')->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')->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')->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')->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, '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); From ffe3ef075779f963fd2a4b3b0fbebc7a4faabb34 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 28 Apr 2020 12:08:15 +0200 Subject: [PATCH 07/17] Fix tests --- composer.json | 3 + serializer.patch | 15 +++ .../Symfony/Bundle/Command/OpenApiCommand.php | 20 +++- .../Bundle/Resources/config/openapi.xml | 4 + src/OpenApi/Factory/OpenApiFactory.php | 27 ++--- src/OpenApi/Model/Components.php | 92 ++++++++++++++-- src/OpenApi/Model/Encoding.php | 99 +++++++++++++++++ src/OpenApi/Model/ExternalDocumentation.php | 54 ++++++++++ src/OpenApi/Model/Link.php | 10 +- src/OpenApi/Model/MediaType.php | 14 +-- src/OpenApi/Model/OAuthFlow.php | 6 +- src/OpenApi/Model/Operation.php | 10 +- src/OpenApi/Model/Parameter.php | 16 +-- src/OpenApi/Model/RequestBody.php | 6 +- src/OpenApi/Model/Response.php | 14 +-- src/OpenApi/Model/Server.php | 6 +- src/OpenApi/OpenApi.php | 2 +- src/OpenApi/Serializer/OpenApiNormalizer.php | 11 +- tests/OpenApi/Factory/OpenApiFactoryTest.php | 102 +++++++++--------- 19 files changed, 388 insertions(+), 123 deletions(-) create mode 100644 serializer.patch create mode 100644 src/OpenApi/Model/Encoding.php create mode 100644 src/OpenApi/Model/ExternalDocumentation.php diff --git a/composer.json b/composer.json index d5de7dbb567..1cf5a7793c5 100644 --- a/composer.json +++ b/composer.json @@ -138,5 +138,8 @@ "symfony": { "require": "^3.4 || ^4.0 || ^5.0" } + }, + "scripts": { + "post-update-cmd": "cd vendor/symfony/serializer && cat ../../../serializer.patch | patch" } } diff --git a/serializer.patch b/serializer.patch new file mode 100644 index 00000000000..d367c4ba954 --- /dev/null +++ b/serializer.patch @@ -0,0 +1,15 @@ +diff --git a/Serializer.php b/Serializer.php +index 3f2461cf96..8e92abe29c 100644 +--- a/Serializer.php ++++ b/Serializer.php +@@ -157,6 +157,10 @@ class Serializer implements SerializerInterface, ContextAwareNormalizerInterface + } + + if (\is_array($data) || $data instanceof \Traversable) { ++ if ($data instanceof \Countable && 0 === $data->count()) { ++ return $data; ++ } ++ + $normalized = []; + foreach ($data as $key => $val) { + $normalized[$key] = $this->normalize($val, $format, $context); diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php index ecb7fcfa062..d22c7c99804 100644 --- a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -15,6 +15,7 @@ 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; @@ -46,7 +47,9 @@ protected function configure() ->setName('api:openapi:export') ->setDescription('Dump the OpenAPI documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') - ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file'); + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') + ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'OpenAPI version to use (2 or 3) (deprecated)', 3) + ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'API Gateway compatibility'); } /** @@ -54,8 +57,21 @@ protected function configure() */ protected function execute(InputInterface $input, OutputInterface $output) { - $io = new SymfonyStyle($input, $output); + // Backwards compatibility + if (2 === $specVersion = (int) $input->getOption('spec-version')) { + @trigger_error('The command "api:openapi:export --spec-version=2" is deprecated for the OpenApi version 2 use "api:swagger:export".', E_USER_DEPRECATED); + $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); + } + $io = new SymfonyStyle($input, $output); $data = $this->normalizer->normalize($this->openApiFactory->create(), '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) diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index 12b767eec3a..24970c6efd7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -49,6 +49,10 @@ + + + + diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 42b3fa9551a..d44fbb9c42c 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -25,6 +25,7 @@ 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; @@ -101,7 +102,7 @@ public function create(array $context = []): OpenApi $securityRequirements[$key] = []; } - return new OpenApi($info, $servers, $paths, new Model\Components($schemas, [], [], [], [], [], $securitySchemes), $securityRequirements); + 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); } private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array $links, array $schemas = []): array @@ -143,18 +144,18 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $responses[$successStatus] = new Model\Response(sprintf('%s %s', $resourceShortName, OperationType::COLLECTION === $operationType ? 'collection' : 'resource'), $responseContent); break; case 'POST': - $responseLinks = isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []; + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201'); - $responses[$successStatus] = new Model\Response(sprintf('%s resource created', $resourceShortName), $responseContent, [], $responseLinks); + $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 = isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []; + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); - $responses[$successStatus] = new Model\Response(sprintf('%s resource updated', $resourceShortName), $responseContent, [], $responseLinks); + $responses[$successStatus] = new Model\Response(sprintf('%s resource updated', $resourceShortName), $responseContent, null, $responseLinks); $responses['400'] = new Model\Response('Invalid input'); break; case 'DELETE': @@ -186,10 +187,10 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $responses, $operation['openapi_context']['summary'] ?? '', $operation['openapi_context']['description'] ?? $this->getPathDescription($resourceShortName, $method, $operationType), - $operation['openapi_context']['externalDocs'] ?? [], + isset($operation['openapi_context']['externalDocs']) ? new ExternalDocumentation($operation['openapi_context']['externalDocs']['description'] ?? null, $operation['openapi_context']['externalDocs']['url']) : null, $parameters, $requestBody, - $operation['openapi_context']['callbacks'] ?? [], + new \ArrayObject($operation['openapi_context']['callbacks'] ?? []), $operation['openapi_context']['deprecated'] ?? (bool) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', false, true), $operation['openapi_context']['security'] ?? [], $operation['openapi_context']['servers'] ?? [] @@ -204,12 +205,12 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour /** * @TODO: support multiple format schemas */ - private function buildContent(array $responseMimeTypes, $operationSchema) + private function buildContent(array $responseMimeTypes, $operationSchema): \ArrayObject { - $content = []; + $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { - $content[$mimeType] = new Model\MediaType($operationSchema->getArrayCopy(false)); + $content[$mimeType] = new Model\MediaType(new \ArrayObject($operationSchema->getArrayCopy(false))); } return $content; @@ -299,7 +300,7 @@ private function getLink(string $resourceClass, string $operationId, string $pat return new Model\Link( $operationId, - $parameters, + 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) ); @@ -320,7 +321,7 @@ private function getFiltersParameters(ResourceMetadata $resourceMetadata, string 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, $data['openapi']['examples'] ?? []); + $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); } } @@ -362,7 +363,7 @@ private function getPaginationParameters(ResourceMetadata $resourceMetadata, str private function getOauthSecurityScheme() { - $oauthFlow = new Model\OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl(), $this->openApiOptions->getOAuthRefreshUrl(), $this->openApiOptions->getOAuthScopes()); + $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()))) diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php index dd8a8f32326..f2a17b5596f 100644 --- a/src/OpenApi/Model/Components.php +++ b/src/OpenApi/Model/Components.php @@ -27,7 +27,7 @@ class Components private $links; private $callbacks; - public function __construct(array $schemas = [], array $responses = [], array $parameters = [], array $examples = [], array $requestBodies = [], array $headers = [], array $securitySchemes = [], array $links = [], array $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; @@ -40,48 +40,120 @@ public function __construct(array $schemas = [], array $responses = [], array $p $this->callbacks = $callbacks; } - public function getSchemas(): array + public function getSchemas(): ?\ArrayObject { return $this->schemas; } - public function getResponses(): array + public function getResponses(): ?\ArrayObject { return $this->responses; } - public function getParameters(): array + public function getParameters(): ?\ArrayObject { return $this->parameters; } - public function getExamples(): array + public function getExamples(): ?\ArrayObject { return $this->examples; } - public function getRequestBodies(): array + public function getRequestBodies(): ?\ArrayObject { return $this->requestBodies; } - public function getHeaders(): array + public function getHeaders(): ?\ArrayObject { return $this->headers; } - public function getSecuritySchemes(): array + public function getSecuritySchemes(): ?\ArrayObject { return $this->securitySchemes; } - public function getLinks(): array + public function getLinks(): ?\ArrayObject { return $this->links; } - public function getCallbacks(): array + 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/Encoding.php b/src/OpenApi/Model/Encoding.php new file mode 100644 index 00000000000..4d78b4485c3 --- /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; + +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/ExternalDocumentation.php b/src/OpenApi/Model/ExternalDocumentation.php new file mode 100644 index 00000000000..f4d888d5e96 --- /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; + +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/Link.php b/src/OpenApi/Model/Link.php index 918dbd700ef..56d2b94e55f 100644 --- a/src/OpenApi/Model/Link.php +++ b/src/OpenApi/Model/Link.php @@ -25,7 +25,7 @@ class Link private $server; // string $operationRef, ? - public function __construct(string $operationId, array $parameters = [], array $requestBody = [], string $description = '', Server $server = null) + public function __construct(string $operationId, \ArrayObject $parameters = null, $requestBody = null, string $description = '', Server $server = null) { // $this->operationRef = $operationRef; $this->operationId = $operationId; @@ -40,12 +40,12 @@ public function getOperationId(): string return $this->operationId; } - public function getParameters(): array + public function getParameters(): \ArrayObject { return $this->parameters; } - public function getRequestBody(): array + public function getRequestBody() { return $this->requestBody; } @@ -68,7 +68,7 @@ public function withOperationId(string $operationId): self return $clone; } - public function withParameters(array $parameters): self + public function withParameters(\ArrayObject $parameters): self { $clone = clone $this; $clone->parameters = $parameters; @@ -76,7 +76,7 @@ public function withParameters(array $parameters): self return $clone; } - public function withRequestBody(array $requestBody): self + public function withRequestBody($requestBody): self { $clone = clone $this; $clone->requestBody = $requestBody; diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php index 47cbaeb64b0..bb6cea5f003 100644 --- a/src/OpenApi/Model/MediaType.php +++ b/src/OpenApi/Model/MediaType.php @@ -22,7 +22,7 @@ class MediaType private $examples; private $encoding; - public function __construct(array $schema = [], $example = null, array $examples = [], array $encoding = []) + public function __construct(\ArrayObject $schema = null, $example = null, \ArrayObject $examples = null, Encoding $encoding = null) { $this->schema = $schema; $this->example = $example; @@ -30,7 +30,7 @@ public function __construct(array $schema = [], $example = null, array $examples $this->encoding = $encoding; } - public function getSchema(): array + public function getSchema(): ?\ArrayObject { return $this->schema; } @@ -40,17 +40,17 @@ public function getExample() return $this->example; } - public function getExamples(): array + public function getExamples(): ?\ArrayObject { return $this->examples; } - public function getEncoding(): array + public function getEncoding(): ?Encoding { return $this->encoding; } - public function withSchema(array $schema): self + public function withSchema(\ArrayObject $schema): self { $clone = clone $this; $clone->schema = $schema; @@ -66,7 +66,7 @@ public function withExample($example): self return $clone; } - public function withExamples(array $examples): self + public function withExamples(\ArrayObject $examples): self { $clone = clone $this; $clone->examples = $examples; @@ -74,7 +74,7 @@ public function withExamples(array $examples): self return $clone; } - public function withEncoding(array $encoding): self + public function withEncoding(Encoding $encoding): self { $clone = clone $this; $clone->encoding = $encoding; diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php index 5b8c727f68a..9d43281baf3 100644 --- a/src/OpenApi/Model/OAuthFlow.php +++ b/src/OpenApi/Model/OAuthFlow.php @@ -22,7 +22,7 @@ class OAuthFlow private $refreshUrl; private $scopes; - public function __construct(string $authorizationUrl = null, string $tokenUrl = null, string $refreshUrl = null, array $scopes = []) + public function __construct(string $authorizationUrl = null, string $tokenUrl = null, string $refreshUrl = null, \ArrayObject $scopes = null) { $this->authorizationUrl = $authorizationUrl; $this->tokenUrl = $tokenUrl; @@ -45,7 +45,7 @@ public function getRefreshUrl(): ?string return $this->refreshUrl; } - public function getScopes(): array + public function getScopes(): \ArrayObject { return $this->scopes; } @@ -74,7 +74,7 @@ public function withRefreshUrl(string $refreshUrl): self return $clone; } - public function withScopes(array $scopes): self + public function withScopes(\ArrayObject $scopes): self { $clone = clone $this; $clone->scopes = $scopes; diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php index cd97ea6cd7e..1de96363fb6 100644 --- a/src/OpenApi/Model/Operation.php +++ b/src/OpenApi/Model/Operation.php @@ -30,7 +30,7 @@ class Operation private $servers; private $externalDocs; - public function __construct(string $operationId = null, array $tags = [], array $responses = [], string $summary = '', string $description = '', $externalDocs = [], array $parameters = [], RequestBody $requestBody = null, $callbacks = [], bool $deprecated = false, $security = [], array $servers = []) + 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; @@ -78,7 +78,7 @@ public function getDescription(): string return $this->description; } - public function getExternalDocs(): array + public function getExternalDocs(): ?ExternalDocumentation { return $this->externalDocs; } @@ -93,7 +93,7 @@ public function getRequestBody(): ?RequestBody return $this->requestBody; } - public function getCallbacks(): array + public function getCallbacks(): ?\ArrayObject { return $this->callbacks; } @@ -153,7 +153,7 @@ public function withDescription(string $description): self return $clone; } - public function withExternalDocs(array $externalDocs): self + public function withExternalDocs(ExternalDocumentation $externalDocs): self { $clone = clone $this; $clone->externalDocs = $externalDocs; @@ -177,7 +177,7 @@ public function withRequestBody(RequestBody $requestBody): self return $clone; } - public function withCallbacks(array $callbacks): self + public function withCallbacks(\ArrayObject $callbacks): self { $clone = clone $this; $clone->callbacks = $callbacks; diff --git a/src/OpenApi/Model/Parameter.php b/src/OpenApi/Model/Parameter.php index 66beecea5e3..4148613935e 100644 --- a/src/OpenApi/Model/Parameter.php +++ b/src/OpenApi/Model/Parameter.php @@ -31,7 +31,7 @@ class Parameter 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, $examples = [], array $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; @@ -82,7 +82,7 @@ public function getDeprecated(): bool return $this->deprecated; } - public function getAllowEmptyValue(): bool + public function canAllowEmptyValue(): bool { return $this->allowEmptyValue; } @@ -97,12 +97,12 @@ public function getStyle(): string return $this->style; } - public function getExplode(): bool + public function canExplode(): bool { return $this->explode; } - public function getAllowReserved(): bool + public function canAllowReserved(): bool { return $this->allowReserved; } @@ -112,12 +112,12 @@ public function getExample() return $this->example; } - public function getExamples(): array + public function getExamples(): ?\ArrayObject { return $this->examples; } - public function getContent(): array + public function getContent(): ?\ArrayObject { return $this->content; } @@ -210,7 +210,7 @@ public function withExample($example): self return $clone; } - public function withExamples(array $examples): self + public function withExamples(\ArrayObject $examples): self { $clone = clone $this; $clone->examples = $examples; @@ -218,7 +218,7 @@ public function withExamples(array $examples): self return $clone; } - public function withContent(array $content): self + public function withContent(\ArrayObject $content): self { $clone = clone $this; $clone->content = $content; diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php index be1b74a89eb..64939cc9b29 100644 --- a/src/OpenApi/Model/RequestBody.php +++ b/src/OpenApi/Model/RequestBody.php @@ -21,7 +21,7 @@ class RequestBody private $content; private $required; - public function __construct(string $description = '', array $content = [], bool $required = false) + public function __construct(string $description = '', \ArrayObject $content = null, bool $required = false) { $this->description = $description; $this->content = $content; @@ -33,7 +33,7 @@ public function getDescription(): string return $this->description; } - public function getContent(): array + public function getContent(): \ArrayObject { return $this->content; } @@ -51,7 +51,7 @@ public function withDescription(string $description): self return $clone; } - public function withContent(array $content): self + public function withContent(\ArrayObject $content): self { $clone = clone $this; $clone->content = $content; diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php index c471d9ea37a..89a800f149d 100644 --- a/src/OpenApi/Model/Response.php +++ b/src/OpenApi/Model/Response.php @@ -22,7 +22,7 @@ class Response private $headers; private $links; - public function __construct(string $description = '', array $content = [], array $headers = [], array $links = []) + public function __construct(string $description = '', \ArrayObject $content = null, \ArrayObject $headers = null, \ArrayObject $links = null) { $this->description = $description; $this->content = $content; @@ -35,17 +35,17 @@ public function getDescription(): string return $this->description; } - public function getContent(): array + public function getContent(): ?\ArrayObject { return $this->content; } - public function getHeaders(): array + public function getHeaders(): ?\ArrayObject { return $this->headers; } - public function getLinks(): array + public function getLinks(): ?\ArrayObject { return $this->links; } @@ -58,7 +58,7 @@ public function withDescription(string $description): self return $clone; } - public function withContent(array $content): self + public function withContent(\ArrayObject $content): self { $clone = clone $this; $clone->content = $content; @@ -66,7 +66,7 @@ public function withContent(array $content): self return $clone; } - public function withHeaders(array $headers): self + public function withHeaders(\ArrayObject $headers): self { $clone = clone $this; $clone->headers = $headers; @@ -74,7 +74,7 @@ public function withHeaders(array $headers): self return $clone; } - public function withLinks(array $links): self + public function withLinks(\ArrayObject $links): self { $clone = clone $this; $clone->links = $links; diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php index a1cbcab5f03..ee7d21640f4 100644 --- a/src/OpenApi/Model/Server.php +++ b/src/OpenApi/Model/Server.php @@ -21,7 +21,7 @@ class Server private $description; private $variables; - public function __construct(string $url, string $description = '', array $variables = []) + public function __construct(string $url, string $description = '', \ArrayObject $variables = null) { $this->url = $url; $this->description = $description; @@ -38,7 +38,7 @@ public function getDescription(): string return $this->description; } - public function getVariables(): array + public function getVariables(): \ArrayObject { return $this->variables; } @@ -59,7 +59,7 @@ public function withDescription(string $description): self return $clone; } - public function withVariables(array $variables): self + public function withVariables(\ArrayObject $variables): self { $clone = clone $this; $clone->variables = $variables; diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index e1ec04c0608..e072b972d63 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -23,7 +23,7 @@ class OpenApi implements DocumentationInterface { use ExtensionTrait; - public const VERSION = '3.0.2'; + public const VERSION = '3.0.3'; private $openapi; private $info; diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index 82bc74ebc19..975e340c14c 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -14,6 +14,7 @@ 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; @@ -23,8 +24,9 @@ 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) @@ -37,6 +39,9 @@ public function __construct(NormalizerInterface $decorated) */ 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)); } @@ -61,10 +66,6 @@ private function recursiveClean($data): array // arrays must stay even if empty continue; } - - if (empty($value)) { - unset($data[$key]); - } } unset($data[self::EXTENSION_PROPERTIES_KEY]); diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 8c13508ca13..887447e553b 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -213,7 +213,7 @@ public function testCreate(): void $this->assertEquals($components->getSchemas(), ['Dummy' => $dummySchema->getDefinitions()]); $this->assertEquals($components->getSecuritySchemes(), [ - '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', ['scope param']))), + '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'), ]); @@ -229,16 +229,16 @@ public function testCreate(): void 'getDummyCollection', ['Dummy'], [ - '200' => new Model\Response('Dummy collection', [ - 'application/ld+json' => new Model\MediaType([ + '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', @@ -261,23 +261,23 @@ public function testCreate(): void [ '201' => new Model\Response( 'Dummy resource created', - [ - 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ], - [], - ['GetDummyItem' => new Model\Link('getDummyItem', ['id' => '$response.body#/id'], [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')] + 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', - [ - 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ], + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), true ) )); @@ -294,15 +294,15 @@ public function testCreate(): void [ '200' => new Model\Response( 'Dummy resource', - [ - 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ] + 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'])] )); @@ -312,24 +312,24 @@ public function testCreate(): void [ '200' => new Model\Response( 'Dummy resource updated', - [ - 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ], - [], - ['GetDummyItem' => new Model\Link('getDummyItem', ['id' => '$response.body#/id'], [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')] + 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', - [ - 'application/ld+json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ], + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), true ) )); @@ -343,7 +343,7 @@ public function testCreate(): void ], '', 'Removes the Dummy resource.', - [], + null, [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] )); @@ -356,7 +356,7 @@ public function testCreate(): void ], '', 'Custom description', - [], + null, [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] )); @@ -367,26 +367,26 @@ public function testCreate(): void [ '200' => new Model\Response( 'Dummy resource updated', - [ - 'application/json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - 'text/csv' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ], - [], - ['GetDummyItem' => new Model\Link('getDummyItem', ['id' => '$response.body#/id'], [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')] + 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', - [ - 'application/json' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - 'text/csv' => new Model\MediaType(['$ref' => '#/components/schemas/Dummy']), - ], + 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 ) )); @@ -396,16 +396,16 @@ public function testCreate(): void 'filteredDummyCollection', ['Dummy'], [ - '200' => new Model\Response('Dummy collection', [ - 'application/ld+json' => new Model\MediaType([ + '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', @@ -440,16 +440,16 @@ public function testCreate(): void 'paginatedDummyCollection', ['Dummy'], [ - '200' => new Model\Response('Dummy collection', [ - 'application/ld+json' => new Model\MediaType([ + '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', From 4e4af91670f3297d881d64f80aca1d0eef37bb07 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 12 Jun 2020 16:34:45 +0200 Subject: [PATCH 08/17] fix tests --- composer.json | 3 --- serializer.patch | 15 --------------- .../Symfony/Bundle/Resources/config/openapi.xml | 2 +- src/OpenApi/Factory/OpenApiFactory.php | 2 +- src/OpenApi/Model/PathItem.php | 6 +++--- src/OpenApi/Model/Server.php | 2 +- .../Symfony/Bundle/Command/OpenApiCommandTest.php | 3 ++- .../ApiPlatformExtensionTest.php | 1 + tests/OpenApi/Factory/OpenApiFactoryTest.php | 6 +++--- 9 files changed, 12 insertions(+), 28 deletions(-) delete mode 100644 serializer.patch diff --git a/composer.json b/composer.json index 1cf5a7793c5..d5de7dbb567 100644 --- a/composer.json +++ b/composer.json @@ -138,8 +138,5 @@ "symfony": { "require": "^3.4 || ^4.0 || ^5.0" } - }, - "scripts": { - "post-update-cmd": "cd vendor/symfony/serializer && cat ../../../serializer.patch | patch" } } diff --git a/serializer.patch b/serializer.patch deleted file mode 100644 index d367c4ba954..00000000000 --- a/serializer.patch +++ /dev/null @@ -1,15 +0,0 @@ -diff --git a/Serializer.php b/Serializer.php -index 3f2461cf96..8e92abe29c 100644 ---- a/Serializer.php -+++ b/Serializer.php -@@ -157,6 +157,10 @@ class Serializer implements SerializerInterface, ContextAwareNormalizerInterface - } - - if (\is_array($data) || $data instanceof \Traversable) { -+ if ($data instanceof \Countable && 0 === $data->count()) { -+ return $data; -+ } -+ - $normalized = []; - foreach ($data as $key => $val) { - $normalized[$key] = $this->normalize($val, $format, $context); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index 24970c6efd7..c220964e7ca 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -20,7 +20,7 @@ %api_platform.oauth.flow% %api_platform.oauth.tokenUrl% %api_platform.oauth.authorizationUrl% - + %api_platform.oauth.scopes% %api_platform.swagger.api_keys% diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index d44fbb9c42c..948900643b1 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -190,7 +190,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour isset($operation['openapi_context']['externalDocs']) ? new ExternalDocumentation($operation['openapi_context']['externalDocs']['description'] ?? null, $operation['openapi_context']['externalDocs']['url']) : null, $parameters, $requestBody, - new \ArrayObject($operation['openapi_context']['callbacks'] ?? []), + 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'] ?? [] diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php index ea7d7e1f584..f8629330eb2 100644 --- a/src/OpenApi/Model/PathItem.php +++ b/src/OpenApi/Model/PathItem.php @@ -32,7 +32,7 @@ class PathItem private $servers; private $parameters; - public function __construct(?string $ref = null, string $summary = '', string $description = '', 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 = []) + 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; @@ -54,12 +54,12 @@ public function getRef(): ?string return $this->ref; } - public function getSummary(): string + public function getSummary(): ?string { return $this->summary; } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php index ee7d21640f4..c639b79c273 100644 --- a/src/OpenApi/Model/Server.php +++ b/src/OpenApi/Model/Server.php @@ -38,7 +38,7 @@ public function getDescription(): string return $this->description; } - public function getVariables(): \ArrayObject + public function getVariables(): ?\ArrayObject { return $this->variables; } diff --git a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php index a203ab65bff..a9df5225a0e 100644 --- a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php +++ b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -13,6 +13,7 @@ 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; @@ -72,7 +73,7 @@ public function testExecuteWithYaml() YAML; $this->assertStringContainsString($expected, $result, 'arrays should be correctly formatted.'); - $this->assertStringContainsString('openapi: 3.0.2', $result); + $this->assertStringContainsString('openapi: '.OpenApi::VERSION, $result); $expected = <<getComponents(); $this->assertInstanceOf(Model\Components::class, $components); - $this->assertEquals($components->getSchemas(), ['Dummy' => $dummySchema->getDefinitions()]); + $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions()])); - $this->assertEquals($components->getSecuritySchemes(), [ + $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'); From e1a826e82a3be7038b2252308c8967beddc01585 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 12 Jun 2020 16:43:36 +0200 Subject: [PATCH 09/17] Bump serializer min version --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From 856c843f5f642323be6685bf549582ab8f29f96c Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Jun 2020 12:27:21 +0200 Subject: [PATCH 10/17] Fix some comments --- .../Symfony/Bundle/Command/OpenApiCommand.php | 21 ++++++++++--------- .../ApiPlatformExtension.php | 1 + .../DependencyInjection/Configuration.php | 3 ++- .../Bundle/Resources/config/openapi.xml | 3 +-- src/DataProvider/PaginationOptions.php | 5 ++++- .../Action/DocumentationAction.php | 3 +-- src/OpenApi/Factory/OpenApiFactory.php | 13 +++++++----- src/OpenApi/Model/Components.php | 2 +- src/OpenApi/Model/Contact.php | 2 +- src/OpenApi/Model/Encoding.php | 2 +- src/OpenApi/Model/ExternalDocumentation.php | 2 +- src/OpenApi/Model/Info.php | 2 +- src/OpenApi/Model/License.php | 2 +- src/OpenApi/Model/Link.php | 2 +- src/OpenApi/Model/MediaType.php | 2 +- src/OpenApi/Model/OAuthFlow.php | 2 +- src/OpenApi/Model/OAuthFlows.php | 2 +- src/OpenApi/Model/Operation.php | 2 +- src/OpenApi/Model/Parameter.php | 5 ++--- src/OpenApi/Model/PathItem.php | 2 +- src/OpenApi/Model/Paths.php | 2 +- src/OpenApi/Model/RequestBody.php | 2 +- src/OpenApi/Model/Response.php | 2 +- src/OpenApi/Model/Schema.php | 16 ++++++++++++-- src/OpenApi/Model/SecurityScheme.php | 2 +- src/OpenApi/Model/Server.php | 2 +- 26 files changed, 61 insertions(+), 43 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php index d22c7c99804..5a6dfddaae3 100644 --- a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -20,11 +20,12 @@ 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; /** - * Console command to dump OpenApi documentations. + * Dumps Open API documentation. */ final class OpenApiCommand extends Command { @@ -33,9 +34,9 @@ final class OpenApiCommand extends Command public function __construct(OpenApiFactoryInterface $openApiFactory, NormalizerInterface $normalizer) { + parent::__construct(); $this->openApiFactory = $openApiFactory; $this->normalizer = $normalizer; - parent::__construct(); } /** @@ -45,11 +46,11 @@ protected function configure() { $this ->setName('api:openapi:export') - ->setDescription('Dump the OpenAPI documentation') + ->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, 'OpenAPI version to use (2 or 3) (deprecated)', 3) - ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'API Gateway compatibility'); + ->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'); } /** @@ -59,7 +60,6 @@ protected function execute(InputInterface $input, OutputInterface $output) { // Backwards compatibility if (2 === $specVersion = (int) $input->getOption('spec-version')) { - @trigger_error('The command "api:openapi:export --spec-version=2" is deprecated for the OpenApi version 2 use "api:swagger:export".', E_USER_DEPRECATED); $command = $this->getApplication()->find('api:swagger:export'); return $command->run(new ArrayInput([ @@ -71,19 +71,20 @@ protected function execute(InputInterface $input, OutputInterface $output) ]), $output); } + $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); $data = $this->normalizer->normalize($this->openApiFactory->create(), '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) ?: ''); - if (!empty($filename = $input->getOption('output')) && \is_string($filename)) { - file_put_contents($filename, $content); + if (!($filename = $input->getOption('output')) && \is_string($filename)) { + $filesystem->dumpFile($filename, $content); $io->success(sprintf('Data written to %s.', $filename)); - } else { - $output->writeln($content); + return 0; } + $output->writeln($content); return 0; } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 6b3a06173e6..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']); } 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/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index c220964e7ca..03bda2f24da 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -20,8 +20,7 @@ %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% diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php index 5271e6a5c66..c77b24bf4c2 100644 --- a/src/DataProvider/PaginationOptions.php +++ b/src/DataProvider/PaginationOptions.php @@ -13,7 +13,10 @@ namespace ApiPlatform\Core\DataProvider; -class PaginationOptions +/** + * @internal + */ +final class PaginationOptions { private $paginationEnabled; private $paginationPageParameterName; diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 9e2ffd18285..6252ef5be73 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -38,7 +38,6 @@ final class DocumentationAction private $openApiFactory; /** - * @param OpenApiFactoryInterface $openApiFactory * @param int[] $swaggerVersions * @param mixed|array|FormatsProviderInterface $formatsProvider */ @@ -52,7 +51,7 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->openApiFactory = $openApiFactory; if (null === $openApiFactory) { - @trigger_error(sprintf('Not passing an array or 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); + @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) { diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 948900643b1..af517bfcfe0 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -34,7 +34,7 @@ use Symfony\Component\PropertyInfo\Type; /** - * Generates an OpenAPI v3 specification. + * Generates an Open API v3 specification. */ final class OpenApiFactory implements OpenApiFactoryInterface { @@ -105,6 +105,9 @@ public function create(array $context = []): OpenApi 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(); @@ -216,7 +219,7 @@ private function buildContent(array $responseMimeTypes, $operationSchema): \Arra return $content; } - private function getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata = null) + private function getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata = null): array { $requestFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_formats', $this->formats, true); $responseFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_formats', $this->formats, true); @@ -328,7 +331,7 @@ private function getFiltersParameters(ResourceMetadata $resourceMetadata, string return $parameters; } - private function getPaginationParameters(ResourceMetadata $resourceMetadata, string $operationName) + private function getPaginationParameters(ResourceMetadata $resourceMetadata, string $operationName): array { if (!$this->paginationOptions->isPaginationEnabled()) { return []; @@ -361,7 +364,7 @@ private function getPaginationParameters(ResourceMetadata $resourceMetadata, str return $parameters; } - private function getOauthSecurityScheme() + 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( @@ -392,7 +395,7 @@ private function getOauthSecurityScheme() return new Model\SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, 'oauth2', null, new Model\OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null); } - private function getSecuritySchemes() + private function getSecuritySchemes(): array { $securitySchemes = []; diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php index f2a17b5596f..1035ab1d7f0 100644 --- a/src/OpenApi/Model/Components.php +++ b/src/OpenApi/Model/Components.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Components +final class Components { use ExtensionTrait; diff --git a/src/OpenApi/Model/Contact.php b/src/OpenApi/Model/Contact.php index 00139bed3cc..713de0ff65e 100644 --- a/src/OpenApi/Model/Contact.php +++ b/src/OpenApi/Model/Contact.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Contact +final class Contact { use ExtensionTrait; diff --git a/src/OpenApi/Model/Encoding.php b/src/OpenApi/Model/Encoding.php index 4d78b4485c3..12b8d91a1d2 100644 --- a/src/OpenApi/Model/Encoding.php +++ b/src/OpenApi/Model/Encoding.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Encoding +final class Encoding { use ExtensionTrait; diff --git a/src/OpenApi/Model/ExternalDocumentation.php b/src/OpenApi/Model/ExternalDocumentation.php index f4d888d5e96..f606a26b4d7 100644 --- a/src/OpenApi/Model/ExternalDocumentation.php +++ b/src/OpenApi/Model/ExternalDocumentation.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class ExternalDocumentation +final class ExternalDocumentation { use ExtensionTrait; diff --git a/src/OpenApi/Model/Info.php b/src/OpenApi/Model/Info.php index 2565fcb8113..9b16e9e8909 100644 --- a/src/OpenApi/Model/Info.php +++ b/src/OpenApi/Model/Info.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Info +final class Info { use ExtensionTrait; diff --git a/src/OpenApi/Model/License.php b/src/OpenApi/Model/License.php index 686db58ea79..ebc183c9aa0 100644 --- a/src/OpenApi/Model/License.php +++ b/src/OpenApi/Model/License.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class License +final class License { use ExtensionTrait; diff --git a/src/OpenApi/Model/Link.php b/src/OpenApi/Model/Link.php index 56d2b94e55f..df4e5a50453 100644 --- a/src/OpenApi/Model/Link.php +++ b/src/OpenApi/Model/Link.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Link +final class Link { use ExtensionTrait; diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php index bb6cea5f003..8f232bb4b4b 100644 --- a/src/OpenApi/Model/MediaType.php +++ b/src/OpenApi/Model/MediaType.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class MediaType +final class MediaType { use ExtensionTrait; diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php index 9d43281baf3..1b3dfd4849b 100644 --- a/src/OpenApi/Model/OAuthFlow.php +++ b/src/OpenApi/Model/OAuthFlow.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class OAuthFlow +final class OAuthFlow { use ExtensionTrait; diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php index 020bd34e342..afe06ad4824 100644 --- a/src/OpenApi/Model/OAuthFlows.php +++ b/src/OpenApi/Model/OAuthFlows.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class OAuthFlows +final class OAuthFlows { use ExtensionTrait; diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php index 1de96363fb6..c5b7fd76faf 100644 --- a/src/OpenApi/Model/Operation.php +++ b/src/OpenApi/Model/Operation.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Operation +final class Operation { use ExtensionTrait; diff --git a/src/OpenApi/Model/Parameter.php b/src/OpenApi/Model/Parameter.php index 4148613935e..f68f0cbd231 100644 --- a/src/OpenApi/Model/Parameter.php +++ b/src/OpenApi/Model/Parameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Parameter +final class Parameter { use ExtensionTrait; @@ -45,6 +45,7 @@ public function __construct(string $name, string $in, string $description = '', $this->example = $example; $this->examples = $examples; $this->content = $content; + $this->style = $style; if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -52,8 +53,6 @@ public function __construct(string $name, string $in, string $description = '', } elseif ('path' === $in || 'header' === $in) { $this->style = 'simple'; } - } else { - $this->style = $style; } } diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php index f8629330eb2..1285336fa74 100644 --- a/src/OpenApi/Model/PathItem.php +++ b/src/OpenApi/Model/PathItem.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class PathItem +final class PathItem { use ExtensionTrait; diff --git a/src/OpenApi/Model/Paths.php b/src/OpenApi/Model/Paths.php index 61d2415fc03..c0b30a9a64c 100644 --- a/src/OpenApi/Model/Paths.php +++ b/src/OpenApi/Model/Paths.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Paths +final class Paths { private $paths; diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php index 64939cc9b29..04235777a5e 100644 --- a/src/OpenApi/Model/RequestBody.php +++ b/src/OpenApi/Model/RequestBody.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class RequestBody +final class RequestBody { use ExtensionTrait; diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php index 89a800f149d..9bb2f84d353 100644 --- a/src/OpenApi/Model/Response.php +++ b/src/OpenApi/Model/Response.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Response +final class Response { use ExtensionTrait; diff --git a/src/OpenApi/Model/Schema.php b/src/OpenApi/Model/Schema.php index c7f757e4b03..e625bbae40b 100644 --- a/src/OpenApi/Model/Schema.php +++ b/src/OpenApi/Model/Schema.php @@ -15,7 +15,7 @@ use ApiPlatform\Core\JsonSchema\Schema as JsonSchema; -class Schema +final class Schema extends \ArrayObject { use ExtensionTrait; @@ -40,6 +40,8 @@ public function __construct(bool $nullable = false, $discriminator = null, bool $this->example = $example; $this->deprecated = $deprecated; $this->schema = new JsonSchema(); + + parent::__construct([]); } public function setDefinitions(array $definitions) @@ -47,9 +49,19 @@ 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((array) $this->schema->getDefinitions(), (array) $this)); + return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); } public function getNullable(): bool diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php index ce695f03517..385e4e028c5 100644 --- a/src/OpenApi/Model/SecurityScheme.php +++ b/src/OpenApi/Model/SecurityScheme.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class SecurityScheme +final class SecurityScheme { use ExtensionTrait; diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php index c639b79c273..852afd5c9ad 100644 --- a/src/OpenApi/Model/Server.php +++ b/src/OpenApi/Model/Server.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Core\OpenApi\Model; -class Server +final class Server { use ExtensionTrait; From 77f152aaad417869c76431afd9c145fd1896eb5f Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Jun 2020 12:27:35 +0200 Subject: [PATCH 11/17] Fix some comments --- src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php | 2 ++ src/OpenApi/Model/Schema.php | 1 + 2 files changed, 3 insertions(+) diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php index 5a6dfddaae3..d0cbfb3f288 100644 --- a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -81,10 +81,12 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!($filename = $input->getOption('output')) && \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/OpenApi/Model/Schema.php b/src/OpenApi/Model/Schema.php index e625bbae40b..5b9060238f7 100644 --- a/src/OpenApi/Model/Schema.php +++ b/src/OpenApi/Model/Schema.php @@ -56,6 +56,7 @@ public function getArrayCopy(): array { $schema = parent::getArrayCopy(); unset($schema['schema']); + return $schema; } From 6d00d9f4560fa9e8d699d92a7ff17477fa0750af Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Jun 2020 12:56:33 +0200 Subject: [PATCH 12/17] Fix some comments --- .../Symfony/Bundle/Command/OpenApiCommand.php | 5 ++-- src/DataProvider/PaginationOptions.php | 3 -- src/OpenApi/Factory/OpenApiFactory.php | 30 +++++++++++++------ .../Factory/OpenApiFactoryInterface.php | 2 +- src/OpenApi/OpenApi.php | 2 +- tests/OpenApi/Factory/OpenApiFactoryTest.php | 6 ++-- .../Normalizer/OpenApiNormalizerTest.php | 2 +- 7 files changed, 30 insertions(+), 20 deletions(-) diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php index d0cbfb3f288..6bc69f52a86 100644 --- a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -73,12 +73,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); - $data = $this->normalizer->normalize($this->openApiFactory->create(), 'json'); + $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) ?: ''); - if (!($filename = $input->getOption('output')) && \is_string($filename)) { + $filename = $input->getOption('output'); + if ($filename && \is_string($filename)) { $filesystem->dumpFile($filename, $content); $io->success(sprintf('Data written to %s.', $filename)); diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php index c77b24bf4c2..1db7646943f 100644 --- a/src/DataProvider/PaginationOptions.php +++ b/src/DataProvider/PaginationOptions.php @@ -13,9 +13,6 @@ namespace ApiPlatform\Core\DataProvider; -/** - * @internal - */ final class PaginationOptions { private $paginationEnabled; diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index af517bfcfe0..8388a77df41 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -73,11 +73,11 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName /** * {@inheritdoc} */ - public function create(array $context = []): OpenApi + 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)] : []; + $servers = '/' === $baseUrl || '' === $baseUrl ? [] : [new Model\Server($baseUrl)]; $paths = new Model\Paths(); $links = []; $schemas = []; @@ -88,10 +88,8 @@ public function create(array $context = []): OpenApi // 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); - $links = array_merge($links, $itemOperationLinks); $schemas += $itemOperationSchemas; list($collectionOperationLinks, $collectionOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::COLLECTION, $context, $paths, $links, $schemas); - $links = array_merge($links, $collectionOperationLinks); $schemas += $collectionOperationSchemas; } @@ -108,7 +106,7 @@ public function create(array $context = []): OpenApi /** * @return array | array */ - private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array $links, array $schemas = []): array + private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array &$links, array $schemas = []): array { $resourceShortName = $resourceMetadata->getShortName(); if (null === $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) { @@ -208,7 +206,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour /** * @TODO: support multiple format schemas */ - private function buildContent(array $responseMimeTypes, $operationSchema): \ArrayObject + private function buildContent(array $responseMimeTypes, Schema $operationSchema): \ArrayObject { $content = new \ArrayObject(); @@ -219,7 +217,7 @@ private function buildContent(array $responseMimeTypes, $operationSchema): \Arra return $content; } - private function getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata = null): array + 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); @@ -286,7 +284,7 @@ private function getPathDescription(string $resourceShortName, string $method, s } /** - * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. + * @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 { @@ -324,7 +322,21 @@ private function getFiltersParameters(ResourceMetadata $resourceMetadata, string 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); + $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); } } diff --git a/src/OpenApi/Factory/OpenApiFactoryInterface.php b/src/OpenApi/Factory/OpenApiFactoryInterface.php index 9bbfb752b87..d4b04bb190f 100644 --- a/src/OpenApi/Factory/OpenApiFactoryInterface.php +++ b/src/OpenApi/Factory/OpenApiFactoryInterface.php @@ -20,5 +20,5 @@ interface OpenApiFactoryInterface /** * Creates an OpenApi class. */ - public function create(array $context = []): OpenApi; + public function __invoke(array $context = []): OpenApi; } diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index e072b972d63..94c50377671 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -19,7 +19,7 @@ use ApiPlatform\Core\OpenApi\Model\Info; use ApiPlatform\Core\OpenApi\Model\Paths; -class OpenApi implements DocumentationInterface +final class OpenApi implements DocumentationInterface { use ExtensionTrait; diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 3ab2d48dc04..58ab3f221b4 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -48,7 +48,7 @@ class OpenApiFactoryTest extends TestCase 'output_formats' => ['jsonld' => ['application/ld+json']], ]; - public function testCreate(): void + public function testInvoke(): void { $dummyMetadata = new ResourceMetadata( 'Dummy', @@ -201,7 +201,7 @@ public function testCreate(): void 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], ])); - $openApi = $factory->create(['base_url' => '/app_dev.php/']); + $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.')); @@ -538,7 +538,7 @@ public function testOverrideDocumentation() new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') ); - $openApi = $factory->create(['base_url' => '/app_dev.php/']); + $openApi = $factory(['base_url' => '/app_dev.php/']); $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); $operation = $pathItem->getGet(); diff --git a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php index f35d369a14d..342a06bb434 100644 --- a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php @@ -117,7 +117,7 @@ public function testNormalize() new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') ); - $openApi = $factory->create(['base_url' => '/app_dev.php/']); + $openApi = $factory(['base_url' => '/app_dev.php/']); $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); $operation = $pathItem->getGet(); From 76084cf3aa406476380001b62c774d4727d92cdf Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Jun 2020 13:19:29 +0200 Subject: [PATCH 13/17] Fix some tests --- src/Documentation/Action/DocumentationAction.php | 2 +- .../DependencyInjection/ApiPlatformExtensionTest.php | 1 + .../Bundle/DependencyInjection/ConfigurationTest.php | 1 + tests/Documentation/Action/DocumentationActionTest.php | 8 +++++--- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 6252ef5be73..754fc013b4f 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -86,7 +86,7 @@ public function __invoke(Request $request = null): DocumentationInterface } if (null !== $this->openApiFactory && isset($context) && 3 === $context['spec_version']) { - return $this->openApiFactory->create($context ?? []); + return $this->openApiFactory->__invoke($context ?? []); } return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 1e40b2a5b80..2271044208c 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -1149,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, 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 463191e5ed5..728fd06a92e 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -19,6 +19,8 @@ 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; @@ -33,7 +35,7 @@ class DocumentationActionTest extends TestCase { /** * @group legacy - * @expectedDeprecation Not passing an array or 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 + * @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 { @@ -111,9 +113,9 @@ public function testDocumentationActionV2(): void public function testDocumentationActionV3(): void { - $openApi = $this->prophesize(OpenApi::class)->reveal(); + $openApi = new OpenApi(new Info('my api', '1.0.0'), [], new Paths()); $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); - $openApiFactoryProphecy->create(Argument::any())->shouldBeCalled()->willReturn($openApi); + $openApiFactoryProphecy->__invoke(Argument::any())->shouldBeCalled()->willReturn($openApi); $requestProphecy = $this->prophesize(Request::class); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); From 953be4bc1cb900d0eb7fce161c470e240138219c Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 28 Jun 2020 13:11:02 +0200 Subject: [PATCH 14/17] Remove operationRef --- src/OpenApi/Model/Link.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/OpenApi/Model/Link.php b/src/OpenApi/Model/Link.php index df4e5a50453..a0feb9511d6 100644 --- a/src/OpenApi/Model/Link.php +++ b/src/OpenApi/Model/Link.php @@ -17,17 +17,14 @@ final class Link { use ExtensionTrait; - // public $operationRef; private $operationId; private $parameters; private $requestBody; private $description; private $server; - // string $operationRef, ? public function __construct(string $operationId, \ArrayObject $parameters = null, $requestBody = null, string $description = '', Server $server = null) { - // $this->operationRef = $operationRef; $this->operationId = $operationId; $this->parameters = $parameters; $this->requestBody = $requestBody; From c7104073867df7e5cabc8f9e233605324d2ed1c9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 17 Jul 2020 16:48:17 +0200 Subject: [PATCH 15/17] openapi: add subresource support --- src/OpenApi/Factory/OpenApiFactory.php | 18 +- tests/OpenApi/Factory/OpenApiFactoryTest.php | 282 ++++++++----------- 2 files changed, 126 insertions(+), 174 deletions(-) diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 8388a77df41..8f0065779e7 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -90,6 +90,8 @@ public function __invoke(array $context = []): OpenApi 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; } @@ -109,12 +111,13 @@ public function __invoke(array $context = []): OpenApi private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array &$links, array $schemas = []): array { $resourceShortName = $resourceMetadata->getShortName(); - if (null === $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : $resourceMetadata->getItemOperations()) { + $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); + $path = $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); @@ -135,6 +138,15 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $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) { + foreach ($operation['identifiers'] as $identifier) { + $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 @@ -184,7 +196,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $pathItem = $pathItem->{'with'.ucfirst($method)}(new Model\Operation( $operationId, - $operation['openapi_context']['tags'] ?? [$resourceShortName], + $operation['openapi_context']['tags'] ?? (OperationType::SUBRESOURCE === $operationType ? $operation['shortNames'] : [$resourceShortName]), $responses, $operation['openapi_context']['summary'] ?? '', $operation['openapi_context']['description'] ?? $this->getPathDescription($resourceShortName, $method, $operationType), diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 58ab3f221b4..f960dc3a113 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -13,6 +13,7 @@ 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; @@ -21,6 +22,7 @@ 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; @@ -29,16 +31,22 @@ 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 @@ -71,6 +79,7 @@ public function testInvoke(): void ); $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); @@ -489,6 +498,7 @@ public function testOverrideDocumentation() ); $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); @@ -558,175 +568,105 @@ public function testOverrideDocumentation() $this->assertEquals($openApi->getExtensionProperties(), ['x-key' => 'Custom x-key value', 'x-value' => 'Custom x-value value']); } - // public function testSubresourceDocumentation() { - // $documentation = new Documentation(new ResourceNameCollection([Question::class]), 'Test API', 'This is a test API.', '1.2.3'); - // - // $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')->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')->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_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()); - // - // $normalizer = new DocumentationNormalizer( - // $resourceMetadataFactory, - // $propertyNameCollectionFactory, - // $propertyMetadataFactory, - // null, - // null, - // $operationPathResolver, - // null, - // null, - // null, - // false, - // '', - // '', - // '', - // '', - // [], - // [], - // $subresourceOperationFactory, - // true, - // 'page', - // false, - // 'itemsPerPage', - // $formatProvider ?? [], - // false, - // 'pagination', - // ['spec_version' => 3] - // ); - // - // $expected = [ - // 'openapi' => '3.0.2', - // 'info' => [ - // 'title' => 'Test API', - // 'description' => 'This is a test API.', - // 'version' => '1.2.3', - // ], - // 'paths' => new \ArrayObject([ - // '/api/questions/{id}' => [ - // 'get' => new \ArrayObject([ - // 'tags' => ['Question'], - // 'operationId' => 'getQuestionItem', - // 'summary' => 'Retrieves a Question resource.', - // 'parameters' => [ - // [ - // 'name' => 'id', - // 'in' => 'path', - // 'schema' => ['type' => 'string'], - // 'required' => true, - // ], - // ], - // 'responses' => [ - // '200' => [ - // 'description' => 'Question resource response', - // 'content' => [ - // 'application/json' => [ - // 'schema' => ['$ref' => '#/components/schemas/Question'], - // ], - // 'text/csv' => [ - // 'schema' => ['$ref' => '#/components/schemas/Question'], - // ], - // ], - // ], - // '404' => ['description' => 'Resource not found'], - // ], - // ]), - // ], - // '/api/questions/{id}/answer' => new \ArrayObject([ - // 'get' => new \ArrayObject([ - // 'tags' => ['Answer', 'Question'], - // 'operationId' => 'api_questions_answer_get_subresource', - // 'summary' => 'Retrieves a Answer resource.', - // 'responses' => [ - // '200' => [ - // 'description' => 'Answer resource response', - // 'content' => [ - // 'text/xml' => [ - // 'schema' => ['$ref' => '#/components/schemas/Answer'], - // ], - // ], - // ], - // '404' => ['description' => 'Resource not found'], - // ], - // 'parameters' => [ - // [ - // 'name' => 'id', - // 'in' => 'path', - // 'schema' => ['type' => 'string'], - // 'required' => true, - // ], - // ], - // ]), - // ]), - // ]), - // 'components' => [ - // 'schemas' => new \ArrayObject([ - // 'Question' => new \ArrayObject([ - // 'type' => 'object', - // 'description' => 'This is a question.', - // 'externalDocs' => ['url' => 'http://schema.example.com/Question'], - // 'properties' => [ - // 'answer' => new \ArrayObject([ - // 'type' => 'array', - // 'description' => 'This is a name.', - // 'items' => ['$ref' => '#/components/schemas/Answer'], - // ]), - // ], - // ]), - // 'Answer' => new \ArrayObject([ - // 'type' => 'object', - // 'description' => 'This is an answer.', - // 'externalDocs' => ['url' => 'http://schema.example.com/Answer'], - // 'properties' => [ - // 'content' => new \ArrayObject([ - // 'type' => 'array', - // 'description' => 'This is a name.', - // 'items' => ['$ref' => '#/components/schemas/Answer'], - // ]), - // ], - // ]), - // ]), - // ], - // ]; - // - // $this->assertEquals($expected, $normalizer->normalize($documentation)); - // } + 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'])] + )); + } } From 9dbca67f104d622e325cd28ed24cc1348589f13b Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 17 Jul 2020 16:58:31 +0200 Subject: [PATCH 16/17] openapi: fix multiple formats --- src/OpenApi/Factory/OpenApiFactory.php | 36 ++++++++++++++------------ 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 8f0065779e7..3da64bc90c9 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -124,11 +124,13 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $linkedOperationId = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM); $pathItem = $paths->getPath($path) ?: new Model\PathItem(); - /** @TODO support multiple formats for schemas? */ - $operationFormat = array_values($responseMimeTypes)[0]; - $operationSchemaOutput = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), $context); + $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; + } - $schemas += $operationSchemaOutput->getDefinitions()->getArrayCopy(); $parameters = []; $responses = []; @@ -153,12 +155,12 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour switch ($method) { case 'GET': $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); - $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); + $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, $operationSchemaOutput); + $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'); @@ -167,7 +169,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour case 'PUT': $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); - $responseContent = $this->buildContent($responseMimeTypes, $operationSchemaOutput); + $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; @@ -187,11 +189,14 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $requestBody = null; if ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { - /** @TODO support multiple input formats */ - $operationFormatInput = array_values($requestMimeTypes)[0]; - $operationSchemaInput = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormatInput, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), $context); - $schemas += $operationSchemaInput->getDefinitions()->getArrayCopy(); - $requestBody = new Model\RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationSchemaInput), true); + $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( @@ -215,15 +220,12 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour return [$links, $schemas]; } - /** - * @TODO: support multiple format schemas - */ - private function buildContent(array $responseMimeTypes, Schema $operationSchema): \ArrayObject + private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject { $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { - $content[$mimeType] = new Model\MediaType(new \ArrayObject($operationSchema->getArrayCopy(false))); + $content[$mimeType] = new Model\MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); } return $content; From 7a221899c96b89a4a98d5421a2c81bac93e4b257 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 20 Jul 2020 09:50:16 +0200 Subject: [PATCH 17/17] Fix tests --- src/OpenApi/Factory/OpenApiFactory.php | 11 ++++++++++- .../OpenApiNormalizerTest.php | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) rename tests/OpenApi/{Normalizer => Serializer}/OpenApiNormalizerTest.php (97%) diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 3da64bc90c9..7ce8cc4d137 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -117,7 +117,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour } foreach ($operations as $operationName => $operation) { - $path = $operation['path'] ?? $this->getPath($resourceShortName, $operationName, $operation, $operationType); + $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); @@ -141,7 +141,13 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour } 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']); } @@ -264,6 +270,9 @@ private function flattenMimeTypes(array $responseFormats): array */ 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); diff --git a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php similarity index 97% rename from tests/OpenApi/Normalizer/OpenApiNormalizerTest.php rename to tests/OpenApi/Serializer/OpenApiNormalizerTest.php index 342a06bb434..30444db0e4c 100644 --- a/tests/OpenApi/Normalizer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\OpenApi\Normalizer; +namespace ApiPlatform\Core\Tests\OpenApi\Serializer; use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\JsonSchema\SchemaFactory; @@ -74,6 +74,8 @@ public function testNormalize() ); $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);