diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..531589d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/Tests export-ignore +/phpunit.baseline.xml 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..26573d8 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,30 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); - $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $specVersion = $input->getOption('spec-version'); + $data = $this->normalizer->normalize( + $this->openApiFactory->__invoke([ + 'filter_tags' => $input->getOption('filter-tags'), + 'spec_version' => $specVersion, + ]), + 'json', + ['spec_version' => $specVersion] + ); + + 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 +88,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..6273ea8 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -13,28 +13,35 @@ namespace ApiPlatform\OpenApi\Factory; -use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions; +use ApiPlatform\JsonSchema\BackwardCompatibleSchemaFactory; 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\InvalidArgumentException; +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,14 +49,21 @@ 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\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Generates an Open API v3 specification. @@ -57,20 +71,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,22 +109,27 @@ 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 { $baseUrl = $context[self::BASE_URL] ?? '/'; $contact = null === $this->openApiOptions->getContactUrl() || null === $this->openApiOptions->getContactEmail() ? null : new Contact($this->openApiOptions->getContactName(), $this->openApiOptions->getContactUrl(), $this->openApiOptions->getContactEmail()); - $license = null === $this->openApiOptions->getLicenseName() ? null : new License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl()); + $license = null === $this->openApiOptions->getLicenseName() ? null : new License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl(), $this->openApiOptions->getLicenseIdentifier()); $info = new Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription()), $this->openApiOptions->getTermsOfService(), $contact, $license); $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 +140,8 @@ public function __invoke(array $context = []): OpenApi $securityRequirements[] = [$key => []]; } + $globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: []; + return new OpenApi( $info, $servers, @@ -117,27 +155,54 @@ 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; } + $schemaSerializerContext = '3.0.0' === ($context['spec_version'] ?? null) + ? [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true] + : null; + + $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 (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,85 +216,54 @@ 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(); 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; } + $summary = null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface); + // 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() : [], - summary: null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), - description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), + summary: $summary, + description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $summary, externalDocs: $openapiOperation->getExternalDocs(), parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [], requestBody: $openapiOperation->getRequestBody(), callbacks: $openapiOperation->getCallbacks(), - deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : (bool) $operation->getDeprecationReason(), + deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : ($operation->getDeprecationReason() ? true : null), security: null !== $openapiOperation->getSecurity() ? $openapiOperation->getSecurity() : null, servers: null !== $openapiOperation->getServers() ? $openapiOperation->getServers() : null, 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'])); + foreach ($openapiOperation->getTags() as $v) { + $tags[$v] = new Tag(name: $v, description: $resource->getDescription() ?? "Resource '$v' operations."); } - // 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'])); - } - - // 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; @@ -239,42 +273,59 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $operationOutputSchemas = []; foreach ($responseMimeTypes as $operationFormat) { - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); - $operationOutputSchemas[$operationFormat] = $operationOutputSchema; + $operationOutputSchema = null; + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); $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); + $operationOutputSchemas[$operationFormat] = $operationOutputSchema; } // 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)) { + $description = $uriVariable->getDescription(); + if (null === $description) { + $fromClass = $uriVariable->getFromClass(); + if (null !== $fromClass && $fromClass !== $resourceClass) { + $uriResourceName = (new \ReflectionClass($fromClass))->getShortName(); + } else { + $uriResourceName = $resourceShortName; + } + + $description = "$uriResourceName identifier"; + } + + $parameter = new Parameter( + $parameterName, + 'path', + $description, + $uriVariable->getRequired() ?? true, + false, + null, + $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,142 +333,233 @@ 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; + } + } + + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; + $defaultSchema = ['type' => 'string']; + if (null !== $p->getDefault()) { + $defaultSchema['default'] = $p->getDefault(); + } + + $defaultParameter = new Parameter( + $key, + $in, + $p->getDescription() ?? "$resourceShortName $key", + $p->getRequired() ?? false, + false, + null, + $p->getSchema() ?? $defaultSchema, + ); + + $linkParameter = $p->getOpenApi(); + if (null === $linkParameter) { + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $defaultParameter)) { + $openapiParameters[$i] = $this->mergeParameter($defaultParameter, $operationParameter); + } else { + $openapiParameters[] = $defaultParameter; } - $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); + continue; + } + + 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; + } - break; + $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 (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, $schemaSerializerContext); + } + + 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, $schemaSerializerContext); + } + 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, $schemaSerializerContext); + } + 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, $schemaSerializerContext); + } + + 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, $schemaSerializerContext); } 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 = null; + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + + $operationInputSchemas[$operationFormat] = $operationInputSchema; + } + $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 + /** + * @param array $existingResponses + */ + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Operation $openapiOperation, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null, bool $isErrorResponse = false): Operation { - if (isset($existingResponses[$status])) { - return $openapiOperation; + $noOutput = !$isErrorResponse && \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; + + $response = $existingResponses[$status] ?? new Response($description); + if (null === $response->getDescription()) { + $response = $response->withDescription($description); } - $responseLinks = $responseContent = null; - if ($responseMimeTypes && $operationOutputSchemas) { - $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + + if (null === $response->getContent() && $responseMimeTypes && $operationOutputSchemas && !$noOutput) { + $response = $response->withContent($this->buildContent($responseMimeTypes, $operationOutputSchemas)); } - if ($resourceMetadataCollection && $operation) { - $responseLinks = $this->getLinks($resourceMetadataCollection, $operation); + + if (null === $response->getLinks() && $resourceMetadataCollection && $operation) { + $response = $response->withLinks($this->getLinks($resourceMetadataCollection, $operation)); } - return $openapiOperation->withResponse($status, new Response($description, $responseContent, null, $responseLinks)); + return $openapiOperation->withResponse($status, $response); } /** - * @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) { - $content[$mimeType] = new MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); + $content[$mimeType] = isset($operationSchemas[$format]) ? new MediaType(schema: new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))) : new \ArrayObject(); } return $content; } + /** + * @return array{array, array} + */ private function getMimeTypes(HttpOperation $operation): array { $requestFormats = $operation->getInputFormats() ?: []; @@ -429,6 +571,11 @@ private function getMimeTypes(HttpOperation $operation): array return [$requestMimeTypes, $responseMimeTypes]; } + /** + * @param array $responseFormats + * + * @return array + */ private function flattenMimeTypes(array $responseFormats): array { $responseMimeTypes = []; @@ -481,35 +628,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->getMethod(); 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 (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook) { continue; } @@ -520,7 +667,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 +677,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,57 +699,142 @@ 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 => $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 (method_exists(PropertyInfoExtractor::class, 'getType')) { + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, Type::int()); + } + + $schema += $this->getType($type); + } + // TODO: remove in 5.x + } else { + if (isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)); + } } - 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']); + if (!isset($schema['type'])) { + $schema['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 - ); + $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; + $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + + $isArraySchema = 'array' === ($schema['type'] ?? null); + $style = $isArraySchema && \in_array( + $description['type'], + [$arrayValueType, $objectValueType], + true + ) ? 'deepObject' : 'form'; + + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $isArraySchema ? ($description['is_collection'] ?? false) : 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); } - return $parameters; + 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'] ?? null; + + if (!$schema) { + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, key: Type::int()); + } + $schema = $this->getType($type); + } else { + $schema = ['type' => 'string']; + } + // TODO: remove in 5.x + } else { + $schema = isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) + ? $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)) + : ['type' => 'string']; + } + } + + $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; + $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + + $isArraySchema = 'array' === $schema['type']; + + return new Parameter( + $name, + 'query', + $description['description'] ?? '', + $description['required'] ?? false, + $description['openapi']['deprecated'] ?? false, + $description['openapi']['allowEmptyValue'] ?? null, + $schema, + $isArraySchema && \in_array( + $description['type'], + [$arrayValueType, $objectValueType], + true + ) ? 'deepObject' : 'form', + $description['openapi']['explode'] ?? $isArraySchema, + $description['openapi']['allowReserved'] ?? null, + $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()) { - return []; - } - $parameters = []; if ($operation->getPaginationEnabled() ?? $this->paginationOptions->isPaginationEnabled()) { - $parameters[] = new Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]); + $parameters[] = new Parameter( + $this->paginationOptions->getPaginationPageParameterName(), + 'query', + 'The collection page number', + false, + false, + null, + ['type' => 'integer', 'default' => 1], + ); if ($operation->getPaginationClientItemsPerPage() ?? $this->paginationOptions->getClientItemsPerPage()) { $schema = [ @@ -615,12 +847,32 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat $schema['maximum'] = $maxItemsPerPage; } - $parameters[] = new Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema); + $parameters[] = new Parameter( + $this->paginationOptions->getItemsPerPageParameterName(), + 'query', + 'The number of items per page', + false, + false, + null, + $schema, + ); } } if ($operation->getPaginationClientEnabled() ?? $this->paginationOptions->isPaginationClientEnabled()) { - $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); + $parameters[] = new Parameter( + $this->paginationOptions->getPaginationClientEnabledParameterName(), + 'query', + 'Enable or disable pagination', + false, + false, + null, + ['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; @@ -629,7 +881,7 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat 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 +918,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 +941,153 @@ 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 instanceof Parameter) { + throw new InvalidArgumentException(\sprintf('OpenAPI operation parameters must be instances of "%s", "%s" given.', Parameter::class, get_debug_type($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 + { + // Handle description separately: only override if the new value is non-empty + $newDescription = $defined->getDescription(); + if ('' !== $newDescription && $actual->getDescription() !== $newDescription) { + $actual = $actual->withDescription($newDescription); + } + + foreach ( + [ + 'name', + 'in', + '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 $actual; + } + + /** + * @param ErrorResource[] $errors + * @param \ArrayObject $schemas + */ + private function addOperationErrors( + Operation $operation, + array $errors, + ResourceMetadataCollection $resourceMetadataCollection, + Schema $schema, + \ArrayObject $schemas, + HttpOperation $originalOperation, + ?array $serializerContext = null, + ): 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 = null; + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema, $serializerContext); + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + $operationErrorSchemas[$operationFormat] = $operationErrorSchema; + } + + 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, true); + } + + 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 false; + 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..f711e92 --- /dev/null +++ b/Factory/TypeFactoryTrait.php @@ -0,0 +1,196 @@ + + * + * 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 as LegacyType; +use Symfony\Component\TypeInfo\Type as NativeType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +trait TypeFactoryTrait +{ + /** + * @return array + */ + private function getType(LegacyType|NativeType $type): array + { + if ($type instanceof NativeType) { + return $this->getNativeType($type); + } + + if ($type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && LegacyType::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->makeLegacyBasicType($type), $type); + } + + /** + * @return array + */ + private function getNativeType(NativeType $type): array + { + if ($type instanceof CollectionType) { + $keyType = $type->getCollectionKeyType(); + $subType = $type->getCollectionValueType(); + + if ($keyType->isIdentifiedBy(TypeIdentifier::STRING)) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getNativeType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getNativeType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); + } + + /** + * @return array + */ + private function makeLegacyBasicType(LegacyType $type): array + { + return match ($type->getBuiltinType()) { + LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], + LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + LegacyType::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), + default => ['type' => 'string'], + }; + } + + /** + * @return array + */ + private function makeBasicType(NativeType $type): array + { + if ($type->isIdentifiedBy(TypeIdentifier::INT)) { + return ['type' => 'integer']; + } + if ($type->isIdentifiedBy(TypeIdentifier::FLOAT)) { + return ['type' => 'number']; + } + if ($type->isIdentifiedBy(TypeIdentifier::BOOL)) { + return ['type' => 'boolean']; + } + if ($type instanceof ObjectType) { + return $this->getClassType($type->getClassName(), $type->isNullable()); + } + + // Default for other built-in types like string, resource, mixed, etc. + return ['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. + * + * @return array + */ + 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, LegacyType|NativeType $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..04c6ef1 100644 --- a/Model/Components.php +++ b/Model/Components.php @@ -21,7 +21,7 @@ final class Components /** * @param \ArrayObject|\ArrayObject $schemas - * @param \ArrayObject|\ArrayObject $responses + * @param \ArrayObject|\ArrayObject $responses * @param \ArrayObject|\ArrayObject $parameters * @param \ArrayObject|\ArrayObject $examples * @param \ArrayObject|\ArrayObject $requestBodies @@ -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/ExtensionTrait.php b/Model/ExtensionTrait.php index 903c42b..f91f6bc 100644 --- a/Model/ExtensionTrait.php +++ b/Model/ExtensionTrait.php @@ -17,7 +17,7 @@ trait ExtensionTrait { private array $extensionProperties = []; - public function withExtensionProperty(string $key, $value): mixed + public function withExtensionProperty(string $key, mixed $value): mixed { if (!str_starts_with($key, 'x-')) { $key = 'x-'.$key; diff --git a/Model/Header.php b/Model/Header.php index 8f5ca7f..fac71e4 100644 --- a/Model/Header.php +++ b/Model/Header.php @@ -17,7 +17,7 @@ final class Header { use ExtensionTrait; - public function __construct(private readonly string $in = 'header', 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 readonly string $in = 'header', 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 mixed $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { $this->style = 'simple'; @@ -84,7 +84,7 @@ public function getAllowReserved(): bool return $this->allowReserved; } - public function getExample() + public function getExample(): mixed { return $this->example; } @@ -163,7 +163,7 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; diff --git a/Model/Link.php b/Model/Link.php index 692c4f7..a7d48cf 100644 --- a/Model/Link.php +++ b/Model/Link.php @@ -17,7 +17,7 @@ final class Link { use ExtensionTrait; - public function __construct(private string $operationId, private ?\ArrayObject $parameters = null, private $requestBody = null, private string $description = '', private ?Server $server = null) + public function __construct(private string $operationId, private ?\ArrayObject $parameters = null, private mixed $requestBody = null, private string $description = '', private ?Server $server = null) { } @@ -31,7 +31,7 @@ public function getParameters(): \ArrayObject return $this->parameters; } - public function getRequestBody() + public function getRequestBody(): mixed { return $this->requestBody; } @@ -62,7 +62,7 @@ public function withParameters(\ArrayObject $parameters): self return $clone; } - public function withRequestBody($requestBody): self + public function withRequestBody(mixed $requestBody): self { $clone = clone $this; $clone->requestBody = $requestBody; diff --git a/Model/MediaType.php b/Model/MediaType.php index 97b7e79..ea50465 100644 --- a/Model/MediaType.php +++ b/Model/MediaType.php @@ -17,7 +17,7 @@ final class MediaType { use ExtensionTrait; - public function __construct(private ?\ArrayObject $schema = null, private $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) + public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) { } @@ -26,7 +26,7 @@ public function getSchema(): ?\ArrayObject return $this->schema; } - public function getExample() + public function getExample(): mixed { return $this->example; } @@ -49,7 +49,7 @@ public function withSchema(\ArrayObject $schema): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; diff --git a/Model/Operation.php b/Model/Operation.php index 22de521..e2716ae 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -17,12 +17,16 @@ final class Operation { use ExtensionTrait; + /** + * @param ?string[] $tags + * @param array|null $responses + */ public function __construct(private ?string $operationId = null, private ?array $tags = null, private ?array $responses = null, private ?string $summary = null, private ?string $description = null, private ?ExternalDocumentation $externalDocs = null, private ?array $parameters = null, private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private ?bool $deprecated = null, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) { $this->extensionProperties = $extensionProperties; } - public function addResponse(Response $response, $status = 'default'): self + public function addResponse(Response $response, int|string $status = 'default'): self { $this->responses[$status] = $response; @@ -59,6 +63,9 @@ public function getExternalDocs(): ?ExternalDocumentation return $this->externalDocs; } + /** + * @return ?Parameter[] + */ public function getParameters(): ?array { return $this->parameters; @@ -148,6 +155,9 @@ public function withExternalDocs(ExternalDocumentation $externalDocs): self return $clone; } + /** + * @param Parameter[] $parameters + */ public function withParameters(array $parameters): self { $clone = clone $this; @@ -167,7 +177,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 +201,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 +209,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..ab8af92 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 = null, private array $schema = [], private ?string $style = null, private ?bool $explode = null, private ?bool $allowReserved = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -26,6 +26,10 @@ public function __construct(private string $name, private string $in, private st $this->style = 'simple'; } } + + if (null === $explode) { + $this->explode = \in_array($this->style, ['form', 'cookie'], true); + } } public function getName(): string @@ -53,12 +57,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,17 +87,17 @@ 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; } - public function getExample() + public function getExample(): mixed { return $this->example; } @@ -148,7 +152,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 +184,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; @@ -188,7 +192,7 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; 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/Reference.php b/Model/Reference.php index 0c5bf8e..a9e1a59 100644 --- a/Model/Reference.php +++ b/Model/Reference.php @@ -13,7 +13,7 @@ namespace ApiPlatform\OpenApi\Model; -use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedName; final class Reference { 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/Response.php b/Model/Response.php index 46b55f1..187e8be 100644 --- a/Model/Response.php +++ b/Model/Response.php @@ -17,11 +17,11 @@ final class Response { use ExtensionTrait; - public function __construct(private string $description = '', private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) { } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } diff --git a/Model/Schema.php b/Model/Schema.php index 7647b4b..dd6b074 100644 --- a/Model/Schema.php +++ b/Model/Schema.php @@ -20,7 +20,7 @@ final class Schema extends \ArrayObject use ExtensionTrait; private readonly JsonSchema $schema; - public function __construct(private $discriminator = null, private bool $readOnly = false, private bool $writeOnly = false, private ?string $xml = null, private $externalDocs = null, private $example = null, private bool $deprecated = false) + public function __construct(private mixed $discriminator = null, private bool $readOnly = false, private bool $writeOnly = false, private ?string $xml = null, private mixed $externalDocs = null, private mixed $example = null, private bool $deprecated = false) { $this->schema = new JsonSchema(); @@ -48,7 +48,7 @@ public function getDefinitions(): \ArrayObject return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); } - public function getDiscriminator() + public function getDiscriminator(): mixed { return $this->discriminator; } @@ -68,12 +68,12 @@ public function getXml(): string return $this->xml; } - public function getExternalDocs() + public function getExternalDocs(): mixed { return $this->externalDocs; } - public function getExample() + public function getExample(): mixed { return $this->example; } @@ -83,7 +83,7 @@ public function getDeprecated(): bool return $this->deprecated; } - public function withDiscriminator($discriminator): self + public function withDiscriminator(mixed $discriminator): self { $clone = clone $this; $clone->discriminator = $discriminator; @@ -115,7 +115,7 @@ public function withXml(string $xml): self return $clone; } - public function withExternalDocs($externalDocs): self + public function withExternalDocs(mixed $externalDocs): self { $clone = clone $this; $clone->externalDocs = $externalDocs; @@ -123,7 +123,7 @@ public function withExternalDocs($externalDocs): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; 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..61a43d7 100644 --- a/OpenApi.php +++ b/OpenApi.php @@ -22,14 +22,17 @@ 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) + /** + * @param array|null $externalDocs + */ + 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..e91976a 100644 --- a/Options.php +++ b/Options.php @@ -13,10 +13,41 @@ 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, + private ?string $licenseIdentifier = null, + ) { } public function getTitle(): string @@ -74,6 +105,11 @@ public function getApiKeys(): array return $this->apiKeys; } + public function getHttpAuth(): array + { + return $this->httpAuth; + } + public function getContactName(): ?string { return $this->contactName; @@ -103,4 +139,43 @@ 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; + } + + public function getLicenseIdentifier(): ?string + { + return $this->licenseIdentifier; + } } 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..fed1cf8 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,14 +25,14 @@ * * @author Vincent Chalamon */ -final class ApiGatewayNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +final class ApiGatewayNormalizer implements NormalizerInterface { public const API_GATEWAY = 'api_gateway'; private array $defaultContext = [ self::API_GATEWAY => false, ]; - public function __construct(private readonly NormalizerInterface $documentationNormalizer, $defaultContext = []) + public function __construct(private readonly NormalizerInterface $documentationNormalizer, array $defaultContext = []) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -44,108 +42,89 @@ 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 $data, ?string $format = null, array $context = []): array { - $data = $this->documentationNormalizer->normalize($object, $format, $context); - if (!\is_array($data)) { + $normalizedData = $this->documentationNormalizer->normalize($data, $format, $context); + if (!\is_array($normalizedData)) { throw new UnexpectedValueException('Expected data to be an array'); } if (!($context[self::API_GATEWAY] ?? $this->defaultContext[self::API_GATEWAY])) { - return $data; + return $normalizedData; } - if (empty($data['basePath'])) { - $data['basePath'] = '/'; + if (empty($normalizedData['basePath'])) { + $normalizedData['basePath'] = '/'; } - foreach ($data['paths'] as $path => $operations) { + foreach ($normalizedData['paths'] as $path => $operations) { foreach ($operations as $operation => $options) { if (isset($options['parameters'])) { foreach ($options['parameters'] as $key => $parameter) { if (!preg_match('/^[a-zA-Z0-9._$-]+$/', (string) $parameter['name'])) { - unset($data['paths'][$path][$operation]['parameters'][$key]); + unset($normalizedData['paths'][$path][$operation]['parameters'][$key]); } if (isset($parameter['schema']['$ref']) && $this->isLocalRef($parameter['schema']['$ref'])) { - $data['paths'][$path][$operation]['parameters'][$key]['schema']['$ref'] = $this->normalizeRef($parameter['schema']['$ref']); + $normalizedData['paths'][$path][$operation]['parameters'][$key]['schema']['$ref'] = $this->normalizeRef($parameter['schema']['$ref']); } } - $data['paths'][$path][$operation]['parameters'] = array_values($data['paths'][$path][$operation]['parameters']); + $normalizedData['paths'][$path][$operation]['parameters'] = array_values($normalizedData['paths'][$path][$operation]['parameters']); } if (isset($options['responses'])) { foreach ($options['responses'] as $statusCode => $response) { if (isset($response['schema']['items']['$ref']) && $this->isLocalRef($response['schema']['items']['$ref'])) { - $data['paths'][$path][$operation]['responses'][$statusCode]['schema']['items']['$ref'] = $this->normalizeRef($response['schema']['items']['$ref']); + $normalizedData['paths'][$path][$operation]['responses'][$statusCode]['schema']['items']['$ref'] = $this->normalizeRef($response['schema']['items']['$ref']); } if (isset($response['schema']['$ref']) && $this->isLocalRef($response['schema']['$ref'])) { - $data['paths'][$path][$operation]['responses'][$statusCode]['schema']['$ref'] = $this->normalizeRef($response['schema']['$ref']); + $normalizedData['paths'][$path][$operation]['responses'][$statusCode]['schema']['$ref'] = $this->normalizeRef($response['schema']['$ref']); } } } } } - foreach ($data['components']['schemas'] as $definition => $options) { + foreach ($normalizedData['components']['schemas'] as $definition => $options) { if (!isset($options['properties'])) { continue; } foreach ($options['properties'] as $property => $propertyOptions) { if (isset($propertyOptions['readOnly'])) { - unset($data['components']['schemas'][$definition]['properties'][$property]['readOnly']); + unset($normalizedData['components']['schemas'][$definition]['properties'][$property]['readOnly']); } if (isset($propertyOptions['$ref']) && $this->isLocalRef($propertyOptions['$ref'])) { - $data['components']['schemas'][$definition]['properties'][$property]['$ref'] = $this->normalizeRef($propertyOptions['$ref']); + $normalizedData['components']['schemas'][$definition]['properties'][$property]['$ref'] = $this->normalizeRef($propertyOptions['$ref']); } if (isset($propertyOptions['items']['$ref']) && $this->isLocalRef($propertyOptions['items']['$ref'])) { - $data['components']['schemas'][$definition]['properties'][$property]['items']['$ref'] = $this->normalizeRef($propertyOptions['items']['$ref']); + $normalizedData['components']['schemas'][$definition]['properties'][$property]['items']['$ref'] = $this->normalizeRef($propertyOptions['items']['$ref']); } } } - // $data['definitions'] is an instance of \ArrayObject - foreach (array_keys($data['components']['schemas']) as $definition) { + // $normalizedData['definitions'] is an instance of \ArrayObject + foreach (array_keys($normalizedData['components']['schemas']) as $definition) { if (!preg_match('/^[0-9A-Za-z]+$/', (string) $definition)) { - $data['components']['schemas'][preg_replace('/[^0-9A-Za-z]/', '', (string) $definition)] = $data['components']['schemas'][$definition]; - unset($data['components']['schemas'][$definition]); + $normalizedData['components']['schemas'][preg_replace('/[^0-9A-Za-z]/', '', (string) $definition)] = $normalizedData['components']['schemas'][$definition]; + unset($normalizedData['components']['schemas'][$definition]); } } - return $data; + return $normalizedData; } /** * {@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 + public function getSupportedTypes(?string $format): array { - 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(); + return $this->documentationNormalizer->getSupportedTypes($format); } private function isLocalRef(string $ref): bool 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..747b2d2 --- /dev/null +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -0,0 +1,165 @@ + + * + * 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 const SCHEMA_BRANCH_KEYS = ['properties', 'patternProperties']; + private const SCHEMA_LIST_KEYS = ['allOf', 'oneOf', 'anyOf']; + private const SCHEMA_NESTED_KEYS = ['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else']; + + private array $defaultContext = [ + self::SPEC_VERSION => '3.1.0', + ]; + + public function __construct(private readonly NormalizerInterface $decorated, array $defaultContext = []) + { + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + } + + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $openapi = $this->decorated->normalize($data, $format, $context); + + if ('3.0.0' !== ($context['spec_version'] ?? null)) { + return $openapi; + } + + $openapi['openapi'] = '3.0.0'; + + foreach ($openapi['components']['schemas'] ?? [] as $name => $component) { + $openapi['components']['schemas'][$name] = $this->downgradeSchema($component); + } + + foreach ($openapi['paths'] ?? [] as $path => $operations) { + $openapi['paths'][$path] = $this->downgradePathItem($operations); + } + + return $openapi; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return $this->decorated->getSupportedTypes($format); + } + + private function downgradePathItem(mixed $pathItem): mixed + { + if (!\is_array($pathItem)) { + return $pathItem; + } + + foreach ($pathItem as $method => $operation) { + if (!\is_array($operation)) { + continue; + } + + if (isset($operation['requestBody']['content']) && \is_array($operation['requestBody']['content'])) { + foreach ($operation['requestBody']['content'] as $mediaType => $media) { + if (isset($media['schema'])) { + $pathItem[$method]['requestBody']['content'][$mediaType]['schema'] = $this->downgradeSchema($media['schema']); + } + } + } + + foreach ($operation['responses'] ?? [] as $status => $response) { + if (!\is_array($response)) { + continue; + } + foreach ($response['content'] ?? [] as $mediaType => $media) { + if (isset($media['schema'])) { + $pathItem[$method]['responses'][$status]['content'][$mediaType]['schema'] = $this->downgradeSchema($media['schema']); + } + } + } + + foreach ($operation['parameters'] ?? [] as $index => $parameter) { + if (isset($parameter['schema'])) { + $pathItem[$method]['parameters'][$index]['schema'] = $this->downgradeSchema($parameter['schema']); + } + } + } + + return $pathItem; + } + + private function downgradeSchema(mixed $schema): mixed + { + if (!\is_array($schema)) { + return $schema; + } + + if (\is_array($schema['type'] ?? null)) { + $types = array_values($schema['type']); + $nullable = \in_array('null', $types, true); + $nonNull = array_values(array_filter($types, static fn ($t) => 'null' !== $t)); + + if (1 === \count($nonNull)) { + $schema['type'] = $nonNull[0]; + } elseif ([] === $nonNull) { + unset($schema['type']); + } else { + unset($schema['type']); + $schema['anyOf'] = array_map(static fn ($t) => ['type' => $t], $nonNull); + } + + if ($nullable) { + $schema['nullable'] = true; + } + } + + if (\array_key_exists('examples', $schema)) { + $schema['example'] = $schema['examples']; + unset($schema['examples']); + } + + foreach (self::SCHEMA_BRANCH_KEYS as $key) { + if (!isset($schema[$key]) || !\is_array($schema[$key])) { + continue; + } + foreach ($schema[$key] as $name => $child) { + $schema[$key][$name] = $this->downgradeSchema($child); + } + } + + foreach (self::SCHEMA_LIST_KEYS as $key) { + if (!isset($schema[$key]) || !\is_array($schema[$key])) { + continue; + } + foreach ($schema[$key] as $index => $child) { + $schema[$key][$index] = $this->downgradeSchema($child); + } + } + + foreach (self::SCHEMA_NESTED_KEYS as $key) { + if (!isset($schema[$key])) { + continue; + } + if (\is_array($schema[$key])) { + $schema[$key] = $this->downgradeSchema($schema[$key]); + } + } + + return $schema; + } +} diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index 83ebf63..e55a6a2 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,16 +36,16 @@ public function __construct(private readonly NormalizerInterface $decorated) /** * {@inheritdoc} */ - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $data, ?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] = [ 'paths' => $pathsCallback, ]; - return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); + return $this->recursiveClean($this->decorated->normalize($data, $format, $context)); } private function recursiveClean(array $data): array @@ -70,27 +71,58 @@ 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 + /** + * {@inheritdoc} + */ + public function getSupportedTypes(?string $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, static 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..89463d4 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -13,14 +13,18 @@ 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\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\NotExposed; @@ -30,10 +34,12 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; 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 +59,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; +use Symfony\Component\TypeInfo\Type; class OpenApiFactoryTest extends TestCase { - use ExpectDeprecationTrait; use ProphecyTrait; private const OPERATION_FORMATS = [ @@ -79,211 +88,404 @@ 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'], ]), + required: true, ), - '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', + 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, + 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', + content: new \ArrayObject([]), + ), + ], + )), + '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', + ), + ], + )), + 'postDummyItemWithEmptyResponse' => (new Post())->withUriTemplate('/dummyitems/noresponse')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '201' => new OpenApiResponse( + description: '', + content: new \ArrayObject(), + headers: new \ArrayObject(), + links: new \ArrayObject(), + ), + '400' => new OpenApiResponse(), + ], + requestBody: new RequestBody(''), + )), + 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), + 'postDummyItemWithoutOutput' => (new Post())->withUriTemplate('/dummyitem/nooutput')->withOperation($baseOperation)->withOutput(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'))]))->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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(Type::nullable(Type::object(\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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(Type::nullable(Type::object(\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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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()) + ->withNativeType(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 +493,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, allowReserved: true, explode: true), ]]), 'f2' => new DummyFilter(['ha' => [ 'property' => 'foo', @@ -329,9 +531,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 +547,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 +558,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'); @@ -360,7 +577,6 @@ public function testInvoke(): void 'type' => 'object', 'description' => 'This is a dummy', 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], - 'deprecated' => false, 'properties' => [ 'id' => new \ArrayObject([ 'type' => 'integer', @@ -379,10 +595,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 +607,36 @@ public function testInvoke(): void ]), ], ])); + $dummyErrorSchema = new Schema('openapi'); + $dummyErrorSchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => 'nice one!', + '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(); + unset($errorSchema['description']); $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -399,21 +644,46 @@ 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(), + 'Dummy.OutputDto.csv' => $dummySchema->getDefinitions(), + 'Dummy.jsonld' => $dummySchema->getDefinitions(), + 'Dummy.csv' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto.jsonld' => $dummySchema->getDefinitions(), + 'Parameter.jsonld' => $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(); @@ -428,26 +698,26 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response('Dummy collection', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], - ]))), + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], + ])), ])), ], 'Retrieves the collection of Dummy resources.', 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'Test modified collection page number', false, false, true, [ + new Parameter('page', 'query', 'Test modified collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), ] @@ -460,13 +730,21 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), 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.', @@ -475,7 +753,7 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ) @@ -494,10 +772,14 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]) ), - '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.', @@ -512,14 +794,26 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource updated', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), 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.', @@ -528,7 +822,7 @@ public function testInvoke(): void new RequestBody( 'The updated Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ) @@ -539,7 +833,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 +860,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', @@ -600,14 +902,26 @@ public function testInvoke(): void 'Dummy resource updated', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), - 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), + 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.csv'])), ]), 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.', @@ -617,7 +931,7 @@ public function testInvoke(): void 'The updated Dummy resource', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), - 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.csv'])), ]), true ) @@ -631,7 +945,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ])), ])), ], @@ -639,33 +953,33 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), - new Parameter('name', 'query', '', true, true, true, [ + new Parameter('name', 'query', '', true, true, null, [ 'type' => 'string', ], 'form', true, true, 'bar'), - new Parameter('ha', 'query', '', false, false, true, [ + new Parameter('ha', 'query', '', false, false, null, [ 'type' => 'integer', - ]), - new Parameter('toto', 'query', '', true, false, true, [ + ], 'form', false), + new Parameter('toto', 'query', '', true, false, null, [ 'type' => 'array', 'items' => ['type' => 'string'], ], 'deepObject', true), - new Parameter('order[name]', 'query', '', false, false, true, [ + new Parameter('order[name]', 'query', '', false, false, null, [ 'type' => 'string', 'enum' => ['asc', 'desc'], - ]), - ] + ], 'form', false), + ], ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); @@ -676,7 +990,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ])), ])), ], @@ -684,17 +998,20 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 20, 'minimum' => 0, 'maximum' => 80, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ + 'type' => 'boolean', + ]), + new Parameter('partial', 'query', 'Enable or disable partial pagination', false, false, true, [ 'type' => 'boolean', ]), ] @@ -708,13 +1025,21 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), 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.', @@ -741,7 +1066,43 @@ public function testInvoke(): void ]), false ), - 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(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), + ]), + 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(['$ref' => '#/components/schemas/Dummy.jsonld'])), + ]), + false + ), ), $requestBodyPath->getPost()); $dummyItemPath = $paths->getPath('/dummyitems/{id}'); @@ -763,9 +1124,21 @@ public function testInvoke(): void 'link' => ['$ref' => '#/components/schemas/Dummy'], ]) ), - '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Error', + 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')]) + ), + '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.', @@ -774,11 +1147,10 @@ public function testInvoke(): void new RequestBody( 'The updated Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ), - deprecated: false ), $dummyItemPath->getPut()); $dummyItemPath = $paths->getPath('/dummyitems'); @@ -800,8 +1172,16 @@ public function testInvoke(): void 'link' => ['$ref' => '#/components/schemas/Dummy'], ]) ), - '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Error', + 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.', @@ -810,11 +1190,10 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ), - deprecated: false ), $dummyItemPath->getPost()); $dummyItemPath = $paths->getPath('/dummyitems/{id}/images'); @@ -824,26 +1203,299 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response( - 'Success' + 'Success', + content: new \ArrayObject([]), ), ], 'Retrieves the collection of Dummy resources.', 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), ] ), $dummyItemPath->getGet()); + + $emptyReponsePath = $paths->getPath('/dummyitems/noresponse'); + $this->assertEquals(new Operation( + 'postDummyItemWithEmptyResponse', + ['Dummy'], + [ + '201' => new Response( + '', + new \ArrayObject(), + new \ArrayObject(), + new \ArrayObject() + ), + '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( + '', + content: new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), + ]), + ), + ), $emptyReponsePath->getPost()); + + $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(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), + ]), + 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()); + + $emptyResponsePath = $paths->getPath('/dummyitem/nooutput'); + $this->assertEquals(new Operation( + 'postDummyItemWithoutOutput', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject([])), + ]), + 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( + 'The new Dummy resource', + content: new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), + ]), + required: true + ) + ), $emptyResponsePath->getPost()); + + $parameter = $paths->getPath('/uri_variable_uuid')->getGet()->getParameters()[0]; + $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([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], + ])), + ])), + '418' => new Response( + 'A Teapot Exception', + new \ArrayObject([ + 'application/problem+json' => new MediaType(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, null, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ + 'type' => 'boolean', + ]), + ], + ), $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(static 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(); + } + + public function testMetadataParameterInOpenApiOperationParametersThrows(): 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 GetCollection()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withName('api_dummies_get_collection') + ->withUriTemplate('/dummies') + ->withOpenapi(new Operation(parameters: [new QueryParameter(key: 'bar')])), + ]))->withClass(Dummy::class)]); + + $resourceCollectionMetadataFactory + ->method('create') + ->willReturnCallback(static 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']] + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(Parameter::class); + + $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/Model/ParameterTest.php b/Tests/Model/ParameterTest.php new file mode 100644 index 0000000..e3e3218 --- /dev/null +++ b/Tests/Model/ParameterTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Tests\Model; + +use ApiPlatform\OpenApi\Model\Parameter; +use PHPUnit\Framework\TestCase; + +class ParameterTest extends TestCase +{ + public function testExplodeDefaultsTrueForFormStyle(): void + { + $parameter = new Parameter('test', 'query'); + $this->assertSame('form', $parameter->getStyle()); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsTrueForCookieStyle(): void + { + $parameter = new Parameter('test', 'cookie'); + $this->assertSame('form', $parameter->getStyle()); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsFalseForPathStyle(): void + { + $parameter = new Parameter('test', 'path'); + $this->assertSame('simple', $parameter->getStyle()); + $this->assertFalse($parameter->getExplode()); + } + + public function testExplodeDefaultsFalseForHeaderStyle(): void + { + $parameter = new Parameter('test', 'header'); + $this->assertSame('simple', $parameter->getStyle()); + $this->assertFalse($parameter->getExplode()); + } + + public function testExplicitExplodeFalseOverridesDefault(): void + { + $parameter = new Parameter('test', 'query', explode: false); + $this->assertSame('form', $parameter->getStyle()); + $this->assertFalse($parameter->getExplode()); + } + + public function testExplicitExplodeTrueOnSimpleStyle(): void + { + $parameter = new Parameter('test', 'path', explode: true); + $this->assertSame('simple', $parameter->getStyle()); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsTrueForExplicitFormStyle(): void + { + $parameter = new Parameter('test', 'path', style: 'form'); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsFalseForExplicitDeepObjectStyle(): void + { + $parameter = new Parameter('test', 'query', style: 'deepObject'); + $this->assertFalse($parameter->getExplode()); + } +} 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/LegacyOpenApiNormalizerTest.php b/Tests/Serializer/LegacyOpenApiNormalizerTest.php new file mode 100644 index 0000000..26899b3 --- /dev/null +++ b/Tests/Serializer/LegacyOpenApiNormalizerTest.php @@ -0,0 +1,356 @@ + + * + * 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\Serializer; + +use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class LegacyOpenApiNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testReturnsUntouchedWhenSpecVersionIsNot30(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($document, $normalizer->normalize(new \stdClass(), null, [])); + } + + public function testConvertsNullableScalarUsingNullableFlag(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'nullable' => true], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testKeepsItemsWhenArrayTypeIsNullable(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => ['array', 'null'], + 'items' => ['type' => 'string'], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'nullable' => true, + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoNestedProperties(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'TestResource' => [ + 'properties' => [ + 'testEmbeddable' => [ + 'type' => ['object', 'null'], + 'properties' => [ + 'testArrayOrNull' => [ + 'type' => ['array', 'null'], + 'items' => ['type' => 'string'], + ], + ], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'TestResource' => [ + 'properties' => [ + 'testEmbeddable' => [ + 'type' => 'object', + 'properties' => [ + 'testArrayOrNull' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'nullable' => true, + ], + ], + 'nullable' => true, + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoItems(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => ['string', 'null']], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'nullable' => true], + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoAllOfOneOfAnyOf(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'a' => ['allOf' => [['type' => ['string', 'null']]]], + 'b' => ['oneOf' => [['type' => ['integer', 'null']]]], + 'c' => ['anyOf' => [['type' => ['boolean', 'null']]]], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'a' => ['allOf' => [['type' => 'string', 'nullable' => true]]], + 'b' => ['oneOf' => [['type' => 'integer', 'nullable' => true]]], + 'c' => ['anyOf' => [['type' => 'boolean', 'nullable' => true]]], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoAdditionalProperties(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => ['string', 'null']], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string', 'nullable' => true], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testFallsBackToAnyOfForMultipleNonNullTypes(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'mixed' => ['type' => ['string', 'integer', 'null']], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + $result = $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0']); + + $property = $result['components']['schemas']['Dummy']['properties']['mixed']; + $this->assertArrayNotHasKey('type', $property); + $this->assertSame([ + ['type' => 'string'], + ['type' => 'integer'], + ], $property['anyOf']); + $this->assertTrue($property['nullable']); + } + + public function testConvertsExamplesToExampleRecursively(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'examples' => ['Alice', 'Bob']], + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'inner' => ['type' => 'string', 'examples' => ['x']], + ], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'example' => ['Alice', 'Bob']], + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'inner' => ['type' => 'string', 'example' => ['x']], + ], + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testWalksPathOperationSchemas(): void + { + $document = [ + 'openapi' => '3.1.0', + 'paths' => [ + '/dummies' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ], + ], + ], + ], + ], + ], + 'components' => ['schemas' => []], + ]; + + $normalizer = $this->buildNormalizer($document); + $result = $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0']); + + $schema = $result['paths']['/dummies']['post']['requestBody']['content']['application/json']['schema']; + $this->assertSame('object', $schema['type']); + $this->assertSame(['type' => 'string', 'nullable' => true], $schema['properties']['name']); + } + + private function buildNormalizer(array $document): LegacyOpenApiNormalizer + { + $decorated = $this->prophesize(NormalizerInterface::class); + $decorated->normalize(\Prophecy\Argument::any(), \Prophecy\Argument::any(), \Prophecy\Argument::any())->willReturn($document); + + return new LegacyOpenApiNormalizer($decorated->reveal()); + } +} diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 9a5c924..efe1f25 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; @@ -37,22 +37,27 @@ use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\Paths; +use ApiPlatform\OpenApi\Model\Reference; use ApiPlatform\OpenApi\Model\Schema; use ApiPlatform\OpenApi\Model\Server; 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\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Component\TypeInfo\Type; class OpenApiNormalizerTest extends TestCase { @@ -94,13 +99,40 @@ public function testNormalizeWithEmptySchemas(): void $this->assertCount(0, $array['components']['schemas']); } + public function testNormalizeReferenceUsesDollarRef(): void + { + $openApi = new OpenApi( + new Info('My API', '1.0.0', 'An amazing API'), + [new Server('https://example.com')], + new Paths(), + new Components(responses: new \ArrayObject([ + '401' => new Reference(ref: '#/components/responses/401'), + ])) + ); + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory))]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $array = $normalizer->normalize($openApi); + + $this->assertArrayHasKey('$ref', $array['components']['responses']['401']); + $this->assertSame('#/components/responses/401', $array['components']['responses']['401']['$ref']); + $this->assertArrayNotHasKey('ref', $array['components']['responses']['401']); + } + public function testNormalize(): void { $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, 'Zorro'])); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, \stdClass::class])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); - $propertyNameCollectionFactoryProphecy->create('Zorro', Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); + $propertyNameCollectionFactoryProphecy->create(\stdClass::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) @@ -126,7 +158,7 @@ public function testNormalize(): void $zorroBaseOperation = (new HttpOperation()) ->withTypes(['http://schema.example.com/Zorro']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) - ->withClass('Zorro') + ->withClass(\stdClass::class) ->withShortName('Zorro') ->withDescription('This is zorro.'); @@ -141,20 +173,23 @@ public function testNormalize(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); - $resourceCollectionMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); + $resourceCollectionMetadataFactoryProphecy->create(\stdClass::class)->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( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withNativeType(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(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is a name.') ->withReadable(true) ->withWritable(true) @@ -162,11 +197,11 @@ 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()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an initializable but not writable property.') ->withReadable(true) ->withWritable(false) @@ -174,10 +209,11 @@ 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()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withNativeType(Type::nullable(Type::object(\DateTime::class))) ->withDescription('This is a \DateTimeInterface object.') ->withReadable(true) ->withWritable(true) @@ -185,15 +221,17 @@ 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( + $propertyMetadataFactoryProphecy->create(\stdClass::class, 'id', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withNativeType(Type::int()) ->withDescription('This is an id.') ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -201,9 +239,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(null); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + definitionNameFactory: $definitionNameFactory, + ); $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), @@ -211,7 +254,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..11ea83c 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,24 @@ } ], "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.3", + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1" + "phpspec/prophecy-phpunit": "^2.2", + "phpunit/phpunit": "^11.5 || ^12.2", + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/serializer": "^4.3.12", + "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { "psr-4": { @@ -60,24 +66,22 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "4.4.x-dev", + "dev-4.2": "4.2.x-dev", + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.1" - } - }, - "repositories": [ - { - "type": "path", - "url": "../Metadata" - }, - { - "type": "path", - "url": "../JsonSchema" + "require": "^6.4 || ^7.0 || ^8.0" }, - { - "type": "path", - "url": "../State" + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } - ] + }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, + "minimum-stability": "beta", + "prefer-stable": true } 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 + + -