diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..801f208 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/close_pr.yml b/.github/workflows/close_pr.yml new file mode 100644 index 0000000..72a8ab4 --- /dev/null +++ b/.github/workflows/close_pr.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on a read-only sub split of `api-platform/core`. Please submit your PR on the https://github.com/api-platform/core repository.

Thanks!" diff --git a/Attributes/Webhook.php b/Attributes/Webhook.php new file mode 100644 index 0000000..a39d68c --- /dev/null +++ b/Attributes/Webhook.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\OpenApi\Attributes; + +use ApiPlatform\OpenApi\Model\ExtensionTrait; +use ApiPlatform\OpenApi\Model\PathItem; + +class Webhook +{ + use ExtensionTrait; + + public function __construct( + protected string $name, + protected ?PathItem $pathItem = null, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function withName(string $name): self + { + $self = clone $this; + $self->name = $name; + + return $self; + } + + public function getPathItem(): ?PathItem + { + return $this->pathItem; + } + + public function withPathItem(PathItem $pathItem): self + { + $self = clone $this; + $self->pathItem = $pathItem; + + return $self; + } +} diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 88d3c0c..30802a4 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -14,6 +14,7 @@ namespace ApiPlatform\OpenApi\Command; use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -26,6 +27,7 @@ /** * Dumps Open API documentation. */ +#[AsCommand(name: 'api:openapi:export')] final class OpenApiCommand extends Command { public function __construct(private readonly OpenApiFactoryInterface $openApiFactory, private readonly NormalizerInterface $normalizer) @@ -41,9 +43,10 @@ protected function configure(): void $this ->setDescription('Dump the Open API documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') - ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') - ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'Open API version to use (2 or 3) (2 is deprecated)', '3') - ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode'); + ->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Write output to file') + ->addOption('spec-version', null, InputOption::VALUE_REQUIRED, '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') + ->addOption('filter-tags', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter only matching x-apiplatform-tag operations', null); } /** @@ -53,15 +56,26 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); - $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $data = $this->normalizer->normalize( + $this->openApiFactory->__invoke(['filter_tags' => $input->getOption('filter-tags')]), + 'json', + ['spec_version' => $input->getOption('spec-version')] + ); + + if ($input->getOption('yaml') && !class_exists(Yaml::class)) { + $output->writeln('The "symfony/yaml" component is not installed.'); + + return 1; + } + $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) + ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_NUMERIC_KEY_AS_STRING) : (json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) ?: ''); $filename = $input->getOption('output'); if ($filename && \is_string($filename)) { $filesystem->dumpFile($filename, $content); - $io->success(sprintf('Data written to %s.', $filename)); + $io->success(\sprintf('Data written to %s.', $filename)); return \defined(Command::class.'::SUCCESS') ? Command::SUCCESS : 0; } @@ -70,11 +84,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return \defined(Command::class.'::SUCCESS') ? Command::SUCCESS : 0; } - - public static function getDefaultName(): string - { - return 'api:openapi:export'; - } } - -class_alias(OpenApiCommand::class, \ApiPlatform\Symfony\Bundle\Command\OpenApiCommand::class); diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 0b39647..1b943c9 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -13,28 +13,33 @@ namespace ApiPlatform\OpenApi\Factory; -use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; -use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; -use ApiPlatform\OpenApi\Model; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Contact; -use ApiPlatform\OpenApi\Model\ExternalDocumentation; use ApiPlatform\OpenApi\Model\Info; use ApiPlatform\OpenApi\Model\License; use ApiPlatform\OpenApi\Model\Link; use ApiPlatform\OpenApi\Model\MediaType; use ApiPlatform\OpenApi\Model\OAuthFlow; use ApiPlatform\OpenApi\Model\OAuthFlows; +use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\PathItem; use ApiPlatform\OpenApi\Model\Paths; @@ -42,10 +47,14 @@ use ApiPlatform\OpenApi\Model\Response; use ApiPlatform\OpenApi\Model\SecurityScheme; use ApiPlatform\OpenApi\Model\Server; +use ApiPlatform\OpenApi\Model\Tag; use ApiPlatform\OpenApi\OpenApi; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait; +use ApiPlatform\State\ApiResource\Error as ApiResourceError; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\State\Util\StateOptionsTrait; +use ApiPlatform\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Routing\RouteCollection; @@ -57,20 +66,37 @@ final class OpenApiFactory implements OpenApiFactoryInterface { use NormalizeOperationNameTrait; + use StateOptionsTrait; + use TypeFactoryTrait; public const BASE_URL = 'base_url'; + public const API_PLATFORM_TAG = 'x-apiplatform-tag'; + public const OVERRIDE_OPENAPI_RESPONSES = 'open_api_override_responses'; private readonly Options $openApiOptions; private readonly PaginationOptions $paginationOptions; private ?RouteCollection $routeCollection = null; private ?ContainerInterface $filterLocator = null; - /** - * @deprecated use SchemaFactory::OPENAPI_DEFINITION_NAME this will be removed in API Platform 4 + * @var array */ - public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; + private array $localErrorResourceCache = []; - public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly SchemaFactoryInterface $jsonSchemaFactory, private readonly TypeFactoryInterface $jsonSchemaTypeFactory, ContainerInterface $filterLocator, private readonly array $formats = [], Options $openApiOptions = null, PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null) - { + /** + * @param array $formats + */ + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly SchemaFactoryInterface $jsonSchemaFactory, + ?ContainerInterface $filterLocator = null, + private readonly array $formats = [], + ?Options $openApiOptions = null, + ?PaginationOptions $paginationOptions = null, + private readonly ?RouterInterface $router = null, + private readonly array $errorFormats = [], + ) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); @@ -78,6 +104,10 @@ public function __construct(private readonly ResourceNameCollectionFactoryInterf /** * {@inheritdoc} + * + * You can filter openapi operations with the `x-apiplatform-tag` on an OpenApi Operation using the `filter_tags`. + * + * @param array{base_url?: string, filter_tags?: string[]}&array $context */ public function __invoke(array $context = []): OpenApi { @@ -88,12 +118,13 @@ public function __invoke(array $context = []): OpenApi $servers = '/' === $baseUrl || '' === $baseUrl ? [new Server('/')] : [new Server($baseUrl)]; $paths = new Paths(); $schemas = new \ArrayObject(); + $webhooks = new \ArrayObject(); + $tags = []; foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); - foreach ($resourceMetadataCollection as $resourceMetadata) { - $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas); + $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context); } } @@ -104,6 +135,8 @@ public function __invoke(array $context = []): OpenApi $securityRequirements[] = [$key => []]; } + $globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: []; + return new OpenApi( $info, $servers, @@ -117,27 +150,50 @@ public function __invoke(array $context = []): OpenApi new \ArrayObject(), new \ArrayObject($securitySchemes) ), - $securityRequirements + $securityRequirements, + $globalTags, + null, + null, + $webhooks ); } - private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas): void + private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks, array &$tags, array $context = []): void { if (0 === $resource->getOperations()->count()) { return; } + $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class); + $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity'); + + // This filters on our extension x-apiplatform-tag as the openapi operation tag is used for ordering operations + $filteredTags = $context['filter_tags'] ?? []; + if (!\is_array($filteredTags)) { + $filteredTags = [$filteredTags]; + } + foreach ($resource->getOperations() as $operationName => $operation) { - $resourceShortName = $operation->getShortName(); + $resourceShortName = $operation->getShortName() ?? $operation; // No path to return if (null === $operation->getUriTemplate() && null === $operation->getRouteName()) { continue; } - $openapiOperation = $operation->getOpenapi(); + $openapiAttribute = $operation->getOpenapi(); // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $openapiOperation) { + if ($operation instanceof HttpOperation && false === $openapiAttribute) { + continue; + } + + // See https://github.com/api-platform/core/issues/6993 we would like to allow only `false` but as we typed `bool` we have this check + $operationTag = !\is_object($openapiAttribute) ? [] : ($openapiAttribute->getExtensionProperties()[self::API_PLATFORM_TAG] ?? []); + if (!\is_array($operationTag)) { + $operationTag = [$operationTag]; + } + + if ($filteredTags && $filteredTags !== array_intersect($filteredTags, $operationTag)) { continue; } @@ -151,22 +207,29 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($this->routeCollection && $routeName && $route = $this->routeCollection->get($routeName)) { $path = $route->getPath(); } else { - $path = ($operation->getRoutePrefix() ?? '').$operation->getUriTemplate(); + $path = rtrim($operation->getRoutePrefix() ?? '', '/').'/'.ltrim($operation->getUriTemplate() ?? '', '/'); } $path = $this->getPath($path); - $method = $operation->getMethod() ?? HttpOperation::METHOD_GET; + $method = $operation->getMethod() ?? 'GET'; if (!\in_array($method, PathItem::$methods, true)) { continue; } - if (!\is_object($openapiOperation)) { - $openapiOperation = new Model\Operation(); + $pathItem = null; + + if ($openapiAttribute instanceof Webhook) { + $pathItem = $openapiAttribute->getPathItem() ?: new PathItem(); + $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation(); + } elseif (!\is_object($openapiAttribute)) { + $openapiOperation = new Operation(); + } else { + $openapiOperation = $openapiAttribute; } // Complete with defaults - $openapiOperation = new Model\Operation( + $openapiOperation = new Operation( operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName), tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName], responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [], @@ -182,54 +245,14 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection extensionProperties: $openapiOperation->getExtensionProperties(), ); - [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); - - // TODO Remove in 4.0 - foreach (['operationId', 'tags', 'summary', 'description', 'security', 'servers'] as $key) { - if (null !== ($operation->getOpenapiContext()[$key] ?? null)) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $openapiOperation = $openapiOperation->{'with'.ucfirst($key)}($operation->getOpenapiContext()[$key]); - } - } - - // TODO Remove in 4.0 - if (null !== ($operation->getOpenapiContext()['externalDocs'] ?? null)) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $openapiOperation = $openapiOperation->withExternalDocs(new ExternalDocumentation($operation->getOpenapiContext()['externalDocs']['description'] ?? null, $operation->getOpenapiContext()['externalDocs']['url'])); - } - - // TODO Remove in 4.0 - if (null !== ($operation->getOpenapiContext()['callbacks'] ?? null)) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $openapiOperation = $openapiOperation->withCallbacks(new \ArrayObject($operation->getOpenapiContext()['callbacks'])); + foreach ($openapiOperation->getTags() as $v) { + $tags[$v] = new Tag(name: $v, description: $resource->getDescription()); } - // TODO Remove in 4.0 - if (null !== ($operation->getOpenapiContext()['deprecated'] ?? null)) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $openapiOperation = $openapiOperation->withDeprecated((bool) $operation->getOpenapiContext()['deprecated']); - } + [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); - if ($path) { - $pathItem = $paths->getPath($path) ?: new PathItem(); - } else { - $pathItem = new PathItem(); + if (null === $pathItem) { + $pathItem = $paths->getPath($path) ?? new PathItem(); } $forceSchemaCollection = $operation instanceof CollectionOperationInterface && 'GET' === $method; @@ -244,37 +267,32 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); } - // TODO Remove in 4.0 - if ($operation->getOpenapiContext()['parameters'] ?? false) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $parameters = []; - foreach ($operation->getOpenapiContext()['parameters'] as $parameter) { - $parameters[] = new Parameter($parameter['name'], $parameter['in'], $parameter['description'] ?? '', $parameter['required'] ?? false, $parameter['deprecated'] ?? false, $parameter['allowEmptyValue'] ?? false, $parameter['schema'] ?? [], $parameter['style'] ?? null, $parameter['explode'] ?? false, $parameter['allowReserved '] ?? false, $parameter['example'] ?? null, isset($parameter['examples']) ? new \ArrayObject($parameter['examples']) : null, isset($parameter['content']) ? new \ArrayObject($parameter['content']) : null); - } - $openapiOperation = $openapiOperation->withParameters($parameters); - } - // Set up parameters + $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) { if ($uriVariable->getExpandedValue() ?? false) { continue; } - $parameter = new Parameter($parameterName, 'path', (new \ReflectionClass($uriVariable->getFromClass()))->getShortName().' identifier', true, false, false, ['type' => 'string']); - if ($this->hasParameter($openapiOperation, $parameter)) { + $parameter = new Parameter($parameterName, 'path', $uriVariable->getDescription() ?? "$resourceShortName identifier", $uriVariable->getRequired() ?? true, false, false, $uriVariable->getSchema() ?? ['type' => 'string']); + + if ($linkParameter = $uriVariable->getOpenApi()) { + $parameter = $this->mergeParameter($parameter, $linkParameter); + } + + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); continue; } - $openapiOperation = $openapiOperation->withParameter($parameter); + $openapiParameters[] = $parameter; } - if ($operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $method) { + $openapiOperation = $openapiOperation->withParameters($openapiParameters); + + if ($operation instanceof CollectionOperationInterface && 'POST' !== $method) { foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) { - if ($this->hasParameter($openapiOperation, $parameter)) { + if ($operationParameter = $this->hasParameter($openapiOperation, $parameter)) { continue; } @@ -282,112 +300,175 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - $existingResponses = $openapiOperation?->getResponses() ?: []; - // Create responses - switch ($method) { - case HttpOperation::METHOD_GET: - $successStatus = (string) $operation->getStatus() ?: 200; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas); - break; - case HttpOperation::METHOD_POST: - $successStatus = (string) $operation->getStatus() ?: 201; - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); - break; - case HttpOperation::METHOD_PATCH: - case HttpOperation::METHOD_PUT: - $successStatus = (string) $operation->getStatus() ?: 200; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - if (!isset($existingResponses[400])) { - $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass()); + $openapiParameters = $openapiOperation->getParameters(); + foreach ($operation->getParameters() ?? [] as $key => $p) { + if (false === $p->getOpenApi()) { + continue; + } + + if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) { + $filter = $this->filterLocator->get($f); + + if ($d = $filter->getDescription($entityClass)) { + foreach ($d as $name => $description) { + if ($prop = $p->getProperty()) { + $name = str_replace($prop, $key, $name); + } + + $openapiParameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $f); + } + + continue; } - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); - break; - case HttpOperation::METHOD_DELETE: - $successStatus = (string) $operation->getStatus() ?: 204; + } - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; + $defaultParameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + + $linkParameter = $p->getOpenApi(); + if (null === $linkParameter) { + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $defaultParameter)) { + $openapiParameters[$i] = $this->mergeParameter($defaultParameter, $operationParameter); + } else { + $openapiParameters[] = $defaultParameter; + } + + continue; + } - break; + if (\is_array($linkParameter)) { + foreach ($linkParameter as $lp) { + $parameter = $this->mergeParameter($defaultParameter, $lp); + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); + continue; + } + + $openapiParameters[] = $parameter; + } + continue; + } + + $parameter = $this->mergeParameter($defaultParameter, $linkParameter); + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); + continue; + } + $openapiParameters[] = $parameter; } - if (!$operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $operation->getMethod()) { - if (!isset($existingResponses[404])) { - $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); + $openapiOperation = $openapiOperation->withParameters($openapiParameters); + $existingResponses = $openapiOperation->getResponses() ?: []; + $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); + $errors = null; + if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { + /** @var array */ + $errorOperations = []; + foreach ($errors as $error) { + $errorOperations[$error] = $this->getErrorResource($error); } + + $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation); + } + + if ($overrideResponses || !$existingResponses) { + // Create responses + switch ($method) { + case 'GET': + $successStatus = (string) $operation->getStatus() ?: 200; + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s %s', $resourceShortName, $operation instanceof CollectionOperationInterface ? 'collection' : 'resource'), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas); + break; + case 'POST': + $successStatus = (string) $operation->getStatus() ?: 201; + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); + + if (null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(400)->withDescription('Invalid input'), + $defaultValidationError, + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } + break; + case 'PATCH': + case 'PUT': + $successStatus = (string) $operation->getStatus() ?: 200; + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); + + if (null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(400)->withDescription('Invalid input'), + $defaultValidationError, + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } + break; + case 'DELETE': + $successStatus = (string) $operation->getStatus() ?: 204; + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource deleted', $resourceShortName), $openapiOperation); + break; + } + } + + if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(403)->withDescription('Forbidden'), + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } + + if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(404)->withDescription('Not found'), + ], $resourceMetadataCollection, $schema, $schemas, $operation); } if (!$openapiOperation->getResponses()) { $openapiOperation = $openapiOperation->withResponse('default', new Response('Unexpected error')); } - if ($contextResponses = $operation->getOpenapiContext()['responses'] ?? false) { - // TODO Remove this "elseif" in 4.0 - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - foreach ($contextResponses as $statusCode => $contextResponse) { - $openapiOperation = $openapiOperation->withResponse($statusCode, new Response($contextResponse['description'] ?? '', isset($contextResponse['content']) ? new \ArrayObject($contextResponse['content']) : null, isset($contextResponse['headers']) ? new \ArrayObject($contextResponse['headers']) : null, isset($contextResponse['links']) ? new \ArrayObject($contextResponse['links']) : null)); + if ( + \in_array($method, ['PATCH', 'PUT', 'POST'], true) + && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) + ) { + $content = $openapiOperation->getRequestBody()?->getContent(); + if (null === $content) { + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchemas[$operationFormat] = $operationInputSchema; + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + } + $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); } - } - if ($contextRequestBody = $operation->getOpenapiContext()['requestBody'] ?? false) { - // TODO Remove this "elseif" in 4.0 - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false)); - } elseif (null === $openapiOperation->getRequestBody() && \in_array($method, [HttpOperation::METHOD_PATCH, HttpOperation::METHOD_PUT, HttpOperation::METHOD_POST], true)) { - $operationInputSchemas = []; - foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); - $operationInputSchemas[$operationFormat] = $operationInputSchema; - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); - } + $openapiOperation = $openapiOperation->withRequestBody(new RequestBody( + description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), + content: $content, + required: $openapiOperation->getRequestBody()?->getRequired() ?? true, + )); + } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', HttpOperation::METHOD_POST === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + if ($openapiAttribute instanceof Webhook) { + $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation); + continue; } - // TODO Remove in 4.0 - if (null !== $operation->getOpenapiContext() && \count($operation->getOpenapiContext())) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(Model\Operation::class))->getProperties()); - foreach ($operation->getOpenapiContext() as $key => $value) { - $value = match ($key) { - 'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''), - 'requestBody' => new RequestBody(description: $value['description'] ?? '', content: isset($value['content']) ? new \ArrayObject($value['content'] ?? []) : null, required: $value['required'] ?? false), - 'callbacks' => new \ArrayObject($value ?? []), - 'parameters' => $openapiOperation->getParameters(), - default => $value, - }; - - if (\in_array($key, $allowedProperties, true)) { - $openapiOperation = $openapiOperation->{'with'.ucfirst($key)}($value); - continue; - } + // We merge content types for errors, maybe that this logic could be applied to every resources at some point + if ($operation instanceof Error && ($existingPathItem = $paths->getPath($path)) && ($existingOperation = $existingPathItem->getGet()) && ($currentResponse = $openapiOperation->getResponses()[200] ?? null)) { + $errorResponse = $existingOperation->getResponses()[200]; + $currentResponseContent = $currentResponse->getContent(); - $openapiOperation = $openapiOperation->withExtensionProperty((string) $key, $value); + foreach ($errorResponse->getContent() as $mime => $content) { + $currentResponseContent[$mime] = $content; } + + $openapiOperation = $existingOperation->withResponse(200, $currentResponse->withContent($currentResponseContent)); } $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); } } - private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Model\Operation $openapiOperation = null, HttpOperation $operation = null, array $responseMimeTypes = null, array $operationOutputSchemas = null, ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Operation { if (isset($existingResponses[$status])) { return $openapiOperation; @@ -404,11 +485,14 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat } /** - * @return \ArrayObject + * @param array $responseMimeTypes + * @param array $operationSchemas + * + * @return \ArrayObject */ private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject { - /** @var \ArrayObject $content */ + /** @var \ArrayObject $content */ $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { @@ -418,6 +502,9 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas) return $content; } + /** + * @return array[array, array] + */ private function getMimeTypes(HttpOperation $operation): array { $requestFormats = $operation->getInputFormats() ?: []; @@ -429,6 +516,11 @@ private function getMimeTypes(HttpOperation $operation): array return [$requestMimeTypes, $responseMimeTypes]; } + /** + * @param array $responseFormats + * + * @return array + */ private function flattenMimeTypes(array $responseFormats): array { $responseMimeTypes = []; @@ -481,35 +573,35 @@ private function getPathDescription(string $resourceShortName, string $method, b return $resourceShortName; } - return sprintf($pathSummary, $resourceShortName); + return \sprintf($pathSummary, $resourceShortName); } /** * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. * - * @return \ArrayObject + * @return \ArrayObject */ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection, HttpOperation $currentOperation): \ArrayObject { - /** @var \ArrayObject $links */ + /** @var \ArrayObject $links */ $links = new \ArrayObject(); // Only compute get links for now foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operationName => $operation) { $parameters = []; - $method = $operation instanceof HttpOperation ? $operation->getMethod() : HttpOperation::METHOD_GET; + $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if ( $operationName === $operation->getName() || isset($links[$operationName]) || $operation instanceof CollectionOperationInterface - || HttpOperation::METHOD_GET !== $method + || 'GET' !== $method ) { continue; } // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + if ($operation instanceof HttpOperation && (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook)) { continue; } @@ -520,7 +612,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } if ($operationUriVariables[$parameterName]->getIdentifiers() === $uriVariableDefinition->getIdentifiers() && $operationUriVariables[$parameterName]->getFromClass() === $uriVariableDefinition->getFromClass()) { - $parameters[$parameterName] = '$request.path.'.$uriVariableDefinition->getIdentifiers()[0]; + $parameters[$parameterName] = '$request.path.'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id'); } } @@ -530,7 +622,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } if ($uriVariableDefinition->getFromClass() === $currentOperation->getClass()) { - $parameters[$parameterName] = '$response.body#/'.$uriVariableDefinition->getIdentifiers()[0]; + $parameters[$parameterName] = '$response.body#/'.($uriVariableDefinition->getIdentifiers()[0] ?? 'id'); } } @@ -552,47 +644,87 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array { $parameters = []; - $resourceFilters = $operation->getFilters(); + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass()); + foreach ($resourceFilters ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { continue; } $filter = $this->filterLocator->get($filterId); - $entityClass = $operation->getClass(); - if (($options = $operation->getStateOptions()) && $options instanceof DoctrineOptions && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } - - foreach ($filter->getDescription($entityClass) 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 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', - $data['openapi']['explode'] ?? ('array' === $schema['type']), - $data['openapi']['allowReserved'] ?? false, - $data['openapi']['example'] ?? null, - isset($data['openapi']['examples'] - ) ? new \ArrayObject($data['openapi']['examples']) : null - ); + foreach ($filter->getDescription($entityClass) as $name => $description) { + $parameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $filterId); } } return $parameters; } + /** + * @param array $description + */ + private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter + { + if (isset($description['swagger'])) { + trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter, $shortName)); + } + + if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { + $schema = $description['schema'] ?? []; + + if (isset($description['type']) && \in_array($description['type'], Type::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)); + } + + if (!isset($schema['type'])) { + $schema['type'] = 'string'; + } + + $style = 'array' === ($schema['type'] ?? null) && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form'; + + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false); + + if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) { + $parameter = $parameter->withDescription($str); + } + + if (false === $parameter->getRequired() && false !== ($required = $description['required'] ?? false)) { + $parameter = $parameter->withRequired($required); + } + + return $parameter->withSchema($schema); + } + + trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter, $shortName)); + $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : ['type' => 'string']); + + return new Parameter( + $name, + 'query', + $description['description'] ?? '', + $description['required'] ?? false, + $description['openapi']['deprecated'] ?? false, + $description['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form', + $description['openapi']['explode'] ?? ('array' === $schema['type']), + $description['openapi']['allowReserved'] ?? false, + $description['openapi']['example'] ?? null, + isset( + $description['openapi']['examples'] + ) ? new \ArrayObject($description['openapi']['examples']) : null + ); + } + private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array { if (!$this->paginationOptions->isPaginationEnabled()) { @@ -623,13 +755,17 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); } + if ($operation->getPaginationClientPartial() ?? $this->paginationOptions->isClientPartialPaginationEnabled()) { + $parameters[] = new Parameter($this->paginationOptions->getPartialPaginationParameterName(), 'query', 'Enable or disable partial pagination', false, false, true, ['type' => 'boolean']); + } + return $parameters; } private function getOauthSecurityScheme(): SecurityScheme { $oauthFlow = new OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl() ?: null, $this->openApiOptions->getOAuthRefreshUrl() ?: null, new \ArrayObject($this->openApiOptions->getOAuthScopes())); - $description = sprintf( + $description = \sprintf( 'OAuth 2.0 %s Grant', strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) ); @@ -666,13 +802,22 @@ private function getSecuritySchemes(): array } foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) { - $description = sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']); + $description = \sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']); $securitySchemes[$key] = new SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type']); } + foreach ($this->openApiOptions->getHttpAuth() as $key => $httpAuth) { + $description = \sprintf('Value for the http %s parameter.', $httpAuth['scheme']); + $securitySchemes[$key] = new SecurityScheme('http', $description, null, null, $httpAuth['scheme'], $httpAuth['bearerFormat'] ?? null); + } + return $securitySchemes; } + /** + * @param \ArrayObject $schemas + * @param \ArrayObject $definitions + */ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $definitions): void { foreach ($definitions as $key => $value) { @@ -680,14 +825,142 @@ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $de } } - private function hasParameter(Model\Operation $operation, Parameter $parameter): bool + /** + * @return array{0: int, 1: Parameter}|null + */ + private function hasParameter(Operation $operation, Parameter $parameter): ?array { - foreach ($operation->getParameters() as $existingParameter) { + foreach ($operation->getParameters() as $key => $existingParameter) { if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { - return true; + return [$key, $existingParameter]; + } + } + + return null; + } + + private function mergeParameter(Parameter $actual, Parameter $defined): Parameter + { + foreach ( + [ + 'name', + 'in', + 'description', + 'required', + 'deprecated', + 'allowEmptyValue', + 'style', + 'explode', + 'allowReserved', + 'example', + ] as $method + ) { + $newValue = $defined->{"get$method"}(); + if (null !== $newValue && $actual->{"get$method"}() !== $newValue) { + $actual = $actual->{"with$method"}($newValue); + } + } + + foreach (['examples', 'content', 'schema'] as $method) { + $newValue = $defined->{"get$method"}(); + if ($newValue && \count($newValue) > 0 && $actual->{"get$method"}() !== $newValue) { + $actual = $actual->{"with$method"}($newValue); } } - return false; + return $actual; + } + + /** + * @param ErrorResource[] $errors + * @param \ArrayObject $schemas + */ + private function addOperationErrors( + Operation $operation, + array $errors, + ResourceMetadataCollection $resourceMetadataCollection, + Schema $schema, + \ArrayObject $schemas, + HttpOperation $originalOperation, + ): Operation { + foreach ($errors as $errorResource) { + $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $this->errorFormats); + foreach ($errorResource->getOperations() as $errorOperation) { + if (false === $errorOperation->getOpenApi()) { + continue; + } + + $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $this->errorFormats); + } + + foreach ($responseMimeTypes as $mime => $format) { + if (!isset($this->errorFormats[$format])) { + unset($responseMimeTypes[$mime]); + } + } + + $operationErrorSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchemas[$operationFormat] = $operationErrorSchema; + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } + + if (!$status = $errorResource->getStatus()) { + throw new RuntimeException(\sprintf('The error class "%s" has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $errorResource->getClass())); + } + + $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + } + + return $operation; + } + + /** + * @param string|class-string $error + */ + private function getErrorResource(string $error, ?int $status = null, ?string $description = null): ErrorResource + { + if ($this->localErrorResourceCache[$error] ?? null) { + return $this->localErrorResourceCache[$error]; + } + + if (is_a($error, ProblemExceptionInterface::class, true)) { + try { + /** @var ProblemExceptionInterface $exception */ + $exception = new $error(); + $status = $exception->getStatus(); + $description = $exception->getTitle(); + } catch (\TypeError) { + } + } elseif (class_exists($error)) { + throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); + } + + $defaultErrorResourceClass = $this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class; + + try { + $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass); + if (!($errorResource instanceof ErrorResource)) { + throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); + } + + // Here we want the exception status and expression to override the resource one when available + if ($status) { + $errorResource = $errorResource->withStatus($status); + } + + if ($description) { + $errorResource = $errorResource->withDescription($description); + } + } catch (ResourceClassNotFoundException|OperationNotFoundException) { + $errorResource = new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass); + } + + if (!$errorResource->getClass()) { + $errorResource = $errorResource->withClass($error); + } + + return $this->localErrorResourceCache[$error] = $errorResource; } } diff --git a/Factory/OpenApiFactoryInterface.php b/Factory/OpenApiFactoryInterface.php index cbd14fd..5e125ee 100644 --- a/Factory/OpenApiFactoryInterface.php +++ b/Factory/OpenApiFactoryInterface.php @@ -19,6 +19,8 @@ interface OpenApiFactoryInterface { /** * Creates an OpenApi class. + * + * @param array $context */ public function __invoke(array $context = []): OpenApi; } diff --git a/Factory/TypeFactoryTrait.php b/Factory/TypeFactoryTrait.php new file mode 100644 index 0000000..6a86070 --- /dev/null +++ b/Factory/TypeFactoryTrait.php @@ -0,0 +1,132 @@ + + * + * 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\OpenApi\Factory; + +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +trait TypeFactoryTrait +{ + private function getType(Type $type): array + { + if ($type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); + } + + private function makeBasicType(Type $type): array + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_INT => ['type' => 'integer'], + Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), + default => ['type' => 'string'], + }; + } + + /** + * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + */ + private function getClassType(?string $className, bool $nullable): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return [ + 'type' => 'string', + 'format' => 'date-time', + ]; + } + if (is_a($className, \DateInterval::class, true)) { + return [ + 'type' => 'string', + 'format' => 'duration', + ]; + } + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'uuid', + ]; + } + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } + if (is_a($className, \SplFileInfo::class, true)) { + return [ + 'type' => 'string', + 'format' => 'binary', + ]; + } + if (is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + return ['type' => 'string']; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + { + if (!$type->isNullable()) { + return $jsonSchema; + } + + $typeDefinition = ['anyOf' => [$jsonSchema]]; + $typeDefinition['anyOf'][] = ['type' => 'null']; + + return $typeDefinition; + } +} diff --git a/Model/Components.php b/Model/Components.php index 8500575..cc420cf 100644 --- a/Model/Components.php +++ b/Model/Components.php @@ -31,7 +31,7 @@ final class Components * @param \ArrayObject>|\ArrayObject> $callbacks * @param \ArrayObject|\ArrayObject $pathItems */ - public function __construct(\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) + public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) { $schemas?->ksort(); diff --git a/Model/Operation.php b/Model/Operation.php index 22de521..db2aac8 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -167,7 +167,7 @@ public function withParameter(Parameter $parameter): self return $clone; } - public function withRequestBody(RequestBody $requestBody = null): self + public function withRequestBody(?RequestBody $requestBody = null): self { $clone = clone $this; $clone->requestBody = $requestBody; @@ -191,7 +191,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withSecurity(array $security = null): self + public function withSecurity(?array $security = null): self { $clone = clone $this; $clone->security = $security; @@ -199,7 +199,7 @@ public function withSecurity(array $security = null): self return $clone; } - public function withServers(array $servers = null): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/Model/Parameter.php b/Model/Parameter.php index 918da61..7272c61 100644 --- a/Model/Parameter.php +++ b/Model/Parameter.php @@ -17,7 +17,7 @@ final class Parameter { use ExtensionTrait; - public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) + public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private ?bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private ?bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -53,12 +53,12 @@ public function getDeprecated(): bool return $this->deprecated; } - public function canAllowEmptyValue(): bool + public function canAllowEmptyValue(): ?bool { return $this->allowEmptyValue; } - public function getAllowEmptyValue(): bool + public function getAllowEmptyValue(): ?bool { return $this->allowEmptyValue; } @@ -83,12 +83,12 @@ public function getExplode(): bool return $this->explode; } - public function canAllowReserved(): bool + public function canAllowReserved(): ?bool { return $this->allowReserved; } - public function getAllowReserved(): bool + public function getAllowReserved(): ?bool { return $this->allowReserved; } @@ -148,7 +148,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withAllowEmptyValue(bool $allowEmptyValue): self + public function withAllowEmptyValue(?bool $allowEmptyValue): self { $clone = clone $this; $clone->allowEmptyValue = $allowEmptyValue; @@ -180,7 +180,7 @@ public function withExplode(bool $explode): self return $clone; } - public function withAllowReserved(bool $allowReserved): self + public function withAllowReserved(?bool $allowReserved): self { $clone = clone $this; $clone->allowReserved = $allowReserved; diff --git a/Model/PathItem.php b/Model/PathItem.php index 5a101bc..e481e75 100644 --- a/Model/PathItem.php +++ b/Model/PathItem.php @@ -19,7 +19,7 @@ final class PathItem public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; - public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private array $parameters = []) + public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null) { } @@ -83,7 +83,7 @@ public function getServers(): ?array return $this->servers; } - public function getParameters(): array + public function getParameters(): ?array { return $this->parameters; } @@ -176,7 +176,7 @@ public function withTrace(Operation $trace): self return $clone; } - public function withServers(array $servers = null): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; @@ -184,7 +184,7 @@ public function withServers(array $servers = null): self return $clone; } - public function withParameters(array $parameters): self + public function withParameters(?array $parameters = null): self { $clone = clone $this; $clone->parameters = $parameters; diff --git a/Model/Paths.php b/Model/Paths.php index 2b28d2e..aaf5a5e 100644 --- a/Model/Paths.php +++ b/Model/Paths.php @@ -20,8 +20,6 @@ final class Paths public function addPath(string $path, PathItem $pathItem): void { $this->paths[$path] = $pathItem; - - ksort($this->paths); } public function getPath(string $path): ?PathItem diff --git a/Model/RequestBody.php b/Model/RequestBody.php index b1f452f..6d85b4d 100644 --- a/Model/RequestBody.php +++ b/Model/RequestBody.php @@ -17,16 +17,16 @@ final class RequestBody { use ExtensionTrait; - public function __construct(private string $description = '', private ?\ArrayObject $content = null, private bool $required = false) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private bool $required = false) { } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } - public function getContent(): \ArrayObject + public function getContent(): ?\ArrayObject { return $this->content; } diff --git a/Model/Tag.php b/Model/Tag.php new file mode 100644 index 0000000..c079352 --- /dev/null +++ b/Model/Tag.php @@ -0,0 +1,62 @@ + + * + * 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\OpenApi\Model; + +final class Tag +{ + use ExtensionTrait; + + public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null) + { + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function getExternalDocs(): ?string + { + return $this->externalDocs; + } + + public function withExternalDocs(string $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } +} diff --git a/OpenApi.php b/OpenApi.php index 8a95e18..943a639 100644 --- a/OpenApi.php +++ b/OpenApi.php @@ -22,14 +22,14 @@ final class OpenApi { use ExtensionTrait; - // We're actually supporting 3.1 but swagger ui has a version constraint - // public const VERSION = '3.1.0'; - public const VERSION = '3.0.0'; + public const VERSION = '3.1.0'; private string $openapi = self::VERSION; + private Components $components; - public function __construct(private Info $info, private array $servers, private Paths $paths, private ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) + public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) { + $this->components = $components ?? new Components(); } public function getOpenapi(): string diff --git a/Options.php b/Options.php index c43a976..a6f0035 100644 --- a/Options.php +++ b/Options.php @@ -13,10 +13,40 @@ namespace ApiPlatform\OpenApi; -final class Options +use ApiPlatform\OpenApi\Model\Tag; + +final readonly class Options { - public function __construct(private readonly string $title, private readonly string $description = '', private readonly string $version = '', private readonly bool $oAuthEnabled = false, private readonly ?string $oAuthType = null, private readonly ?string $oAuthFlow = null, private readonly ?string $oAuthTokenUrl = null, private readonly ?string $oAuthAuthorizationUrl = null, private readonly ?string $oAuthRefreshUrl = null, private readonly array $oAuthScopes = [], private readonly array $apiKeys = [], private readonly ?string $contactName = null, private readonly ?string $contactUrl = null, private readonly ?string $contactEmail = null, private readonly ?string $termsOfService = null, private readonly ?string $licenseName = null, private readonly ?string $licenseUrl = null) - { + /** + * @param Tag[] $tags + * @param class-string $errorResourceClass + * @param class-string $validationErrorResourceClass + */ + public function __construct( + private string $title, + private string $description = '', + private string $version = '', + private bool $oAuthEnabled = false, + private ?string $oAuthType = null, + private ?string $oAuthFlow = null, + private ?string $oAuthTokenUrl = null, + private ?string $oAuthAuthorizationUrl = null, + private ?string $oAuthRefreshUrl = null, + private array $oAuthScopes = [], + private array $apiKeys = [], + private ?string $contactName = null, + private ?string $contactUrl = null, + private ?string $contactEmail = null, + private ?string $termsOfService = null, + private ?string $licenseName = null, + private ?string $licenseUrl = null, + private bool $overrideResponses = true, + private bool $persistAuthorization = false, + private array $httpAuth = [], + private array $tags = [], + private ?string $errorResourceClass = null, + private ?string $validationErrorResourceClass = null, + ) { } public function getTitle(): string @@ -74,6 +104,11 @@ public function getApiKeys(): array return $this->apiKeys; } + public function getHttpAuth(): array + { + return $this->httpAuth; + } + public function getContactName(): ?string { return $this->contactName; @@ -103,4 +138,38 @@ public function getLicenseUrl(): ?string { return $this->licenseUrl; } + + public function getOverrideResponses(): bool + { + return $this->overrideResponses; + } + + public function hasPersistAuthorization(): bool + { + return $this->persistAuthorization; + } + + /** + * @return Tag[] + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * @return class-string|null + */ + public function getErrorResourceClass(): ?string + { + return $this->errorResourceClass; + } + + /** + * @return class-string|null + */ + public function getValidationErrorResourceClass(): ?string + { + return $this->validationErrorResourceClass; + } } diff --git a/README.md b/README.md index 8515317..fe8dcbe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # API Platform - OpenAPI -Models to build and serialize an OpenAPI specification. +The [OpenAPI](https://www.openapis.org) component of the [API Platform](https://api-platform.com) framework. -## Resources +Builds and serializes OpenAPI specifications. OpenAPI was formerly known as Swagger. -- [Documentation](https://api-platform.com/docs) -- [Report issues](https://github.com/api-platform/core/issues) and [send Pull Requests](https://github.com/api-platform/core/pulls) on the [main API Platform repository](https://github.com/api-platform/core) +[Documentation](https://api-platform.com/docs/core/openapi/) + +> [!CAUTION] +> +> This is a read-only sub split of `api-platform/core`, please +> [report issues](https://github.com/api-platform/core/issues) and +> [send Pull Requests](https://github.com/api-platform/core/pulls) +> in the [core API Platform repository](https://github.com/api-platform/core). diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index 78b84c6..aaea891 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -14,9 +14,7 @@ namespace ApiPlatform\OpenApi\Serializer; use Symfony\Component\Serializer\Exception\UnexpectedValueException; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; /** * Removes features unsupported by Amazon API Gateway. @@ -27,7 +25,7 @@ * * @author Vincent Chalamon */ -final class ApiGatewayNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +final class ApiGatewayNormalizer implements NormalizerInterface { public const API_GATEWAY = 'api_gateway'; private array $defaultContext = [ @@ -44,7 +42,7 @@ public function __construct(private readonly NormalizerInterface $documentationN * * @throws UnexpectedValueException */ - public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $data = $this->documentationNormalizer->normalize($object, $format, $context); if (!\is_array($data)) { @@ -116,38 +114,16 @@ public function normalize(mixed $object, string $format = null, array $context = /** * {@inheritdoc} */ - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->documentationNormalizer->supportsNormalization($data, $format); } public function getSupportedTypes($format): array { - // @deprecated remove condition when support for symfony versions under 6.3 is dropped - if (!method_exists($this->documentationNormalizer, 'getSupportedTypes')) { - return ['*' => $this->documentationNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->documentationNormalizer->hasCacheableSupportsMethod()]; - } - return $this->documentationNormalizer->getSupportedTypes($format); } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - if (method_exists(Serializer::class, 'getSupportedTypes')) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', - __METHOD__ - ); - } - - return $this->documentationNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->documentationNormalizer->hasCacheableSupportsMethod(); - } - private function isLocalRef(string $ref): bool { return str_starts_with($ref, '#/'); diff --git a/Serializer/CacheableSupportsMethodInterface.php b/Serializer/CacheableSupportsMethodInterface.php deleted file mode 100644 index f131ecd..0000000 --- a/Serializer/CacheableSupportsMethodInterface.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * 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\OpenApi\Serializer; - -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface; -use Symfony\Component\Serializer\Serializer; - -if (method_exists(Serializer::class, 'getSupportedTypes')) { - /** - * Backward compatibility layer for getSupportedTypes(). - * - * @internal - * - * @author Kévin Dunglas - * - * @todo remove this interface when dropping support for Serializer < 6.3 - */ - interface CacheableSupportsMethodInterface - { - public function getSupportedTypes(?string $format): array; - } -} else { - /** - * Backward compatibility layer for NormalizerInterface::getSupportedTypes(). - * - * @internal - * - * @author Kévin Dunglas - * - * @todo remove this interface when dropping support for Serializer < 6.3 - */ - interface CacheableSupportsMethodInterface extends BaseCacheableSupportsMethodInterface - { - } -} diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php new file mode 100644 index 0000000..dc203c7 --- /dev/null +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -0,0 +1,71 @@ + + * + * 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\OpenApi\Serializer; + +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class LegacyOpenApiNormalizer implements NormalizerInterface +{ + public const SPEC_VERSION = 'spec_version'; + private array $defaultContext = [ + self::SPEC_VERSION => '3.1.0', + ]; + + public function __construct(private readonly NormalizerInterface $decorated, $defaultContext = []) + { + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array + { + $openapi = $this->decorated->normalize($object, $format, $context); + + if ('3.0.0' !== ($context['spec_version'] ?? null)) { + return $openapi; + } + + $schemas = &$openapi['components']['schemas']; + $openapi['openapi'] = '3.0.0'; + foreach ($openapi['components']['schemas'] as $name => $component) { + foreach ($component['properties'] ?? [] as $property => $value) { + if (\is_array($value['type'] ?? false)) { + foreach ($value['type'] as $type) { + $schemas[$name]['properties'][$property]['anyOf'][] = ['type' => $type]; + } + unset($schemas[$name]['properties'][$property]['type']); + } + + if (\is_array($value['examples'] ?? false)) { + $schemas[$name]['properties'][$property]['example'] = $value['examples']; + unset($schemas[$name]['properties'][$property]['examples']); + } + } + } + + return $openapi; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format, $context); + } + + public function getSupportedTypes($format): array + { + return $this->decorated->getSupportedTypes($format); + } +} diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index 83ebf63..a5f1c2c 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -18,14 +18,15 @@ use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; /** * Generates an OpenAPI v3 specification. */ -final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +final class OpenApiNormalizer implements NormalizerInterface { public const FORMAT = 'json'; + public const JSON_FORMAT = 'jsonopenapi'; + public const YAML_FORMAT = 'yamlopenapi'; private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; public function __construct(private readonly NormalizerInterface $decorated) @@ -35,9 +36,9 @@ public function __construct(private readonly NormalizerInterface $decorated) /** * {@inheritdoc} */ - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { - $pathsCallback = static fn ($innerObject): array => $innerObject instanceof Paths ? $innerObject->getPaths() : []; + $pathsCallback = $this->getPathsCallBack(); $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; $context[AbstractNormalizer::CALLBACKS] = [ @@ -70,27 +71,55 @@ private function recursiveClean(array $data): array /** * {@inheritdoc} */ - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return self::FORMAT === $format && $data instanceof OpenApi; + return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) && $data instanceof OpenApi; } public function getSupportedTypes($format): array { - return self::FORMAT === $format ? [OpenApi::class => true] : []; + return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) ? [OpenApi::class => true] : []; } - public function hasCacheableSupportsMethod(): bool + private function getPathsCallBack(): \Closure { - if (method_exists(Serializer::class, 'getSupportedTypes')) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', - __METHOD__ - ); - } + return static function ($decoratedObject): array { + if ($decoratedObject instanceof Paths) { + $paths = $decoratedObject->getPaths(); + + // sort paths by tags, then by path for each tag + uksort($paths, function ($keyA, $keyB) use ($paths) { + $a = $paths[$keyA]; + $b = $paths[$keyB]; + + $tagsA = [ + ...($a->getGet()?->getTags() ?? []), + ...($a->getPost()?->getTags() ?? []), + ...($a->getPatch()?->getTags() ?? []), + ...($a->getPut()?->getTags() ?? []), + ...($a->getDelete()?->getTags() ?? []), + ]; + sort($tagsA); + + $tagsB = [ + ...($b->getGet()?->getTags() ?? []), + ...($b->getPost()?->getTags() ?? []), + ...($b->getPatch()?->getTags() ?? []), + ...($b->getPut()?->getTags() ?? []), + ...($b->getDelete()?->getTags() ?? []), + ]; + sort($tagsB); + + return match (true) { + current($tagsA) === current($tagsB) => $keyA <=> $keyB, + default => current($tagsA) <=> current($tagsB), + }; + }); + + return $paths; + } - return true; + return []; + }; } } diff --git a/Serializer/SerializerContextBuilder.php b/Serializer/SerializerContextBuilder.php new file mode 100644 index 0000000..afb4824 --- /dev/null +++ b/Serializer/SerializerContextBuilder.php @@ -0,0 +1,38 @@ + + * + * 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\OpenApi\Serializer; + +use ApiPlatform\State\SerializerContextBuilderInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * @internal + */ +final class SerializerContextBuilder implements SerializerContextBuilderInterface +{ + public function __construct(private readonly SerializerContextBuilderInterface $decorated) + { + } + + public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array + { + $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes); + + return $context + [ + 'api_gateway' => $request->query->getBoolean(ApiGatewayNormalizer::API_GATEWAY), + 'base_url' => $request->getBaseUrl(), + 'spec_version' => (string) $request->query->get(LegacyOpenApiNormalizer::SPEC_VERSION), + ]; + } +} diff --git a/State/OpenApiProvider.php b/State/OpenApiProvider.php new file mode 100644 index 0000000..1aeebbc --- /dev/null +++ b/State/OpenApiProvider.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\OpenApi\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\OpenApi\OpenApi; +use ApiPlatform\State\ProviderInterface; + +/** + * @internal + */ +final class OpenApiProvider implements ProviderInterface +{ + public function __construct(private readonly OpenApiFactoryInterface $openApiFactory) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): OpenApi + { + return $this->openApiFactory->__invoke($context); + } +} diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index b4c0b96..d44c3d7 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -13,14 +13,17 @@ namespace ApiPlatform\OpenApi\Tests\Factory; +use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; -use ApiPlatform\JsonSchema\TypeFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Error as ErrorOperation; +use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\NotExposed; @@ -34,6 +37,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Factory\OpenApiFactory; use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; @@ -53,20 +57,23 @@ use ApiPlatform\OpenApi\OpenApi; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Tests\Fixtures\Dummy; +use ApiPlatform\OpenApi\Tests\Fixtures\DummyErrorResource; use ApiPlatform\OpenApi\Tests\Fixtures\DummyFilter; +use ApiPlatform\OpenApi\Tests\Fixtures\Issue6872\Diamond; use ApiPlatform\OpenApi\Tests\Fixtures\OutputDto; +use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; +use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; class OpenApiFactoryTest extends TestCase { - use ExpectDeprecationTrait; use ProphecyTrait; private const OPERATION_FORMATS = [ @@ -79,211 +86,391 @@ public function testInvoke(): void $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withOutput([ 'class' => OutputDto::class, ])->withPaginationClientItemsPerPage(true)->withShortName('Dummy')->withDescription('This is a dummy'); - $dummyResource = (new ApiResource())->withOperations(new Operations([ - 'ignored' => new NotExposed(), - 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), - 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), - 'customDummyItem' => (new HttpOperation())->withMethod(HttpOperation::METHOD_HEAD)->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( - tags: ['Dummy', 'Profile'], - responses: [ - '202' => new OpenApiResponse( - description: 'Success', - content: new \ArrayObject([ - 'application/json' => [ - 'schema' => ['$ref' => '#/components/schemas/Dummy'], - ], - ]), - headers: new \ArrayObject([ - 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], - ]), - links: new \ArrayObject([ - 'Foo' => ['$ref' => '#/components/schemas/Dummy'], - ]), - ), - '205' => new OpenApiResponse(), - ], - description: 'Custom description', - externalDocs: new ExternalDocumentation( - description: 'See also', - url: 'http://schema.example.com/Dummy', - ), - parameters: [ - new Parameter( - name: 'param', - in: 'path', - description: 'Test parameter', - required: true, - ), - new Parameter( - name: 'id', - in: 'path', - description: 'Replace parameter', - required: true, - schema: ['type' => 'string', 'format' => 'uuid'], - ), - ], - requestBody: new RequestBody( - description: 'Custom request body', - content: new \ArrayObject([ - 'multipart/form-data' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'file' => [ - 'type' => 'string', - 'format' => 'binary', - ], + + $dummyResourceWebhook = (new ApiResource())->withOperations(new Operations([ + 'dummy webhook' => (new Get())->withUriTemplate('/dummy/{id}')->withShortName('short')->withOpenapi(new Webhook('first webhook')), + 'an other dummy webhook' => (new Post())->withUriTemplate('/dummies')->withShortName('short something')->withOpenapi(new Webhook('happy webhook', new Model\PathItem(post: new Operation( + summary: 'well...', + description: 'I dont\'t know what to say', + )))), + ])); + + $dummyResource = (new ApiResource())->withOperations( + new Operations([ + 'ignored' => new NotExposed(), + 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), + 'getDummyItem' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), + 'putDummyItem' => (new Put())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), + 'deleteDummyItem' => (new Delete())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])]), + 'customDummyItem' => (new HttpOperation())->withMethod('HEAD')->withUriTemplate('/foo/{id}')->withOperation($baseOperation)->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withOpenapi(new OpenApiOperation( + tags: ['Dummy', 'Profile'], + responses: [ + '202' => new OpenApiResponse( + description: 'Success', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], ], - ], - ], - ]), - required: true, - ), - extensionProperties: ['x-visibility' => 'hide'], - )), - 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), - 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), - 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), - 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( - parameters: [ - new Parameter( - name: 'page', - in: 'query', - description: 'Test modified collection page number', - required: false, - allowEmptyValue: true, - schema: ['type' => 'integer', 'default' => 1], + ]), + headers: new \ArrayObject([ + 'Foo' => ['description' => 'A nice header', 'schema' => ['type' => 'integer']], + ]), + links: new \ArrayObject([ + 'Foo' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '205' => new OpenApiResponse(), + ], + description: 'Custom description', + externalDocs: new ExternalDocumentation( + description: 'See also', + url: 'http://schema.example.com/Dummy', ), - ], - ))->withOperation($baseOperation), - 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), - // Filtered - 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), - // Paginated - 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') - ->withPaginationClientEnabled(true) - ->withPaginationClientItemsPerPage(true) - ->withPaginationItemsPerPage(20) - ->withPaginationMaximumItemsPerPage(80) - ->withOperation($baseOperation), - 'postDummyCollectionWithRequestBody' => (new Post())->withUriTemplate('/dummiesRequestBody')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - requestBody: new RequestBody( - description: 'List of Ids', - content: new \ArrayObject([ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'ids' => [ - 'type' => 'array', - 'items' => ['type' => 'string'], - 'example' => [ - '1e677e04-d461-4389-bedc-6d1b665cc9d6', - '01111b43-f53a-4d50-8639-148850e5da19', + parameters: [ + new Parameter( + name: 'param', + in: 'path', + description: 'Test parameter', + required: true, + ), + new Parameter( + name: 'id', + in: 'path', + description: 'Replace parameter', + required: true, + schema: ['type' => 'string', 'format' => 'uuid'], + ), + ], + requestBody: new RequestBody( + description: 'Custom request body', + content: new \ArrayObject([ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'file' => [ + 'type' => 'string', + 'format' => 'binary', ], ], ], ], - ], - ]), - ), - )), - 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '200' => new OpenApiResponse( - description: 'Success', - content: new \ArrayObject([ - 'application/json' => [ - 'schema' => ['$ref' => '#/components/schemas/Dummy'], - ], ]), - headers: new \ArrayObject([ - 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], - ]), - links: new \ArrayObject([ - 'link' => ['$ref' => '#/components/schemas/Dummy'], - ]), - ), - '400' => new OpenApiResponse( - description: 'Error', - ), - ], - )), - 'getDummyItemImageCollection' => (new GetCollection())->withUriTemplate('/dummyitems/{id}/images')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '200' => new OpenApiResponse( - description: 'Success', + required: true, ), - ], - )), - 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '201' => new OpenApiResponse( - description: 'Created', + extensionProperties: ['x-visibility' => 'hide'], + )), + 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), + 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), + 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), + 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( + parameters: [ + new Parameter( + name: 'page', + in: 'query', + description: 'Test modified collection page number', + required: false, + allowEmptyValue: true, + schema: ['type' => 'integer', 'default' => 1], + ), + ], + ))->withOperation($baseOperation), + 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), + // Filtered + 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), + // Paginated + 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') + ->withPaginationClientEnabled(true) + ->withPaginationClientItemsPerPage(true) + ->withPaginationClientPartial(true) + ->withPaginationItemsPerPage(20) + ->withPaginationMaximumItemsPerPage(80) + ->withOperation($baseOperation), + 'postDummyCollectionWithRequestBody' => (new Post())->withUriTemplate('/dummiesRequestBody')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + requestBody: new RequestBody( + description: 'List of Ids', content: new \ArrayObject([ 'application/json' => [ - 'schema' => ['$ref' => '#/components/schemas/Dummy'], + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'ids' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'example' => [ + '1e677e04-d461-4389-bedc-6d1b665cc9d6', + '01111b43-f53a-4d50-8639-148850e5da19', + ], + ], + ], + ], ], ]), - headers: new \ArrayObject([ - 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], - ]), - links: new \ArrayObject([ - 'link' => ['$ref' => '#/components/schemas/Dummy'], - ]), ), - '400' => new OpenApiResponse( - description: 'Error', + )), + 'postDummyCollectionWithRequestBodyWithoutContent' => (new Post())->withUriTemplate('/dummiesRequestBodyWithoutContent')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + requestBody: new RequestBody( + description: 'Extended description for the new Dummy resource', ), - ], - )), - ]) + )), + 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '200' => new OpenApiResponse( + description: 'Success', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], + ], + ]), + headers: new \ArrayObject([ + 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], + ]), + links: new \ArrayObject([ + 'link' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '400' => new OpenApiResponse( + description: 'Error', + ), + ], + )), + 'getDummyItemImageCollection' => (new GetCollection())->withUriTemplate('/dummyitems/{id}/images')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '200' => new OpenApiResponse( + description: 'Success', + ), + ], + )), + 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '201' => new OpenApiResponse( + description: 'Created', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], + ], + ]), + headers: new \ArrayObject([ + 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], + ]), + links: new \ArrayObject([ + 'link' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '400' => new OpenApiResponse( + description: 'Error', + ), + ], + )), + 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), + 'getDummyCollectionWithErrors' => (new GetCollection())->withUriTemplate('erroredDummies')->withErrors([DummyErrorResource::class])->withOperation($baseOperation), + ]) ); + $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withShortName('Parameter')->withDescription('This is a dummy'); + $parameterResource = (new ApiResource())->withOperations(new Operations([ + 'uriVariableSchema' => (new Get(uriTemplate: '/uri_variable_uuid', uriVariables: ['id' => new Link(schema: ['type' => 'string', 'format' => 'uuid'], description: 'hello', required: true, openApi: new Parameter('id', 'path', allowEmptyValue: true))]))->withOperation($baseOperation), + 'parameters' => (new Put(uriTemplate: '/parameters', parameters: [ + 'foo' => new HeaderParameter(description: 'hi', schema: ['type' => 'string', 'format' => 'uuid']), + ]))->withOperation($baseOperation), + ])); + + $diamondResource = (new ApiResource()) + ->withOperations(new Operations([ + 'getDiamondCollection' => (new GetCollection(uriTemplate: '/diamonds')) + ->withSecurity("is_granted('ROLE_USER')") + ->withOperation($baseOperation), + 'putDiamond' => (new Put(uriTemplate: '/diamond/{id}')) + ->withOperation($baseOperation), + ])); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, WithParameter::class, Diamond::class])); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource])); + $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); + $resourceCollectionMetadataFactoryProphecy->create(DummyErrorResource::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(DummyErrorResource::class, [new ErrorResource(operations: [new ErrorOperation(name: 'err', description: 'nice one!')])])); + $resourceCollectionMetadataFactoryProphecy->create(WithParameter::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(WithParameter::class, [$parameterResource])); + $resourceCollectionMetadataFactoryProphecy->create(Diamond::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Diamond::class, [$diamondResource])); + $resourceCollectionMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceCollectionMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + $propertyNameCollectionFactoryProphecy->create(DummyErrorResource::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['type', 'title', 'status', 'detail', 'instance'])); $propertyNameCollectionFactoryProphecy->create(OutputDto::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + $propertyNameCollectionFactoryProphecy->create(Error::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['type', 'title', 'status', 'detail', 'instance'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'readOnly' => true, 'description' => 'This is an id.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$', 'description' => 'This is a name.', 'type' => 'string']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => ['string', 'null'], 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); + foreach ([DummyErrorResource::class, Error::class] as $cl) { + $propertyMetadataFactoryProphecy->create($cl, 'type', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error type.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error type.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'title', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error title.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error title.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'status', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an error status.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an error status.', 'readOnly' => true]) + ); + $propertyMetadataFactoryProphecy->create($cl, 'detail', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error detail.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error detail.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'instance', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error instance.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error instance.']) + ); + } + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $filters = [ 'f1' => new DummyFilter(['name' => [ @@ -291,7 +478,7 @@ public function testInvoke(): void 'type' => 'string', 'required' => true, 'strategy' => 'exact', - 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true, 'explode' => true], + 'openapi' => new Parameter(in: 'query', name: 'name', example: 'bar', deprecated: true, allowEmptyValue: true, allowReserved: true, explode: true), ]]), 'f2' => new DummyFilter(['ha' => [ 'property' => 'foo', @@ -329,9 +516,15 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); - $typeFactory->setSchemaFactory($schemaFactory); + $definitionNameFactory = new DefinitionNameFactory([]); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceCollectionMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), @@ -339,7 +532,6 @@ public function testInvoke(): void $propertyNameCollectionFactory, $propertyMetadataFactory, $schemaFactory, - $typeFactory, $filterLocatorProphecy->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'], [ @@ -351,8 +543,18 @@ public function testInvoke(): void 'type' => 'query', 'name' => 'key', ], + ], null, null, null, null, null, null, true, true, [ + 'bearer' => [ + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ], + 'basic' => [ + 'scheme' => 'basic', + ], ]), - new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination'), + null, + ['json' => ['application/problem+json']] ); $dummySchema = new Schema('openapi'); @@ -379,10 +581,9 @@ public function testInvoke(): void 'description' => 'This is an initializable but not writable property.', ]), 'dummy_date' => new \ArrayObject([ - 'type' => 'string', + 'type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time', - 'nullable' => true, ]), 'enum' => new \ArrayObject([ 'type' => 'string', @@ -392,6 +593,37 @@ public function testInvoke(): void ]), ], ])); + $dummyErrorSchema = new Schema('openapi'); + $dummyErrorSchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => 'nice one!', + 'deprecated' => false, + 'properties' => [ + 'type' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error type.', + ]), + 'title' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error title.', + ]), + 'status' => new \ArrayObject([ + 'type' => 'integer', + 'description' => 'This is an error status.', + 'readOnly' => true, + ]), + 'detail' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error detail.', + ]), + 'instance' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error instance.', + ]), + ], + ])); + $errorSchema = clone $dummyErrorSchema->getDefinitions(); + $errorSchema['description'] = ''; $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -399,21 +631,42 @@ public function testInvoke(): void $this->assertEquals($openApi->getInfo(), new Info('Test API', '1.2.3', 'This is a test API.')); $this->assertEquals($openApi->getServers(), [new Server('/app_dev.php/')]); + $webhooks = $openApi->getWebhooks(); + $this->assertCount(2, $webhooks); + + $firstOperationWebhook = $webhooks['first webhook']; + $secondOperationWebhook = $webhooks['happy webhook']; + $this->assertSame('dummy webhook', $firstOperationWebhook->getGet()->getOperationId()); + $this->assertSame('an other dummy webhook', $secondOperationWebhook->getPost()->getOperationId()); + $this->assertSame('I dont\'t know what to say', $secondOperationWebhook->getPost()->getDescription()); + $this->assertSame('well...', $secondOperationWebhook->getPost()->getSummary()); + $components = $openApi->getComponents(); $this->assertInstanceOf(Components::class, $components); - $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions()])); + $parameterSchema = $dummySchema->getDefinitions(); + $this->assertEquals($components->getSchemas(), new \ArrayObject([ + 'Dummy' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto' => $dummySchema->getDefinitions(), + 'Parameter' => $parameterSchema, + 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), + 'Error' => $errorSchema, + ])); $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ 'oauth' => new SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, null, null, new OAuthFlows(null, null, null, new OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), 'header' => new SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header'), 'query' => new SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query'), + 'bearer' => new SecurityScheme('http', 'Value for the http bearer parameter.', null, null, 'bearer', 'JWT'), + 'basic' => new SecurityScheme('http', 'Value for the http basic parameter.', null, null, 'basic', null), ])); $this->assertEquals([ ['oauth' => []], ['header' => []], ['query' => []], + ['bearer' => []], + ['basic' => []], ], $openApi->getSecurity()); $paths = $openApi->getPaths(); @@ -465,8 +718,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -497,7 +758,11 @@ public function testInvoke(): void 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), ]) ), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Retrieves a Dummy resource.', 'Retrieves a Dummy resource.', @@ -517,9 +782,21 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -539,7 +816,11 @@ public function testInvoke(): void ['Dummy'], [ '204' => new Response('Dummy resource deleted'), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Removes the Dummy resource.', 'Removes the Dummy resource.', @@ -562,7 +843,11 @@ public function testInvoke(): void 'Foo' => ['$ref' => '#/components/schemas/Dummy'], ])), '205' => new Response(), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Dummy', 'Custom description', @@ -605,9 +890,21 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -654,18 +951,19 @@ public function testInvoke(): void new Parameter('name', 'query', '', true, true, true, [ 'type' => 'string', ], 'form', true, true, 'bar'), - new Parameter('ha', 'query', '', false, false, true, [ + new Parameter('ha', 'query', '', false, false, false, [ 'type' => 'integer', ]), - new Parameter('toto', 'query', '', true, false, true, [ + new Parameter('toto', 'query', '', true, false, false, [ 'type' => 'array', 'items' => ['type' => 'string'], ], 'deepObject', true), - new Parameter('order[name]', 'query', '', false, false, true, [ + new Parameter('order[name]', 'query', '', false, false, false, [ 'type' => 'string', 'enum' => ['asc', 'desc'], ]), - ] + ], + deprecated: false ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); @@ -697,6 +995,9 @@ public function testInvoke(): void new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ 'type' => 'boolean', ]), + new Parameter('partial', 'query', 'Enable or disable partial pagination', false, false, true, [ + 'type' => 'boolean', + ]), ] ), $paginatedPath->getGet()); @@ -713,8 +1014,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -744,6 +1053,44 @@ public function testInvoke(): void deprecated: false, ), $requestBodyPath->getPost()); + $requestBodyPath = $paths->getPath('/dummiesRequestBodyWithoutContent'); + $this->assertEquals(new Operation( + 'postDummyCollectionWithRequestBodyWithoutContent', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + 'Extended description for the new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + false + ), + deprecated: false, + ), $requestBodyPath->getPost()); + $dummyItemPath = $paths->getPath('/dummyitems/{id}'); $this->assertEquals(new Operation( 'putDummyItemWithResponse', @@ -764,8 +1111,16 @@ public function testInvoke(): void ]) ), '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -801,7 +1156,11 @@ public function testInvoke(): void ]) ), '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -845,5 +1204,150 @@ public function testInvoke(): void ]), ] ), $dummyItemPath->getGet()); + + $emptyRequestBodyPath = $paths->getPath('/dummyitem/noinput'); + $this->assertEquals(new Operation( + 'postDummyItemWithoutInput', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + null + ), $emptyRequestBodyPath->getPost()); + + $parameter = $paths->getPath('/uri_variable_uuid')->getGet()->getParameters()[0]; + $this->assertTrue($parameter->getAllowEmptyValue()); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); + + $parameter = $paths->getPath('/parameters')->getPut()->getParameters()[0]; + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); + $this->assertEquals('header', $parameter->getIn()); + $this->assertEquals('hi', $parameter->getDescription()); + + $this->assertEquals(new Operation( + 'getDummyCollectionWithErrors', + ['Dummy'], + [ + '200' => new Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + ]))), + ])), + '418' => new Response( + 'A Teapot Exception', + new \ArrayObject([ + 'application/problem+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + '$ref' => '#/components/schemas/DummyErrorResource', + ]))), + ]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Retrieves the collection of Dummy resources.', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ], + deprecated: false + ), $paths->getPath('/erroredDummies')->getGet()); + + $diamondsGetPath = $paths->getPath('/diamonds'); + $diamondGetOperation = $diamondsGetPath->getGet(); + $diamondGetResponses = $diamondGetOperation->getResponses(); + + $this->assertNotNull($diamondGetOperation); + $this->assertArrayHasKey('403', $diamondGetResponses); + $this->assertSame('Forbidden', $diamondGetResponses['403']->getDescription()); + + $diamondsPutPath = $paths->getPath('/diamond/{id}'); + $diamondPutOperation = $diamondsPutPath->getPut(); + $diamondPutResponses = $diamondPutOperation->getResponses(); + + $this->assertNotNull($diamondPutOperation); + $this->assertArrayNotHasKey('403', $diamondPutResponses); + } + + public function testGetExtensionPropertiesWithFalseValue(): void + { + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $definitionNameFactory = new DefinitionNameFactory([]); + + $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ + (new Get())->withOpenapi(true)->withShortName('Dummy')->withName('api_dummies_get_collection')->withRouteName('api_dummies_get_collection'), + ]))->withClass(Dummy::class)]); + + $resourceCollectionMetadataFactory + ->method('create') + ->willReturnCallback(fn (string $resourceClass): ResourceMetadataCollection => match ($resourceClass) { + default => new ResourceMetadataCollection($resourceClass, []), + Dummy::class => $resourceCollectionMetadata, + }); + + $resourceNameCollectionFactory->expects($this->once()) + ->method('create') + ->willReturn(new ResourceNameCollection([Dummy::class])); + + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([])); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceCollectionMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactory, + $resourceCollectionMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + null, + [], + new Options('Test API', 'This is a test API.', '1.2.3'), + new PaginationOptions(), + null, + ['json' => ['application/problem+json']] + ); + + $openApi = $factory->__invoke(); } } diff --git a/Tests/Fixtures/Dummy.php b/Tests/Fixtures/Dummy.php index c8a0349..f6d73d7 100644 --- a/Tests/Fixtures/Dummy.php +++ b/Tests/Fixtures/Dummy.php @@ -156,12 +156,12 @@ public function getFoo(): ?array return $this->foo; } - public function setFoo(array $foo = null): void + public function setFoo(?array $foo = null): void { $this->foo = $foo; } - public function setDummyDate(\DateTime $dummyDate = null): void + public function setDummyDate(?\DateTime $dummyDate = null): void { $this->dummyDate = $dummyDate; } diff --git a/Tests/Fixtures/DummyErrorResource.php b/Tests/Fixtures/DummyErrorResource.php new file mode 100644 index 0000000..406075f --- /dev/null +++ b/Tests/Fixtures/DummyErrorResource.php @@ -0,0 +1,46 @@ + + * + * 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\OpenApi\Tests\Fixtures; + +use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; + +#[ErrorResource] +class DummyErrorResource extends \Exception implements ProblemExceptionInterface +{ + public function getType(): string + { + return 'Teapot'; + } + + public function getTitle(): ?string + { + return 'A Teapot Exception'; + } + + public function getStatus(): ?int + { + return 418; + } + + public function getDetail(): ?string + { + return 'I am not a coffee maker'; + } + + public function getInstance(): ?string + { + return null; + } +} diff --git a/Tests/Fixtures/Issue6872/Diamond.php b/Tests/Fixtures/Issue6872/Diamond.php new file mode 100644 index 0000000..e2cf520 --- /dev/null +++ b/Tests/Fixtures/Issue6872/Diamond.php @@ -0,0 +1,19 @@ + + * + * 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\OpenApi\Tests\Fixtures\Issue6872; + +class Diamond +{ + public float $weight; +} diff --git a/Tests/Fixtures/OutputDto.php b/Tests/Fixtures/OutputDto.php index 0ca3227..068aaa7 100644 --- a/Tests/Fixtures/OutputDto.php +++ b/Tests/Fixtures/OutputDto.php @@ -13,8 +13,6 @@ namespace ApiPlatform\OpenApi\Tests\Fixtures; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; - /** * @author Kévin Dunglas */ diff --git a/Tests/Serializer/ApiGatewayNormalizerTest.php b/Tests/Serializer/ApiGatewayNormalizerTest.php index 37dc305..d52dad9 100644 --- a/Tests/Serializer/ApiGatewayNormalizerTest.php +++ b/Tests/Serializer/ApiGatewayNormalizerTest.php @@ -21,35 +21,12 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; final class ApiGatewayNormalizerTest extends TestCase { use ProphecyTrait; - /** - * @group legacy - */ - public function testSupportsNormalization(): void - { - $normalizerProphecy = $this->prophesize(NormalizerInterface::class); - $normalizerProphecy->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)->willReturn(true); - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $normalizerProphecy->willImplement(CacheableSupportsMethodInterface::class); - $normalizerProphecy->hasCacheableSupportsMethod()->willReturn(true); - } - - $normalizer = new ApiGatewayNormalizer($normalizerProphecy->reveal()); - - $this->assertTrue($normalizer->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)); - - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); - } - } - public function testNormalize(): void { $swaggerDocument = [ diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 9a5c924..ccde651 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -13,8 +13,8 @@ namespace ApiPlatform\OpenApi\Tests\Serializer; +use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\SchemaFactory; -use ApiPlatform\JsonSchema\TypeFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -42,15 +42,16 @@ use ApiPlatform\OpenApi\OpenApi; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\OpenApi\Tests\Fixtures\Dummy; +use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\Pagination\PaginationOptions; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; 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; @@ -142,6 +143,8 @@ public function testNormalize(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $resourceCollectionMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); + $resourceCollectionMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceCollectionMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( @@ -151,6 +154,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -162,7 +166,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) - ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -174,6 +178,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'readOnly' => true, 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -185,6 +190,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( @@ -194,6 +200,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -201,9 +208,14 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); - $typeFactory->setSchemaFactory($schemaFactory); + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + definitionNameFactory: $definitionNameFactory, + ); $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), @@ -211,7 +223,6 @@ public function testNormalize(): void $propertyNameCollectionFactory, $propertyMetadataFactory, $schemaFactory, - $typeFactory, $filterLocatorProphecy->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'], [ diff --git a/composer.json b/composer.json index 0c1d8e7..4465c51 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,11 @@ "GraphQL", "API", "JSON-LD", - "Hydra", + "hydra", "JSONAPI", "OpenAPI", "HAL", - "Swagger" + "swagger" ], "homepage": "https://api-platform.com", "license": "MIT", @@ -27,18 +27,21 @@ } ], "require": { - "php": ">=8.1", - "api-platform/json-schema": "*@dev || ^3.1", - "api-platform/metadata": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1", - "symfony/console": "^6.1", - "symfony/property-access": "^6.1", - "symfony/serializer": "^6.1", - "sebastian/comparator": "<5.0" + "php": ">=8.2", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", + "symfony/console": "^6.4 || ^7.0", + "symfony/filesystem": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1" + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "11.5.x-dev", + "api-platform/doctrine-common": "^4.1", + "api-platform/doctrine-orm": "^4.1", + "api-platform/doctrine-odm": "^4.1" }, "autoload": { "psr-4": { @@ -60,24 +63,25 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "4.2.x-dev", + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4 || ^7.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, "repositories": [ { - "type": "path", - "url": "../Metadata" - }, - { - "type": "path", - "url": "../JsonSchema" - }, - { - "type": "path", - "url": "../State" + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" } ] } diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml new file mode 100644 index 0000000..e3ef619 --- /dev/null +++ b/phpunit.baseline.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eec6b86..f92db3b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,23 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - - ./Tests - ./vendor - - + + + + + + + ./Tests/ + + + + + trigger_deprecation + + + ./ + + + ./Tests + ./vendor + + -