From 7db3063b2e9014f267db6f444cc4819c7021d479 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 23 Mar 2023 11:41:58 +0100 Subject: [PATCH 01/90] fix(openapi): use 3.1 version (#5489) --- OpenApi.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/OpenApi.php b/OpenApi.php index 8a95e18..abd2c01 100644 --- a/OpenApi.php +++ b/OpenApi.php @@ -22,9 +22,7 @@ final class OpenApi { use ExtensionTrait; - // We're actually supporting 3.1 but swagger ui has a version constraint - // public const VERSION = '3.1.0'; - public const VERSION = '3.0.0'; + public const VERSION = '3.1.0'; private string $openapi = self::VERSION; From 1b86f487c3107e49b261176686ace3e46a67150f Mon Sep 17 00:00:00 2001 From: helyakin Date: Fri, 21 Apr 2023 10:15:43 +0200 Subject: [PATCH 02/90] refactor(metadata): replace HttpOperation constants by raw strings (#5494) updateing metadata --- Factory/OpenApiFactory.php | 24 ++++++++++++------------ Tests/Factory/OpenApiFactoryTest.php | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index f1457cd..5b7a3ca 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -150,7 +150,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $path = $this->getPath($path); - $method = $operation->getMethod() ?? HttpOperation::METHOD_GET; + $method = $operation->getMethod() ?? 'GET'; if (!\in_array($method, PathItem::$methods, true)) { continue; @@ -267,7 +267,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiOperation->withParameter($parameter); } - if ($operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $method) { + if ($operation instanceof CollectionOperationInterface && 'POST' !== $method) { foreach (array_merge($this->getPaginationParameters($operation), $this->getFiltersParameters($operation)) as $parameter) { if ($this->hasParameter($openapiOperation, $parameter)) { continue; @@ -280,11 +280,11 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation?->getResponses() ?: []; // Create responses switch ($method) { - case HttpOperation::METHOD_GET: + 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 HttpOperation::METHOD_POST: + case 'POST': $successStatus = (string) $operation->getStatus() ?: 201; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); @@ -293,8 +293,8 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); break; - case HttpOperation::METHOD_PATCH: - case HttpOperation::METHOD_PUT: + case 'PATCH': + case '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); @@ -303,7 +303,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); break; - case HttpOperation::METHOD_DELETE: + case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); @@ -311,7 +311,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection break; } - if (!$operation instanceof CollectionOperationInterface && HttpOperation::METHOD_POST !== $operation->getMethod()) { + if (!$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { if (!isset($existingResponses[404])) { $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); } @@ -341,7 +341,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection '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)) { + } elseif (null === $openapiOperation->getRequestBody() && \in_array($method, ['PATCH', 'PUT', 'POST'], true)) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); @@ -349,7 +349,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', HttpOperation::METHOD_POST === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); } // TODO Remove in 4.0 @@ -492,12 +492,12 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operationName => $operation) { $parameters = []; - $method = $operation instanceof HttpOperation ? $operation->getMethod() : HttpOperation::METHOD_GET; + $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if ( $operationName === $operation->getName() || isset($links[$operationName]) || $operation instanceof CollectionOperationInterface || - HttpOperation::METHOD_GET !== $method + 'GET' !== $method ) { continue; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index b4c0b96..2ea1637 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -85,7 +85,7 @@ public function testInvoke(): void '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( + '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( From 35e70de934ba14d167b8abd9867f6383640fd3e9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 23 May 2023 22:43:31 +0200 Subject: [PATCH 03/90] style: symfony rules has use_nullable_type_declaration to false --- Factory/OpenApiFactory.php | 8 ++++---- Model/Operation.php | 6 +++--- Model/PathItem.php | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 61e1ed9..1276ef2 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -499,10 +499,10 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection $parameters = []; $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; if ( - $operationName === $operation->getName() || - isset($links[$operationName]) || - $operation instanceof CollectionOperationInterface || - 'GET' !== $method + $operationName === $operation->getName() + || isset($links[$operationName]) + || $operation instanceof CollectionOperationInterface + || 'GET' !== $method ) { continue; } diff --git a/Model/Operation.php b/Model/Operation.php index db2aac8..22de521 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -167,7 +167,7 @@ public function withParameter(Parameter $parameter): self return $clone; } - public function withRequestBody(?RequestBody $requestBody = null): self + public function withRequestBody(RequestBody $requestBody = null): self { $clone = clone $this; $clone->requestBody = $requestBody; @@ -191,7 +191,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withSecurity(?array $security = null): self + public function withSecurity(array $security = null): self { $clone = clone $this; $clone->security = $security; @@ -199,7 +199,7 @@ public function withSecurity(?array $security = null): self return $clone; } - public function withServers(?array $servers = null): self + public function withServers(array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/Model/PathItem.php b/Model/PathItem.php index 42a6d88..5a101bc 100644 --- a/Model/PathItem.php +++ b/Model/PathItem.php @@ -176,7 +176,7 @@ public function withTrace(Operation $trace): self return $clone; } - public function withServers(?array $servers = null): self + public function withServers(array $servers = null): self { $clone = clone $this; $clone->servers = $servers; From 0a76c0811ce4f20be0b52e6df903af45567ada94 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Thu, 6 Jul 2023 10:39:36 +0200 Subject: [PATCH 04/90] feat: union/intersect types (#5470) * fix(metadata): handle union/intersect types * review * try to move SchemaFactory onto SchemaPropertyMetadataFactory * complete property schema on SchemaFactory * Apply suggestions from code review Co-authored-by: Antoine Bluchet * fix: review * fix: cs * fix: phpunit * fix: cs * fix: tests about maker * fix: JsonSchema::SchemaFactory * fix: behat tests * fix deprec * tests --------- Co-authored-by: Antoine Bluchet --- Tests/Factory/OpenApiFactoryTest.php | 108 ++++++++++++++++++--- Tests/Serializer/OpenApiNormalizerTest.php | 9 +- 2 files changed, 102 insertions(+), 15 deletions(-) diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 2ea1637..8d4c5df 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -254,34 +254,116 @@ public function testInvoke(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'readOnly' => true, 'description' => 'This is an id.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$', 'description' => 'This is a name.', 'type' => 'string']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => ['string', 'null'], 'description' => 'This is a \DateTimeInterface object.', 'format' => 'date-time']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'id', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)])->withDescription('This is an id.')->withReadable(true)->withWritable(false)->withIdentifier(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an id.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'name', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is a name.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withRequired(false)->withIdentifier(false)->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is a name.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withRequired(false) + ->withIdentifier(false) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'description', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an initializable but not writable property.')->withReadable(true)->withWritable(false)->withReadableLink(true)->withWritableLink(true)->withInitializable(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an initializable but not writable property.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)])->withDescription('This is a \DateTimeInterface object.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withDescription('This is a \DateTimeInterface object.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => ['string', 'null'], 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty())->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)])->withDescription('This is an enum.')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an enum.') + ->withReadable(true) + ->withWritable(true) + ->withReadableLink(true) + ->withWritableLink(true) + ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) + ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -329,8 +411,9 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + $schemaFactory = new SchemaFactory(null, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); $factory = new OpenApiFactory( @@ -379,10 +462,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', diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index dfe676b..2787fd8 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -150,6 +150,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -161,7 +162,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) - ->withSchema(['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) + ->withSchema(['type' => 'string', 'description' => 'This is a name.', 'minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -173,6 +174,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'readOnly' => true, 'description' => 'This is an initializable but not writable property.']) ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) @@ -184,6 +186,7 @@ public function testNormalize(): void ->withWritableLink(true) ->withRequired(false) ->withIdentifier(false) + ->withSchema(['type' => 'string', 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( @@ -193,6 +196,7 @@ public function testNormalize(): void ->withReadable(true) ->withWritable(false) ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true]) ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -200,8 +204,9 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + $schemaFactory = new SchemaFactory(null, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory = new TypeFactory(); - $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $typeFactory->setSchemaFactory($schemaFactory); $factory = new OpenApiFactory( From 5af5d29d726dc8626b8ed3bf2e320516627a6062 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 2 Sep 2023 09:26:22 +0200 Subject: [PATCH 05/90] refactor: use provider/processor instead of event listeners (#5657) * refactor: use provider/processor instead of event listeners * tests --- Serializer/OpenApiNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index 83ebf63..8eb6523 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -37,7 +37,7 @@ public function __construct(private readonly NormalizerInterface $decorated) */ public function normalize(mixed $object, string $format = null, array $context = []): array { - $pathsCallback = static fn ($innerObject): array => $innerObject instanceof Paths ? $innerObject->getPaths() : []; + $pathsCallback = static fn ($decoratedObject): array => $decoratedObject instanceof Paths ? $decoratedObject->getPaths() : []; $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; $context[AbstractNormalizer::CALLBACKS] = [ From b48e6ca029be14a44a3b7d180c90904a647e8a78 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 8 Sep 2023 15:37:59 +0200 Subject: [PATCH 06/90] feat(doctrine): stateOptions can handleLinks for query optimization (#5732) --- Factory/OpenApiFactory.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index bb5117d..d6014da 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\OpenApi\Factory; +use ApiPlatform\Doctrine\Odm\State\Options as DoctrineODMOptions; use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; @@ -561,8 +562,14 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation $filter = $this->filterLocator->get($filterId); $entityClass = $operation->getClass(); - if (($options = $operation->getStateOptions()) && $options instanceof DoctrineOptions && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); + if ($options = $operation->getStateOptions()) { + if ($options instanceof DoctrineOptions && $options->getEntityClass()) { + $entityClass = $options->getEntityClass(); + } + + if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { + $entityClass = $options->getDocumentClass(); + } } foreach ($filter->getDescription($entityClass) as $name => $data) { From 07914edaf94ddb8acf2cb41d8f1969c5305e0996 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 11 Sep 2023 12:27:22 +0200 Subject: [PATCH 07/90] feat: deprecate not setting formats manually (#5808) introduces documentation formats Co-authored-by: Sarahshr --- Serializer/OpenApiNormalizer.php | 6 ++++-- Tests/Serializer/ApiGatewayNormalizerTest.php | 12 +++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index 8eb6523..a11fcb6 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -26,6 +26,8 @@ final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface { 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) @@ -72,12 +74,12 @@ private function recursiveClean(array $data): array */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { - return self::FORMAT === $format && $data instanceof OpenApi; + return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) && $data instanceof OpenApi; } public function getSupportedTypes($format): array { - return self::FORMAT === $format ? [OpenApi::class => true] : []; + return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) ? [OpenApi::class => true] : []; } public function hasCacheableSupportsMethod(): bool diff --git a/Tests/Serializer/ApiGatewayNormalizerTest.php b/Tests/Serializer/ApiGatewayNormalizerTest.php index 37dc305..1e07ea4 100644 --- a/Tests/Serializer/ApiGatewayNormalizerTest.php +++ b/Tests/Serializer/ApiGatewayNormalizerTest.php @@ -23,7 +23,6 @@ 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 { @@ -35,19 +34,14 @@ final class ApiGatewayNormalizerTest extends TestCase public function testSupportsNormalization(): void { $normalizerProphecy = $this->prophesize(NormalizerInterface::class); + $normalizerProphecy->willImplement(CacheableSupportsMethodInterface::class); $normalizerProphecy->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)->willReturn(true); - if (!method_exists(Serializer::class, 'getSupportedTypes')) { - $normalizerProphecy->willImplement(CacheableSupportsMethodInterface::class); - $normalizerProphecy->hasCacheableSupportsMethod()->willReturn(true); - } + $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()); - } + $this->assertTrue($normalizer->hasCacheableSupportsMethod()); } public function testNormalize(): void From bd5ca52466fa02bc9ac7846fda82073f87b8b8c3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 23 Oct 2023 10:41:16 +0200 Subject: [PATCH 08/90] fix(jsonschema): restore type factory usage (#5897) fixes #5896 --- Factory/OpenApiFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index d6014da..6f18de7 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -573,7 +573,7 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation } 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']); + $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false), 'openapi') : ['type' => 'string']); $parameters[] = new Parameter( $name, From e7a3e645f9523d558543937af01d7620382a77fe Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 13 Dec 2023 15:17:18 +0100 Subject: [PATCH 09/90] ci: conflict sebastian/comparator (#6032) * ci: conflict sebastian/comparator * for lowest --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index e2cd13f..0b5acea 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,9 @@ "/Tests/" ] }, + "conflict": { + "sebastian/comparator": ">=5.0" + }, "config": { "preferred-install": { "*": "dist" From e8ae3db6ccb6fb55fb69e82a74b2b3e6486855bc Mon Sep 17 00:00:00 2001 From: Romain Herault Date: Wed, 10 Jan 2024 12:03:41 +0100 Subject: [PATCH 10/90] fix(openapi): compatibility with OpenAPI 3.0 (#6065) fixes #5978 Co-authored-by: soyuka --- Command/OpenApiCommand.php | 4 +- Serializer/LegacyOpenApiNormalizer.php | 67 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 Serializer/LegacyOpenApiNormalizer.php diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 88d3c0c..a7d1c43 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -53,7 +53,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); - $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json', [ + 'spec_version' => $input->getOption('spec-version'), + ]); $content = $input->getOption('yaml') ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) : (json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) ?: ''); diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php new file mode 100644 index 0000000..a7d7381 --- /dev/null +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Serializer; + +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class LegacyOpenApiNormalizer implements NormalizerInterface +{ + public const SPEC_VERSION = 'spec_version'; + private array $defaultContext = [ + self::SPEC_VERSION => '3.1.0', + ]; + + public function __construct(private readonly NormalizerInterface $decorated, $defaultContext = []) + { + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + } + + public function normalize(mixed $object, string $format = null, array $context = []): array + { + $openapi = $this->decorated->normalize($object, $format, $context); + + if ('3.0.0' !== ($context['spec_version'] ?? null)) { + return $openapi; + } + + $schemas = &$openapi['components']['schemas']; + $openapi['openapi'] = '3.0.0'; + foreach ($openapi['components']['schemas'] as $name => $component) { + foreach ($component['properties'] ?? [] as $property => $value) { + if (\is_array($value['type'] ?? false)) { + foreach ($value['type'] as $type) { + $schemas[$name]['properties'][$property]['anyOf'][] = ['type' => $type]; + } + unset($schemas[$name]['properties'][$property]['type']); + } + unset($schemas[$name]['properties'][$property]['owl:maxCardinality']); + } + } + + return $openapi; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format, $context); + } + + public function getSupportedTypes($format): array + { + return $this->decorated->getSupportedTypes($format); + } +} From 4d3cf232840a841d11287758ead7c87dcf66ce4e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jan 2024 19:55:30 +0100 Subject: [PATCH 11/90] chore: components dependencies (#6113) * chore: components dependencies * test --- .gitattributes | 2 ++ Tests/Fixtures/OutputDto.php | 2 -- Tests/Serializer/ApiGatewayNormalizerTest.php | 17 ------------- Tests/Serializer/OpenApiNormalizerTest.php | 2 +- composer.json | 25 +++++++++++++++---- 5 files changed, 23 insertions(+), 25 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae3c2e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/.gitignore export-ignore +/Tests export-ignore diff --git a/Tests/Fixtures/OutputDto.php b/Tests/Fixtures/OutputDto.php index 0ca3227..068aaa7 100644 --- a/Tests/Fixtures/OutputDto.php +++ b/Tests/Fixtures/OutputDto.php @@ -13,8 +13,6 @@ namespace ApiPlatform\OpenApi\Tests\Fixtures; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; - /** * @author Kévin Dunglas */ diff --git a/Tests/Serializer/ApiGatewayNormalizerTest.php b/Tests/Serializer/ApiGatewayNormalizerTest.php index 1e07ea4..d52dad9 100644 --- a/Tests/Serializer/ApiGatewayNormalizerTest.php +++ b/Tests/Serializer/ApiGatewayNormalizerTest.php @@ -21,29 +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; final class ApiGatewayNormalizerTest extends TestCase { use ProphecyTrait; - /** - * @group legacy - */ - public function testSupportsNormalization(): void - { - $normalizerProphecy = $this->prophesize(NormalizerInterface::class); - $normalizerProphecy->willImplement(CacheableSupportsMethodInterface::class); - $normalizerProphecy->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)->willReturn(true); - $normalizerProphecy->hasCacheableSupportsMethod()->willReturn(true); - - $normalizer = new ApiGatewayNormalizer($normalizerProphecy->reveal()); - - $this->assertTrue($normalizer->supportsNormalization(OpenApiNormalizer::FORMAT, OpenApi::class)); - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); - } - public function testNormalize(): void { $swaggerDocument = [ diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 2ee4599..632ce4d 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -42,8 +42,8 @@ use ApiPlatform\OpenApi\OpenApi; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\OpenApi\Tests\Fixtures\Dummy; use ApiPlatform\State\Pagination\PaginationOptions; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; diff --git a/composer.json b/composer.json index 04fc5f7..076ae99 100644 --- a/composer.json +++ b/composer.json @@ -31,14 +31,17 @@ "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", + "symfony/console": "^6.4 || ^7.0", + "symfony/property-access": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0", "sebastian/comparator": "<5.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.1" + "symfony/phpunit-bridge": "^6.4 || ^7.0", + "api-platform/doctrine-common": "*@dev || ^3.2", + "api-platform/doctrine-orm": "*@dev || ^3.2", + "api-platform/doctrine-odm": "*@dev || ^3.2" }, "autoload": { "psr-4": { @@ -66,7 +69,7 @@ "dev-main": "3.2.x-dev" }, "symfony": { - "require": "^6.1" + "require": "^6.4" } }, "repositories": [ @@ -81,6 +84,18 @@ { "type": "path", "url": "../State" + }, + { + "type": "path", + "url": "../Doctrine/Orm" + }, + { + "type": "path", + "url": "../Doctrine/Odm" + }, + { + "type": "path", + "url": "../Doctrine/Common" } ] } From b8825485355a01260afe35a9dde090735b14a753 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 31 Jan 2024 18:12:46 +0100 Subject: [PATCH 12/90] fix(hydra): move owl:maxCardinality from JsonSchema to Hydra (#6136) --- Serializer/LegacyOpenApiNormalizer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index a7d7381..16a6b73 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -45,7 +45,6 @@ public function normalize(mixed $object, string $format = null, array $context = } unset($schemas[$name]['properties'][$property]['type']); } - unset($schemas[$name]['properties'][$property]['owl:maxCardinality']); } } From b69a2a696278131b6ca470ab582d9e504d13262a Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Fri, 2 Feb 2024 22:45:18 +0100 Subject: [PATCH 13/90] chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --- Factory/OpenApiFactory.php | 4 ++-- Model/Components.php | 2 +- Model/Operation.php | 6 +++--- Model/PathItem.php | 2 +- Serializer/ApiGatewayNormalizer.php | 4 ++-- Serializer/LegacyOpenApiNormalizer.php | 4 ++-- Serializer/OpenApiNormalizer.php | 4 ++-- Tests/Fixtures/Dummy.php | 4 ++-- 8 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 6f18de7..89548f4 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -70,7 +70,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface */ public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - 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) + 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) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); @@ -388,7 +388,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Model\Operation $openapiOperation = null, HttpOperation $operation = null, array $responseMimeTypes = null, array $operationOutputSchemas = null, ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Model\Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation { if (isset($existingResponses[$status])) { return $openapiOperation; diff --git a/Model/Components.php b/Model/Components.php index 8500575..cc420cf 100644 --- a/Model/Components.php +++ b/Model/Components.php @@ -31,7 +31,7 @@ final class Components * @param \ArrayObject>|\ArrayObject> $callbacks * @param \ArrayObject|\ArrayObject $pathItems */ - public function __construct(\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) + public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) { $schemas?->ksort(); diff --git a/Model/Operation.php b/Model/Operation.php index 22de521..db2aac8 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -167,7 +167,7 @@ public function withParameter(Parameter $parameter): self return $clone; } - public function withRequestBody(RequestBody $requestBody = null): self + public function withRequestBody(?RequestBody $requestBody = null): self { $clone = clone $this; $clone->requestBody = $requestBody; @@ -191,7 +191,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withSecurity(array $security = null): self + public function withSecurity(?array $security = null): self { $clone = clone $this; $clone->security = $security; @@ -199,7 +199,7 @@ public function withSecurity(array $security = null): self return $clone; } - public function withServers(array $servers = null): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/Model/PathItem.php b/Model/PathItem.php index 5a101bc..42a6d88 100644 --- a/Model/PathItem.php +++ b/Model/PathItem.php @@ -176,7 +176,7 @@ public function withTrace(Operation $trace): self return $clone; } - public function withServers(array $servers = null): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index 78b84c6..66e36e4 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -44,7 +44,7 @@ public function __construct(private readonly NormalizerInterface $documentationN * * @throws UnexpectedValueException */ - public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $data = $this->documentationNormalizer->normalize($object, $format, $context); if (!\is_array($data)) { @@ -116,7 +116,7 @@ public function normalize(mixed $object, string $format = null, array $context = /** * {@inheritdoc} */ - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->documentationNormalizer->supportsNormalization($data, $format); } diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index 16a6b73..64d7f16 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -27,7 +27,7 @@ public function __construct(private readonly NormalizerInterface $decorated, $de $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { $openapi = $this->decorated->normalize($object, $format, $context); @@ -54,7 +54,7 @@ public function normalize(mixed $object, string $format = null, array $context = /** * {@inheritdoc} */ - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->decorated->supportsNormalization($data, $format, $context); } diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index a11fcb6..b2d1794 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -37,7 +37,7 @@ public function __construct(private readonly NormalizerInterface $decorated) /** * {@inheritdoc} */ - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { $pathsCallback = static fn ($decoratedObject): array => $decoratedObject instanceof Paths ? $decoratedObject->getPaths() : []; $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; @@ -72,7 +72,7 @@ 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 || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) && $data instanceof OpenApi; } 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; } From 7f10e8cc7db1fd6d453750a0ff800c48ae27f0aa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 3 Feb 2024 13:33:43 +0100 Subject: [PATCH 14/90] Merge 3.2 (#6145) * fix(hydra): move owl:maxCardinality from JsonSchema to Hydra (#6136) * docs: changelog v3.2.13 * cs: missing strict type * docs: guide sf/apip version * test: security configuration * chore: fix CI (#6143) * chore: fix wrong namespace in test document * chore: fix CS nullable_type_declaration_for_default_null_value * chore: update GitHub Actions versions --------- Co-authored-by: Vincent <407859+vincentchalamon@users.noreply.github.com> --- Factory/OpenApiFactory.php | 4 ++-- Model/Components.php | 2 +- Model/Operation.php | 6 +++--- Model/PathItem.php | 2 +- Serializer/ApiGatewayNormalizer.php | 4 ++-- Serializer/LegacyOpenApiNormalizer.php | 5 ++--- Serializer/OpenApiNormalizer.php | 4 ++-- Tests/Fixtures/Dummy.php | 4 ++-- 8 files changed, 15 insertions(+), 16 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 6f18de7..89548f4 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -70,7 +70,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface */ public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - 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) + 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) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); @@ -388,7 +388,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Model\Operation $openapiOperation = null, HttpOperation $operation = null, array $responseMimeTypes = null, array $operationOutputSchemas = null, ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Model\Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation { if (isset($existingResponses[$status])) { return $openapiOperation; diff --git a/Model/Components.php b/Model/Components.php index 8500575..cc420cf 100644 --- a/Model/Components.php +++ b/Model/Components.php @@ -31,7 +31,7 @@ final class Components * @param \ArrayObject>|\ArrayObject> $callbacks * @param \ArrayObject|\ArrayObject $pathItems */ - public function __construct(\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) + public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) { $schemas?->ksort(); diff --git a/Model/Operation.php b/Model/Operation.php index 22de521..db2aac8 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -167,7 +167,7 @@ public function withParameter(Parameter $parameter): self return $clone; } - public function withRequestBody(RequestBody $requestBody = null): self + public function withRequestBody(?RequestBody $requestBody = null): self { $clone = clone $this; $clone->requestBody = $requestBody; @@ -191,7 +191,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withSecurity(array $security = null): self + public function withSecurity(?array $security = null): self { $clone = clone $this; $clone->security = $security; @@ -199,7 +199,7 @@ public function withSecurity(array $security = null): self return $clone; } - public function withServers(array $servers = null): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/Model/PathItem.php b/Model/PathItem.php index 5a101bc..42a6d88 100644 --- a/Model/PathItem.php +++ b/Model/PathItem.php @@ -176,7 +176,7 @@ public function withTrace(Operation $trace): self return $clone; } - public function withServers(array $servers = null): self + public function withServers(?array $servers = null): self { $clone = clone $this; $clone->servers = $servers; diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index 78b84c6..66e36e4 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -44,7 +44,7 @@ public function __construct(private readonly NormalizerInterface $documentationN * * @throws UnexpectedValueException */ - public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $data = $this->documentationNormalizer->normalize($object, $format, $context); if (!\is_array($data)) { @@ -116,7 +116,7 @@ public function normalize(mixed $object, string $format = null, array $context = /** * {@inheritdoc} */ - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->documentationNormalizer->supportsNormalization($data, $format); } diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index a7d7381..64d7f16 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -27,7 +27,7 @@ public function __construct(private readonly NormalizerInterface $decorated, $de $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { $openapi = $this->decorated->normalize($object, $format, $context); @@ -45,7 +45,6 @@ public function normalize(mixed $object, string $format = null, array $context = } unset($schemas[$name]['properties'][$property]['type']); } - unset($schemas[$name]['properties'][$property]['owl:maxCardinality']); } } @@ -55,7 +54,7 @@ public function normalize(mixed $object, string $format = null, array $context = /** * {@inheritdoc} */ - public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->decorated->supportsNormalization($data, $format, $context); } diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index a11fcb6..b2d1794 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -37,7 +37,7 @@ public function __construct(private readonly NormalizerInterface $decorated) /** * {@inheritdoc} */ - public function normalize(mixed $object, string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { $pathsCallback = static fn ($decoratedObject): array => $decoratedObject instanceof Paths ? $decoratedObject->getPaths() : []; $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; @@ -72,7 +72,7 @@ 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 || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) && $data instanceof OpenApi; } 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; } From 8554872ee8d9999d460bc4c38901929a5c7e8a06 Mon Sep 17 00:00:00 2001 From: Vincent <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 12 Feb 2024 17:11:55 +0100 Subject: [PATCH 15/90] fix(openapi): method OpenApi::getComponents must always return a Components object (#6158) --- OpenApi.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/OpenApi.php b/OpenApi.php index abd2c01..943a639 100644 --- a/OpenApi.php +++ b/OpenApi.php @@ -25,9 +25,11 @@ final class OpenApi public const VERSION = '3.1.0'; private string $openapi = self::VERSION; + private Components $components; - public function __construct(private Info $info, private array $servers, private Paths $paths, private ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) + public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) { + $this->components = $components ?? new Components(); } public function getOpenapi(): string From 1561e568b9b3c7fb7e8d85700bb8fc3a9f207b53 Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Tue, 20 Feb 2024 15:20:44 +0700 Subject: [PATCH 16/90] fix(openapi): skip requestBody if input is false (#6163) * fix(openapi): skip requestBody if input is false * cs fix * fix phpstan error * cs fix * merge condition --------- Co-authored-by: soyuka --- Factory/OpenApiFactory.php | 5 ++++- Tests/Factory/OpenApiFactoryTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 89548f4..5704f69 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -347,7 +347,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection '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, ['PATCH', 'PUT', 'POST'], true)) { + } elseif ( + null === $openapiOperation->getRequestBody() && \in_array($method, ['PATCH', 'PUT', 'POST'], true) + && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) + ) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 8d4c5df..5c38748 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -239,6 +239,7 @@ public function testInvoke(): void ), ], )), + 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), ]) ); @@ -927,5 +928,28 @@ public function testInvoke(): void ]), ] ), $dummyItemPath->getGet()); + + $emptyRequestBodyPath = $paths->getPath('/dummyitem/noinput'); + $this->assertEquals(new Operation( + 'postDummyItemWithoutInput', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response('Invalid input'), + '422' => new Response('Unprocessable entity'), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + null + ), $emptyRequestBodyPath->getPost()); } } From f75ce8f11dac758e785d2b418bdf5dd926170b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timucin=20=C3=9Cnal?= Date: Thu, 29 Feb 2024 11:11:11 +0100 Subject: [PATCH 17/90] fix(openapi): resource name parameter description (#6178) fixes #6155 --- Factory/OpenApiFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 5704f69..7d39bbc 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -265,7 +265,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - $parameter = new Parameter($parameterName, 'path', (new \ReflectionClass($uriVariable->getFromClass()))->getShortName().' identifier', true, false, false, ['type' => 'string']); + $parameter = new Parameter($parameterName, 'path', "$resourceShortName identifier", true, false, false, ['type' => 'string']); if ($this->hasParameter($openapiOperation, $parameter)) { continue; } From 14e8174316604ec943694158766a8099bc07f8f5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 29 Feb 2024 11:30:12 +0100 Subject: [PATCH 18/90] fix(openapi): sebastian/comparator as dev require (#6187) --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 076ae99..deca67f 100644 --- a/composer.json +++ b/composer.json @@ -33,15 +33,15 @@ "api-platform/state": "*@dev || ^3.1", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0", - "sebastian/comparator": "<5.0" + "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", "symfony/phpunit-bridge": "^6.4 || ^7.0", "api-platform/doctrine-common": "*@dev || ^3.2", "api-platform/doctrine-orm": "*@dev || ^3.2", - "api-platform/doctrine-odm": "*@dev || ^3.2" + "api-platform/doctrine-odm": "*@dev || ^3.2", + "sebastian/comparator": "<5.0" }, "autoload": { "psr-4": { From 7ab7a7967c1e2457248f4ab49395e6dc3c925fba Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 29 Feb 2024 18:53:32 +0100 Subject: [PATCH 19/90] fix: components split dependencies (#6186) --- composer.json | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/composer.json b/composer.json index deca67f..d4cc512 100644 --- a/composer.json +++ b/composer.json @@ -66,36 +66,13 @@ }, "extra": { "branch-alias": { - "dev-main": "3.2.x-dev" + "dev-main": "3.3.x-dev" }, "symfony": { "require": "^6.4" } }, - "repositories": [ - { - "type": "path", - "url": "../Metadata" - }, - { - "type": "path", - "url": "../JsonSchema" - }, - { - "type": "path", - "url": "../State" - }, - { - "type": "path", - "url": "../Doctrine/Orm" - }, - { - "type": "path", - "url": "../Doctrine/Odm" - }, - { - "type": "path", - "url": "../Doctrine/Common" - } - ] + "scripts": { + "test": "./vendor/bin/phpunit" + } } From 838a8946970ebde2e31516843175246eac68ccb3 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 15 Mar 2024 14:35:47 +0100 Subject: [PATCH 20/90] feat(openapi): disable response override (#6221) fixes #6151 --- Factory/OpenApiFactory.php | 69 ++++++++++++++++++++------------------ Options.php | 7 +++- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 7d39bbc..12a7571 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -60,6 +60,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface use NormalizeOperationNameTrait; public const BASE_URL = 'base_url'; + public const OVERRIDE_OPENAPI_RESPONSES = 'open_api_override_responses'; private readonly Options $openApiOptions; private readonly PaginationOptions $paginationOptions; private ?RouteCollection $routeCollection = null; @@ -284,37 +285,40 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $existingResponses = $openapiOperation?->getResponses() ?: []; - // 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); - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); - 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); - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - if (!isset($existingResponses[400])) { - $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); - } - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); - break; - case 'DELETE': - $successStatus = (string) $operation->getStatus() ?: 204; - - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); - - break; + $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); + 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); + + $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); + + $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); + 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); + $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); + if (!isset($existingResponses[400])) { + $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); + } + $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); + break; + case 'DELETE': + $successStatus = (string) $operation->getStatus() ?: 204; + + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); + + break; + } } if (!$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { @@ -594,7 +598,8 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation $data['openapi']['explode'] ?? ('array' === $schema['type']), $data['openapi']['allowReserved'] ?? false, $data['openapi']['example'] ?? null, - isset($data['openapi']['examples'] + isset( + $data['openapi']['examples'] ) ? new \ArrayObject($data['openapi']['examples']) : null ); } diff --git a/Options.php b/Options.php index c43a976..5683c7c 100644 --- a/Options.php +++ b/Options.php @@ -15,7 +15,7 @@ final 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) + 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, private bool $overrideResponses = true) { } @@ -103,4 +103,9 @@ public function getLicenseUrl(): ?string { return $this->licenseUrl; } + + public function getOverrideResponses(): bool + { + return $this->overrideResponses; + } } From 4cc39279dd6c02cb22f485c8c4034b7ecda7c494 Mon Sep 17 00:00:00 2001 From: a_guilhem Date: Thu, 21 Mar 2024 01:10:28 +1100 Subject: [PATCH 21/90] feat: add webhook - openapi (#5873) * feat: add webhook - openapi * cs --------- Co-authored-by: Antoine Bluchet --- Attributes/Webhook.php | 51 ++++++++++++++++++++++++++++ Factory/OpenApiFactory.php | 38 ++++++++++++++++----- Tests/Factory/OpenApiFactoryTest.php | 25 +++++++++++++- 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 Attributes/Webhook.php diff --git a/Attributes/Webhook.php b/Attributes/Webhook.php new file mode 100644 index 0000000..2bf205a --- /dev/null +++ b/Attributes/Webhook.php @@ -0,0 +1,51 @@ + + * + * 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\PathItem; + +class Webhook +{ + 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/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 12a7571..a89b0b1 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Contact; @@ -90,12 +91,13 @@ public function __invoke(array $context = []): OpenApi $servers = '/' === $baseUrl || '' === $baseUrl ? [new Server('/')] : [new Server($baseUrl)]; $paths = new Paths(); $schemas = new \ArrayObject(); + $webhooks = new \ArrayObject(); 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); } } @@ -119,11 +121,15 @@ public function __invoke(array $context = []): OpenApi new \ArrayObject(), new \ArrayObject($securitySchemes) ), - $securityRequirements + $securityRequirements, + [], + 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): void { if (0 === $resource->getOperations()->count()) { return; @@ -136,10 +142,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - $openapiOperation = $operation->getOpenapi(); + $openapiAttribute = $operation->getOpenapi(); // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $openapiOperation) { + if ($operation instanceof HttpOperation && false === $openapiAttribute) { continue; } @@ -163,8 +169,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - if (!\is_object($openapiOperation)) { + $pathItem = null; + + if ($openapiAttribute instanceof Webhook) { + $pathItem = $openapiAttribute->getPathItem() ?: new PathItem(); + $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Model\Operation(); + } elseif (!\is_object($openapiAttribute)) { $openapiOperation = new Model\Operation(); + } else { + $openapiOperation = $openapiAttribute; } // Complete with defaults @@ -230,7 +243,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($path) { $pathItem = $paths->getPath($path) ?: new PathItem(); - } else { + } elseif (!$pathItem) { $pathItem = new PathItem(); } @@ -391,7 +404,14 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + if ($openapiAttribute instanceof Webhook) { + if (!isset($webhooks[$openapiAttribute->getName()])) { + $webhooks[$openapiAttribute->getName()] = new \ArrayObject(); + } + $webhooks[$openapiAttribute->getName()]->append($pathItem->{'with'.ucfirst($method)}($openapiOperation)); + } else { + $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + } } } @@ -517,7 +537,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + if ($operation instanceof HttpOperation && (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook)) { continue; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 5c38748..932bb3f 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Factory\OpenApiFactory; use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; @@ -79,6 +80,14 @@ 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'); + $dummyResourceWebhook = (new ApiResource())->withOperations(new Operations([ + 'dummy webhook' => (new Get())->withUriTemplate('/dummy/{id}')->withShortName('short')->withOpenapi(new Webhook('happy 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}'), @@ -247,7 +256,7 @@ public function testInvoke(): void $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::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])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); @@ -482,6 +491,20 @@ 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(1, $webhooks); + + $this->assertNotNull($webhooks['happy webhook']); + $this->assertCount(2, $webhooks['happy webhook']); + + $firstOperationWebhook = $webhooks['happy webhook'][0]; + $secondOperationWebhook = $webhooks['happy webhook'][1]; + + $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); From 051d23321a4df2710cf23da5af8439ff5f00374c Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 21 Mar 2024 10:08:14 +0100 Subject: [PATCH 22/90] fix(openapi): webhook has pathItem --- Factory/OpenApiFactory.php | 5 +---- Tests/Factory/OpenApiFactoryTest.php | 12 ++++-------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index a89b0b1..d2a1922 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -405,10 +405,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } if ($openapiAttribute instanceof Webhook) { - if (!isset($webhooks[$openapiAttribute->getName()])) { - $webhooks[$openapiAttribute->getName()] = new \ArrayObject(); - } - $webhooks[$openapiAttribute->getName()]->append($pathItem->{'with'.ucfirst($method)}($openapiOperation)); + $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation); } else { $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 932bb3f..8a0271b 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -81,7 +81,7 @@ public function testInvoke(): void 'class' => OutputDto::class, ])->withPaginationClientItemsPerPage(true)->withShortName('Dummy')->withDescription('This is a dummy'); $dummyResourceWebhook = (new ApiResource())->withOperations(new Operations([ - 'dummy webhook' => (new Get())->withUriTemplate('/dummy/{id}')->withShortName('short')->withOpenapi(new Webhook('happy webhook')), + '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', @@ -492,14 +492,10 @@ public function testInvoke(): void $this->assertEquals($openApi->getServers(), [new Server('/app_dev.php/')]); $webhooks = $openApi->getWebhooks(); - $this->assertCount(1, $webhooks); - - $this->assertNotNull($webhooks['happy webhook']); - $this->assertCount(2, $webhooks['happy webhook']); - - $firstOperationWebhook = $webhooks['happy webhook'][0]; - $secondOperationWebhook = $webhooks['happy webhook'][1]; + $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()); From e1bfc92542ed0a84151e5e9552856cad6ad8685b Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 26 Mar 2024 19:17:38 +0100 Subject: [PATCH 23/90] feat(openapi): document parameter --- Factory/OpenApiFactory.php | 78 +++++++++++++++++++++++++--- Tests/Factory/OpenApiFactoryTest.php | 29 ++++++++++- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index d2a1922..b059d9c 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -274,22 +275,31 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } // Set up parameters + $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) { if ($uriVariable->getExpandedValue() ?? false) { continue; } - $parameter = new Parameter($parameterName, 'path', "$resourceShortName identifier", true, false, false, ['type' => 'string']); - if ($this->hasParameter($openapiOperation, $parameter)) { + $parameter = new Parameter($parameterName, 'path', $uriVariable->getDescription() ?? "$resourceShortName identifier", $uriVariable->getRequired() ?? true, false, false, $uriVariable->getSchema() ?? ['type' => 'string']); + + if ($linkParameter = $uriVariable->getOpenApi()) { + $parameter = $this->mergeParameter($parameter, $linkParameter); + } + + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); continue; } - $openapiOperation = $openapiOperation->withParameter($parameter); + $openapiParameters[] = $parameter; } + $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; } @@ -297,6 +307,25 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } + $openapiParameters = $openapiOperation->getParameters(); + foreach ($operation->getParameters() ?? [] as $key => $p) { + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; + $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + + if ($linkParameter = $p->getOpenApi()) { + $parameter = $this->mergeParameter($parameter, $linkParameter); + } + + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { + $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); + continue; + } + + $openapiParameters[] = $parameter; + } + + $openapiOperation = $openapiOperation->withParameters($openapiParameters); + $existingResponses = $openapiOperation?->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); if ($overrideResponses || !$existingResponses) { @@ -712,14 +741,47 @@ 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(Model\Operation $operation, Parameter $parameter): ?array { - foreach ($operation->getParameters() as $existingParameter) { + foreach ($operation->getParameters() as $key => $existingParameter) { if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { - return true; + return [$key, $existingParameter]; + } + } + + return null; + } + + private function mergeParameter(Parameter $actual, Parameter $defined): Parameter + { + foreach ([ + 'name', + 'in', + 'description', + 'required', + 'deprecated', + 'allowEmptyValue', + 'style', + 'explode', + 'allowReserved', + 'example', + ] as $method) { + $newValue = $defined->{"get$method"}(); + if (null !== $newValue && $actual->{"get$method"}() !== $newValue) { + $actual = $actual->{"with$method"}($newValue); + } + } + + foreach (['examples', 'content', 'schema'] as $method) { + $newValue = $defined->{"get$method"}(); + if ($newValue && \count($newValue) > 0 && $actual->{"get$method"}() !== $newValue) { + $actual = $actual->{"with$method"}($newValue); } } - return false; + return $actual; } } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 8a0271b..4780a58 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HeaderParameter; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\NotExposed; @@ -57,6 +58,7 @@ use ApiPlatform\OpenApi\Tests\Fixtures\DummyFilter; use ApiPlatform\OpenApi\Tests\Fixtures\OutputDto; use ApiPlatform\State\Pagination\PaginationOptions; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -252,11 +254,20 @@ 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)->withShortName('Parameter')->withDescription('This is a dummy'); + $parameterResource = (new ApiResource())->withOperations(new Operations([ + 'uriVariableSchema' => (new Get(uriTemplate: '/uri_variable_uuid', uriVariables: ['id' => new Link(schema: ['type' => 'string', 'format' => 'uuid'], description: 'hello', required: true, openApi: new Parameter('id', 'path', allowEmptyValue: true))]))->withOperation($baseOperation), + 'parameters' => (new Put(uriTemplate: '/parameters', parameters: [ + 'foo' => new HeaderParameter(description: 'hi', schema: ['type' => 'string', 'format' => 'uuid']), + ]))->withOperation($baseOperation), + ])); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, WithParameter::class])); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); + $resourceCollectionMetadataFactoryProphecy->create(WithParameter::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(WithParameter::class, [$parameterResource])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); @@ -504,7 +515,12 @@ public function testInvoke(): void $components = $openApi->getComponents(); $this->assertInstanceOf(Components::class, $components); - $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions()])); + $parameterSchema = $dummySchema->getDefinitions(); + $this->assertEquals($components->getSchemas(), new \ArrayObject([ + 'Dummy' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto' => $dummySchema->getDefinitions(), + 'Parameter' => $parameterSchema, + ])); $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'])))), @@ -970,5 +986,14 @@ public function testInvoke(): void [], null ), $emptyRequestBodyPath->getPost()); + + $parameter = $paths->getPath('/uri_variable_uuid')->getGet()->getParameters()[0]; + $this->assertTrue($parameter->getAllowEmptyValue()); + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); + + $parameter = $paths->getPath('/parameters')->getPut()->getParameters()[0]; + $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); + $this->assertEquals('header', $parameter->getIn()); + $this->assertEquals('hi', $parameter->getDescription()); } } From a64e139cd9bf6449ee31e2006ed358673987d5a1 Mon Sep 17 00:00:00 2001 From: Gwendolen Lynch Date: Fri, 5 Apr 2024 11:24:10 +0200 Subject: [PATCH 24/90] fix(jsonapi): add missing "included" schema parts (#6277) * fix(jsonapi): add missing "included" schema parts * fix(test): test correct format * chore(jsonschema): refactor definition name logic * remove useless comment * remove empty line * add on invalid --------- Co-authored-by: Antoine Bluchet --- Tests/Factory/OpenApiFactoryTest.php | 12 +++++++++++- Tests/Serializer/OpenApiNormalizerTest.php | 12 ++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 4780a58..7be076f 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\OpenApi\Tests\Factory; +use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\TypeFactory; @@ -432,7 +433,16 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $schemaFactory = new SchemaFactory(null, $resourceCollectionMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $definitionNameFactory = new DefinitionNameFactory([]); + + $schemaFactory = new SchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceCollectionMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); $typeFactory = new TypeFactory(); $typeFactory->setSchemaFactory($schemaFactory); diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 632ce4d..a0fcf60 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\OpenApi\Tests\Serializer; +use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\SchemaFactory; use ApiPlatform\JsonSchema\TypeFactory; use ApiPlatform\Metadata\ApiProperty; @@ -50,7 +51,6 @@ use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Encoder\JsonEncoder; -use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; @@ -205,7 +205,15 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $schemaFactory = new SchemaFactory(null, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + + $schemaFactory = new SchemaFactory( + typeFactory: null, + resourceMetadataFactory: $resourceMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + definitionNameFactory: $definitionNameFactory, + ); $typeFactory = new TypeFactory(); $typeFactory->setSchemaFactory($schemaFactory); From 8d1a7801f485b1e2819444f2b091221ed48874ff Mon Sep 17 00:00:00 2001 From: Benjamin ROTHAN Date: Fri, 24 May 2024 08:10:30 +0200 Subject: [PATCH 25/90] feat(openapi): allow optional request body content (#6374) --- Factory/OpenApiFactory.php | 22 +++++++++----- Model/RequestBody.php | 6 ++-- Tests/Factory/OpenApiFactoryTest.php | 45 ++++++++++++++++++++++++---- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index b059d9c..12148ed 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -394,17 +394,25 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection ); $openapiOperation = $openapiOperation->withRequestBody(new RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false)); } elseif ( - null === $openapiOperation->getRequestBody() && \in_array($method, ['PATCH', 'PUT', 'POST'], true) + \in_array($method, ['PATCH', 'PUT', 'POST'], true) && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) ) { - $operationInputSchemas = []; - foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); - $operationInputSchemas[$operationFormat] = $operationInputSchema; - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + $content = $openapiOperation->getRequestBody()?->getContent(); + if (null === $content) { + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchemas[$operationFormat] = $operationInputSchema; + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + } + $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + $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, + )); } // TODO Remove in 4.0 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/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 7be076f..924032e 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -175,11 +175,11 @@ public function testInvoke(): void '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), + ->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', @@ -202,6 +202,11 @@ public function testInvoke(): void ]), ), )), + '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( @@ -872,6 +877,36 @@ public function testInvoke(): void deprecated: false, ), $requestBodyPath->getPost()); + $requestBodyPath = $paths->getPath('/dummiesRequestBodyWithoutContent'); + $this->assertEquals(new Operation( + 'postDummyCollectionWithRequestBodyWithoutContent', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response('Invalid input'), + '422' => new Response('Unprocessable entity'), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + 'Extended description for the new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + false + ), + deprecated: false, + ), $requestBodyPath->getPost()); + $dummyItemPath = $paths->getPath('/dummyitems/{id}'); $this->assertEquals(new Operation( 'putDummyItemWithResponse', From 39cc5ea29072ef07de73e36f7300497556613dfc Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 May 2024 11:47:26 +0200 Subject: [PATCH 26/90] fix(symfony): documentation request _format (#6390) fixes #6372 --- Serializer/SerializerContextBuilder.php | 38 +++++++++++++++++++++++++ State/OpenApiProvider.php | 34 ++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 Serializer/SerializerContextBuilder.php create mode 100644 State/OpenApiProvider.php 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); + } +} From 7a7da287ac3d1bf04710d4a49f5dcaff60f2101d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 4 Jun 2024 13:40:18 +0200 Subject: [PATCH 27/90] chore: openapi container lint (#6410) --- Serializer/SerializerContextBuilder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Serializer/SerializerContextBuilder.php b/Serializer/SerializerContextBuilder.php index afb4824..a7244a3 100644 --- a/Serializer/SerializerContextBuilder.php +++ b/Serializer/SerializerContextBuilder.php @@ -13,13 +13,14 @@ namespace ApiPlatform\OpenApi\Serializer; +use ApiPlatform\Serializer\SerializerContextBuilderInterface as LegacySerializerContextBuilderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Symfony\Component\HttpFoundation\Request; /** * @internal */ -final class SerializerContextBuilder implements SerializerContextBuilderInterface +final class SerializerContextBuilder implements SerializerContextBuilderInterface, LegacySerializerContextBuilderInterface { public function __construct(private readonly SerializerContextBuilderInterface $decorated) { From df09bdb5ab68e62aa5ab6d0ca6ffece40b77c3bc Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 25 Jun 2024 16:01:23 +0200 Subject: [PATCH 28/90] feat(laravel): laravel component (#5882) * feat(laravel): laravel component * try to skip laravel * feat(jsonapi): component * feat(laravel): json api support (needs review) * work on relations * relations (needs toMany) + skolem + IRI to resource * links handler * ulid * validation * slug post * remove deprecations * move classes * fix tests * fix tests metadata * phpstan * missing class * fix laravel tests * fix stan --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index d4cc512..02ae130 100644 --- a/composer.json +++ b/composer.json @@ -40,8 +40,7 @@ "symfony/phpunit-bridge": "^6.4 || ^7.0", "api-platform/doctrine-common": "*@dev || ^3.2", "api-platform/doctrine-orm": "*@dev || ^3.2", - "api-platform/doctrine-odm": "*@dev || ^3.2", - "sebastian/comparator": "<5.0" + "api-platform/doctrine-odm": "*@dev || ^3.2" }, "autoload": { "psr-4": { @@ -51,9 +50,6 @@ "/Tests/" ] }, - "conflict": { - "sebastian/comparator": ">=5.0" - }, "config": { "preferred-install": { "*": "dist" From c58db2c4bc739fb010a90bd850154fb7b18ea556 Mon Sep 17 00:00:00 2001 From: DartCZ Date: Thu, 27 Jun 2024 09:38:56 +0200 Subject: [PATCH 29/90] fix(openapi): yaml openapi export should have numeric keys as string (#6436) --- Command/OpenApiCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index a7d1c43..79c4d77 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -57,7 +57,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'spec_version' => $input->getOption('spec-version'), ]); $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'); From 571f978a258f5eaa284d7fa743a8bdcab11a4f0d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Jun 2024 11:16:48 +0200 Subject: [PATCH 30/90] fix(openapi): parameters can disable openapi (#6440) --- Factory/OpenApiFactory.php | 4 ++++ Model/PathItem.php | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index b059d9c..eb8bb5a 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -309,6 +309,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getParameters() ?? [] as $key => $p) { + if (false === $p->getOpenApi()) { + continue; + } + $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); diff --git a/Model/PathItem.php b/Model/PathItem.php index 42a6d88..9d84173 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) { } @@ -184,7 +184,7 @@ public function withServers(?array $servers = null): self return $clone; } - public function withParameters(array $parameters): self + public function withParameters(?array $parameters = null): self { $clone = clone $this; $clone->parameters = $parameters; From 614a0a04220635391dd44618a5cf745fa8d8da5a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 1 Jul 2024 14:49:40 +0200 Subject: [PATCH 31/90] fix(openapi): optional yaml component (#6445) --- Command/OpenApiCommand.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 79c4d77..4de3b79 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -56,6 +56,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json', [ 'spec_version' => $input->getOption('spec-version'), ]); + + if ($input->getOption('yaml') && !class_exists(Yaml::class)) { + $output->writeln('The "symfony/yaml" component is not installed.'); + + return 1; + } + $content = $input->getOption('yaml') ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_NUMERIC_KEY_AS_STRING) : (json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES) ?: ''); From 304cdbb7b598505ea24c0e87225f080a395f3f55 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Jul 2024 11:45:37 +0200 Subject: [PATCH 32/90] chore: symfony 7.1 dependency and branch alias (#6468) * chore: symfony 7.1 dependency and branch alias * chore: main_request --- composer.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index d4cc512..c4ea86b 100644 --- a/composer.json +++ b/composer.json @@ -32,8 +32,8 @@ "api-platform/metadata": "*@dev || ^3.1", "api-platform/state": "*@dev || ^3.1", "symfony/console": "^6.4 || ^7.0", - "symfony/property-access": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0" + "symfony/property-access": "^6.4 || ^7.1", + "symfony/serializer": "^6.4 || ^7.1" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.0", @@ -66,13 +66,14 @@ }, "extra": { "branch-alias": { - "dev-main": "3.3.x-dev" + "dev-main": "4.0.x-dev", + "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4" + "require": "^6.4 || ^7.1" } }, "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From fd8189824535c424de58422f6802ed5c688d4c4f Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 25 Jun 2024 22:24:35 +0200 Subject: [PATCH 33/90] chore: remove deprecations --- Command/OpenApiCommand.php | 2 - Factory/OpenApiFactory.php | 166 ++++-------------- Factory/TypeFactoryTrait.php | 132 ++++++++++++++ Serializer/ApiGatewayNormalizer.php | 26 +-- .../CacheableSupportsMethodInterface.php | 46 ----- Serializer/OpenApiNormalizer.php | 17 +- Tests/Factory/OpenApiFactoryTest.php | 16 +- Tests/Serializer/OpenApiNormalizerTest.php | 6 - composer.json | 9 +- phpunit.xml.dist | 47 ++--- 10 files changed, 192 insertions(+), 275 deletions(-) create mode 100644 Factory/TypeFactoryTrait.php delete mode 100644 Serializer/CacheableSupportsMethodInterface.php diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 4de3b79..b79f43d 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -85,5 +85,3 @@ 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 541e54c..83ac102 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -17,7 +17,6 @@ use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; -use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\HeaderParameterInterface; @@ -31,7 +30,6 @@ use ApiPlatform\OpenApi\Model; 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; @@ -60,6 +58,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface { use NormalizeOperationNameTrait; + use TypeFactoryTrait; public const BASE_URL = 'base_url'; public const OVERRIDE_OPENAPI_RESPONSES = 'open_api_override_responses'; @@ -68,12 +67,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface private ?RouteCollection $routeCollection = null; private ?ContainerInterface $filterLocator = null; - /** - * @deprecated use SchemaFactory::OPENAPI_DEFINITION_NAME this will be removed in API Platform 4 - */ - public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - - 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) + 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, private readonly array $formats = [], ?Options $openApiOptions = null, ?PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); @@ -200,48 +194,6 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection [$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'])); - } - - // 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']); - } - if ($path) { $pathItem = $paths->getPath($path) ?: new PathItem(); } elseif (!$pathItem) { @@ -260,20 +212,6 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); } - // TODO Remove in 4.0 - if ($operation->getOpenapiContext()['parameters'] ?? false) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "openapiContext" option is deprecated, use "openapi" instead.' - ); - $parameters = []; - foreach ($operation->getOpenapiContext()['parameters'] as $parameter) { - $parameters[] = new Parameter($parameter['name'], $parameter['in'], $parameter['description'] ?? '', $parameter['required'] ?? false, $parameter['deprecated'] ?? false, $parameter['allowEmptyValue'] ?? false, $parameter['schema'] ?? [], $parameter['style'] ?? null, $parameter['explode'] ?? false, $parameter['allowReserved '] ?? false, $parameter['example'] ?? null, isset($parameter['examples']) ? new \ArrayObject($parameter['examples']) : null, isset($parameter['content']) ? new \ArrayObject($parameter['content']) : null); - } - $openapiOperation = $openapiOperation->withParameters($parameters); - } - // Set up parameters $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getUriVariables() ?? [] as $parameterName => $uriVariable) { @@ -329,8 +267,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiOperation = $openapiOperation->withParameters($openapiParameters); - - $existingResponses = $openapiOperation?->getResponses() ?: []; + $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); if ($overrideResponses || !$existingResponses) { // Create responses @@ -377,27 +314,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $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 ($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 ( + if ( \in_array($method, ['PATCH', 'PUT', 'POST'], true) && !(false === ($input = $operation->getInput()) || (\is_array($input) && null === $input['class'])) ) { @@ -419,32 +336,6 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection )); } - // 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; - } - - $openapiOperation = $openapiOperation->withExtensionProperty((string) $key, $value); - } - } - if ($openapiAttribute instanceof Webhook) { $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation); } else { @@ -638,28 +529,33 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation } 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), 'openapi') : ['type' => 'string']); - - $parameters[] = new Parameter( - $name, - 'query', - $data['description'] ?? '', - $data['required'] ?? false, - $data['openapi']['deprecated'] ?? false, - $data['openapi']['allowEmptyValue'] ?? true, - $schema, - 'array' === $schema['type'] && \in_array( - $data['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], - true - ) ? 'deepObject' : 'form', - $data['openapi']['explode'] ?? ('array' === $schema['type']), - $data['openapi']['allowReserved'] ?? false, - $data['openapi']['example'] ?? null, - isset( - $data['openapi']['examples'] - ) ? new \ArrayObject($data['openapi']['examples']) : null - ); + $schema = $data['schema'] ?? []; + + if (isset($data['type']) && \in_array($data['type'] ?? null, Type::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)); + } + + if (!isset($schema['type'])) { + $schema['type'] = 'string'; + } + + $style = 'array' === ($schema['type'] ?? null) && \in_array( + $data['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form'; + + $parameter = isset($data['openapi']) && $data['openapi'] instanceof Parameter ? $data['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $data['is_collection'] ?? false); + + if ('' === $parameter->getDescription() && ($description = $data['description'] ?? '')) { + $parameter = $parameter->withDescription($description); + } + + if (false === $parameter->getRequired() && false !== ($required = $data['required'] ?? false)) { + $parameter = $parameter->withRequired($required); + } + + $parameters[] = $parameter->withSchema($schema); } } diff --git a/Factory/TypeFactoryTrait.php b/Factory/TypeFactoryTrait.php new file mode 100644 index 0000000..6a86070 --- /dev/null +++ b/Factory/TypeFactoryTrait.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Factory; + +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +trait TypeFactoryTrait +{ + private function getType(Type $type): array + { + if ($type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); + } + + private function makeBasicType(Type $type): array + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_INT => ['type' => 'integer'], + Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), + default => ['type' => 'string'], + }; + } + + /** + * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + */ + private function getClassType(?string $className, bool $nullable): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return [ + 'type' => 'string', + 'format' => 'date-time', + ]; + } + if (is_a($className, \DateInterval::class, true)) { + return [ + 'type' => 'string', + 'format' => 'duration', + ]; + } + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'uuid', + ]; + } + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } + if (is_a($className, \SplFileInfo::class, true)) { + return [ + 'type' => 'string', + 'format' => 'binary', + ]; + } + if (is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + return ['type' => 'string']; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + { + if (!$type->isNullable()) { + return $jsonSchema; + } + + $typeDefinition = ['anyOf' => [$jsonSchema]]; + $typeDefinition['anyOf'][] = ['type' => 'null']; + + return $typeDefinition; + } +} diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index 66e36e4..aaea891 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -14,9 +14,7 @@ namespace ApiPlatform\OpenApi\Serializer; use Symfony\Component\Serializer\Exception\UnexpectedValueException; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Serializer; /** * Removes features unsupported by Amazon API Gateway. @@ -27,7 +25,7 @@ * * @author Vincent Chalamon */ -final class ApiGatewayNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +final class ApiGatewayNormalizer implements NormalizerInterface { public const API_GATEWAY = 'api_gateway'; private array $defaultContext = [ @@ -123,31 +121,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array public function getSupportedTypes($format): array { - // @deprecated remove condition when support for symfony versions under 6.3 is dropped - if (!method_exists($this->documentationNormalizer, 'getSupportedTypes')) { - return ['*' => $this->documentationNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->documentationNormalizer->hasCacheableSupportsMethod()]; - } - return $this->documentationNormalizer->getSupportedTypes($format); } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - if (method_exists(Serializer::class, 'getSupportedTypes')) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', - __METHOD__ - ); - } - - return $this->documentationNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->documentationNormalizer->hasCacheableSupportsMethod(); - } - private function isLocalRef(string $ref): bool { return str_starts_with($ref, '#/'); diff --git a/Serializer/CacheableSupportsMethodInterface.php b/Serializer/CacheableSupportsMethodInterface.php deleted file mode 100644 index f131ecd..0000000 --- a/Serializer/CacheableSupportsMethodInterface.php +++ /dev/null @@ -1,46 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\OpenApi\Serializer; - -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface; -use Symfony\Component\Serializer\Serializer; - -if (method_exists(Serializer::class, 'getSupportedTypes')) { - /** - * Backward compatibility layer for getSupportedTypes(). - * - * @internal - * - * @author Kévin Dunglas - * - * @todo remove this interface when dropping support for Serializer < 6.3 - */ - interface CacheableSupportsMethodInterface - { - public function getSupportedTypes(?string $format): array; - } -} else { - /** - * Backward compatibility layer for NormalizerInterface::getSupportedTypes(). - * - * @internal - * - * @author Kévin Dunglas - * - * @todo remove this interface when dropping support for Serializer < 6.3 - */ - interface CacheableSupportsMethodInterface extends BaseCacheableSupportsMethodInterface - { - } -} diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index b2d1794..a50bcd9 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -18,12 +18,11 @@ 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'; @@ -81,18 +80,4 @@ public function getSupportedTypes($format): array { return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) ? [OpenApi::class => true] : []; } - - public function hasCacheableSupportsMethod(): bool - { - if (method_exists(Serializer::class, 'getSupportedTypes')) { - trigger_deprecation( - 'api-platform/core', - '3.1', - 'The "%s()" method is deprecated, use "getSupportedTypes()" instead.', - __METHOD__ - ); - } - - return true; - } } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 924032e..0112d8c 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -16,7 +16,6 @@ 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; @@ -64,13 +63,11 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; class OpenApiFactoryTest extends TestCase { - use ExpectDeprecationTrait; use ProphecyTrait; private const OPERATION_FORMATS = [ @@ -400,7 +397,7 @@ public function testInvoke(): void 'type' => 'string', 'required' => true, 'strategy' => 'exact', - 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true, 'explode' => true], + 'openapi' => new Parameter(in: 'query', name: 'name', example: 'bar', deprecated: true, allowEmptyValue: true, allowReserved: true, explode: true), ]]), 'f2' => new DummyFilter(['ha' => [ 'property' => 'foo', @@ -441,7 +438,6 @@ public function testInvoke(): void $definitionNameFactory = new DefinitionNameFactory([]); $schemaFactory = new SchemaFactory( - typeFactory: null, resourceMetadataFactory: $resourceCollectionMetadataFactory, propertyNameCollectionFactory: $propertyNameCollectionFactory, propertyMetadataFactory: $propertyMetadataFactory, @@ -449,16 +445,12 @@ public function testInvoke(): void definitionNameFactory: $definitionNameFactory, ); - $typeFactory = new TypeFactory(); - $typeFactory->setSchemaFactory($schemaFactory); - $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), $resourceCollectionMetadataFactory, $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'], [ @@ -787,14 +779,14 @@ public function testInvoke(): void new Parameter('name', 'query', '', true, true, true, [ 'type' => 'string', ], 'form', true, true, 'bar'), - new Parameter('ha', 'query', '', false, false, true, [ + new Parameter('ha', 'query', '', false, false, false, [ 'type' => 'integer', ]), - new Parameter('toto', 'query', '', true, false, true, [ + new Parameter('toto', 'query', '', true, false, false, [ 'type' => 'array', 'items' => ['type' => 'string'], ], 'deepObject', true), - new Parameter('order[name]', 'query', '', false, false, true, [ + new Parameter('order[name]', 'query', '', false, false, false, [ 'type' => 'string', 'enum' => ['asc', 'desc'], ]), diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index a0fcf60..defc4ad 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -15,7 +15,6 @@ use ApiPlatform\JsonSchema\DefinitionNameFactory; use ApiPlatform\JsonSchema\SchemaFactory; -use ApiPlatform\JsonSchema\TypeFactory; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -208,23 +207,18 @@ public function testNormalize(): void $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); $schemaFactory = new SchemaFactory( - typeFactory: null, resourceMetadataFactory: $resourceMetadataFactory, propertyNameCollectionFactory: $propertyNameCollectionFactory, propertyMetadataFactory: $propertyMetadataFactory, definitionNameFactory: $definitionNameFactory, ); - $typeFactory = new TypeFactory(); - $typeFactory->setSchemaFactory($schemaFactory); - $factory = new OpenApiFactory( $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $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 57bbdd3..c232ce2 100644 --- a/composer.json +++ b/composer.json @@ -36,11 +36,12 @@ "symfony/serializer": "^6.4 || ^7.1" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", - "symfony/phpunit-bridge": "^6.4 || ^7.0", "api-platform/doctrine-common": "*@dev || ^3.2", + "api-platform/doctrine-odm": "*@dev || ^3.2", "api-platform/doctrine-orm": "*@dev || ^3.2", - "api-platform/doctrine-odm": "*@dev || ^3.2" + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^11.2", + "symfony/phpunit-bridge": "^6.4 || ^7.0" }, "autoload": { "psr-4": { @@ -72,4 +73,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index eec6b86..338e9e1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,20 @@ - - - - - - - - - ./Tests/ - - - - - - ./ - - - ./Tests - ./vendor - - + + + + + + + ./Tests/ + + + + + ./ + + + ./Tests + ./vendor + + - From 48604eb269c41f0cdcd0777d8ee91d0e75fcd1c7 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 19 Jul 2024 17:54:26 +0200 Subject: [PATCH 34/90] chore: missing deprecations (#6480) --- Factory/OpenApiFactory.php | 51 +++++++- Factory/TypeFactoryTrait.php | 132 +++++++++++++++++++++ Tests/Factory/OpenApiFactoryTest.php | 11 +- Tests/Serializer/OpenApiNormalizerTest.php | 2 +- 4 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 Factory/TypeFactoryTrait.php diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 541e54c..e335532 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -60,12 +60,14 @@ final class OpenApiFactory implements OpenApiFactoryInterface { use NormalizeOperationNameTrait; + use TypeFactoryTrait; public const BASE_URL = 'base_url'; public const OVERRIDE_OPENAPI_RESPONSES = 'open_api_override_responses'; private readonly Options $openApiOptions; private readonly PaginationOptions $paginationOptions; private ?RouteCollection $routeCollection = null; + private ?TypeFactoryInterface $jsonSchemaTypeFactory = null; private ?ContainerInterface $filterLocator = null; /** @@ -73,11 +75,16 @@ final class OpenApiFactory implements OpenApiFactoryInterface */ public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - 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) + public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly SchemaFactoryInterface $jsonSchemaFactory, ?TypeFactoryInterface $jsonSchemaTypeFactory, ContainerInterface $filterLocator, private readonly array $formats = [], ?Options $openApiOptions = null, ?PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); + + if ($jsonSchemaTypeFactory) { + trigger_deprecation('api-platform/core', '3.4', sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class)); + $this->jsonSchemaTypeFactory = $jsonSchemaTypeFactory; + } } /** @@ -638,7 +645,47 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation } 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), 'openapi') : ['type' => 'string']); + if (isset($data['swagger'])) { + trigger_deprecation('api-platform/core', '4.0', sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter::class, $operation->getShortName())); + } + + if (!isset($data['openapi']) || $data['openapi'] instanceof Parameter) { + $schema = $data['schema'] ?? []; + + if (isset($data['type']) && \in_array($data['type'] ?? null, Type::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->jsonSchemaTypeFactory ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)); + } + + if (!isset($schema['type'])) { + $schema['type'] = 'string'; + } + + $style = 'array' === ($schema['type'] ?? null) && \in_array( + $data['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form'; + + $parameter = isset($data['openapi']) && $data['openapi'] instanceof Parameter ? $data['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $data['is_collection'] ?? false); + + if ('' === $parameter->getDescription() && ($description = $data['description'] ?? '')) { + $parameter = $parameter->withDescription($description); + } + + if (false === $parameter->getRequired() && false !== ($required = $data['required'] ?? false)) { + $parameter = $parameter->withRequired($required); + } + + $parameters[] = $parameter->withSchema($schema); + continue; + } + + 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::class, $operation->getShortName())); + if ($this->jsonSchemaTypeFactory) { + $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false), 'openapi') : ['type' => 'string']); + } else { + $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']); + } $parameters[] = new Parameter( $name, diff --git a/Factory/TypeFactoryTrait.php b/Factory/TypeFactoryTrait.php new file mode 100644 index 0000000..6a86070 --- /dev/null +++ b/Factory/TypeFactoryTrait.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Factory; + +use Ramsey\Uuid\UuidInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; + +/** + * @internal + */ +trait TypeFactoryTrait +{ + private function getType(Type $type): array + { + if ($type->isCollection()) { + $keyType = $type->getCollectionKeyTypes()[0] ?? null; + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + + if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); + } + + private function makeBasicType(Type $type): array + { + return match ($type->getBuiltinType()) { + Type::BUILTIN_TYPE_INT => ['type' => 'integer'], + Type::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + Type::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + Type::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), + default => ['type' => 'string'], + }; + } + + /** + * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + */ + private function getClassType(?string $className, bool $nullable): array + { + if (null === $className) { + return ['type' => 'string']; + } + + if (is_a($className, \DateTimeInterface::class, true)) { + return [ + 'type' => 'string', + 'format' => 'date-time', + ]; + } + if (is_a($className, \DateInterval::class, true)) { + return [ + 'type' => 'string', + 'format' => 'duration', + ]; + } + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'uuid', + ]; + } + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } + if (is_a($className, \SplFileInfo::class, true)) { + return [ + 'type' => 'string', + 'format' => 'binary', + ]; + } + if (is_a($className, \BackedEnum::class, true)) { + $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); + + $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; + + if ($nullable) { + $enumCases[] = null; + } + + return [ + 'type' => $type, + 'enum' => $enumCases, + ]; + } + + return ['type' => 'string']; + } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + { + if (!$type->isNullable()) { + return $jsonSchema; + } + + $typeDefinition = ['anyOf' => [$jsonSchema]]; + $typeDefinition['anyOf'][] = ['type' => 'null']; + + return $typeDefinition; + } +} diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 924032e..098eb8f 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -400,7 +400,7 @@ public function testInvoke(): void 'type' => 'string', 'required' => true, 'strategy' => 'exact', - 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true, 'explode' => true], + 'openapi' => new Parameter(in: 'query', name: 'name', example: 'bar', deprecated: true, allowEmptyValue: true, allowReserved: true, explode: true), ]]), 'f2' => new DummyFilter(['ha' => [ 'property' => 'foo', @@ -458,7 +458,7 @@ public function testInvoke(): void $propertyNameCollectionFactory, $propertyMetadataFactory, $schemaFactory, - $typeFactory, + null, $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'], [ @@ -787,14 +787,15 @@ public function testInvoke(): void new Parameter('name', 'query', '', true, true, true, [ 'type' => 'string', ], 'form', true, true, 'bar'), - new Parameter('ha', 'query', '', false, false, true, [ + new Parameter('ha', 'query', '', false, false, false, [ 'type' => 'integer', ]), - new Parameter('toto', 'query', '', true, false, true, [ + new Parameter('toto', 'query', '', true, false, false, [ 'type' => 'array', 'items' => ['type' => 'string'], ], 'deepObject', true), - new Parameter('order[name]', 'query', '', false, false, true, [ + + new Parameter('order[name]', 'query', '', false, false, false, [ 'type' => 'string', 'enum' => ['asc', 'desc'], ]), diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index a0fcf60..418147a 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -224,7 +224,7 @@ public function testNormalize(): void $propertyNameCollectionFactory, $propertyMetadataFactory, $schemaFactory, - $typeFactory, + null, $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'], [ From 95f40b510b99b35d2d59fdc8baedf0b82cbaf880 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 9 Aug 2024 09:21:07 +0200 Subject: [PATCH 35/90] style: various cs fixes (#6504) * cs: fixes * chore: phpstan fixes --- Command/OpenApiCommand.php | 2 +- Factory/OpenApiFactory.php | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 4de3b79..f2fd5ab 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -70,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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; } diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index eb8bb5a..809b213 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -337,12 +337,12 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection 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); + $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); + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); @@ -351,7 +351,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection case 'PATCH': case '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, $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')); @@ -361,7 +361,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource deleted', $resourceShortName), $openapiOperation); break; } @@ -408,7 +408,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); } - $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); + $openapiOperation = $openapiOperation->withRequestBody(new RequestBody(\sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true)); } // TODO Remove in 4.0 @@ -539,7 +539,7 @@ private function getPathDescription(string $resourceShortName, string $method, b return $resourceShortName; } - return sprintf($pathSummary, $resourceShortName); + return \sprintf($pathSummary, $resourceShortName); } /** @@ -694,7 +694,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()))) ); @@ -731,7 +731,7 @@ 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']); } From 831215a7fbbd361f07c65810f035ccaca22b8e7e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 Aug 2024 09:35:20 +0200 Subject: [PATCH 36/90] style: cs fixes --- Factory/OpenApiFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index f98178a..993ca34 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -82,7 +82,7 @@ public function __construct(private readonly ResourceNameCollectionFactoryInterf $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); if ($jsonSchemaTypeFactory) { - trigger_deprecation('api-platform/core', '3.4', sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class)); + trigger_deprecation('api-platform/core', '3.4', \sprintf('Injecting the "%s" inside "%s" is deprecated and "%s" will be removed in 4.x.', TypeFactoryInterface::class, self::class, TypeFactoryInterface::class)); $this->jsonSchemaTypeFactory = $jsonSchemaTypeFactory; } } @@ -646,7 +646,7 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation foreach ($filter->getDescription($entityClass) as $name => $data) { if (isset($data['swagger'])) { - trigger_deprecation('api-platform/core', '4.0', sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter::class, $operation->getShortName())); + trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter::class, $operation->getShortName())); } if (!isset($data['openapi']) || $data['openapi'] instanceof Parameter) { @@ -680,7 +680,7 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation continue; } - 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::class, $operation->getShortName())); + 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::class, $operation->getShortName())); if ($this->jsonSchemaTypeFactory) { $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false), 'openapi') : ['type' => 'string']); } else { From 9fe9fb8c1d9a2de6dc1c4d2e542b8fc66a794106 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 Aug 2024 09:49:50 +0200 Subject: [PATCH 37/90] style: cs fixes --- Command/OpenApiCommand.php | 2 +- Factory/OpenApiFactory.php | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index b79f43d..6f32b9a 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -70,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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; } diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 83ac102..3b6007a 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -274,12 +274,12 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection 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); + $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); + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); @@ -288,7 +288,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection case 'PATCH': case '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, $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')); @@ -298,7 +298,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, sprintf('%s resource deleted', $resourceShortName), $openapiOperation); + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource deleted', $resourceShortName), $openapiOperation); break; } @@ -330,7 +330,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $openapiOperation = $openapiOperation->withRequestBody(new RequestBody( - description: $openapiOperation->getRequestBody()?->getDescription() ?? sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), + description: $openapiOperation->getRequestBody()?->getDescription() ?? \sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), content: $content, required: $openapiOperation->getRequestBody()?->getRequired() ?? true, )); @@ -438,7 +438,7 @@ private function getPathDescription(string $resourceShortName, string $method, b return $resourceShortName; } - return sprintf($pathSummary, $resourceShortName); + return \sprintf($pathSummary, $resourceShortName); } /** @@ -598,7 +598,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()))) ); @@ -635,7 +635,7 @@ 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']); } From e129f34c53337f1d8dc3d421cc924ccc9e12209c Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 09:26:44 +0200 Subject: [PATCH 38/90] chore: bump api-platform dependencies --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index c232ce2..1891051 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.1", - "api-platform/json-schema": "*@dev || ^3.1", - "api-platform/metadata": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1", + "api-platform/json-schema": "*@dev || ^3.2 || ^4.0", + "api-platform/metadata": "*@dev || ^3.2 || ^4.0", + "api-platform/state": "*@dev || ^3.2 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1" From 3f51e1a429c50698558cccf9813084e0a3345bba Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 12 Aug 2024 12:17:47 +0200 Subject: [PATCH 39/90] chore: update branch aliases with 3.4 and 4.0 (#6509) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1891051..940cb88 100644 --- a/composer.json +++ b/composer.json @@ -73,4 +73,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} +} \ No newline at end of file From cdca52e955a209437880a91c540ea787da5f263c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Mon, 12 Aug 2024 12:19:38 +0200 Subject: [PATCH 40/90] fix: add missing v4 version constraints (#6510) --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 940cb88..d94a133 100644 --- a/composer.json +++ b/composer.json @@ -36,9 +36,9 @@ "symfony/serializer": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/doctrine-common": "*@dev || ^3.2", - "api-platform/doctrine-odm": "*@dev || ^3.2", - "api-platform/doctrine-orm": "*@dev || ^3.2", + "api-platform/doctrine-common": "*@dev || ^3.2 || ^4.0", + "api-platform/doctrine-odm": "*@dev || ^3.2 || ^4.0", + "api-platform/doctrine-orm": "*@dev || ^3.2 || ^4.0", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^11.2", "symfony/phpunit-bridge": "^6.4 || ^7.0" From e1be3b30b7b20bb3c7fd0cbed29f92ad07f7d6c1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:36:55 +0200 Subject: [PATCH 41/90] cs: newline ending --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d94a133..fc8f94b 100644 --- a/composer.json +++ b/composer.json @@ -73,4 +73,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From cf95998a0e4750ee2326ab789d27a56bc09741ce Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 12 Aug 2024 16:44:43 +0200 Subject: [PATCH 42/90] chore: align dependencies across components --- composer.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index fc8f94b..328ee92 100644 --- a/composer.json +++ b/composer.json @@ -28,18 +28,18 @@ ], "require": { "php": ">=8.1", - "api-platform/json-schema": "*@dev || ^3.2 || ^4.0", - "api-platform/metadata": "*@dev || ^3.2 || ^4.0", - "api-platform/state": "*@dev || ^3.2 || ^4.0", + "api-platform/json-schema": "@dev || ^3.2 || ^4.0", + "api-platform/metadata": "@dev || ^3.2 || ^4.0", + "api-platform/state": "@dev || ^3.2 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/doctrine-common": "*@dev || ^3.2 || ^4.0", - "api-platform/doctrine-odm": "*@dev || ^3.2 || ^4.0", - "api-platform/doctrine-orm": "*@dev || ^3.2 || ^4.0", - "phpspec/prophecy-phpunit": "^2.0", + "api-platform/doctrine-common": "@dev || ^3.2 || ^4.0", + "api-platform/doctrine-odm": "@dev || ^3.2 || ^4.0", + "api-platform/doctrine-orm": "@dev || ^3.2 || ^4.0", + "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", "symfony/phpunit-bridge": "^6.4 || ^7.0" }, From 53a82deca0491c20d2b20a8ecc701fa1e7abb594 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 19 Aug 2024 14:51:02 +0200 Subject: [PATCH 43/90] chore: remove @dev constraint (#6513) --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 328ee92..eb0fae0 100644 --- a/composer.json +++ b/composer.json @@ -28,17 +28,17 @@ ], "require": { "php": ">=8.1", - "api-platform/json-schema": "@dev || ^3.2 || ^4.0", - "api-platform/metadata": "@dev || ^3.2 || ^4.0", - "api-platform/state": "@dev || ^3.2 || ^4.0", + "api-platform/json-schema": "^3.2 || ^4.0", + "api-platform/metadata": "^3.2 || ^4.0", + "api-platform/state": "^3.2 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1" }, "require-dev": { - "api-platform/doctrine-common": "@dev || ^3.2 || ^4.0", - "api-platform/doctrine-odm": "@dev || ^3.2 || ^4.0", - "api-platform/doctrine-orm": "@dev || ^3.2 || ^4.0", + "api-platform/doctrine-common": "^3.2 || ^4.0", + "api-platform/doctrine-odm": "^3.2 || ^4.0", + "api-platform/doctrine-orm": "^3.2 || ^4.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", "symfony/phpunit-bridge": "^6.4 || ^7.0" From 6295a22199c07080f1fc7928fd2b72981c92e649 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 29 Aug 2024 22:34:22 +0200 Subject: [PATCH 44/90] fix(openapi): allow null return type fixes #6548 --- Model/PathItem.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/PathItem.php b/Model/PathItem.php index 9d84173..e481e75 100644 --- a/Model/PathItem.php +++ b/Model/PathItem.php @@ -83,7 +83,7 @@ public function getServers(): ?array return $this->servers; } - public function getParameters(): array + public function getParameters(): ?array { return $this->parameters; } From 04d3ecd410be0576c25f2f1b74510fbea150962a Mon Sep 17 00:00:00 2001 From: Benjamin ROTHAN Date: Fri, 30 Aug 2024 10:06:28 +0200 Subject: [PATCH 45/90] feat(openapi): make open_api_override_responses act on default 404 response generation (#6551) --- Factory/OpenApiFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 993ca34..a41a82f 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -374,7 +374,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - if (!$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { + if (true === $overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { if (!isset($existingResponses[404])) { $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); } From 9588163f6a2d214ced4ac0606ad72f7ece7f7d28 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 30 Aug 2024 22:19:12 +0200 Subject: [PATCH 46/90] chore: deprecations (#6563) * chore: deprecations * chore: deprecations --- composer.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index c4ea86b..ef7ebdb 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.1", - "api-platform/json-schema": "*@dev || ^3.1", - "api-platform/metadata": "*@dev || ^3.1", - "api-platform/state": "*@dev || ^3.1", + "api-platform/json-schema": "^3.1", + "api-platform/metadata": "^3.1", + "api-platform/state": "^3.1", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1" @@ -38,9 +38,9 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.0", "symfony/phpunit-bridge": "^6.4 || ^7.0", - "api-platform/doctrine-common": "*@dev || ^3.2", - "api-platform/doctrine-orm": "*@dev || ^3.2", - "api-platform/doctrine-odm": "*@dev || ^3.2", + "api-platform/doctrine-common": "^3.2", + "api-platform/doctrine-orm": "^3.2", + "api-platform/doctrine-odm": "^3.2", "sebastian/comparator": "<5.0" }, "autoload": { @@ -76,4 +76,4 @@ "scripts": { "test": "./vendor/bin/phpunit" } -} \ No newline at end of file +} From 5f315c885725e65e50cff692257b7424712aa65b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Sep 2024 09:00:23 +0200 Subject: [PATCH 47/90] chore: bump dependencies to ^3.4 || ^4.0 (#6576) * chore: bump dependencies to ^3.4 || ^4.0 * fix: use baseline for serializer context builder * ci: use pmu 0.12 --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index ef7ebdb..fc7dfb1 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.1", - "api-platform/json-schema": "^3.1", - "api-platform/metadata": "^3.1", - "api-platform/state": "^3.1", + "api-platform/json-schema": "^3.4 || ^4.0", + "api-platform/metadata": "^3.4 || ^4.0", + "api-platform/state": "^3.4 || ^4.0", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.1", "symfony/serializer": "^6.4 || ^7.1" @@ -38,9 +38,9 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.0", "symfony/phpunit-bridge": "^6.4 || ^7.0", - "api-platform/doctrine-common": "^3.2", - "api-platform/doctrine-orm": "^3.2", - "api-platform/doctrine-odm": "^3.2", + "api-platform/doctrine-common": "^3.4 || ^4.0", + "api-platform/doctrine-orm": "^3.4 || ^4.0", + "api-platform/doctrine-odm": "^3.4 || ^4.0", "sebastian/comparator": "<5.0" }, "autoload": { From 85da8beeee69ad2ed0db9eabb46498d50768d118 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 3 Sep 2024 10:47:24 +0200 Subject: [PATCH 48/90] chore(openapi): remove comparator conflict (#6577) --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index fc7dfb1..c455fdf 100644 --- a/composer.json +++ b/composer.json @@ -40,8 +40,7 @@ "symfony/phpunit-bridge": "^6.4 || ^7.0", "api-platform/doctrine-common": "^3.4 || ^4.0", "api-platform/doctrine-orm": "^3.4 || ^4.0", - "api-platform/doctrine-odm": "^3.4 || ^4.0", - "sebastian/comparator": "<5.0" + "api-platform/doctrine-odm": "^3.4 || ^4.0" }, "autoload": { "psr-4": { @@ -51,9 +50,6 @@ "/Tests/" ] }, - "conflict": { - "sebastian/comparator": ">=5.0" - }, "config": { "preferred-install": { "*": "dist" From bf545f55aff151fc0ccc024be1689cff477aefaa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Sep 2024 00:48:47 +0200 Subject: [PATCH 49/90] feat: remove hydra prefix (#6418) * feat: remove hydra prefix * fix: add hydra context --- Factory/OpenApiFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index a41a82f..410ebfd 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -105,7 +105,7 @@ public function __invoke(array $context = []): OpenApi $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); foreach ($resourceMetadataCollection as $resourceMetadata) { - $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks); + $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $context); } } @@ -137,7 +137,7 @@ public function __invoke(array $context = []): OpenApi ); } - private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks): void + private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks, array $context = []): void { if (0 === $resource->getOperations()->count()) { return; From c811464bcecfd54c2cd8d2432bec71ecc75c7190 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Sep 2024 15:11:14 +0200 Subject: [PATCH 50/90] feat(laravel): eloquent filters (search, date, equals, or) (#6593) * feat(laravel): Eloquent filters search date or * feat(laravel): eloquent filters order range equals afterdate * fix(laravel): order afterDate filters * temp * test(laravel): filter with eloquent --------- Co-authored-by: Nathan --- Factory/OpenApiFactory.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 8cca2b6..63f29c7 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -67,8 +67,18 @@ final class OpenApiFactory implements OpenApiFactoryInterface private ?RouteCollection $routeCollection = null; private ?ContainerInterface $filterLocator = null; - 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, private readonly array $formats = [], ?Options $openApiOptions = null, ?PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null) - { + 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, + ) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); From bba2a82f7da74170c482b08399a50409f2c96f86 Mon Sep 17 00:00:00 2001 From: Manuel Rossard <95523073+mrossard@users.noreply.github.com> Date: Sat, 7 Sep 2024 08:19:10 +0200 Subject: [PATCH 51/90] feat: better path sorting for openapi UIs (#6583) * feat: better path sorting for openapi UIs * fix: cs fixer * fix: move the paths sorting logic to the normalizer * fix: cs fixer * fix: refactoring for readability * fix: missing the tags for patch --- Model/Paths.php | 2 -- Serializer/OpenApiNormalizer.php | 44 +++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) 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/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index b2d1794..d061d56 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -39,7 +39,7 @@ public function __construct(private readonly NormalizerInterface $decorated) */ public function normalize(mixed $object, ?string $format = null, array $context = []): array { - $pathsCallback = static fn ($decoratedObject): array => $decoratedObject instanceof Paths ? $decoratedObject->getPaths() : []; + $pathsCallback = $this->getPathsCallBack(); $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; $context[AbstractNormalizer::CALLBACKS] = [ @@ -95,4 +95,46 @@ public function hasCacheableSupportsMethod(): bool return true; } + + private function getPathsCallBack(): \Closure + { + return static function ($decoratedObject): array { + if ($decoratedObject instanceof Paths) { + $paths = $decoratedObject->getPaths(); + + // sort paths by tags, then by path for each tag + uksort($paths, function ($keyA, $keyB) use ($paths) { + $a = $paths[$keyA]; + $b = $paths[$keyB]; + + $tagsA = [ + ...($a->getGet()?->getTags() ?? []), + ...($a->getPost()?->getTags() ?? []), + ...($a->getPatch()?->getTags() ?? []), + ...($a->getPut()?->getTags() ?? []), + ...($a->getDelete()?->getTags() ?? []), + ]; + sort($tagsA); + + $tagsB = [ + ...($b->getGet()?->getTags() ?? []), + ...($b->getPost()?->getTags() ?? []), + ...($b->getPatch()?->getTags() ?? []), + ...($b->getPut()?->getTags() ?? []), + ...($b->getDelete()?->getTags() ?? []), + ]; + sort($tagsB); + + return match (true) { + current($tagsA) === current($tagsB) => $keyA <=> $keyB, + default => current($tagsA) <=> current($tagsB), + }; + }); + + return $paths; + } + + return []; + }; + } } From 6fb6075d184ac4daab86c9a3b3285574b18aa4c6 Mon Sep 17 00:00:00 2001 From: JacquesDurand <59364973+JacquesDurand@users.noreply.github.com> Date: Mon, 9 Sep 2024 09:42:01 +0200 Subject: [PATCH 52/90] feat(openapi): add error resources schemes (#6332) * feat(openapi): add error resources schemes Allow linking ErrorResources to ApiResources to automatically generate openapi documentation for errors * review * fix(review): throw instead of logging --------- Co-authored-by: soyuka --- Factory/OpenApiFactory.php | 96 +++++- Tests/Factory/OpenApiFactoryTest.php | 435 +++++++++++++++++--------- Tests/Fixtures/DummyErrorResource.php | 46 +++ 3 files changed, 416 insertions(+), 161 deletions(-) create mode 100644 Tests/Fixtures/DummyErrorResource.php diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 410ebfd..b05d6a5 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -20,6 +20,11 @@ use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error; +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; @@ -38,6 +43,7 @@ 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; @@ -75,8 +81,19 @@ final class OpenApiFactory implements OpenApiFactoryInterface */ public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly SchemaFactoryInterface $jsonSchemaFactory, ?TypeFactoryInterface $jsonSchemaTypeFactory, ContainerInterface $filterLocator, private readonly array $formats = [], ?Options $openApiOptions = null, ?PaginationOptions $paginationOptions = null, private readonly ?RouterInterface $router = null) - { + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly SchemaFactoryInterface $jsonSchemaFactory, + ?TypeFactoryInterface $jsonSchemaTypeFactory, + ContainerInterface $filterLocator, + private readonly array $formats = [], + ?Options $openApiOptions = null, + ?PaginationOptions $paginationOptions = null, + private readonly ?RouterInterface $router = null, + ) { $this->filterLocator = $filterLocator; $this->openApiOptions = $openApiOptions ?: new Options('API Platform'); $this->paginationOptions = $paginationOptions ?: new PaginationOptions(); @@ -181,15 +198,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($openapiAttribute instanceof Webhook) { $pathItem = $openapiAttribute->getPathItem() ?: new PathItem(); - $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Model\Operation(); + $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation(); } elseif (!\is_object($openapiAttribute)) { - $openapiOperation = new Model\Operation(); + $openapiOperation = new Operation(); } else { $openapiOperation = $openapiAttribute; } // Complete with defaults - $openapiOperation = new Model\Operation( + $openapiOperation = new Operation( operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName), tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName], responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [], @@ -339,6 +356,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation?->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); + if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { + $openapiOperation = $this->addOperationErrors($openapiOperation, $errors, $responseMimeTypes, $resourceMetadataCollection, $schema, $schemas); + } + if ($overrideResponses || !$existingResponses) { // Create responses switch ($method) { @@ -433,7 +454,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection '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()); + $allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(Operation::class))->getProperties()); foreach ($operation->getOpenapiContext() as $key => $value) { $value = match ($key) { 'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''), @@ -460,7 +481,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Model\Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Model\Operation + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, ?Operation $openapiOperation = null, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Operation { if (isset($existingResponses[$status])) { return $openapiOperation; @@ -491,6 +512,9 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas) return $content; } + /** + * @return array[array, array] + */ private function getMimeTypes(HttpOperation $operation): array { $requestFormats = $operation->getInputFormats() ?: []; @@ -502,6 +526,11 @@ private function getMimeTypes(HttpOperation $operation): array return [$requestMimeTypes, $responseMimeTypes]; } + /** + * @param array $responseFormats + * + * @return array + */ private function flattenMimeTypes(array $responseFormats): array { $responseMimeTypes = []; @@ -803,7 +832,7 @@ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $de /** * @return array{0: int, 1: Parameter}|null */ - private function hasParameter(Model\Operation $operation, Parameter $parameter): ?array + private function hasParameter(Operation $operation, Parameter $parameter): ?array { foreach ($operation->getParameters() as $key => $existingParameter) { if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { @@ -843,4 +872,55 @@ private function mergeParameter(Parameter $actual, Parameter $defined): Paramete return $actual; } + + /** + * @param string[] $errors + * @param array $responseMimeTypes + */ + private function addOperationErrors(Operation $operation, array $errors, array $responseMimeTypes, ResourceMetadataCollection $resourceMetadataCollection, Schema $schema, \ArrayObject $schemas): Operation + { + $existingResponses = null; + foreach ($errors as $error) { + if (!is_a($error, ProblemExceptionInterface::class, true)) { + throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); + } + + $status = null; + $description = null; + + try { + /** @var ProblemExceptionInterface $exception */ + $exception = new $error(); + $status = $exception->getStatus(); + $description = $exception->getTitle(); + } catch (\TypeError) { + } + + try { + $errorOperation = $this->resourceMetadataFactory->create($error)->getOperation(); + if (!is_a($errorOperation, Error::class)) { + throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); + } + } catch (ResourceClassNotFoundException|OperationNotFoundException) { + $errorOperation = null; + } + $status ??= $errorOperation?->getStatus(); + $description ??= $errorOperation?->getDescription(); + + if (!$status) { + throw new RuntimeException(\sprintf('The error class %s has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $error)); + } + + $operationErrorSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($error, $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchemas[$operationFormat] = $operationErrorSchema; + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } + + $operation = $this->buildOpenApiResponse($existingResponses ??= $operation->getResponses() ?: [], $status, $description ?? '', $operation, $errorOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + } + + return $operation; + } } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 098eb8f..0541cfd 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; @@ -56,6 +57,7 @@ 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\OutputDto; use ApiPlatform\State\Pagination\PaginationOptions; @@ -91,173 +93,175 @@ public function testInvoke(): void )))), ])); - $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'], - ], - ]), - 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', - ], + $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', ], ], ], ], - ], - ]), - ), - )), - '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', + required: true, ), - ], - )), - 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( - responses: [ - '201' => new OpenApiResponse( - description: 'Created', + extensionProperties: ['x-visibility' => 'hide'], + )), + 'custom-http-verb' => (new HttpOperation())->withMethod('TEST')->withOperation($baseOperation), + 'withRoutePrefix' => (new GetCollection())->withUriTemplate('/dummies')->withRoutePrefix('/prefix')->withOperation($baseOperation), + 'formatsDummyItem' => (new Put())->withOperation($baseOperation)->withUriTemplate('/formatted/{id}')->withUriVariables(['id' => (new Link())->withFromClass(Dummy::class)->withIdentifiers(['id'])])->withInputFormats(['json' => ['application/json'], 'csv' => ['text/csv']])->withOutputFormats(['json' => ['application/json'], 'csv' => ['text/csv']]), + 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOpenapi(new OpenApiOperation( + parameters: [ + new Parameter( + name: 'page', + in: 'query', + description: 'Test modified collection page number', + required: false, + allowEmptyValue: true, + schema: ['type' => 'integer', 'default' => 1], + ), + ], + ))->withOperation($baseOperation), + 'postDummyCollection' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), + // Filtered + 'filteredDummyCollection' => (new GetCollection())->withUriTemplate('/filtered')->withFilters(['f1', 'f2', 'f3', 'f4', 'f5'])->withOperation($baseOperation), + // Paginated + 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') + ->withPaginationClientEnabled(true) + ->withPaginationClientItemsPerPage(true) + ->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', ), - ], - )), - 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), - ]) + )), + 'putDummyItemWithResponse' => (new Put())->withUriTemplate('/dummyitems/{id}')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '200' => new OpenApiResponse( + description: 'Success', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], + ], + ]), + headers: new \ArrayObject([ + 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], + ]), + links: new \ArrayObject([ + 'link' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '400' => new OpenApiResponse( + description: 'Error', + ), + ], + )), + 'getDummyItemImageCollection' => (new GetCollection())->withUriTemplate('/dummyitems/{id}/images')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '200' => new OpenApiResponse( + description: 'Success', + ), + ], + )), + 'postDummyItemWithResponse' => (new Post())->withUriTemplate('/dummyitems')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '201' => new OpenApiResponse( + description: 'Created', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => ['$ref' => '#/components/schemas/Dummy'], + ], + ]), + headers: new \ArrayObject([ + 'API_KEY' => ['description' => 'Api Key', 'schema' => ['type' => 'string']], + ]), + links: new \ArrayObject([ + 'link' => ['$ref' => '#/components/schemas/Dummy'], + ]), + ), + '400' => new OpenApiResponse( + description: 'Error', + ), + ], + )), + 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), + 'getDummyCollectionWithErrors' => (new GetCollection())->withUriTemplate('erroredDummies')->withErrors([DummyErrorResource::class])->withOperation($baseOperation), + ]) ); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withShortName('Parameter')->withDescription('This is a dummy'); @@ -273,10 +277,12 @@ public function testInvoke(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); + $resourceCollectionMetadataFactoryProphecy->create(DummyErrorResource::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(DummyErrorResource::class, [new ApiResource(operations: [new ErrorOperation(name: 'err', description: 'nice one!')])])); $resourceCollectionMetadataFactoryProphecy->create(WithParameter::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(WithParameter::class, [$parameterResource])); $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'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); @@ -392,6 +398,59 @@ public function testInvoke(): void ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); + $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'type', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error type.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error type.']) + ); + $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'title', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error title.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error title.']) + ); + $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'status', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an error status.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an error status.', 'readOnly' => true]) + ); + $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'detail', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error detail.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error detail.']) + ); + $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'instance', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error instance.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error instance.']) + ); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $filters = [ @@ -510,6 +569,35 @@ public function testInvoke(): void ]), ], ])); + $dummyErrorSchema = new Schema('openapi'); + $dummyErrorSchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => 'nice one!', + 'deprecated' => false, + 'properties' => [ + 'type' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error type.', + ]), + 'title' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error title.', + ]), + 'status' => new \ArrayObject([ + 'type' => 'integer', + 'description' => 'This is an error status.', + 'readOnly' => true, + ]), + 'detail' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error detail.', + ]), + 'instance' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an error instance.', + ]), + ], + ])); $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -534,6 +622,7 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), + 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), 'Parameter' => $parameterSchema, ])); @@ -1041,5 +1130,45 @@ public function testInvoke(): void $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); $this->assertEquals('header', $parameter->getIn()); $this->assertEquals('hi', $parameter->getDescription()); + + $this->assertEquals(new Operation( + 'getDummyCollectionWithErrors', + ['Dummy'], + [ + '200' => new Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + ]))), + ])), + '418' => new Response( + 'A Teapot Exception', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + '$ref' => '#/components/schemas/DummyErrorResource', + ]))), + ]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(), null, 'This is a dummy')]) + ), + ], + 'Retrieves the collection of Dummy resources.', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ], + deprecated: false + ), $paths->getPath('/erroredDummies')->getGet()); } } 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; + } +} From fbaee5bbe8f983db5c30cbb2a9a6daf886c7cdb7 Mon Sep 17 00:00:00 2001 From: Nathan Pesneau <129308244+NathanPesneau@users.noreply.github.com> Date: Wed, 11 Sep 2024 17:14:16 +0200 Subject: [PATCH 53/90] feat(laravel): eloquent filters date range (#6606) * feat(laravel): eloquent filters date range * cs * parameter * fix(laravel): eloquent test * fix test * fix(laravel): corrections * fix(larevel): eloquent test filters * fix(laravel): date filter const * fix(laravel): range filter const --------- Co-authored-by: Nathan Co-authored-by: soyuka --- Factory/OpenApiFactory.php | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 1db4ed6..d5c4e2f 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -268,17 +268,37 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; - $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + $defaultParameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); + + $linkParameter = $p->getOpenApi(); + if (null === $linkParameter) { + if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $defaultParameter)) { + $openapiParameters[$i] = $this->mergeParameter($defaultParameter, $operationParameter); + } else { + $openapiParameters[] = $defaultParameter; + } - if ($linkParameter = $p->getOpenApi()) { - $parameter = $this->mergeParameter($parameter, $linkParameter); + 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; + } + continue; } + $parameter = $this->mergeParameter($defaultParameter, $linkParameter); if ([$i, $operationParameter] = $this->hasParameter($openapiOperation, $parameter)) { $openapiParameters[$i] = $this->mergeParameter($parameter, $operationParameter); continue; } - $openapiParameters[] = $parameter; } From 0567615806c0b8a4d08cc37c15fa8f27b8c7d9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Thu, 19 Sep 2024 17:56:07 +0200 Subject: [PATCH 54/90] chore: setup star forwarding (#6630) --- composer.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index c455fdf..4d7f535 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", @@ -67,6 +67,10 @@ }, "symfony": { "require": "^6.4 || ^7.1" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "scripts": { From 08bf0b6bbdac5d41a4ca1544ab8927a2f8d6973b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 19 Sep 2024 17:56:20 +0200 Subject: [PATCH 55/90] chore: php version >= 8.2 (#6628) * chore: php version >= 8.2 * down to sf 7.0 * symfony extra require * missing hal constraint --- composer.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 3ca9cf6..a8425b0 100644 --- a/composer.json +++ b/composer.json @@ -27,16 +27,16 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "api-platform/json-schema": "^3.4 || ^4.0", "api-platform/metadata": "^3.4 || ^4.0", "api-platform/state": "^3.4 || ^4.0", "symfony/console": "^6.4 || ^7.0", - "symfony/property-access": "^6.4 || ^7.1", - "symfony/serializer": "^6.4 || ^7.1" + "symfony/property-access": "^6.4 || ^7.0", + "symfony/serializer": "^6.4 || ^7.0" }, "require-dev": { - "phpspec/prophecy-phpunit": "^2.0", + "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", "api-platform/doctrine-common": "^3.4 || ^4.0", "api-platform/doctrine-orm": "^3.4 || ^4.0", @@ -66,7 +66,7 @@ "dev-3.4": "3.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1" + "require": "^6.4 || ^7.0" } }, "scripts": { From b6dff23a51fde62320b24c4f1c394c856abdc73d Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 19 Sep 2024 17:58:51 +0200 Subject: [PATCH 56/90] chore: fix thanks url --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 4f38566..3576b85 100644 --- a/composer.json +++ b/composer.json @@ -67,6 +67,10 @@ }, "symfony": { "require": "^6.4 || ^7.0" + }, + "thanks": { + "name": "api-platform/api-platform", + "url": "https://github.com/api-platform/api-platform" } }, "scripts": { From 8ac9d8533355b088c4e2f22c1e84261ce3327c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 20 Sep 2024 15:47:23 +0200 Subject: [PATCH 57/90] chore: add GitHub Action to automatically close PRs on subtree splits (#6648) --- .gitattributes | 3 +++ .github/workflows/close_pr.yml | 13 +++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 .github/workflows/close_pr.yml diff --git a/.gitattributes b/.gitattributes index ae3c2e1..801f208 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +/.github export-ignore +/.gitattributes export-ignore /.gitignore export-ignore /Tests export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/close_pr.yml b/.github/workflows/close_pr.yml new file mode 100644 index 0000000..72a8ab4 --- /dev/null +++ b/.github/workflows/close_pr.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Thank you for your pull request. However, you have submitted this PR on a read-only sub split of `api-platform/core`. Please submit your PR on the https://github.com/api-platform/core repository.

Thanks!" From ba63275a732403968f1efa835edb575dadbada82 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 20 Sep 2024 11:50:56 +0200 Subject: [PATCH 58/90] fix(serializer): remove serializer context builder interface --- Serializer/SerializerContextBuilder.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Serializer/SerializerContextBuilder.php b/Serializer/SerializerContextBuilder.php index a7244a3..afb4824 100644 --- a/Serializer/SerializerContextBuilder.php +++ b/Serializer/SerializerContextBuilder.php @@ -13,14 +13,13 @@ namespace ApiPlatform\OpenApi\Serializer; -use ApiPlatform\Serializer\SerializerContextBuilderInterface as LegacySerializerContextBuilderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Symfony\Component\HttpFoundation\Request; /** * @internal */ -final class SerializerContextBuilder implements SerializerContextBuilderInterface, LegacySerializerContextBuilderInterface +final class SerializerContextBuilder implements SerializerContextBuilderInterface { public function __construct(private readonly SerializerContextBuilderInterface $decorated) { From 300e4b92e0ee302fd9cea3fb435316865b893a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Sat, 21 Sep 2024 12:54:56 +0200 Subject: [PATCH 59/90] doc: add README files for components (#6653) * docs: add README for components * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * Update README.md Co-authored-by: Antoine Bluchet * remove trailing spaces * typo * better title --------- Co-authored-by: Antoine Bluchet --- README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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). From 085a3e59ce698f351ecddd1105012fb12911f605 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 25 Oct 2024 11:44:09 +0200 Subject: [PATCH 60/90] fix(hydra): iri template when using query parameter (#6742) --- Factory/OpenApiFactory.php | 152 ++++++++++++++++----------- Tests/Factory/OpenApiFactoryTest.php | 3 +- 2 files changed, 90 insertions(+), 65 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index b05d6a5..7bdf676 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -331,12 +331,26 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } + $entityClass = $this->getFilterClass($operation); $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getParameters() ?? [] as $key => $p) { if (false === $p->getOpenApi()) { continue; } + if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator->has($f)) { + $filter = $this->filterLocator->get($f); + foreach ($filter->getDescription($entityClass) 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'; $parameter = new Parameter($key, $in, $p->getDescription() ?? "$resourceShortName $key", $p->getRequired() ?? false, false, false, $p->getSchema() ?? ['type' => 'string']); @@ -654,92 +668,102 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection private function getFiltersParameters(CollectionOperationInterface|HttpOperation $operation): array { $parameters = []; - $resourceFilters = $operation->getFilters(); + $entityClass = $this->getFilterClass($operation); + foreach ($resourceFilters ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { continue; } $filter = $this->filterLocator->get($filterId); - $entityClass = $operation->getClass(); - if ($options = $operation->getStateOptions()) { - if ($options instanceof DoctrineOptions && $options->getEntityClass()) { - $entityClass = $options->getEntityClass(); - } - - if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { - $entityClass = $options->getDocumentClass(); - } + foreach ($filter->getDescription($entityClass) as $name => $description) { + $parameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $filterId); } + } - foreach ($filter->getDescription($entityClass) as $name => $data) { - if (isset($data['swagger'])) { - trigger_deprecation('api-platform/core', '4.0', \sprintf('Using the "swagger" field of the %s::getDescription() (%s) is deprecated.', $filter::class, $operation->getShortName())); - } + return $parameters; + } - if (!isset($data['openapi']) || $data['openapi'] instanceof Parameter) { - $schema = $data['schema'] ?? []; + private function getFilterClass(HttpOperation $operation): ?string + { + $entityClass = $operation->getClass(); + if ($options = $operation->getStateOptions()) { + if ($options instanceof DoctrineOptions && $options->getEntityClass()) { + return $options->getEntityClass(); + } - if (isset($data['type']) && \in_array($data['type'] ?? null, Type::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->jsonSchemaTypeFactory ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)); - } + if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { + return $options->getDocumentClass(); + } + } - if (!isset($schema['type'])) { - $schema['type'] = 'string'; - } + return $entityClass; + } - $style = 'array' === ($schema['type'] ?? null) && \in_array( - $data['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], - true - ) ? 'deepObject' : 'form'; + 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)); + } - $parameter = isset($data['openapi']) && $data['openapi'] instanceof Parameter ? $data['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $data['is_collection'] ?? false); + if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { + $schema = $description['schema'] ?? []; - if ('' === $parameter->getDescription() && ($description = $data['description'] ?? '')) { - $parameter = $parameter->withDescription($description); - } + if (isset($description['type']) && \in_array($description['type'], Type::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->jsonSchemaTypeFactory ? $this->jsonSchemaTypeFactory->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)); + } - if (false === $parameter->getRequired() && false !== ($required = $data['required'] ?? false)) { - $parameter = $parameter->withRequired($required); - } + if (!isset($schema['type'])) { + $schema['type'] = 'string'; + } - $parameters[] = $parameter->withSchema($schema); - continue; - } + $style = 'array' === ($schema['type'] ?? null) && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form'; - 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::class, $operation->getShortName())); - if ($this->jsonSchemaTypeFactory) { - $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false), 'openapi') : ['type' => 'string']); - } else { - $schema = $data['schema'] ?? (\in_array($data['type'], Type::$builtinTypes, true) ? $this->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']); - } + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false); - $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 - ); + if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) { + $parameter = $parameter->withDescription($str); + } + + if (false === $parameter->getRequired() && false !== ($required = $description['required'] ?? false)) { + $parameter = $parameter->withRequired($required); } + + return $parameter->withSchema($schema); } - return $parameters; + trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter, $shortName)); + if ($this->jsonSchemaTypeFactory) { + $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false), 'openapi') : ['type' => 'string']); + } else { + $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : ['type' => 'string']); + } + + return new Parameter( + $name, + 'query', + $description['description'] ?? '', + $description['required'] ?? false, + $description['openapi']['deprecated'] ?? false, + $description['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array( + $description['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + true + ) ? 'deepObject' : 'form', + $description['openapi']['explode'] ?? ('array' === $schema['type']), + $description['openapi']['allowReserved'] ?? false, + $description['openapi']['example'] ?? null, + isset( + $description['openapi']['examples'] + ) ? new \ArrayObject($description['openapi']['examples']) : null + ); } private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 0541cfd..1d3511d 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -888,7 +888,8 @@ public function testInvoke(): void 'type' => 'string', 'enum' => ['asc', 'desc'], ]), - ] + ], + deprecated: false ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); From 2c06d113ef84eada28fe64a069edf98588a32d3f Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Wed, 27 Nov 2024 17:15:37 +0100 Subject: [PATCH 61/90] cs: php-cs-fixer 3.65.0 compatibility (#6832) --- Factory/OpenApiFactory.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 7bdf676..84a779c 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -33,7 +33,6 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Attributes\Webhook; -use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Contact; use ApiPlatform\OpenApi\Model\ExternalDocumentation; @@ -512,11 +511,11 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat } /** - * @return \ArrayObject + * @return \ArrayObject */ private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject { - /** @var \ArrayObject $content */ + /** @var \ArrayObject $content */ $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { @@ -603,11 +602,11 @@ private function getPathDescription(string $resourceShortName, string $method, b /** * @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 From 04324316158c271149e0dd4d9b1f9c0fed8c031e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 13 Dec 2024 12:23:29 +0100 Subject: [PATCH 62/90] fix: filter may not use FilterInterface (#6858) * fix: filter may not use FilterInterface * add tests --- Factory/OpenApiFactory.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 3a07e9a..435cd4f 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -269,15 +269,18 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if (($f = $p->getFilter()) && \is_string($f) && $this->filterLocator && $this->filterLocator->has($f)) { $filter = $this->filterLocator->get($f); - foreach ($filter->getDescription($entityClass) as $name => $description) { - if ($prop = $p->getProperty()) { - $name = str_replace($prop, $key, $name); + + 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); } - $openapiParameters[] = $this->getFilterParameter($name, $description, $operation->getShortName(), $f); + continue; } - - continue; } $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; From 3de993ac4f96a7d484ead1dccfa5f4d82f6db307 Mon Sep 17 00:00:00 2001 From: Tobias Oitzinger <42447585+toitzi@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:26:42 +0100 Subject: [PATCH 63/90] feat(openapi): HTTP Authentication Support for Swagger UI (#6665) * feat(openapi): http authentication support add support for http authentication (for example http basic or bearer tokens) Closes: #6664 * add symfony support --------- Co-authored-by: soyuka --- Factory/OpenApiFactory.php | 5 +++++ Options.php | 7 ++++++- Tests/Factory/OpenApiFactoryTest.php | 12 ++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 435cd4f..30fe6cb 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -744,6 +744,11 @@ private function getSecuritySchemes(): array $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; } diff --git a/Options.php b/Options.php index 5683c7c..a229b4d 100644 --- a/Options.php +++ b/Options.php @@ -15,7 +15,7 @@ final 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, private bool $overrideResponses = true) + 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, private bool $overrideResponses = true, private readonly array $httpAuth = []) { } @@ -74,6 +74,11 @@ public function getApiKeys(): array return $this->apiKeys; } + public function getHttpAuth(): array + { + return $this->httpAuth; + } + public function getContactName(): ?string { return $this->contactName; diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index d95b74b..648ca63 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -521,6 +521,14 @@ public function testInvoke(): void 'type' => 'query', 'name' => 'key', ], + ], null, null, null, null, null, null, true, [ + 'bearer' => [ + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ], + 'basic' => [ + 'scheme' => 'basic', + ], ]), new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') ); @@ -622,12 +630,16 @@ public function testInvoke(): void '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(); From 3313d511f5257a1f0ed1929e7d809d02d63d90cf Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Thu, 9 Jan 2025 11:29:41 +0100 Subject: [PATCH 64/90] fix(openapi): not forbidden response on openAPI doc (#6886) --- Factory/OpenApiFactory.php | 4 ++++ Tests/Factory/OpenApiFactoryTest.php | 29 +++++++++++++++++++++++++++- Tests/Fixtures/Issue6872/Diamond.php | 19 ++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 Tests/Fixtures/Issue6872/Diamond.php diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 84a779c..1a51fbb 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -408,6 +408,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } + if (true === $overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { + $openapiOperation = $openapiOperation->withResponse(403, new Response('Forbidden')); + } + if (true === $overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { if (!isset($existingResponses[404])) { $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 1d3511d..0acf7db 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -59,6 +59,7 @@ 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\Pagination\PaginationOptions; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; @@ -85,6 +86,7 @@ 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'); + $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( @@ -272,13 +274,23 @@ public function testInvoke(): void ]))->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, WithParameter::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, $dummyResourceWebhook])); $resourceCollectionMetadataFactoryProphecy->create(DummyErrorResource::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(DummyErrorResource::class, [new ApiResource(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])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); @@ -1171,5 +1183,20 @@ public function testInvoke(): void ], deprecated: false ), $paths->getPath('/erroredDummies')->getGet()); + + $diamondsGetPath = $paths->getPath('/diamonds'); + $diamondGetOperation = $diamondsGetPath->getGet(); + $diamondGetResponses = $diamondGetOperation->getResponses(); + + $this->assertNotNull($diamondGetOperation); + $this->assertArrayHasKey('403', $diamondGetResponses); + $this->assertSame('Forbidden', $diamondGetResponses['403']->getDescription()); + + $diamondsPutPath = $paths->getPath('/diamond/{id}'); + $diamondPutOperation = $diamondsPutPath->getPut(); + $diamondPutResponses = $diamondPutOperation->getResponses(); + + $this->assertNotNull($diamondPutOperation); + $this->assertArrayNotHasKey('403', $diamondPutResponses); } } 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; +} From 3ac2a39398bcdf26df9b79224b1a017c580c1d5a Mon Sep 17 00:00:00 2001 From: Martin Chudoba Date: Fri, 17 Jan 2025 10:45:07 +0100 Subject: [PATCH 65/90] feat: swagger ui persist authorization option (#6877) Co-authored-by: Antoine Bluchet --- Options.php | 31 +++++++++++++++++++++++++--- Tests/Factory/OpenApiFactoryTest.php | 2 +- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Options.php b/Options.php index a229b4d..49beecd 100644 --- a/Options.php +++ b/Options.php @@ -13,10 +13,30 @@ namespace ApiPlatform\OpenApi; -final class Options +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, private bool $overrideResponses = true, private readonly array $httpAuth = []) - { + 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 = [], + ) { } public function getTitle(): string @@ -113,4 +133,9 @@ public function getOverrideResponses(): bool { return $this->overrideResponses; } + + public function hasPersistAuthorization(): bool + { + return $this->persistAuthorization; + } } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 45dfdef..2a7fd03 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -533,7 +533,7 @@ public function testInvoke(): void 'type' => 'query', 'name' => 'key', ], - ], null, null, null, null, null, null, true, [ + ], null, null, null, null, null, null, true, true, [ 'bearer' => [ 'scheme' => 'bearer', 'bearerFormat' => 'JWT', From 6297e8a3df81ea2ac0354f2c7de5bce1fe1cc215 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 27 Jan 2025 17:16:57 +0100 Subject: [PATCH 66/90] feat(openapi): document error outputs using json-schemas (#6923) --- Factory/OpenApiFactory.php | 199 +++++++++++------ Tests/Factory/OpenApiFactoryTest.php | 246 ++++++++++++++------- Tests/Serializer/OpenApiNormalizerTest.php | 5 + 3 files changed, 311 insertions(+), 139 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index caeb5be..f5103eb 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Error; +use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; @@ -51,7 +52,9 @@ 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\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Routing\RouteCollection; @@ -71,7 +74,14 @@ final class OpenApiFactory implements OpenApiFactoryInterface private readonly PaginationOptions $paginationOptions; private ?RouteCollection $routeCollection = null; private ?ContainerInterface $filterLocator = null; + /** + * @var array + */ + private array $localErrorResourceCache = []; + /** + * @param array $formats + */ public function __construct( private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, @@ -322,7 +332,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { - $openapiOperation = $this->addOperationErrors($openapiOperation, $errors, $responseMimeTypes, $resourceMetadataCollection, $schema, $schemas); + /** @var array */ + $errorOperations = []; + foreach ($errors as $error) { + $errorOperations[$error] = $this->getErrorResource($error); + } + + $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation); } if ($overrideResponses || !$existingResponses) { @@ -334,40 +350,39 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection break; case '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); + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(400)->withDescription('Invalid input'), + ValidationException::class => $this->getErrorResource(ValidationException::class, 422, 'Unprocessable entity'), // we add defaults as ValidationException can not be installed + ], $resourceMetadataCollection, $schema, $schemas, $operation); break; case 'PATCH': case 'PUT': $successStatus = (string) $operation->getStatus() ?: 200; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '400', 'Invalid input', $openapiOperation); - if (!isset($existingResponses[400])) { - $openapiOperation = $openapiOperation->withResponse(400, new Response('Invalid input')); - } - $openapiOperation = $this->buildOpenApiResponse($existingResponses, '422', 'Unprocessable entity', $openapiOperation); + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(400)->withDescription('Invalid input'), + ValidationException::class => $this->getErrorResource(ValidationException::class, 422, 'Unprocessable entity'), // we add defaults as ValidationException can not be installed + ], $resourceMetadataCollection, $schema, $schemas, $operation); break; case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; - $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource deleted', $resourceShortName), $openapiOperation); - break; } } - if (true === $overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { - $openapiOperation = $openapiOperation->withResponse(403, new Response('Forbidden')); + if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(403)->withDescription('Forbidden'), + ], $resourceMetadataCollection, $schema, $schemas, $operation); } - if (true === $overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { - if (!isset($existingResponses[404])) { - $openapiOperation = $openapiOperation->withResponse(404, new Response('Resource not found')); - } + if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404])) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(404)->withDescription('Not found'), + ], $resourceMetadataCollection, $schema, $schemas, $operation); } if (!$openapiOperation->getResponses()) { @@ -398,9 +413,22 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($openapiAttribute instanceof Webhook) { $webhooks[$openapiAttribute->getName()] = $pathItem->{'with'.ucfirst($method)}($openapiOperation); - } else { - $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + 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(); + + foreach ($errorResponse->getContent() as $mime => $content) { + $currentResponseContent[$mime] = $content; + } + + $openapiOperation = $existingOperation->withResponse(200, $currentResponse->withContent($currentResponseContent)); } + + $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); } } @@ -421,6 +449,9 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat } /** + * @param array $responseMimeTypes + * @param array $operationSchemas + * * @return \ArrayObject */ private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject @@ -545,7 +576,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'); } } @@ -555,7 +586,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'); } } @@ -610,6 +641,9 @@ private function getFilterClass(HttpOperation $operation): ?string return $entityClass; } + /** + * @param array $description + */ private function getFilterParameter(string $name, array $description, string $shortName, string $filter): Parameter { if (isset($description['swagger'])) { @@ -756,6 +790,10 @@ private function getSecuritySchemes(): array return $securitySchemes; } + /** + * @param \ArrayObject $schemas + * @param \ArrayObject $definitions + */ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $definitions): void { foreach ($definitions as $key => $value) { @@ -779,18 +817,20 @@ private function hasParameter(Operation $operation, Parameter $parameter): ?arra private function mergeParameter(Parameter $actual, Parameter $defined): Parameter { - foreach ([ - 'name', - 'in', - 'description', - 'required', - 'deprecated', - 'allowEmptyValue', - 'style', - 'explode', - 'allowReserved', - 'example', - ] as $method) { + foreach ( + [ + 'name', + 'in', + 'description', + 'required', + 'deprecated', + 'allowEmptyValue', + 'style', + 'explode', + 'allowReserved', + 'example', + ] as $method + ) { $newValue = $defined->{"get$method"}(); if (null !== $newValue && $actual->{"get$method"}() !== $newValue) { $actual = $actual->{"with$method"}($newValue); @@ -808,20 +848,55 @@ private function mergeParameter(Parameter $actual, Parameter $defined): Paramete } /** - * @param string[] $errors - * @param array $responseMimeTypes + * @param array $errors + * @param \ArrayObject $schemas */ - private function addOperationErrors(Operation $operation, array $errors, array $responseMimeTypes, ResourceMetadataCollection $resourceMetadataCollection, Schema $schema, \ArrayObject $schemas): Operation - { - $existingResponses = null; - foreach ($errors as $error) { - if (!is_a($error, ProblemExceptionInterface::class, true)) { - throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); + private function addOperationErrors( + Operation $operation, + array $errors, + ResourceMetadataCollection $resourceMetadataCollection, + Schema $schema, + \ArrayObject $schemas, + HttpOperation $originalOperation, + ): Operation { + $defaultFormat = ['json' => ['application/problem+json']]; + foreach ($errors as $error => $errorResource) { + $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $defaultFormat); + foreach ($errorResource->getOperations() as $errorOperation) { + if (false === $errorOperation->getOpenApi()) { + continue; + } + + $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $defaultFormat); } - $status = null; - $description = null; + $operationErrorSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchemas[$operationFormat] = $operationErrorSchema; + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } + if (!$status = $errorResource->getStatus()) { + throw new RuntimeException(\sprintf('The error class %s has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $error)); + } + + $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + } + + return $operation; + } + + /** + * @param string|class-string $error + */ + private function getErrorResource(string $error, ?int $status = null, ?string $description = null): ErrorResource + { + if ($this->localErrorResourceCache[$error] ?? null) { + return $this->localErrorResourceCache[$error]; + } + + if (is_a($error, ProblemExceptionInterface::class, true)) { try { /** @var ProblemExceptionInterface $exception */ $exception = new $error(); @@ -829,32 +904,32 @@ private function addOperationErrors(Operation $operation, array $errors, array $ $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)); + } - try { - $errorOperation = $this->resourceMetadataFactory->create($error)->getOperation(); - if (!is_a($errorOperation, Error::class)) { - throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); - } - } catch (ResourceClassNotFoundException|OperationNotFoundException) { - $errorOperation = null; + try { + $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + if (!($errorResource instanceof ErrorResource)) { + throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); } - $status ??= $errorOperation?->getStatus(); - $description ??= $errorOperation?->getDescription(); - if (!$status) { - throw new RuntimeException(\sprintf('The error class %s has no status defined, please either implement ProblemExceptionInterface, or make it an ErrorResource with a status', $error)); + // Here we want the exception status and expression to override the resource one when available + if ($status) { + $errorResource = $errorResource->withStatus($status); } - $operationErrorSchemas = []; - foreach ($responseMimeTypes as $operationFormat) { - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($error, $operationFormat, Schema::TYPE_OUTPUT, null, $schema); - $operationErrorSchemas[$operationFormat] = $operationErrorSchema; - $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + if ($description) { + $errorResource = $errorResource->withDescription($description); } + } catch (ResourceClassNotFoundException|OperationNotFoundException) { + $errorResource = new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + } - $operation = $this->buildOpenApiResponse($existingResponses ??= $operation->getResponses() ?: [], $status, $description ?? '', $operation, $errorOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + if (!$errorResource->getClass()) { + $errorResource = $errorResource->withClass($error); } - return $operation; + return $this->localErrorResourceCache[$error] = $errorResource; } } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 2a7fd03..7fd600d 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Error as ErrorOperation; +use ApiPlatform\Metadata\ErrorResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; @@ -60,8 +61,10 @@ 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; @@ -285,14 +288,17 @@ public function testInvoke(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); - $resourceCollectionMetadataFactoryProphecy->create(DummyErrorResource::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(DummyErrorResource::class, [new ApiResource(operations: [new ErrorOperation(name: 'err', description: 'nice one!')])])); + $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( @@ -407,59 +413,62 @@ public function testInvoke(): void ->withSchema(['type' => 'string', 'description' => 'This is an enum.']) ->withOpenapiContext(['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']) ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'type', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error type.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error type.']) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'title', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error title.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error title.']) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'status', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) - ->withDescription('This is an error status.') - ->withReadable(true) - ->withWritable(false) - ->withIdentifier(true) - ->withSchema(['type' => 'integer', 'description' => 'This is an error status.', 'readOnly' => true]) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'detail', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error detail.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error detail.']) - ); - $propertyMetadataFactoryProphecy->create(DummyErrorResource::class, 'instance', Argument::any())->shouldBeCalled()->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) - ->withDescription('This is an error instance.') - ->withReadable(true) - ->withWritable(false) - ->withReadableLink(true) - ->withWritableLink(true) - ->withInitializable(true) - ->withSchema(['type' => 'string', 'description' => 'This is an error instance.']) - ); + + foreach ([DummyErrorResource::class, Error::class] as $cl) { + $propertyMetadataFactoryProphecy->create($cl, 'type', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error type.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error type.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'title', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error title.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error title.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'status', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withDescription('This is an error status.') + ->withReadable(true) + ->withWritable(false) + ->withIdentifier(true) + ->withSchema(['type' => 'integer', 'description' => 'This is an error status.', 'readOnly' => true]) + ); + $propertyMetadataFactoryProphecy->create($cl, 'detail', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error detail.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error detail.']) + ); + $propertyMetadataFactoryProphecy->create($cl, 'instance', Argument::any())->shouldBeCalled()->willReturn( + (new ApiProperty()) + ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withDescription('This is an error instance.') + ->withReadable(true) + ->withWritable(false) + ->withReadableLink(true) + ->withWritableLink(true) + ->withInitializable(true) + ->withSchema(['type' => 'string', 'description' => 'This is an error instance.']) + ); + } $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $filters = [ @@ -610,6 +619,8 @@ public function testInvoke(): void ]), ], ])); + $errorSchema = clone $dummyErrorSchema->getDefinitions(); + $errorSchema['description'] = ''; $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -634,8 +645,9 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), - 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), 'Parameter' => $parameterSchema, + 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), + 'Error' => $errorSchema, ])); $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ @@ -703,8 +715,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -735,7 +755,11 @@ public function testInvoke(): void 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), ]) ), - '404' => new Response('Resource not found'), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Retrieves a Dummy resource.', 'Retrieves a Dummy resource.', @@ -755,9 +779,21 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -777,7 +813,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.', @@ -800,7 +840,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', @@ -843,9 +887,21 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -952,8 +1008,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -996,8 +1060,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -1033,8 +1105,16 @@ public function testInvoke(): void ]) ), '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), - '404' => new Response('Resource not found'), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '404' => new Response( + 'Not found', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Replaces the Dummy resource.', 'Replaces the Dummy resource.', @@ -1070,7 +1150,11 @@ public function testInvoke(): void ]) ), '400' => new Response('Error'), - '422' => new Response('Unprocessable entity'), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -1128,8 +1212,16 @@ public function testInvoke(): void null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), - '400' => new Response('Invalid input'), - '422' => new Response('Unprocessable entity'), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -1160,11 +1252,11 @@ public function testInvoke(): void '418' => new Response( 'A Teapot Exception', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/problem+json' => new MediaType(new \ArrayObject(new \ArrayObject([ '$ref' => '#/components/schemas/DummyErrorResource', ]))), ]), - links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(), null, 'This is a dummy')]) + 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.', diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index defc4ad..23b7d7a 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -43,7 +43,9 @@ 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\Validator\Exception\ValidationException; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -100,6 +102,7 @@ public function testNormalize(): void $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(Error::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection([])); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) @@ -141,6 +144,8 @@ public function testNormalize(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); $resourceCollectionMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); + $resourceCollectionMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceCollectionMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( From 1ac6a75fcbe22bf7a13093ab9d4298bb271bc028 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 6 Feb 2025 11:51:59 +0100 Subject: [PATCH 67/90] feat(openapi): filter x-apiplatform-tags to produce different openapi specifications (#6945) --- Attributes/Webhook.php | 3 ++ Command/OpenApiCommand.php | 11 +++-- Factory/OpenApiFactory.php | 34 ++++++++++++++-- Factory/OpenApiFactoryInterface.php | 2 + Model/Tag.php | 62 +++++++++++++++++++++++++++++ Options.php | 14 +++++++ 6 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 Model/Tag.php diff --git a/Attributes/Webhook.php b/Attributes/Webhook.php index 2bf205a..a39d68c 100644 --- a/Attributes/Webhook.php +++ b/Attributes/Webhook.php @@ -13,10 +13,13 @@ 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, diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 6f32b9a..164a5af 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -43,7 +43,8 @@ protected function configure(): void ->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('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,9 +54,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); - $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json', [ - 'spec_version' => $input->getOption('spec-version'), - ]); + $data = $this->normalizer->normalize( + $this->openApiFactory->__invoke(['filter_tags' => $input->getOption('filter-tags')]), + 'json', + ['spec_version' => $input->getOption('spec-version')] + ); if ($input->getOption('yaml') && !class_exists(Yaml::class)) { $output->writeln('The "symfony/yaml" component is not installed.'); diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index f5103eb..9d1cd0e 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -49,6 +49,7 @@ 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; @@ -69,6 +70,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface 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; @@ -101,6 +103,10 @@ public function __construct( /** * {@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 { @@ -112,12 +118,13 @@ public function __invoke(array $context = []): OpenApi $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, $webhooks, $context); + $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context); } } @@ -128,6 +135,8 @@ public function __invoke(array $context = []): OpenApi $securityRequirements[] = [$key => []]; } + $globalTags = $this->openApiOptions->getTags() ?: array_values($tags) ?: []; + return new OpenApi( $info, $servers, @@ -142,19 +151,25 @@ public function __invoke(array $context = []): OpenApi new \ArrayObject($securitySchemes) ), $securityRequirements, - [], + $globalTags, null, null, $webhooks ); } - private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks, array $context = []): 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; } + // 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(); // No path to return @@ -169,6 +184,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + $operationTag = ($openapiAttribute?->getExtensionProperties()[self::API_PLATFORM_TAG] ?? []); + if (!\is_array($operationTag)) { + $operationTag = [$operationTag]; + } + + if ($filteredTags && $filteredTags !== array_intersect($filteredTags, $operationTag)) { + continue; + } + $resourceClass = $operation->getClass() ?? $resource->getClass(); $routeName = $operation->getRouteName() ?? $operation->getName(); @@ -217,6 +241,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection extensionProperties: $openapiOperation->getExtensionProperties(), ); + foreach ($openapiOperation->getTags() as $v) { + $tags[$v] = new Tag(name: $v, description: $resource->getDescription()); + } + [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); if ($path) { 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/Model/Tag.php b/Model/Tag.php new file mode 100644 index 0000000..bb7db4e --- /dev/null +++ b/Model/Tag.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Model; + +final class Tag +{ + use ExtensionTrait; + + public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null) + { + } + + public function getName(): string + { + return $this->name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function getExternalDocs(): string + { + return $this->externalDocs; + } + + public function withExternalDocs(string $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } +} diff --git a/Options.php b/Options.php index 49beecd..175b700 100644 --- a/Options.php +++ b/Options.php @@ -13,8 +13,13 @@ namespace ApiPlatform\OpenApi; +use ApiPlatform\OpenApi\Model\Tag; + final readonly class Options { + /** + * @param Tag[] $tags + */ public function __construct( private string $title, private string $description = '', @@ -36,6 +41,7 @@ public function __construct( private bool $overrideResponses = true, private bool $persistAuthorization = false, private array $httpAuth = [], + private array $tags = [], ) { } @@ -138,4 +144,12 @@ public function hasPersistAuthorization(): bool { return $this->persistAuthorization; } + + /** + * @return Tag[] + */ + public function getTags(): array + { + return $this->tags; + } } From ab8cdb3df96bdca202e068863943ebd9071d5952 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 7 Feb 2025 15:44:27 +0100 Subject: [PATCH 68/90] fix: errors retrieval and documentation (#6952) --- Factory/OpenApiFactory.php | 58 ++++++++++++++-------- Options.php | 22 +++++++- Tests/Factory/OpenApiFactoryTest.php | 4 +- Tests/Serializer/OpenApiNormalizerTest.php | 1 - 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 9d1cd0e..fc4abf3 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -95,6 +95,7 @@ public function __construct( ?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'); @@ -122,7 +123,6 @@ public function __invoke(array $context = []): OpenApi foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); - foreach ($resourceMetadataCollection as $resourceMetadata) { $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks, $tags, $context); } @@ -164,6 +164,9 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection return; } + $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class); + $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity'); + // This filters on our extension x-apiplatform-tag as the openapi operation tag is used for ordering operations $filteredTags = $context['filter_tags'] ?? []; if (!\is_array($filteredTags)) { @@ -359,6 +362,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiOperation->withParameters($openapiParameters); $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); + $errors = null; if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { /** @var array */ $errorOperations = []; @@ -380,19 +384,24 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $successStatus = (string) $operation->getStatus() ?: 201; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource created', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(400)->withDescription('Invalid input'), - ValidationException::class => $this->getErrorResource(ValidationException::class, 422, 'Unprocessable entity'), // we add defaults as ValidationException can not be installed - ], $resourceMetadataCollection, $schema, $schemas, $operation); + if (null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(400)->withDescription('Invalid input'), + $defaultValidationError, + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } break; case 'PATCH': case 'PUT': $successStatus = (string) $operation->getStatus() ?: 200; $openapiOperation = $this->buildOpenApiResponse($existingResponses, $successStatus, \sprintf('%s resource updated', $resourceShortName), $openapiOperation, $operation, $responseMimeTypes, $operationOutputSchemas, $resourceMetadataCollection); - $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(400)->withDescription('Invalid input'), - ValidationException::class => $this->getErrorResource(ValidationException::class, 422, 'Unprocessable entity'), // we add defaults as ValidationException can not be installed - ], $resourceMetadataCollection, $schema, $schemas, $operation); + + if (null === $errors) { + $openapiOperation = $this->addOperationErrors($openapiOperation, [ + $defaultError->withStatus(400)->withDescription('Invalid input'), + $defaultValidationError, + ], $resourceMetadataCollection, $schema, $schemas, $operation); + } break; case 'DELETE': $successStatus = (string) $operation->getStatus() ?: 204; @@ -403,13 +412,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(403)->withDescription('Forbidden'), + $defaultError->withStatus(403)->withDescription('Forbidden'), ], $resourceMetadataCollection, $schema, $schemas, $operation); } - if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404])) { + if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ - ApiResourceError::class => $this->getErrorResource(ApiResourceError::class)->withStatus(404)->withDescription('Not found'), + $defaultError->withStatus(404)->withDescription('Not found'), ], $resourceMetadataCollection, $schema, $schemas, $operation); } @@ -876,8 +885,8 @@ private function mergeParameter(Parameter $actual, Parameter $defined): Paramete } /** - * @param array $errors - * @param \ArrayObject $schemas + * @param ErrorResource[] $errors + * @param \ArrayObject $schemas */ private function addOperationErrors( Operation $operation, @@ -887,15 +896,20 @@ private function addOperationErrors( \ArrayObject $schemas, HttpOperation $originalOperation, ): Operation { - $defaultFormat = ['json' => ['application/problem+json']]; - foreach ($errors as $error => $errorResource) { - $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $defaultFormat); + 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() ?: $defaultFormat); + $responseMimeTypes += $this->flattenMimeTypes($errorOperation->getOutputFormats() ?: $this->errorFormats); + } + + foreach ($responseMimeTypes as $mime => $format) { + if (!isset($this->errorFormats[$format])) { + unset($responseMimeTypes[$mime]); + } } $operationErrorSchemas = []; @@ -906,7 +920,7 @@ private function addOperationErrors( } 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', $error)); + 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); @@ -936,8 +950,10 @@ private function getErrorResource(string $error, ?int $status = null, ?string $d 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: ApiResourceError::class); + $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)); } @@ -951,7 +967,7 @@ private function getErrorResource(string $error, ?int $status = null, ?string $d $errorResource = $errorResource->withDescription($description); } } catch (ResourceClassNotFoundException|OperationNotFoundException) { - $errorResource = new ErrorResource(status: $status, description: $description, class: ApiResourceError::class); + $errorResource = new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass); } if (!$errorResource->getClass()) { diff --git a/Options.php b/Options.php index 175b700..a6f0035 100644 --- a/Options.php +++ b/Options.php @@ -18,7 +18,9 @@ final readonly class Options { /** - * @param Tag[] $tags + * @param Tag[] $tags + * @param class-string $errorResourceClass + * @param class-string $validationErrorResourceClass */ public function __construct( private string $title, @@ -42,6 +44,8 @@ public function __construct( private bool $persistAuthorization = false, private array $httpAuth = [], private array $tags = [], + private ?string $errorResourceClass = null, + private ?string $validationErrorResourceClass = null, ) { } @@ -152,4 +156,20 @@ public function getTags(): array { return $this->tags; } + + /** + * @return class-string|null + */ + public function getErrorResourceClass(): ?string + { + return $this->errorResourceClass; + } + + /** + * @return class-string|null + */ + public function getValidationErrorResourceClass(): ?string + { + return $this->validationErrorResourceClass; + } } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 7fd600d..18c02e4 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -551,7 +551,9 @@ public function testInvoke(): void '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'); diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 23b7d7a..ccde651 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -102,7 +102,6 @@ public function testNormalize(): void $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(Error::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection([])); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) From 9d96b31ad3eb524a2cb6f89f835deca858beacd0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 21 Feb 2025 10:07:11 +0100 Subject: [PATCH 69/90] fix(laravel): handle route prefix (#6978) fixes #6969 --- Factory/OpenApiFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 6c7b092..f9203ed 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -169,7 +169,7 @@ 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); From c5a5c861924c837f60eadd2806adfad84a0bad68 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 28 Feb 2025 11:08:08 +0100 Subject: [PATCH 70/90] chore: dependency constraints (#6988) --- composer.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 3576b85..a361887 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^3.4 || ^4.0", - "api-platform/metadata": "^3.4 || ^4.0", - "api-platform/state": "^3.4 || ^4.0", + "api-platform/json-schema": "^4.1", + "api-platform/metadata": "^4.1", + "api-platform/state": "^4.1", "symfony/console": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0" @@ -38,9 +38,9 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.2", - "api-platform/doctrine-common": "^3.4 || ^4.0", - "api-platform/doctrine-orm": "^3.4 || ^4.0", - "api-platform/doctrine-odm": "^3.4 || ^4.0" + "api-platform/doctrine-common": "^4.1", + "api-platform/doctrine-orm": "^4.1", + "api-platform/doctrine-odm": "^4.1" }, "autoload": { "psr-4": { @@ -62,7 +62,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.0.x-dev", + "dev-main": "4.2.x-dev", "dev-3.4": "3.4.x-dev" }, "symfony": { From 13c4d40ad37d2677250096e4da4d92f17e0895d5 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 7 Mar 2025 17:11:34 +0100 Subject: [PATCH 71/90] fix(openapi): boolean "true" value in HttpOperation::openapi (#7003) fixes #6993 --- Factory/OpenApiFactory.php | 7 ++-- Tests/Factory/OpenApiFactoryTest.php | 50 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index d885ede..fbdc982 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -174,7 +174,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } 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; @@ -187,7 +187,8 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - $operationTag = ($openapiAttribute?->getExtensionProperties()[self::API_PLATFORM_TAG] ?? []); + // 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]; } @@ -206,7 +207,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($this->routeCollection && $routeName && $route = $this->routeCollection->get($routeName)) { $path = $route->getPath(); } else { - $path = rtrim($operation->getRoutePrefix() ?? '', '/').'/'.ltrim($operation->getUriTemplate(), '/'); + $path = rtrim($operation->getRoutePrefix() ?? '', '/').'/'.ltrim($operation->getUriTemplate() ?? '', '/'); } $path = $this->getPath($path); diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 18c02e4..2f938a9 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -1296,4 +1296,54 @@ public function testInvoke(): void $this->assertNotNull($diamondPutOperation); $this->assertArrayNotHasKey('403', $diamondPutResponses); } + + public function testGetExtensionPropertiesWithFalseValue(): void + { + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $definitionNameFactory = new DefinitionNameFactory([]); + + $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ + (new Get())->withOpenapi(true)->withShortName('Dummy')->withName('api_dummies_get_collection')->withRouteName('api_dummies_get_collection'), + ]))->withClass(Dummy::class)]); + + $resourceCollectionMetadataFactory + ->method('create') + ->willReturnCallback(fn (string $resourceClass): ResourceMetadataCollection => match ($resourceClass) { + default => new ResourceMetadataCollection($resourceClass, []), + Dummy::class => $resourceCollectionMetadata, + }); + + $resourceNameCollectionFactory->expects($this->once()) + ->method('create') + ->willReturn(new ResourceNameCollection([Dummy::class])); + + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([])); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceCollectionMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactory, + $resourceCollectionMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + null, + [], + new Options('Test API', 'This is a test API.', '1.2.3'), + new PaginationOptions(), + null, + ['json' => ['application/problem+json']] + ); + + $openApi = $factory->__invoke(); + } } From 2219d18bc368d4ef4884f9ce7c34d936f88e0741 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 13 Mar 2025 11:28:10 +0100 Subject: [PATCH 72/90] feat(laravel): openapi export command (#7016) --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index a361887..a801271 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "symfony/console": "^6.4 || ^7.0", + "symfony/filesystem": "^7.2", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0" }, From 5783f3938a4e6dd344aa20e6bdcb8fe9f68d185f Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 18 Mar 2025 11:52:18 +0100 Subject: [PATCH 73/90] chore(openapi): symfony/filesystem requirement to 6.4 | 7.0 fixes #7025 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a801271..7a134b2 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "api-platform/metadata": "^4.1", "api-platform/state": "^4.1", "symfony/console": "^6.4 || ^7.0", - "symfony/filesystem": "^7.2", + "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0" }, From 32e4591d18b19401f9c0aeb0965ffb178938b978 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 11 Apr 2025 11:32:56 +0200 Subject: [PATCH 74/90] chore: phpunit missing deprecation triggers (#7059) --- phpunit.xml.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 338e9e1..1affd3c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,6 +9,9 @@ + + trigger_deprecation + ./ From 11f49c94b94872894d48e6dae6a7354e8722bb5a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 18 Apr 2025 10:39:51 +0200 Subject: [PATCH 75/90] ci: patch phpunit deprecations inside component (#7103) --- composer.json | 10 ++++++++-- phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 7a134b2..86cd21e 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^11.2", + "phpunit/phpunit": "11.5.x-dev", "api-platform/doctrine-common": "^4.1", "api-platform/doctrine-orm": "^4.1", "api-platform/doctrine-odm": "^4.1" @@ -76,5 +76,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/soyuka/phpunit" + } + ] } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 1affd3c..4fee113 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ ./Tests/ - + trigger_deprecation From abaf442690fa108d5c799ba3ba5a2d650f8cf7ff Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 23 Apr 2025 10:40:08 +0200 Subject: [PATCH 76/90] refactor(state): state options code duplication (#7109) --- Factory/OpenApiFactory.php | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index fbdc982..57da205 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -13,8 +13,6 @@ namespace ApiPlatform\OpenApi\Factory; -use ApiPlatform\Doctrine\Odm\State\Options as DoctrineODMOptions; -use ApiPlatform\Doctrine\Orm\State\Options as DoctrineOptions; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\ApiResource; @@ -55,6 +53,7 @@ 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; @@ -67,6 +66,7 @@ final class OpenApiFactory implements OpenApiFactoryInterface { use NormalizeOperationNameTrait; + use StateOptionsTrait; use TypeFactoryTrait; public const BASE_URL = 'base_url'; @@ -302,7 +302,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - $entityClass = $this->getFilterClass($operation); + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass()); $openapiParameters = $openapiOperation->getParameters(); foreach ($operation->getParameters() ?? [] as $key => $p) { if (false === $p->getOpenApi()) { @@ -647,7 +647,7 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation { $parameters = []; $resourceFilters = $operation->getFilters(); - $entityClass = $this->getFilterClass($operation); + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass()); foreach ($resourceFilters ?? [] as $filterId) { if (!$this->filterLocator->has($filterId)) { @@ -663,22 +663,6 @@ private function getFiltersParameters(CollectionOperationInterface|HttpOperation return $parameters; } - private function getFilterClass(HttpOperation $operation): ?string - { - $entityClass = $operation->getClass(); - if ($options = $operation->getStateOptions()) { - if ($options instanceof DoctrineOptions && $options->getEntityClass()) { - return $options->getEntityClass(); - } - - if ($options instanceof DoctrineODMOptions && $options->getDocumentClass()) { - return $options->getDocumentClass(); - } - } - - return $entityClass; - } - /** * @param array $description */ From 278d5c70923a2648f112d0c47d913d914d9c1b96 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:29:32 +0200 Subject: [PATCH 77/90] fix: command name deprecation --- Command/OpenApiCommand.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 164a5af..6136819 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) @@ -82,9 +84,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return \defined(Command::class.'::SUCCESS') ? Command::SUCCESS : 0; } - - public static function getDefaultName(): string - { - return 'api:openapi:export'; - } } From 437b096851f73d0167e8e48c5eb65c2b449dec3f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 10:27:56 +0200 Subject: [PATCH 78/90] fix(openapi): nullable externalDocs return type fixes #7163 --- Model/Tag.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Model/Tag.php b/Model/Tag.php index bb7db4e..c079352 100644 --- a/Model/Tag.php +++ b/Model/Tag.php @@ -47,7 +47,7 @@ public function withDescription(string $description): self return $clone; } - public function getExternalDocs(): string + public function getExternalDocs(): ?string { return $this->externalDocs; } From df58b06d51ffe8ffb36ff731639c5973ec15874a Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 15:13:30 +0200 Subject: [PATCH 79/90] chore: bump patch dependencies --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 86cd21e..b69f290 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.1", - "api-platform/metadata": "^4.1", - "api-platform/state": "^4.1", + "api-platform/json-schema": "^4.1.11", + "api-platform/metadata": "^4.1.11", + "api-platform/state": "^4.1.11", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", From 4ba49a789d44191dd4ced4082202d9528d1740fd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 May 2025 10:03:08 +0200 Subject: [PATCH 80/90] ci: prefer-lowest to avoid bumping inter components dependencies (#7169) --- composer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index b69f290..c4c6c84 100644 --- a/composer.json +++ b/composer.json @@ -64,7 +64,8 @@ "extra": { "branch-alias": { "dev-main": "4.2.x-dev", - "dev-3.4": "3.4.x-dev" + "dev-3.4": "3.4.x-dev", + "dev-4.1": "4.1.x-dev" }, "symfony": { "require": "^6.4 || ^7.0" @@ -82,5 +83,6 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ] + ], + "version": "4.1.12" } From 3559cf07cdb15f5f524c7bb3776b764fd7e7ff18 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Mon, 2 Jun 2025 22:18:32 +0200 Subject: [PATCH 81/90] fix(openapi): duplicate get path for webhooks (#7174) --- Factory/OpenApiFactory.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 57da205..2d98643 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -251,10 +251,8 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); - if ($path) { - $pathItem = $paths->getPath($path) ?: new PathItem(); - } elseif (!$pathItem) { - $pathItem = new PathItem(); + if (null === $pathItem) { + $pathItem = $paths->getPath($path) ?? new PathItem(); } $forceSchemaCollection = $operation instanceof CollectionOperationInterface && 'GET' === $method; From 86ab162606a1cc9a6f996775d5a96e06b751fe7e Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 4 Jun 2025 12:13:07 +0200 Subject: [PATCH 82/90] ci: symfony 7.3 deprecations (#7192) --- phpunit.baseline.xml | 8 ++++++++ phpunit.xml.dist | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 phpunit.baseline.xml 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 4fee113..f92db3b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -8,7 +8,7 @@ ./Tests/ - + trigger_deprecation From b0ca7b3024f6715049497c47f3ef3bb0d342b5ea Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:02:15 +0200 Subject: [PATCH 83/90] fix: bump composer.json version nodes --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c4c6c84..3925215 100644 --- a/composer.json +++ b/composer.json @@ -84,5 +84,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.12" + "version": "4.1.14" } From 34142b04e394dfc5ec0638e3fcb2b1e7d334eef5 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Jun 2025 16:18:03 +0200 Subject: [PATCH 84/90] chore: missing "v" prefix in composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3925215..33ee7e9 100644 --- a/composer.json +++ b/composer.json @@ -84,5 +84,5 @@ "url": "https://github.com/soyuka/phpunit" } ], - "version": "4.1.14" + "version": "v4.1.15" } From 67b095062d8830cd730b5440632965252b7ea364 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Jun 2025 16:56:47 +0200 Subject: [PATCH 85/90] ci: remove version from composer to avoid release side effects (#7196) --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 33ee7e9..4465c51 100644 --- a/composer.json +++ b/composer.json @@ -83,6 +83,5 @@ "type": "vcs", "url": "https://github.com/soyuka/phpunit" } - ], - "version": "v4.1.15" + ] } From d60890b47f0a75912cec4ea44a98d181145ae6f3 Mon Sep 17 00:00:00 2001 From: Nico Caprioli Date: Thu, 19 Jun 2025 10:57:27 +0200 Subject: [PATCH 86/90] fix(openapi): correct example usage for 3.0 (#7218) --- Serializer/LegacyOpenApiNormalizer.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index 64d7f16..dc203c7 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -45,6 +45,11 @@ public function normalize(mixed $object, ?string $format = null, array $context } unset($schemas[$name]['properties'][$property]['type']); } + + if (\is_array($value['examples'] ?? false)) { + $schemas[$name]['properties'][$property]['example'] = $value['examples']; + unset($schemas[$name]['properties'][$property]['examples']); + } } } From 0153a8090c7726143ec0f14a7d859f45e608216a Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Tue, 1 Jul 2025 14:34:32 +0200 Subject: [PATCH 87/90] fix(openapi): command options mode (`InputOption::VALUE_REQUIRED`) (#7266) --- Command/OpenApiCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 6136819..30802a4 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -43,8 +43,8 @@ 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('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); } From 47d2e25d9a04991d245837a9af0d97e90ed99318 Mon Sep 17 00:00:00 2001 From: Takashi Kanemoto <4360663+ttskch@users.noreply.github.com> Date: Wed, 16 Jul 2025 22:25:16 +0900 Subject: [PATCH 88/90] fix(openapi): output `partial` query parameter to OpenAPI when `pagination_client_enabled` is true (#7295) --- Factory/OpenApiFactory.php | 4 ++++ Tests/Factory/OpenApiFactoryTest.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 2d98643..1b943c9 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -755,6 +755,10 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); } + if ($operation->getPaginationClientPartial() ?? $this->paginationOptions->isClientPartialPaginationEnabled()) { + $parameters[] = new Parameter($this->paginationOptions->getPartialPaginationParameterName(), 'query', 'Enable or disable partial pagination', false, false, true, ['type' => 'boolean']); + } + return $parameters; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 2f938a9..d44c3d7 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -182,6 +182,7 @@ public function testInvoke(): void 'paginatedDummyCollection' => (new GetCollection())->withUriTemplate('/paginated') ->withPaginationClientEnabled(true) ->withPaginationClientItemsPerPage(true) + ->withPaginationClientPartial(true) ->withPaginationItemsPerPage(20) ->withPaginationMaximumItemsPerPage(80) ->withOperation($baseOperation), @@ -994,6 +995,9 @@ public function testInvoke(): void new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ 'type' => 'boolean', ]), + new Parameter('partial', 'query', 'Enable or disable partial pagination', false, false, true, [ + 'type' => 'boolean', + ]), ] ), $paginatedPath->getGet()); From 8e32fd530cb1851676f0812c38e9f41c50c7cde5 Mon Sep 17 00:00:00 2001 From: Yannick Snobbert Date: Tue, 29 Jul 2025 07:50:12 +0200 Subject: [PATCH 89/90] fix(openapi): allow null on allowReserved and allowEmptyValue properties (#7315) --- Model/Parameter.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Model/Parameter.php b/Model/Parameter.php index 918da61..793e582 100644 --- a/Model/Parameter.php +++ b/Model/Parameter.php @@ -17,7 +17,7 @@ final class Parameter { use ExtensionTrait; - public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) + public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private ?bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private ?bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -148,7 +148,7 @@ public function withDeprecated(bool $deprecated): self return $clone; } - public function withAllowEmptyValue(bool $allowEmptyValue): self + public function withAllowEmptyValue(?bool $allowEmptyValue): self { $clone = clone $this; $clone->allowEmptyValue = $allowEmptyValue; @@ -180,7 +180,7 @@ public function withExplode(bool $explode): self return $clone; } - public function withAllowReserved(bool $allowReserved): self + public function withAllowReserved(?bool $allowReserved): self { $clone = clone $this; $clone->allowReserved = $allowReserved; From 793b53e51a5c24076d4024b6aa77de29e74015cd Mon Sep 17 00:00:00 2001 From: Yannick Snobbert Date: Tue, 29 Jul 2025 10:53:27 +0200 Subject: [PATCH 90/90] fix(openapi): sync typehints between properties and getter/canner for alllowReserved and allowEmptyValue (#7322) --- Model/Parameter.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Model/Parameter.php b/Model/Parameter.php index 793e582..7272c61 100644 --- a/Model/Parameter.php +++ b/Model/Parameter.php @@ -53,12 +53,12 @@ public function getDeprecated(): bool return $this->deprecated; } - public function canAllowEmptyValue(): bool + public function canAllowEmptyValue(): ?bool { return $this->allowEmptyValue; } - public function getAllowEmptyValue(): bool + public function getAllowEmptyValue(): ?bool { return $this->allowEmptyValue; } @@ -83,12 +83,12 @@ public function getExplode(): bool return $this->explode; } - public function canAllowReserved(): bool + public function canAllowReserved(): ?bool { return $this->allowReserved; } - public function getAllowReserved(): bool + public function getAllowReserved(): ?bool { return $this->allowReserved; }