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..1612d6e 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,19 @@
use ApiPlatform\OpenApi\Model\Response;
use ApiPlatform\OpenApi\Model\SecurityScheme;
use ApiPlatform\OpenApi\Model\Server;
+use ApiPlatform\OpenApi\Model\Tag;
use ApiPlatform\OpenApi\OpenApi;
use ApiPlatform\OpenApi\Options;
use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait;
+use ApiPlatform\State\ApiResource\Error as ApiResourceError;
use ApiPlatform\State\Pagination\PaginationOptions;
+use ApiPlatform\State\Util\StateOptionsTrait;
+use ApiPlatform\Validator\Exception\ValidationException;
use Psr\Container\ContainerInterface;
-use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Routing\RouteCollection;
use Symfony\Component\Routing\RouterInterface;
+use Symfony\Component\TypeInfo\Type;
+use Symfony\Component\TypeInfo\TypeIdentifier;
/**
* Generates an Open API v3 specification.
@@ -57,20 +69,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 +107,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 +138,8 @@ public function __invoke(array $context = []): OpenApi
$securityRequirements[] = [$key => []];
}
+ $globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: [];
+
return new OpenApi(
$info,
$servers,
@@ -117,27 +153,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 +214,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']));
- }
-
- // TODO Remove in 4.0
- if (null !== ($operation->getOpenapiContext()['callbacks'] ?? null)) {
- trigger_deprecation(
- 'api-platform/core',
- '3.1',
- 'The "openapiContext" option is deprecated, use "openapi" instead.'
- );
- $openapiOperation = $openapiOperation->withCallbacks(new \ArrayObject($operation->getOpenapiContext()['callbacks']));
+ foreach ($openapiOperation->getTags() as $v) {
+ $tags[$v] = new Tag(name: $v, description: $resource->getDescription() ?? "Resource '$v' operations.");
}
- // 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 +271,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 +331,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;
+ }
+
+ 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;
+ }
+
+ $openapiParameters[] = $parameter;
}
- $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation);
- break;
- case HttpOperation::METHOD_DELETE:
- $successStatus = (string) $operation->getStatus() ?: 204;
+ continue;
+ }
- $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation);
+ $parameter = $this->mergeParameter($defaultParameter, $linkParameter);
+ if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) {
+ $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter);
+ continue;
+ }
+ $openapiParameters[] = $parameter;
+ }
- break;
+ $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 (!$operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $operation->getMethod()) {
- if (!isset($existingResponses[404])) {
- $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found'));
+ 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 +569,11 @@ private function getMimeTypes(HttpOperation $operation): array
return [$requestMimeTypes, $responseMimeTypes];
}
+ /**
+ * @param array $responseFormats
+ *
+ * @return array
+ */
private function flattenMimeTypes(array $responseFormats): array
{
$responseMimeTypes = [];
@@ -481,35 +626,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 +665,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 +675,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 +697,128 @@ 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);
}
+ }
- 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']);
+ return $parameters;
+ }
- $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
- );
+ /**
+ * @param array $description
+ */
+ private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter
+ {
+ if (isset($description['swagger'])) {
+ trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter, $shortName));
+ }
+
+ if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) {
+ $schema = $description['schema'] ?? [];
+
+ if (isset($description['type']) && \in_array($description['type'], 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);
+ }
+
+ if (!isset($schema['type'])) {
+ $schema['type'] = 'string';
+ }
+
+ $arrayValueType = TypeIdentifier::ARRAY->value;
+ $objectValueType = TypeIdentifier::OBJECT->value;
+
+ $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);
+ }
+
+ 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 (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'];
}
}
- return $parameters;
+ $arrayValueType = TypeIdentifier::ARRAY->value;
+ $objectValueType = TypeIdentifier::OBJECT->value;
+
+ $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 +831,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 +865,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 +902,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 +925,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..d538624
--- /dev/null
+++ b/Factory/TypeFactoryTrait.php
@@ -0,0 +1,160 @@
+
+ *
+ * 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\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(NativeType $type): array
+ {
+ return $this->getNativeType($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 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, 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..2ecec74 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
@@ -30,8 +30,9 @@ final class Components
* @param \ArrayObject|\ArrayObject $links
* @param \ArrayObject>|\ArrayObject> $callbacks
* @param \ArrayObject|\ArrayObject $pathItems
+ * @param \ArrayObject|\ArrayObject $mediaTypes
*/
- 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, private ?\ArrayObject $mediaTypes = null)
{
$schemas?->ksort();
@@ -88,6 +89,11 @@ public function getPathItems(): ?\ArrayObject
return $this->pathItems;
}
+ public function getMediaTypes(): ?\ArrayObject
+ {
+ return $this->mediaTypes;
+ }
+
public function withSchemas(\ArrayObject $schemas): self
{
$clone = clone $this;
@@ -167,4 +173,12 @@ public function withPathItems(\ArrayObject $pathItems): self
return $clone;
}
+
+ public function withMediaTypes(\ArrayObject $mediaTypes): self
+ {
+ $clone = clone $this;
+ $clone->mediaTypes = $mediaTypes;
+
+ return $clone;
+ }
}
diff --git a/Model/Encoding.php b/Model/Encoding.php
index d56ee0e..4ec8f7d 100644
--- a/Model/Encoding.php
+++ b/Model/Encoding.php
@@ -17,7 +17,10 @@ final class Encoding
{
use ExtensionTrait;
- public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false)
+ /**
+ * @param array|null $prefixEncoding
+ */
+ public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false, private ?\ArrayObject $encoding = null, private ?array $prefixEncoding = null, private ?self $itemEncoding = null)
{
}
@@ -56,6 +59,24 @@ public function getAllowReserved(): bool
return $this->allowReserved;
}
+ public function getEncoding(): ?\ArrayObject
+ {
+ return $this->encoding;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getPrefixEncoding(): ?array
+ {
+ return $this->prefixEncoding;
+ }
+
+ public function getItemEncoding(): ?self
+ {
+ return $this->itemEncoding;
+ }
+
public function withContentType(string $contentType): self
{
$clone = clone $this;
@@ -95,4 +116,31 @@ public function withAllowReserved(bool $allowReserved): self
return $clone;
}
+
+ public function withEncoding(?\ArrayObject $encoding): self
+ {
+ $clone = clone $this;
+ $clone->encoding = $encoding;
+
+ return $clone;
+ }
+
+ /**
+ * @param array|null $prefixEncoding
+ */
+ public function withPrefixEncoding(?array $prefixEncoding): self
+ {
+ $clone = clone $this;
+ $clone->prefixEncoding = $prefixEncoding;
+
+ return $clone;
+ }
+
+ public function withItemEncoding(self $itemEncoding): self
+ {
+ $clone = clone $this;
+ $clone->itemEncoding = $itemEncoding;
+
+ return $clone;
+ }
}
diff --git a/Model/Example.php b/Model/Example.php
index 4b2f190..583820d 100644
--- a/Model/Example.php
+++ b/Model/Example.php
@@ -17,7 +17,7 @@ final class Example
{
use ExtensionTrait;
- public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null)
+ public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null, private mixed $dataValue = null, private ?string $serializedValue = null)
{
}
@@ -72,4 +72,30 @@ public function withExternalValue(string $externalValue): self
return $clone;
}
+
+ public function getDataValue(): mixed
+ {
+ return $this->dataValue;
+ }
+
+ public function withDataValue(mixed $dataValue): self
+ {
+ $clone = clone $this;
+ $clone->dataValue = $dataValue;
+
+ return $clone;
+ }
+
+ public function getSerializedValue(): ?string
+ {
+ return $this->serializedValue;
+ }
+
+ public function withSerializedValue(string $serializedValue): self
+ {
+ $clone = clone $this;
+ $clone->serializedValue = $serializedValue;
+
+ return $clone;
+ }
}
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..10d9c1d 100644
--- a/Model/MediaType.php
+++ b/Model/MediaType.php
@@ -17,7 +17,10 @@ final class MediaType
{
use ExtensionTrait;
- public function __construct(private ?\ArrayObject $schema = null, private $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null)
+ /**
+ * @param array|null $prefixEncoding
+ */
+ public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null, private ?\ArrayObject $itemSchema = null, private ?array $prefixEncoding = null, private ?Encoding $itemEncoding = null)
{
}
@@ -26,7 +29,7 @@ public function getSchema(): ?\ArrayObject
return $this->schema;
}
- public function getExample()
+ public function getExample(): mixed
{
return $this->example;
}
@@ -41,6 +44,24 @@ public function getEncoding(): ?Encoding
return $this->encoding;
}
+ public function getItemSchema(): ?\ArrayObject
+ {
+ return $this->itemSchema;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getPrefixEncoding(): ?array
+ {
+ return $this->prefixEncoding;
+ }
+
+ public function getItemEncoding(): ?Encoding
+ {
+ return $this->itemEncoding;
+ }
+
public function withSchema(\ArrayObject $schema): self
{
$clone = clone $this;
@@ -49,7 +70,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;
@@ -72,4 +93,31 @@ public function withEncoding(Encoding $encoding): self
return $clone;
}
+
+ public function withItemSchema(\ArrayObject $itemSchema): self
+ {
+ $clone = clone $this;
+ $clone->itemSchema = $itemSchema;
+
+ return $clone;
+ }
+
+ /**
+ * @param array|null $prefixEncoding
+ */
+ public function withPrefixEncoding(?array $prefixEncoding): self
+ {
+ $clone = clone $this;
+ $clone->prefixEncoding = $prefixEncoding;
+
+ return $clone;
+ }
+
+ public function withItemEncoding(Encoding $itemEncoding): self
+ {
+ $clone = clone $this;
+ $clone->itemEncoding = $itemEncoding;
+
+ return $clone;
+ }
}
diff --git a/Model/OAuthFlow.php b/Model/OAuthFlow.php
index 2c2e356..479d185 100644
--- a/Model/OAuthFlow.php
+++ b/Model/OAuthFlow.php
@@ -17,7 +17,7 @@ final class OAuthFlow
{
use ExtensionTrait;
- public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null)
+ public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null, private ?string $deviceAuthorizationUrl = null)
{
}
@@ -41,6 +41,11 @@ public function getScopes(): \ArrayObject
return $this->scopes;
}
+ public function getDeviceAuthorizationUrl(): ?string
+ {
+ return $this->deviceAuthorizationUrl;
+ }
+
public function withAuthorizationUrl(string $authorizationUrl): self
{
$clone = clone $this;
@@ -72,4 +77,12 @@ public function withScopes(\ArrayObject $scopes): self
return $clone;
}
+
+ public function withDeviceAuthorizationUrl(string $deviceAuthorizationUrl): self
+ {
+ $clone = clone $this;
+ $clone->deviceAuthorizationUrl = $deviceAuthorizationUrl;
+
+ return $clone;
+ }
}
diff --git a/Model/OAuthFlows.php b/Model/OAuthFlows.php
index ad0f9fb..d677d10 100644
--- a/Model/OAuthFlows.php
+++ b/Model/OAuthFlows.php
@@ -17,7 +17,7 @@ final class OAuthFlows
{
use ExtensionTrait;
- public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null)
+ public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null, private ?OAuthFlow $deviceAuthorization = null)
{
}
@@ -41,6 +41,11 @@ public function getAuthorizationCode(): ?OAuthFlow
return $this->authorizationCode;
}
+ public function getDeviceAuthorization(): ?OAuthFlow
+ {
+ return $this->deviceAuthorization;
+ }
+
public function withImplicit(OAuthFlow $implicit): self
{
$clone = clone $this;
@@ -72,4 +77,12 @@ public function withAuthorizationCode(OAuthFlow $authorizationCode): self
return $clone;
}
+
+ public function withDeviceAuthorization(OAuthFlow $deviceAuthorization): self
+ {
+ $clone = clone $this;
+ $clone->deviceAuthorization = $deviceAuthorization;
+
+ return $clone;
+ }
}
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..8ff59cb 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, private ?Operation $query = null, private ?array $additionalOperations = null)
{
}
@@ -83,11 +83,24 @@ public function getServers(): ?array
return $this->servers;
}
- public function getParameters(): array
+ public function getParameters(): ?array
{
return $this->parameters;
}
+ public function getQuery(): ?Operation
+ {
+ return $this->query;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function getAdditionalOperations(): ?array
+ {
+ return $this->additionalOperations;
+ }
+
public function withRef(string $ref): self
{
$clone = clone $this;
@@ -176,7 +189,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,11 +197,30 @@ 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;
return $clone;
}
+
+ public function withQuery(?Operation $query): self
+ {
+ $clone = clone $this;
+ $clone->query = $query;
+
+ return $clone;
+ }
+
+ /**
+ * @param array|null $additionalOperations
+ */
+ public function withAdditionalOperations(?array $additionalOperations = null): self
+ {
+ $clone = clone $this;
+ $clone->additionalOperations = $additionalOperations;
+
+ return $clone;
+ }
}
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..b417b9d 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, private ?string $summary = null)
{
}
- public function getDescription(): string
+ public function getDescription(): ?string
{
return $this->description;
}
@@ -41,6 +41,11 @@ public function getLinks(): ?\ArrayObject
return $this->links;
}
+ public function getSummary(): ?string
+ {
+ return $this->summary;
+ }
+
public function withDescription(string $description): self
{
$clone = clone $this;
@@ -72,4 +77,12 @@ public function withLinks(\ArrayObject $links): self
return $clone;
}
+
+ public function withSummary(string $summary): self
+ {
+ $clone = clone $this;
+ $clone->summary = $summary;
+
+ return $clone;
+ }
}
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/SecurityScheme.php b/Model/SecurityScheme.php
index 52ed63f..b2ab6ed 100644
--- a/Model/SecurityScheme.php
+++ b/Model/SecurityScheme.php
@@ -17,7 +17,7 @@ final class SecurityScheme
{
use ExtensionTrait;
- public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null)
+ public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null, private ?string $oauth2MetadataUrl = null, private ?bool $deprecated = null)
{
}
@@ -61,6 +61,16 @@ public function getOpenIdConnectUrl(): ?string
return $this->openIdConnectUrl;
}
+ public function getOauth2MetadataUrl(): ?string
+ {
+ return $this->oauth2MetadataUrl;
+ }
+
+ public function getDeprecated(): ?bool
+ {
+ return $this->deprecated;
+ }
+
public function withType(string $type): self
{
$clone = clone $this;
@@ -124,4 +134,20 @@ public function withOpenIdConnectUrl(string $openIdConnectUrl): self
return $clone;
}
+
+ public function withOauth2MetadataUrl(string $oauth2MetadataUrl): self
+ {
+ $clone = clone $this;
+ $clone->oauth2MetadataUrl = $oauth2MetadataUrl;
+
+ return $clone;
+ }
+
+ public function withDeprecated(bool $deprecated): self
+ {
+ $clone = clone $this;
+ $clone->deprecated = $deprecated;
+
+ return $clone;
+ }
}
diff --git a/Model/Server.php b/Model/Server.php
index e5a50a7..8b5d9ed 100644
--- a/Model/Server.php
+++ b/Model/Server.php
@@ -17,7 +17,7 @@ final class Server
{
use ExtensionTrait;
- public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null)
+ public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null, private ?string $name = null)
{
}
@@ -36,6 +36,11 @@ public function getVariables(): ?\ArrayObject
return $this->variables;
}
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
public function withUrl(string $url): self
{
$clone = clone $this;
@@ -59,4 +64,12 @@ public function withVariables(\ArrayObject $variables): self
return $clone;
}
+
+ public function withName(string $name): self
+ {
+ $clone = clone $this;
+ $clone->name = $name;
+
+ return $clone;
+ }
}
diff --git a/Model/Tag.php b/Model/Tag.php
new file mode 100644
index 0000000..82e8e70
--- /dev/null
+++ b/Model/Tag.php
@@ -0,0 +1,101 @@
+
+ *
+ * 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, private ?string $summary = null, private ?string $parent = null, private ?string $kind = 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;
+ }
+
+ public function getSummary(): ?string
+ {
+ return $this->summary;
+ }
+
+ public function withSummary(string $summary): self
+ {
+ $clone = clone $this;
+ $clone->summary = $summary;
+
+ return $clone;
+ }
+
+ public function getParent(): ?string
+ {
+ return $this->parent;
+ }
+
+ public function withParent(string $parent): self
+ {
+ $clone = clone $this;
+ $clone->parent = $parent;
+
+ return $clone;
+ }
+
+ public function getKind(): ?string
+ {
+ return $this->kind;
+ }
+
+ public function withKind(string $kind): self
+ {
+ $clone = clone $this;
+ $clone->kind = $kind;
+
+ return $clone;
+ }
+}
diff --git a/OpenApi.php b/OpenApi.php
index 8a95e18..ac17632 100644
--- a/OpenApi.php
+++ b/OpenApi.php
@@ -17,19 +17,23 @@
use ApiPlatform\OpenApi\Model\ExtensionTrait;
use ApiPlatform\OpenApi\Model\Info;
use ApiPlatform\OpenApi\Model\Paths;
+use Symfony\Component\Serializer\Attribute\SerializedName;
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.2.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, private ?string $self = null)
{
+ $this->components = $components ?? new Components();
}
public function getOpenapi(): string
@@ -82,6 +86,12 @@ public function getWebhooks(): ?\ArrayObject
return $this->webhooks;
}
+ #[SerializedName('$self')]
+ public function getSelf(): ?string
+ {
+ return $this->self;
+ }
+
public function withOpenapi(string $openapi): self
{
$clone = clone $this;
@@ -153,4 +163,12 @@ public function withJsonSchemaDialect(?string $jsonSchemaDialect): self
return $clone;
}
+
+ public function withSelf(?string $self): self
+ {
+ $clone = clone $this;
+ $clone->self = $self;
+
+ return $clone;
+ }
}
diff --git a/Options.php b/Options.php
index c43a976..a22904b 100644
--- a/Options.php
+++ b/Options.php
@@ -13,10 +13,42 @@
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,
+ private bool $withCredentials = false,
+ ) {
}
public function getTitle(): string
@@ -74,6 +106,11 @@ public function getApiKeys(): array
return $this->apiKeys;
}
+ public function getHttpAuth(): array
+ {
+ return $this->httpAuth;
+ }
+
public function getContactName(): ?string
{
return $this->contactName;
@@ -103,4 +140,48 @@ 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;
+ }
+
+ public function getWithCredentials(): bool
+ {
+ return $this->withCredentials;
+ }
}
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..e978743
--- /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.2.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..c556998 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..4e5000e 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();
+
+ $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..da97fb5 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": "^5.0@alpha",
+ "api-platform/metadata": "^5.0@alpha",
+ "api-platform/state": "^5.0@alpha",
+ "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": "^5.0@alpha",
+ "api-platform/doctrine-orm": "^5.0@alpha",
+ "api-platform/doctrine-odm": "^5.0@alpha",
+ "api-platform/serializer": "^5.0@alpha",
+ "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": "5.0.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
+
+
-