From 7db3063b2e9014f267db6f444cc4819c7021d479 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 23 Mar 2023 11:41:58 +0100 Subject: [PATCH 001/148] 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 002/148] 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 003/148] 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 004/148] 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 005/148] 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 006/148] 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 007/148] 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 008/148] 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 009/148] 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 010/148] 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 011/148] 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 012/148] 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 013/148] 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 014/148] 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 015/148] 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 016/148] 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 017/148] 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 018/148] 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 019/148] 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 020/148] 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 021/148] 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 022/148] 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 023/148] 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 024/148] 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 025/148] 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 026/148] 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 027/148] 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 028/148] 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 029/148] 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 030/148] 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 031/148] 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 032/148] 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 033/148] 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 034/148] 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 035/148] 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 036/148] 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 037/148] 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 038/148] 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 039/148] 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 040/148] 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 041/148] 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 042/148] 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 043/148] 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 044/148] 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 045/148] 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 046/148] 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 047/148] 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 048/148] 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 049/148] 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 050/148] 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 051/148] 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 052/148] 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 053/148] 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 054/148] 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 055/148] 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 056/148] 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 057/148] 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 058/148] 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 059/148] 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 060/148] 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 061/148] 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 062/148] 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 063/148] 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 064/148] 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 065/148] 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 066/148] 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 067/148] 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 068/148] 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 069/148] 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 070/148] 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 071/148] 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 072/148] 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 073/148] 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 074/148] 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 8fbf08dad37760026b296148d05f30cb36d38e32 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Wed, 16 Apr 2025 21:29:35 +0200 Subject: [PATCH 075/148] feat: Use `Type` of `TypeInfo` instead of `PropertyInfo` (#6979) Co-authored-by: soyuka scopes: metadata, doctrine, json-schema --- composer.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 7a134b2..ae5821a 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "phpunit/phpunit": "^11.2", "api-platform/doctrine-common": "^4.1", "api-platform/doctrine-orm": "^4.1", - "api-platform/doctrine-odm": "^4.1" + "api-platform/doctrine-odm": "^4.1", + "symfony/type-info": "^7.3-dev" }, "autoload": { "psr-4": { @@ -76,5 +77,11 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/symfony/type-info" + } + ] } From a9232fff8d977ebdb7a6bb2d9cceef497114dee6 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 17 Apr 2025 10:53:40 +0200 Subject: [PATCH 076/148] feat(openapi): use `TypeInfo`'s `Type` (#7096) Co-authored-by: Mathias Arlaud --- Factory/OpenApiFactory.php | 53 ++++++++++++-- Factory/TypeFactoryTrait.php | 84 +++++++++++++++++++--- Tests/Factory/OpenApiFactoryTest.php | 32 ++++----- Tests/Serializer/OpenApiNormalizerTest.php | 12 ++-- composer.json | 3 +- 5 files changed, 145 insertions(+), 39 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index fbdc982..7d3bbc8 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -57,9 +57,12 @@ use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Generates an Open API v3 specification. @@ -691,17 +694,32 @@ private function getFilterParameter(string $name, array $description, string $sh if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { $schema = $description['schema'] ?? []; - if (isset($description['type']) && \in_array($description['type'], Type::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)); + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, Type::int()); + } + + $schema += $this->getType($type); + } + // TODO: remove in 5.x + } else { + if (isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) && !isset($schema['type'])) { + $schema += $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)); + } } if (!isset($schema['type'])) { $schema['type'] = 'string'; } + $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; + $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $style = 'array' === ($schema['type'] ?? null) && \in_array( $description['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + [$arrayValueType, $objectValueType], true ) ? 'deepObject' : 'form'; @@ -719,7 +737,30 @@ private function getFilterParameter(string $name, array $description, string $sh } trigger_deprecation('api-platform/core', '4.0', \sprintf('Not using "%s" on the "openapi" field of the %s::getDescription() (%s) is deprecated.', Parameter::class, $filter, $shortName)); - $schema = $description['schema'] ?? (\in_array($description['type'], Type::$builtinTypes, true) ? $this->getType(new Type($description['type'], false, null, $description['is_collection'] ?? false)) : ['type' => 'string']); + + $schema = $description['schema'] ?? null; + + if (!$schema) { + if (method_exists(PropertyInfoExtractor::class, 'getType')) { + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, key: Type::int()); + } + $schema = $this->getType($type); + } else { + $schema = ['type' => 'string']; + } + // TODO: remove in 5.x + } else { + $schema = isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) + ? $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)) + : ['type' => 'string']; + } + } + + $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; + $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; return new Parameter( $name, @@ -731,7 +772,7 @@ private function getFilterParameter(string $name, array $description, string $sh $schema, 'array' === $schema['type'] && \in_array( $description['type'], - [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], + [$arrayValueType, $objectValueType], true ) ? 'deepObject' : 'form', $description['openapi']['explode'] ?? ('array' === $schema['type']), diff --git a/Factory/TypeFactoryTrait.php b/Factory/TypeFactoryTrait.php index 6a86070..f711e92 100644 --- a/Factory/TypeFactoryTrait.php +++ b/Factory/TypeFactoryTrait.php @@ -14,7 +14,11 @@ namespace ApiPlatform\OpenApi\Factory; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\TypeInfo\Type as NativeType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; @@ -23,13 +27,20 @@ */ trait TypeFactoryTrait { - private function getType(Type $type): array + /** + * @return array + */ + private function getType(LegacyType|NativeType $type): array { + if ($type instanceof NativeType) { + return $this->getNativeType($type); + } + if ($type->isCollection()) { $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { + if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { return $this->addNullabilityToTypeDefinition([ 'type' => 'object', 'additionalProperties' => $this->getType($subType), @@ -42,22 +53,74 @@ private function getType(Type $type): array ], $type); } + return $this->addNullabilityToTypeDefinition($this->makeLegacyBasicType($type), $type); + } + + /** + * @return array + */ + private function getNativeType(NativeType $type): array + { + if ($type instanceof CollectionType) { + $keyType = $type->getCollectionKeyType(); + $subType = $type->getCollectionValueType(); + + if ($keyType->isIdentifiedBy(TypeIdentifier::STRING)) { + return $this->addNullabilityToTypeDefinition([ + 'type' => 'object', + 'additionalProperties' => $this->getNativeType($subType), + ], $type); + } + + return $this->addNullabilityToTypeDefinition([ + 'type' => 'array', + 'items' => $this->getNativeType($subType), + ], $type); + } + return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); } - private function makeBasicType(Type $type): array + /** + * @return array + */ + private function makeLegacyBasicType(LegacyType $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()), + LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], + LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], + LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], + LegacyType::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), default => ['type' => 'string'], }; } + /** + * @return array + */ + private function makeBasicType(NativeType $type): array + { + if ($type->isIdentifiedBy(TypeIdentifier::INT)) { + return ['type' => 'integer']; + } + if ($type->isIdentifiedBy(TypeIdentifier::FLOAT)) { + return ['type' => 'number']; + } + if ($type->isIdentifiedBy(TypeIdentifier::BOOL)) { + return ['type' => 'boolean']; + } + if ($type instanceof ObjectType) { + return $this->getClassType($type->getClassName(), $type->isNullable()); + } + + // Default for other built-in types like string, resource, mixed, etc. + return ['type' => 'string']; + } + /** * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. + * + * @return array */ private function getClassType(?string $className, bool $nullable): array { @@ -95,6 +158,7 @@ private function getClassType(?string $className, bool $nullable): array 'format' => 'binary', ]; } + if (is_a($className, \BackedEnum::class, true)) { $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); @@ -118,7 +182,7 @@ private function getClassType(?string $className, bool $nullable): array * * @return array */ - private function addNullabilityToTypeDefinition(array $jsonSchema, Type $type): array + private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType|NativeType $type): array { if (!$type->isNullable()) { return $jsonSchema; diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 2f938a9..17188ab 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -69,8 +69,8 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\TypeInfo\Type; class OpenApiFactoryTest extends TestCase { @@ -303,7 +303,7 @@ 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)]) + ->withNativeType(Type::int()) ->withDescription('This is an id.') ->withReadable(true) ->withWritable(false) @@ -312,7 +312,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is a name.') ->withReadable(true) ->withWritable(true) @@ -324,7 +324,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an initializable but not writable property.') ->withReadable(true) ->withWritable(false) @@ -337,7 +337,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withNativeType(Type::nullable(Type::object(\DateTime::class))) ->withDescription('This is a \DateTimeInterface object.') ->withReadable(true) ->withWritable(true) @@ -349,7 +349,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an enum.') ->withReadable(true) ->withWritable(true) @@ -362,7 +362,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'id', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withNativeType(Type::int()) ->withDescription('This is an id.') ->withReadable(true) ->withWritable(false) @@ -371,7 +371,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is a name.') ->withReadable(true) ->withWritable(true) @@ -383,7 +383,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an initializable but not writable property.') ->withReadable(true) ->withWritable(false) @@ -394,7 +394,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withNativeType(Type::nullable(Type::object(\DateTime::class))) ->withDescription('This is a \DateTimeInterface object.') ->withReadable(true) ->withWritable(true) @@ -404,7 +404,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create(OutputDto::class, 'enum', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an enum.') ->withReadable(true) ->withWritable(true) @@ -417,7 +417,7 @@ public function testInvoke(): void foreach ([DummyErrorResource::class, Error::class] as $cl) { $propertyMetadataFactoryProphecy->create($cl, 'type', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an error type.') ->withReadable(true) ->withWritable(false) @@ -428,7 +428,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create($cl, 'title', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an error title.') ->withReadable(true) ->withWritable(false) @@ -439,7 +439,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create($cl, 'status', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withNativeType(Type::int()) ->withDescription('This is an error status.') ->withReadable(true) ->withWritable(false) @@ -448,7 +448,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create($cl, 'detail', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an error detail.') ->withReadable(true) ->withWritable(false) @@ -459,7 +459,7 @@ public function testInvoke(): void ); $propertyMetadataFactoryProphecy->create($cl, 'instance', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an error instance.') ->withReadable(true) ->withWritable(false) diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index ccde651..71fe068 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -50,10 +50,10 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; +use Symfony\Component\TypeInfo\Type; class OpenApiNormalizerTest extends TestCase { @@ -149,7 +149,7 @@ public function testNormalize(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withNativeType(Type::int()) ->withDescription('This is an id.') ->withReadable(true) ->withWritable(false) @@ -158,7 +158,7 @@ public function testNormalize(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is a name.') ->withReadable(true) ->withWritable(true) @@ -170,7 +170,7 @@ public function testNormalize(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_STRING)]) + ->withNativeType(Type::string()) ->withDescription('This is an initializable but not writable property.') ->withReadable(true) ->withWritable(false) @@ -182,7 +182,7 @@ public function testNormalize(): void ); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class)]) + ->withNativeType(Type::nullable(Type::object(\DateTime::class))) ->withDescription('This is a \DateTimeInterface object.') ->withReadable(true) ->withWritable(true) @@ -195,7 +195,7 @@ public function testNormalize(): void $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) - ->withBuiltinTypes([new Type(Type::BUILTIN_TYPE_INT)]) + ->withNativeType(Type::int()) ->withDescription('This is an id.') ->withReadable(true) ->withWritable(false) diff --git a/composer.json b/composer.json index ae5821a..6c3ab20 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0" + "symfony/serializer": "^6.4 || ^7.0", + "symfony/type-info": "^7.2" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", From 11f49c94b94872894d48e6dae6a7354e8722bb5a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 18 Apr 2025 10:39:51 +0200 Subject: [PATCH 077/148] 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 078/148] 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 af1456316c408b51f0758447db5cac5283a11ba9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:26:52 +0200 Subject: [PATCH 079/148] chore: symfony/type-info 7.3.0-BETA1 --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 9822a42..2c99cf7 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "^7.2" + "symfony/type-info": "v7.3.0-BETA1" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -80,10 +80,6 @@ "test": "./vendor/bin/phpunit" }, "repositories": [ - { - "type": "vcs", - "url": "https://github.com/symfony/type-info" - }, { "type": "vcs", "url": "https://github.com/soyuka/phpunit" From c8aeff903849789c1d25cde005e97d3578a5e4d8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:29:32 +0200 Subject: [PATCH 080/148] 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 278d5c70923a2648f112d0c47d913d914d9c1b96 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 5 May 2025 13:29:32 +0200 Subject: [PATCH 081/148] 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 b2f5409a76e0e9b7b914ca6b61f2773ff46d058d Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 9 May 2025 10:59:48 +0200 Subject: [PATCH 082/148] !fix(openapi): allowReserved, allowEmtpyValue defaults to null --- Factory/OpenApiFactory.php | 54 ++++++++++++++++++++++++---- Model/Parameter.php | 10 +++--- Tests/Factory/OpenApiFactoryTest.php | 44 +++++++++++------------ 3 files changed, 73 insertions(+), 35 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 047d55d..a6636ad 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -279,7 +279,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - $parameter = new Parameter($parameterName, 'path', $uriVariable->getDescription() ?? "$resourceShortName identifier", $uriVariable->getRequired() ?? true, false, false, $uriVariable->getSchema() ?? ['type' => 'string']); + $parameter = new Parameter( + $parameterName, + 'path', + $uriVariable->getDescription() ?? "$resourceShortName identifier", + $uriVariable->getRequired() ?? true, + false, + null, + $uriVariable->getSchema() ?? ['type' => 'string'], + ); if ($linkParameter = $uriVariable->getOpenApi()) { $parameter = $this->mergeParameter($parameter, $linkParameter); @@ -329,7 +337,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; - $defaultParameter = 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, + null, + $p->getSchema() ?? ['type' => 'string'], + ); $linkParameter = $p->getOpenApi(); if (null === $linkParameter) { @@ -752,7 +768,7 @@ private function getFilterParameter(string $name, array $description, string $sh $description['description'] ?? '', $description['required'] ?? false, $description['openapi']['deprecated'] ?? false, - $description['openapi']['allowEmptyValue'] ?? true, + $description['openapi']['allowEmptyValue'] ?? null, $schema, 'array' === $schema['type'] && \in_array( $description['type'], @@ -760,7 +776,7 @@ private function getFilterParameter(string $name, array $description, string $sh true ) ? 'deepObject' : 'form', $description['openapi']['explode'] ?? ('array' === $schema['type']), - $description['openapi']['allowReserved'] ?? false, + $description['openapi']['allowReserved'] ?? null, $description['openapi']['example'] ?? null, isset( $description['openapi']['examples'] @@ -777,7 +793,15 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat $parameters = []; if ($operation->getPaginationEnabled() ?? $this->paginationOptions->isPaginationEnabled()) { - $parameters[] = new Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]); + $parameters[] = new Parameter( + $this->paginationOptions->getPaginationPageParameterName(), + 'query', + 'The collection page number', + false, + false, + null, + ['type' => 'integer', 'default' => 1], + ); if ($operation->getPaginationClientItemsPerPage() ?? $this->paginationOptions->getClientItemsPerPage()) { $schema = [ @@ -790,12 +814,28 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat $schema['maximum'] = $maxItemsPerPage; } - $parameters[] = new Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema); + $parameters[] = new Parameter( + $this->paginationOptions->getItemsPerPageParameterName(), + 'query', + 'The number of items per page', + false, + false, + null, + $schema, + ); } } if ($operation->getPaginationClientEnabled() ?? $this->paginationOptions->isPaginationClientEnabled()) { - $parameters[] = new Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); + $parameters[] = new Parameter( + $this->paginationOptions->getPaginationClientEnabledParameterName(), + 'query', + 'Enable or disable pagination', + false, + false, + null, + ['type' => 'boolean'], + ); } return $parameters; diff --git a/Model/Parameter.php b/Model/Parameter.php index 918da61..d242fa8 100644 --- a/Model/Parameter.php +++ b/Model/Parameter.php @@ -17,7 +17,7 @@ final class Parameter { use ExtensionTrait; - public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) + public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private ?bool $allowEmptyValue = null, private array $schema = [], private ?string $style = null, private bool $explode = false, private ?bool $allowReserved = null, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -53,12 +53,12 @@ public function getDeprecated(): bool return $this->deprecated; } - public function canAllowEmptyValue(): bool + public function canAllowEmptyValue(): ?bool { return $this->allowEmptyValue; } - public function getAllowEmptyValue(): bool + public function getAllowEmptyValue(): ?bool { return $this->allowEmptyValue; } @@ -83,12 +83,12 @@ public function getExplode(): bool return $this->explode; } - public function canAllowReserved(): bool + public function canAllowReserved(): ?bool { return $this->allowReserved; } - public function getAllowReserved(): bool + public function getAllowReserved(): ?bool { return $this->allowReserved; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 17188ab..c784e15 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -170,7 +170,6 @@ public function testInvoke(): void in: 'query', description: 'Test modified collection page number', required: false, - allowEmptyValue: true, schema: ['type' => 'integer', 'default' => 1], ), ], @@ -268,7 +267,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)->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), + 'uriVariableSchema' => (new Get(uriTemplate: '/uri_variable_uuid', uriVariables: ['id' => new Link(schema: ['type' => 'string', 'format' => 'uuid'], description: 'hello', required: true, openApi: new Parameter('id', 'path'))]))->withOperation($baseOperation), 'parameters' => (new Put(uriTemplate: '/parameters', parameters: [ 'foo' => new HeaderParameter(description: 'hi', schema: ['type' => 'string', 'format' => 'uuid']), ]))->withOperation($baseOperation), @@ -477,7 +476,7 @@ public function testInvoke(): void 'type' => 'string', 'required' => true, 'strategy' => 'exact', - 'openapi' => new Parameter(in: 'query', name: 'name', example: 'bar', deprecated: true, allowEmptyValue: true, allowReserved: true, explode: true), + 'openapi' => new Parameter(in: 'query', name: 'name', example: 'bar', deprecated: true, allowReserved: true, explode: true), ]]), 'f2' => new DummyFilter(['ha' => [ 'property' => 'foo', @@ -690,16 +689,16 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'Test modified collection page number', false, false, true, [ + new Parameter('page', 'query', 'Test modified collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), ] @@ -935,29 +934,29 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), - new Parameter('name', 'query', '', true, true, true, [ + new Parameter('name', 'query', '', true, true, null, [ 'type' => 'string', ], 'form', true, true, 'bar'), - new Parameter('ha', 'query', '', false, false, false, [ + new Parameter('ha', 'query', '', false, false, null, [ 'type' => 'integer', ]), - new Parameter('toto', 'query', '', true, false, false, [ + new Parameter('toto', 'query', '', true, false, null, [ 'type' => 'array', 'items' => ['type' => 'string'], ], 'deepObject', true), - new Parameter('order[name]', 'query', '', false, false, false, [ + new Parameter('order[name]', 'query', '', false, false, null, [ 'type' => 'string', 'enum' => ['asc', 'desc'], ]), @@ -981,17 +980,17 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 20, 'minimum' => 0, 'maximum' => 80, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), ] @@ -1186,16 +1185,16 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), ] @@ -1233,7 +1232,6 @@ public function testInvoke(): void ), $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]; @@ -1265,16 +1263,16 @@ public function testInvoke(): void 'Retrieves the collection of Dummy resources.', null, [ - new Parameter('page', 'query', 'The collection page number', false, false, true, [ + new Parameter('page', 'query', 'The collection page number', false, false, null, [ 'type' => 'integer', 'default' => 1, ]), - new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, null, [ 'type' => 'integer', 'default' => 30, 'minimum' => 0, ]), - new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, null, [ 'type' => 'boolean', ]), ], From c02a8608c7b8864c57aed6bfd3e92706cca8a6ec Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 12 May 2025 16:41:32 +0200 Subject: [PATCH 083/148] feat(openapi): license identifier (#7141) --- Factory/OpenApiFactory.php | 2 +- Options.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index a6636ad..43894a6 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -116,7 +116,7 @@ public function __invoke(array $context = []): OpenApi { $baseUrl = $context[self::BASE_URL] ?? '/'; $contact = null === $this->openApiOptions->getContactUrl() || null === $this->openApiOptions->getContactEmail() ? null : new Contact($this->openApiOptions->getContactName(), $this->openApiOptions->getContactUrl(), $this->openApiOptions->getContactEmail()); - $license = null === $this->openApiOptions->getLicenseName() ? null : new License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl()); + $license = null === $this->openApiOptions->getLicenseName() ? null : new License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl(), $this->openApiOptions->getLicenseIdentifier()); $info = new Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription()), $this->openApiOptions->getTermsOfService(), $contact, $license); $servers = '/' === $baseUrl || '' === $baseUrl ? [new Server('/')] : [new Server($baseUrl)]; $paths = new Paths(); diff --git a/Options.php b/Options.php index a6f0035..e91976a 100644 --- a/Options.php +++ b/Options.php @@ -46,6 +46,7 @@ public function __construct( private array $tags = [], private ?string $errorResourceClass = null, private ?string $validationErrorResourceClass = null, + private ?string $licenseIdentifier = null, ) { } @@ -172,4 +173,9 @@ public function getValidationErrorResourceClass(): ?string { return $this->validationErrorResourceClass; } + + public function getLicenseIdentifier(): ?string + { + return $this->licenseIdentifier; + } } From bd7b60e5acad35fbfe15a5abfd4569496958f933 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 13 May 2025 16:36:41 +0200 Subject: [PATCH 084/148] fix(json-schema): share invariable sub-schemas --- Factory/OpenApiFactory.php | 31 ++++++++++---- Tests/Factory/OpenApiFactoryTest.php | 48 ++++++++++------------ Tests/Serializer/OpenApiNormalizerTest.php | 2 +- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 43894a6..a98d88f 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -242,14 +242,14 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [], requestBody: $openapiOperation->getRequestBody(), callbacks: $openapiOperation->getCallbacks(), - deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : (bool) $operation->getDeprecationReason(), + deprecated: null !== $openapiOperation->getDeprecated() ? $openapiOperation->getDeprecated() : ($operation->getDeprecationReason() ? true : null), security: null !== $openapiOperation->getSecurity() ? $openapiOperation->getSecurity() : null, servers: null !== $openapiOperation->getServers() ? $openapiOperation->getServers() : null, extensionProperties: $openapiOperation->getExtensionProperties(), ); foreach ($openapiOperation->getTags() as $v) { - $tags[$v] = new Tag(name: $v, description: $resource->getDescription()); + $tags[$v] = new Tag(name: $v, description: $resource->getDescription() ?? "Resource '$v' operations."); } [$requestMimeTypes, $responseMimeTypes] = $this->getMimeTypes($operation); @@ -267,9 +267,14 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $operationOutputSchemas = []; foreach ($responseMimeTypes as $operationFormat) { - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $operationOutputSchema = null; + // Having JSONSchema for non-json schema makes no sense + if (str_starts_with($operationFormat, 'json')) { + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); + } + $operationOutputSchemas[$operationFormat] = $operationOutputSchema; - $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); } // Set up parameters @@ -454,9 +459,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if (null === $content) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchema = null; + if (str_starts_with($operationFormat, 'json')) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); + } + $operationInputSchemas[$operationFormat] = $operationInputSchema; - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); } $content = $this->buildContent($requestMimeTypes, $operationInputSchemas); } @@ -517,7 +526,7 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas) $content = new \ArrayObject(); foreach ($responseMimeTypes as $mimeType => $format) { - $content[$mimeType] = new MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); + $content[$mimeType] = isset($operationSchemas[$format]) ? new MediaType(schema: new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))) : new \ArrayObject(); } return $content; @@ -980,9 +989,13 @@ private function addOperationErrors( $operationErrorSchemas = []; foreach ($responseMimeTypes as $operationFormat) { - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchema = null; + // Having JSONSchema for non-json schema makes no sense + if (str_starts_with($operationFormat, 'json')) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } $operationErrorSchemas[$operationFormat] = $operationErrorSchema; - $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); } if (!$status = $errorResource->getStatus()) { diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index c784e15..f85e340 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -560,7 +560,6 @@ public function testInvoke(): void 'type' => 'object', 'description' => 'This is a dummy', 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], - 'deprecated' => false, 'properties' => [ 'id' => new \ArrayObject([ 'type' => 'integer', @@ -595,7 +594,6 @@ public function testInvoke(): void $dummyErrorSchema->setDefinitions(new \ArrayObject([ 'type' => 'object', 'description' => 'nice one!', - 'deprecated' => false, 'properties' => [ 'type' => new \ArrayObject([ 'type' => 'string', @@ -621,7 +619,7 @@ public function testInvoke(): void ], ])); $errorSchema = clone $dummyErrorSchema->getDefinitions(); - $errorSchema['description'] = ''; + unset($errorSchema['description']); $openApi = $factory(['base_url' => '/app_dev.php/']); @@ -646,7 +644,9 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), - 'Parameter' => $parameterSchema, + 'Dummy.jsonld' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto.jsonld' => $dummySchema->getDefinitions(), + 'Parameter.jsonld' => $parameterSchema, 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), 'Error' => $errorSchema, ])); @@ -681,7 +681,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ]))), ])), ], @@ -711,7 +711,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -734,7 +734,7 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), ]), true ) @@ -753,7 +753,7 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]) ), '404' => new Response( @@ -775,7 +775,7 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource updated', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) @@ -803,7 +803,7 @@ public function testInvoke(): void new RequestBody( 'The updated Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ) @@ -883,7 +883,7 @@ public function testInvoke(): void 'Dummy resource updated', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), - 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), + 'text/csv' => new \ArrayObject(), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) @@ -912,7 +912,7 @@ public function testInvoke(): void 'The updated Dummy resource', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), - 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new \ArrayObject(), ]), true ) @@ -926,7 +926,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ])), ])), ], @@ -961,7 +961,6 @@ public function testInvoke(): void 'enum' => ['asc', 'desc'], ]), ], - deprecated: false ), $filteredPath->getGet()); $paginatedPath = $paths->getPath('/paginated'); @@ -972,7 +971,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ])), ])), ], @@ -1004,7 +1003,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1045,7 +1044,6 @@ public function testInvoke(): void ]), false ), - deprecated: false, ), $requestBodyPath->getPost()); $requestBodyPath = $paths->getPath('/dummiesRequestBodyWithoutContent'); @@ -1056,7 +1054,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1079,11 +1077,10 @@ public function testInvoke(): void new RequestBody( 'Extended description for the new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), ]), false ), - deprecated: false, ), $requestBodyPath->getPost()); $dummyItemPath = $paths->getPath('/dummyitems/{id}'); @@ -1124,11 +1121,10 @@ public function testInvoke(): void new RequestBody( 'The updated Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ), - deprecated: false ), $dummyItemPath->getPut()); $dummyItemPath = $paths->getPath('/dummyitems'); @@ -1164,11 +1160,10 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ), - deprecated: false ), $dummyItemPath->getPost()); $dummyItemPath = $paths->getPath('/dummyitems/{id}/images'); @@ -1208,7 +1203,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto']))), + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1246,7 +1241,7 @@ public function testInvoke(): void '200' => new Response('Dummy collection', new \ArrayObject([ 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ 'type' => 'array', - 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto'], + 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], ]))), ])), '418' => new Response( @@ -1276,7 +1271,6 @@ public function testInvoke(): void 'type' => 'boolean', ]), ], - deprecated: false ), $paths->getPath('/erroredDummies')->getGet()); $diamondsGetPath = $paths->getPath('/diamonds'); diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 71fe068..1937401 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -208,7 +208,7 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory(['jsonapi' => true, 'jsonhal' => true, 'jsonld' => true]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactory, From 437b096851f73d0167e8e48c5eb65c2b449dec3f Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 May 2025 10:27:56 +0200 Subject: [PATCH 085/148] 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 086/148] 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 274aea7325de75cb48b5f65bfbb34e02cef59984 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 27 May 2025 15:41:14 +0200 Subject: [PATCH 087/148] chore: type-info v7.3.0-RC1 #7177 (#7178) --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index fd26788..4aeb57f 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "v7.3.0-BETA1" + "symfony/type-info": "v7.3.0-RC1" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -43,7 +43,7 @@ "api-platform/doctrine-common": "^4.1", "api-platform/doctrine-orm": "^4.1", "api-platform/doctrine-odm": "^4.1", - "symfony/type-info": "^7.3-dev" + "symfony/type-info": "v7.3.0-RC1" }, "autoload": { "psr-4": { From 4ba49a789d44191dd4ced4082202d9528d1740fd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 28 May 2025 10:03:08 +0200 Subject: [PATCH 088/148] 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 96e189e882add04e37a527e022ad2bce82433cd1 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 2 Jun 2025 16:22:51 +0200 Subject: [PATCH 089/148] chore: use type-info:^7.3 (#7185) --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 4aeb57f..714ceeb 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "v7.3.0-RC1" + "symfony/type-info": "^7.3" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -43,7 +43,7 @@ "api-platform/doctrine-common": "^4.1", "api-platform/doctrine-orm": "^4.1", "api-platform/doctrine-odm": "^4.1", - "symfony/type-info": "v7.3.0-RC1" + "symfony/type-info": "^7.3" }, "autoload": { "psr-4": { From 3559cf07cdb15f5f524c7bb3776b764fd7e7ff18 Mon Sep 17 00:00:00 2001 From: Aleksey Polyvanyi Date: Mon, 2 Jun 2025 22:18:32 +0200 Subject: [PATCH 090/148] 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 091/148] 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 092/148] 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 093/148] 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 094/148] 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 095/148] 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 13001e36ac2b2f882b042acd5722b884a45dd3ba Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Jun 2025 15:00:58 +0200 Subject: [PATCH 096/148] ci: bump lowest dependencies (#7237) --- Tests/Serializer/OpenApiNormalizerTest.php | 2 +- composer.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index 1937401..ae00233 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -208,7 +208,7 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory(); + $definitionNameFactory = new DefinitionNameFactory(null); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactory, diff --git a/composer.json b/composer.json index bb453ab..62fa245 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.1.11", - "api-platform/metadata": "^4.1.11", - "api-platform/state": "^4.1.11", + "api-platform/json-schema": "4.2.x-dev as dev-main", + "api-platform/metadata": "4.2.x-dev as dev-main", + "api-platform/state": "4.2.x-dev as dev-main", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", From 775fe25311a3347697457038644c5effe9b29f61 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 27 Jun 2025 15:30:43 +0200 Subject: [PATCH 097/148] refactor(metadata): cascade resource to operation (#7246) --- Factory/OpenApiFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index ccd6c53..e8b3cde 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -214,7 +214,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $path = $this->getPath($path); - $method = $operation->getMethod() ?? 'GET'; + $method = $operation->getMethod(); if (!\in_array($method, PathItem::$methods, true)) { continue; @@ -618,7 +618,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection foreach ($resourceMetadataCollection as $resource) { foreach ($resource->getOperations() as $operationName => $operation) { $parameters = []; - $method = $operation instanceof HttpOperation ? $operation->getMethod() : 'GET'; + $method = $operation->getMethod(); if ( $operationName === $operation->getName() || isset($links[$operationName]) From c9a2838ce5a20210e5bdc5444aaa544e908d7010 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 27 Jun 2025 15:58:28 +0200 Subject: [PATCH 098/148] chore: bump phpstan version (#7239) --- Factory/OpenApiFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index e8b3cde..4a6f4ba 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -531,7 +531,7 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas) } /** - * @return array[array, array] + * @return array{array, array} */ private function getMimeTypes(HttpOperation $operation): array { From b89daf843be4ef7ec1454d099426a54cae2d952c Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Jun 2025 14:41:47 +0200 Subject: [PATCH 099/148] chore: solve some phpstan issues (#7249) --- Serializer/ApiGatewayNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index aaea891..534b05e 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -42,7 +42,7 @@ public function __construct(private readonly NormalizerInterface $documentationN * * @throws UnexpectedValueException */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + public function normalize(mixed $object, ?string $format = null, array $context = []): array { $data = $this->documentationNormalizer->normalize($object, $format, $context); if (!\is_array($data)) { From 04e80c5551683c85726210041cac356fa45f4a05 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 30 Jun 2025 14:44:06 +0200 Subject: [PATCH 100/148] chore: use treat phpdoc type as certain (#7250) --- Factory/OpenApiFactory.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 4a6f4ba..d7d3750 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -186,7 +186,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiAttribute = $operation->getOpenapi(); // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $openapiAttribute) { + if (false === $openapiAttribute) { continue; } @@ -386,7 +386,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); $errors = null; - if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) { + if (null !== ($errors = $operation->getErrors())) { /** @var array */ $errorOperations = []; foreach ($errors as $error) { @@ -629,7 +629,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook)) { + if (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook) { continue; } From 0153a8090c7726143ec0f14a7d859f45e608216a Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Tue, 1 Jul 2025 14:34:32 +0200 Subject: [PATCH 101/148] 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 0bbbb2b62c47d03a8ecfd33c2b6c966423056214 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 3 Jul 2025 14:50:21 +0200 Subject: [PATCH 102/148] chore: introduce phpstan level 6 (#7269) --- Model/Header.php | 4 ++-- Model/Link.php | 6 +++--- Model/MediaType.php | 2 +- Model/Parameter.php | 6 +++--- Model/Schema.php | 14 +++++++------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Model/Header.php b/Model/Header.php index 8f5ca7f..271cb98 100644 --- a/Model/Header.php +++ b/Model/Header.php @@ -17,7 +17,7 @@ final class Header { use ExtensionTrait; - public function __construct(private readonly string $in = 'header', private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) + public function __construct(private readonly string $in = 'header', private string $description = '', private bool $required = false, private bool $deprecated = false, private bool $allowEmptyValue = false, private array $schema = [], private ?string $style = null, private bool $explode = false, private bool $allowReserved = false, private mixed $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { $this->style = 'simple'; @@ -84,7 +84,7 @@ public function getAllowReserved(): bool return $this->allowReserved; } - public function getExample() + public function getExample(): mixed { return $this->example; } diff --git a/Model/Link.php b/Model/Link.php index 692c4f7..a7d48cf 100644 --- a/Model/Link.php +++ b/Model/Link.php @@ -17,7 +17,7 @@ final class Link { use ExtensionTrait; - public function __construct(private string $operationId, private ?\ArrayObject $parameters = null, private $requestBody = null, private string $description = '', private ?Server $server = null) + public function __construct(private string $operationId, private ?\ArrayObject $parameters = null, private mixed $requestBody = null, private string $description = '', private ?Server $server = null) { } @@ -31,7 +31,7 @@ public function getParameters(): \ArrayObject return $this->parameters; } - public function getRequestBody() + public function getRequestBody(): mixed { return $this->requestBody; } @@ -62,7 +62,7 @@ public function withParameters(\ArrayObject $parameters): self return $clone; } - public function withRequestBody($requestBody): self + public function withRequestBody(mixed $requestBody): self { $clone = clone $this; $clone->requestBody = $requestBody; diff --git a/Model/MediaType.php b/Model/MediaType.php index 97b7e79..25be343 100644 --- a/Model/MediaType.php +++ b/Model/MediaType.php @@ -26,7 +26,7 @@ public function getSchema(): ?\ArrayObject return $this->schema; } - public function getExample() + public function getExample(): mixed { return $this->example; } diff --git a/Model/Parameter.php b/Model/Parameter.php index d242fa8..f796bfa 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 = null, private array $schema = [], private ?string $style = null, private bool $explode = false, private ?bool $allowReserved = null, private $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) + public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private ?bool $allowEmptyValue = null, private array $schema = [], private ?string $style = null, private bool $explode = false, private ?bool $allowReserved = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -93,7 +93,7 @@ public function getAllowReserved(): ?bool return $this->allowReserved; } - public function getExample() + public function getExample(): mixed { return $this->example; } @@ -188,7 +188,7 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; diff --git a/Model/Schema.php b/Model/Schema.php index 7647b4b..dd6b074 100644 --- a/Model/Schema.php +++ b/Model/Schema.php @@ -20,7 +20,7 @@ final class Schema extends \ArrayObject use ExtensionTrait; private readonly JsonSchema $schema; - public function __construct(private $discriminator = null, private bool $readOnly = false, private bool $writeOnly = false, private ?string $xml = null, private $externalDocs = null, private $example = null, private bool $deprecated = false) + public function __construct(private mixed $discriminator = null, private bool $readOnly = false, private bool $writeOnly = false, private ?string $xml = null, private mixed $externalDocs = null, private mixed $example = null, private bool $deprecated = false) { $this->schema = new JsonSchema(); @@ -48,7 +48,7 @@ public function getDefinitions(): \ArrayObject return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); } - public function getDiscriminator() + public function getDiscriminator(): mixed { return $this->discriminator; } @@ -68,12 +68,12 @@ public function getXml(): string return $this->xml; } - public function getExternalDocs() + public function getExternalDocs(): mixed { return $this->externalDocs; } - public function getExample() + public function getExample(): mixed { return $this->example; } @@ -83,7 +83,7 @@ public function getDeprecated(): bool return $this->deprecated; } - public function withDiscriminator($discriminator): self + public function withDiscriminator(mixed $discriminator): self { $clone = clone $this; $clone->discriminator = $discriminator; @@ -115,7 +115,7 @@ public function withXml(string $xml): self return $clone; } - public function withExternalDocs($externalDocs): self + public function withExternalDocs(mixed $externalDocs): self { $clone = clone $this; $clone->externalDocs = $externalDocs; @@ -123,7 +123,7 @@ public function withExternalDocs($externalDocs): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; 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 103/148] 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 304f6ee07085ee7dd1a01c26e885526a6a61caee Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 25 Jul 2025 11:37:01 +0200 Subject: [PATCH 104/148] feat(metadata): class is now class-string (#7307) --- Tests/Serializer/OpenApiNormalizerTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index ae00233..be36a20 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -98,10 +98,10 @@ public function testNormalizeWithEmptySchemas(): void public function testNormalize(): void { $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, 'Zorro'])); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, \stdClass::class])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); - $propertyNameCollectionFactoryProphecy->create('Zorro', Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); + $propertyNameCollectionFactoryProphecy->create(\stdClass::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) @@ -127,7 +127,7 @@ public function testNormalize(): void $zorroBaseOperation = (new HttpOperation()) ->withTypes(['http://schema.example.com/Zorro']) ->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats']) - ->withClass('Zorro') + ->withClass(\stdClass::class) ->withShortName('Zorro') ->withDescription('This is zorro.'); @@ -142,7 +142,7 @@ public function testNormalize(): void $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); - $resourceCollectionMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); + $resourceCollectionMetadataFactoryProphecy->create(\stdClass::class)->shouldBeCalled()->willReturn($zorroMetadata); $resourceCollectionMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); $resourceCollectionMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); @@ -193,7 +193,7 @@ public function testNormalize(): void ->withSchema(['type' => 'string', 'format' => 'date-time', 'description' => 'This is a \DateTimeInterface object.']) ); - $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn( + $propertyMetadataFactoryProphecy->create(\stdClass::class, 'id', Argument::any())->shouldBeCalled()->willReturn( (new ApiProperty()) ->withNativeType(Type::int()) ->withDescription('This is an id.') From 8e32fd530cb1851676f0812c38e9f41c50c7cde5 Mon Sep 17 00:00:00 2001 From: Yannick Snobbert Date: Tue, 29 Jul 2025 07:50:12 +0200 Subject: [PATCH 105/148] 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 106/148] 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; } From 3e4d3f76c94ad6f102de8220bac46f58d1bcd4fd Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Mon, 18 Aug 2025 15:34:44 +0200 Subject: [PATCH 107/148] chore: add param type (#7313) --- Model/ExtensionTrait.php | 2 +- Model/Header.php | 2 +- Model/MediaType.php | 4 ++-- Model/Operation.php | 3 +++ OpenApi.php | 3 +++ Serializer/ApiGatewayNormalizer.php | 5 ++++- Serializer/LegacyOpenApiNormalizer.php | 5 ++++- Serializer/OpenApiNormalizer.php | 3 +++ 8 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Model/ExtensionTrait.php b/Model/ExtensionTrait.php index 903c42b..f91f6bc 100644 --- a/Model/ExtensionTrait.php +++ b/Model/ExtensionTrait.php @@ -17,7 +17,7 @@ trait ExtensionTrait { private array $extensionProperties = []; - public function withExtensionProperty(string $key, $value): mixed + public function withExtensionProperty(string $key, mixed $value): mixed { if (!str_starts_with($key, 'x-')) { $key = 'x-'.$key; diff --git a/Model/Header.php b/Model/Header.php index 271cb98..fac71e4 100644 --- a/Model/Header.php +++ b/Model/Header.php @@ -163,7 +163,7 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; diff --git a/Model/MediaType.php b/Model/MediaType.php index 25be343..ea50465 100644 --- a/Model/MediaType.php +++ b/Model/MediaType.php @@ -17,7 +17,7 @@ final class MediaType { use ExtensionTrait; - public function __construct(private ?\ArrayObject $schema = null, private $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) + public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) { } @@ -49,7 +49,7 @@ public function withSchema(\ArrayObject $schema): self return $clone; } - public function withExample($example): self + public function withExample(mixed $example): self { $clone = clone $this; $clone->example = $example; diff --git a/Model/Operation.php b/Model/Operation.php index db2aac8..5ee455d 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -22,6 +22,9 @@ public function __construct(private ?string $operationId = null, private ?array $this->extensionProperties = $extensionProperties; } + /** + * @param string $status + */ public function addResponse(Response $response, $status = 'default'): self { $this->responses[$status] = $response; diff --git a/OpenApi.php b/OpenApi.php index 943a639..61a43d7 100644 --- a/OpenApi.php +++ b/OpenApi.php @@ -27,6 +27,9 @@ final class OpenApi private string $openapi = self::VERSION; private Components $components; + /** + * @param array|null $externalDocs + */ public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) { $this->components = $components ?? new Components(); diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index 534b05e..f8fadeb 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -32,7 +32,7 @@ final class ApiGatewayNormalizer implements NormalizerInterface self::API_GATEWAY => false, ]; - public function __construct(private readonly NormalizerInterface $documentationNormalizer, $defaultContext = []) + public function __construct(private readonly NormalizerInterface $documentationNormalizer, array $defaultContext = []) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -119,6 +119,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return $this->documentationNormalizer->supportsNormalization($data, $format); } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return $this->documentationNormalizer->getSupportedTypes($format); diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index dc203c7..d306821 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -22,7 +22,7 @@ final class LegacyOpenApiNormalizer implements NormalizerInterface self::SPEC_VERSION => '3.1.0', ]; - public function __construct(private readonly NormalizerInterface $decorated, $defaultContext = []) + public function __construct(private readonly NormalizerInterface $decorated, array $defaultContext = []) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } @@ -64,6 +64,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return $this->decorated->supportsNormalization($data, $format, $context); } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return $this->decorated->getSupportedTypes($format); diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index a5f1c2c..21c2638 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -76,6 +76,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) && $data instanceof OpenApi; } + /** + * @param string|null $format + */ public function getSupportedTypes($format): array { return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) ? [OpenApi::class => true] : []; From fd4bef2ad1fe5272567bef30f66f629dbb6aed9c Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 19 Aug 2025 10:04:29 +0200 Subject: [PATCH 108/148] chore: 4.2 branch alias --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 62fa245..b9e0056 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,8 @@ }, "extra": { "branch-alias": { - "dev-main": "4.2.x-dev", + "dev-main": "4.3.x-dev", + "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" }, From 667dd286ece4d2fe597159facf0c62f096688a7b Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 15 Sep 2025 14:04:05 +0200 Subject: [PATCH 109/148] fix(openapi): no content schema (#7384) --- Factory/OpenApiFactory.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index fe0e6c8..abed0cf 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -498,11 +498,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection 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 { + $noOutput = \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; + if (isset($existingResponses[$status])) { return $openapiOperation; } $responseLinks = $responseContent = null; - if ($responseMimeTypes && $operationOutputSchemas) { + if ($responseMimeTypes && $operationOutputSchemas && !$noOutput) { $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); } if ($resourceMetadataCollection && $operation) { From 8e400e24ef695f17dbeeaeb60442707ba9c1dbd1 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 16 Sep 2025 14:12:59 +0200 Subject: [PATCH 110/148] chore: lowest fixes --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index b9e0056..5ea7eae 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "4.2.x-dev as dev-main", - "api-platform/metadata": "4.2.x-dev as dev-main", - "api-platform/state": "4.2.x-dev as dev-main", + "api-platform/json-schema": "^4.2@beta", + "api-platform/metadata": "^4.2@beta", + "api-platform/state": "^4.2@beta", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", From eacc566171b5144312c3fd2513351b6204165bdc Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 26 Sep 2025 10:18:25 +0200 Subject: [PATCH 111/148] fix(openapi): ability to override description in response (#7412) --- Factory/OpenApiFactory.php | 24 ++++++++++++++---------- Tests/Factory/OpenApiFactoryTest.php | 16 +++++++++++++--- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index abed0cf..15828a2 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -496,22 +496,26 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - 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 + /** + * @param array $existingResponses + * + * @return Operation + */ + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Operation $openapiOperation, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Operation { $noOutput = \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; - if (isset($existingResponses[$status])) { - return $openapiOperation; - } - $responseLinks = $responseContent = null; - if ($responseMimeTypes && $operationOutputSchemas && !$noOutput) { - $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $response = $existingResponses[$status] ?? new Response($description); + + if (!$response->getContent() && $responseMimeTypes && $operationOutputSchemas && !$noOutput) { + $response = $response->withContent($this->buildContent($responseMimeTypes, $operationOutputSchemas)); } - if ($resourceMetadataCollection && $operation) { - $responseLinks = $this->getLinks($resourceMetadataCollection, $operation); + + if (!$response->getLinks() && $resourceMetadataCollection && $operation) { + $response = $response->withLinks($this->getLinks($resourceMetadataCollection, $operation)); } - return $openapiOperation->withResponse($status, new Response($description, $responseContent, null, $responseLinks)); + return $openapiOperation->withResponse($status, $response); } /** diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index c32286b..71e9c0c 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -237,6 +237,7 @@ public function testInvoke(): void responses: [ '200' => new OpenApiResponse( description: 'Success', + content: new \ArrayObject([]), ), ], )), @@ -1106,7 +1107,11 @@ public function testInvoke(): void 'link' => ['$ref' => '#/components/schemas/Dummy'], ]) ), - '400' => new Response('Error'), + '400' => new Response( + 'Error', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), '422' => new Response( 'Unprocessable entity', content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), @@ -1150,7 +1155,11 @@ public function testInvoke(): void 'link' => ['$ref' => '#/components/schemas/Dummy'], ]) ), - '400' => new Response('Error'), + '400' => new Response( + 'Error', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), '422' => new Response( 'Unprocessable entity', content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), @@ -1177,7 +1186,8 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response( - 'Success' + 'Success', + content: new \ArrayObject([]), ), ], 'Retrieves the collection of Dummy resources.', From 9bc4032d288ddc672691f8abd5c24e78773f0aa2 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 26 Sep 2025 15:39:57 +0200 Subject: [PATCH 112/148] chore(symfony): various cs fixes (#7415) --- Factory/OpenApiFactory.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 15828a2..ceb4346 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -498,8 +498,6 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection /** * @param array $existingResponses - * - * @return Operation */ private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Operation $openapiOperation, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Operation { From 2545f2be06eed0f9a121d631b8f1db22212a7826 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 30 Sep 2025 14:06:50 +0200 Subject: [PATCH 113/148] fix(openapi): Improve response override (#7428) --- Factory/OpenApiFactory.php | 7 +++-- Model/Response.php | 4 +-- Tests/Factory/OpenApiFactoryTest.php | 46 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index ceb4346..2854faf 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -504,12 +504,15 @@ private function buildOpenApiResponse(array $existingResponses, int|string $stat $noOutput = \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; $response = $existingResponses[$status] ?? new Response($description); + if (null === $response->getDescription()) { + $response = $response->withDescription($description); + } - if (!$response->getContent() && $responseMimeTypes && $operationOutputSchemas && !$noOutput) { + if (null === $response->getContent() && $responseMimeTypes && $operationOutputSchemas && !$noOutput) { $response = $response->withContent($this->buildContent($responseMimeTypes, $operationOutputSchemas)); } - if (!$response->getLinks() && $resourceMetadataCollection && $operation) { + if (null === $response->getLinks() && $resourceMetadataCollection && $operation) { $response = $response->withLinks($this->getLinks($resourceMetadataCollection, $operation)); } diff --git a/Model/Response.php b/Model/Response.php index 46b55f1..187e8be 100644 --- a/Model/Response.php +++ b/Model/Response.php @@ -17,11 +17,11 @@ final class Response { use ExtensionTrait; - public function __construct(private string $description = '', private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) { } - public function getDescription(): string + public function getDescription(): ?string { return $this->description; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 71e9c0c..033d0ef 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -262,6 +262,18 @@ public function testInvoke(): void ), ], )), + 'postDummyItemWithEmptyResponse' => (new Post())->withUriTemplate('/dummyitems/noresponse')->withOperation($baseOperation)->withOpenapi(new OpenApiOperation( + responses: [ + '201' => new OpenApiResponse( + description: '', + content: new \ArrayObject(), + headers: new \ArrayObject(), + links: new \ArrayObject(), + ), + '400' => new OpenApiResponse(), + ], + requestBody: new RequestBody(''), + )), 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), 'getDummyCollectionWithErrors' => (new GetCollection())->withUriTemplate('erroredDummies')->withErrors([DummyErrorResource::class])->withOperation($baseOperation), ]) @@ -1209,6 +1221,40 @@ public function testInvoke(): void ] ), $dummyItemPath->getGet()); + $emptyReponsePath = $paths->getPath('/dummyitems/noresponse'); + $this->assertEquals(new Operation( + 'postDummyItemWithEmptyResponse', + ['Dummy'], + [ + '201' => new Response( + '', + new \ArrayObject(), + new \ArrayObject(), + new \ArrayObject() + ), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + '', + content: new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), + ]), + ), + ), $emptyReponsePath->getPost()); + $emptyRequestBodyPath = $paths->getPath('/dummyitem/noinput'); $this->assertEquals(new Operation( 'postDummyItemWithoutInput', From 4a4e265c711954ecbbe9df21ba90d7dc2134beaf Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 31 Oct 2025 17:12:05 +0100 Subject: [PATCH 114/148] chore: bump deps --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 5ea7eae..c337880 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.2@beta", - "api-platform/metadata": "^4.2@beta", - "api-platform/state": "^4.2@beta", + "api-platform/json-schema": "^4.2", + "api-platform/metadata": "^4.2", + "api-platform/state": "^4.2", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", @@ -40,9 +40,9 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "11.5.x-dev", - "api-platform/doctrine-common": "^4.1", - "api-platform/doctrine-orm": "^4.1", - "api-platform/doctrine-odm": "^4.1", + "api-platform/doctrine-common": "^4.2", + "api-platform/doctrine-orm": "^4.2", + "api-platform/doctrine-odm": "^4.2", "symfony/type-info": "^7.3" }, "autoload": { From ce06a22f34a52c728eacb1237c1237e203e93121 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 13 Nov 2025 11:48:47 +0100 Subject: [PATCH 115/148] fix(openapi): maintain json schema for non-json operations (#7528) fixes #7527 --- Factory/OpenApiFactory.php | 20 ++++++-------------- Tests/Factory/OpenApiFactoryTest.php | 6 ++++-- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 2854faf..740f302 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -266,11 +266,8 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection foreach ($responseMimeTypes as $operationFormat) { $operationOutputSchema = null; - // Having JSONSchema for non-json schema makes no sense - if (str_starts_with($operationFormat, 'json')) { - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); - $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); - } + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); $operationOutputSchemas[$operationFormat] = $operationOutputSchema; } @@ -458,10 +455,8 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = null; - if (str_starts_with($operationFormat, 'json')) { - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); - $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); - } + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); $operationInputSchemas[$operationFormat] = $operationInputSchema; } @@ -999,11 +994,8 @@ private function addOperationErrors( $operationErrorSchemas = []; foreach ($responseMimeTypes as $operationFormat) { $operationErrorSchema = null; - // Having JSONSchema for non-json schema makes no sense - if (str_starts_with($operationFormat, 'json')) { - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); - $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); - } + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); $operationErrorSchemas[$operationFormat] = $operationErrorSchema; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 033d0ef..e5cbd0d 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -658,7 +658,9 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), + 'Dummy.OutputDto.csv' => $dummySchema->getDefinitions(), 'Dummy.jsonld' => $dummySchema->getDefinitions(), + 'Dummy.csv' => $dummySchema->getDefinitions(), 'Dummy.OutputDto.jsonld' => $dummySchema->getDefinitions(), 'Parameter.jsonld' => $parameterSchema, 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), @@ -897,7 +899,7 @@ public function testInvoke(): void 'Dummy resource updated', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto'])), - 'text/csv' => new \ArrayObject(), + 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.csv'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$request.path.id']), null, 'This is a dummy')]) @@ -926,7 +928,7 @@ public function testInvoke(): void 'The updated Dummy resource', new \ArrayObject([ 'application/json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), - 'text/csv' => new \ArrayObject(), + 'text/csv' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.csv'])), ]), true ) From 48b0b697619d9cab6b2c6bce59b1fd42f12c4d16 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 13 Nov 2025 16:19:51 +0100 Subject: [PATCH 116/148] chore: bump self dependencies to avoid installing lowest --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index c337880..4d904b2 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "php": ">=8.2", "api-platform/json-schema": "^4.2", "api-platform/metadata": "^4.2", - "api-platform/state": "^4.2", + "api-platform/state": "^4.2.4", "symfony/console": "^6.4 || ^7.0", "symfony/filesystem": "^6.4 || ^7.0", "symfony/property-access": "^6.4 || ^7.0", From ea49d6d7170f8ecc1c239e7769708628183096b8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 30 Nov 2025 13:55:42 +0100 Subject: [PATCH 117/148] chore: support symfony 8 (#7561) --- Model/Reference.php | 2 +- composer.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Model/Reference.php b/Model/Reference.php index 0c5bf8e..a9e1a59 100644 --- a/Model/Reference.php +++ b/Model/Reference.php @@ -13,7 +13,7 @@ namespace ApiPlatform\OpenApi\Model; -use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedName; final class Reference { diff --git a/composer.json b/composer.json index 4d904b2..ac0b0ca 100644 --- a/composer.json +++ b/composer.json @@ -31,11 +31,11 @@ "api-platform/json-schema": "^4.2", "api-platform/metadata": "^4.2", "api-platform/state": "^4.2.4", - "symfony/console": "^6.4 || ^7.0", - "symfony/filesystem": "^6.4 || ^7.0", - "symfony/property-access": "^6.4 || ^7.0", - "symfony/serializer": "^6.4 || ^7.0", - "symfony/type-info": "^7.3" + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", + "symfony/property-access": "^6.4 || ^7.0 || ^8.0", + "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -43,7 +43,7 @@ "api-platform/doctrine-common": "^4.2", "api-platform/doctrine-orm": "^4.2", "api-platform/doctrine-odm": "^4.2", - "symfony/type-info": "^7.3" + "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { "psr-4": { @@ -71,7 +71,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0" + "require": "^6.4 || ^7.0 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", From 882777fa7061472cca66622a373d77111107dd96 Mon Sep 17 00:00:00 2001 From: Nicolas LAURENT Date: Fri, 5 Dec 2025 16:34:42 +0100 Subject: [PATCH 118/148] feat(metadata): expose default attribute on parameters (#7551) --- Factory/OpenApiFactory.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 740f302..b3ffa5f 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -337,6 +337,11 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } $in = $p instanceof HeaderParameterInterface ? 'header' : 'query'; + $defaultSchema = ['type' => 'string']; + if (null !== $p->getDefault()) { + $defaultSchema['default'] = $p->getDefault(); + } + $defaultParameter = new Parameter( $key, $in, @@ -344,7 +349,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $p->getRequired() ?? false, false, null, - $p->getSchema() ?? ['type' => 'string'], + $p->getSchema() ?? $defaultSchema, ); $linkParameter = $p->getOpenApi(); From 16978226a56a933b659dbbaa52436af70a5673f7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 7 Dec 2025 09:10:29 +0100 Subject: [PATCH 119/148] chore: bump metadata to 4.3.x-dev --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ac0b0ca..5b8b34a 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "require": { "php": ">=8.2", "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.2", + "api-platform/metadata": "^4.3.x-dev", "api-platform/state": "^4.2.4", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", From 2d1a528b70768bc74742632b5a45ea92889d1b3e Mon Sep 17 00:00:00 2001 From: aaa2000 Date: Mon, 15 Dec 2025 14:07:25 +0100 Subject: [PATCH 120/148] ci: php 8.5 (#7585) --- Tests/Factory/OpenApiFactoryTest.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index e5cbd0d..59441d7 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -695,10 +695,10 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response('Dummy collection', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], - ]))), + ])), ])), ], 'Retrieves the collection of Dummy resources.', @@ -727,7 +727,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -750,7 +750,7 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ) @@ -769,7 +769,7 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]) ), '404' => new Response( @@ -1022,7 +1022,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1073,7 +1073,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1096,7 +1096,7 @@ public function testInvoke(): void new RequestBody( 'Extended description for the new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), false ), @@ -1265,7 +1265,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1301,17 +1301,17 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response('Dummy collection', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], - ]))), + ])), ])), '418' => new Response( 'A Teapot Exception', new \ArrayObject([ - 'application/problem+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/problem+json' => new MediaType(new \ArrayObject([ '$ref' => '#/components/schemas/DummyErrorResource', - ]))), + ])), ]), links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), From 8bd1e70f29750df102ab1e0384316a151ee020fb Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 27 Dec 2025 22:56:46 +0100 Subject: [PATCH 121/148] ci: upgrade to phpunit 12 Remove soyuka/phpunit fork from all composer.json files and upgrade to PHPUnit 12.2. Update CI workflow to install PHPUnit before other steps and configure MongoDB conditional execution. Migrate tests from Prophecy to PHPUnit native mocking in FieldsBuilderTest and Symfony event listener tests. Remove unused dataprovider and fix warnings. --- composer.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index ac0b0ca..00153da 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "11.5.x-dev", + "phpunit/phpunit": "^12.2", "api-platform/doctrine-common": "^4.2", "api-platform/doctrine-orm": "^4.2", "api-platform/doctrine-odm": "^4.2", @@ -80,11 +80,5 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/soyuka/phpunit" - } - ] + } } From b13f8be828b8df19dcca6d428b168294319051b8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 28 Dec 2025 12:31:54 +0100 Subject: [PATCH 122/148] fix(openapi): respect schema type for non-collection parameter documentation (#7634) Fixes #7548 --- Factory/OpenApiFactory.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 740f302..272feae 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -726,13 +726,14 @@ private function getFilterParameter(string $name, array $description, string $sh $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; - $style = 'array' === ($schema['type'] ?? null) && \in_array( + $isArraySchema = 'array' === ($schema['type'] ?? null); + $style = $isArraySchema && \in_array( $description['type'], [$arrayValueType, $objectValueType], true ) ? 'deepObject' : 'form'; - $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false); + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $isArraySchema ? ($description['is_collection'] ?? false) : false); if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) { $parameter = $parameter->withDescription($str); @@ -771,6 +772,8 @@ private function getFilterParameter(string $name, array $description, string $sh $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $isArraySchema = 'array' === $schema['type']; + return new Parameter( $name, 'query', @@ -779,12 +782,12 @@ private function getFilterParameter(string $name, array $description, string $sh $description['openapi']['deprecated'] ?? false, $description['openapi']['allowEmptyValue'] ?? null, $schema, - 'array' === $schema['type'] && \in_array( + $isArraySchema && \in_array( $description['type'], [$arrayValueType, $objectValueType], true ) ? 'deepObject' : 'form', - $description['openapi']['explode'] ?? ('array' === $schema['type']), + $description['openapi']['explode'] ?? $isArraySchema, $description['openapi']['allowReserved'] ?? null, $description['openapi']['example'] ?? null, isset( From d0829db72205c70f529110dce14daf10dc9665e1 Mon Sep 17 00:00:00 2001 From: Maxcastel <92802347+Maxcastel@users.noreply.github.com> Date: Thu, 8 Jan 2026 15:12:21 +0100 Subject: [PATCH 123/148] refactor: make method parameter names match interfaces (#7643) --- Serializer/ApiGatewayNormalizer.php | 46 +++++++++++++------------- Serializer/LegacyOpenApiNormalizer.php | 11 +++--- Serializer/OpenApiNormalizer.php | 8 ++--- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/Serializer/ApiGatewayNormalizer.php b/Serializer/ApiGatewayNormalizer.php index f8fadeb..fed1cf8 100644 --- a/Serializer/ApiGatewayNormalizer.php +++ b/Serializer/ApiGatewayNormalizer.php @@ -42,73 +42,73 @@ public function __construct(private readonly NormalizerInterface $documentationN * * @throws UnexpectedValueException */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $data = $this->documentationNormalizer->normalize($object, $format, $context); - if (!\is_array($data)) { + $normalizedData = $this->documentationNormalizer->normalize($data, $format, $context); + if (!\is_array($normalizedData)) { throw new UnexpectedValueException('Expected data to be an array'); } if (!($context[self::API_GATEWAY] ?? $this->defaultContext[self::API_GATEWAY])) { - return $data; + return $normalizedData; } - if (empty($data['basePath'])) { - $data['basePath'] = '/'; + if (empty($normalizedData['basePath'])) { + $normalizedData['basePath'] = '/'; } - foreach ($data['paths'] as $path => $operations) { + foreach ($normalizedData['paths'] as $path => $operations) { foreach ($operations as $operation => $options) { if (isset($options['parameters'])) { foreach ($options['parameters'] as $key => $parameter) { if (!preg_match('/^[a-zA-Z0-9._$-]+$/', (string) $parameter['name'])) { - unset($data['paths'][$path][$operation]['parameters'][$key]); + unset($normalizedData['paths'][$path][$operation]['parameters'][$key]); } if (isset($parameter['schema']['$ref']) && $this->isLocalRef($parameter['schema']['$ref'])) { - $data['paths'][$path][$operation]['parameters'][$key]['schema']['$ref'] = $this->normalizeRef($parameter['schema']['$ref']); + $normalizedData['paths'][$path][$operation]['parameters'][$key]['schema']['$ref'] = $this->normalizeRef($parameter['schema']['$ref']); } } - $data['paths'][$path][$operation]['parameters'] = array_values($data['paths'][$path][$operation]['parameters']); + $normalizedData['paths'][$path][$operation]['parameters'] = array_values($normalizedData['paths'][$path][$operation]['parameters']); } if (isset($options['responses'])) { foreach ($options['responses'] as $statusCode => $response) { if (isset($response['schema']['items']['$ref']) && $this->isLocalRef($response['schema']['items']['$ref'])) { - $data['paths'][$path][$operation]['responses'][$statusCode]['schema']['items']['$ref'] = $this->normalizeRef($response['schema']['items']['$ref']); + $normalizedData['paths'][$path][$operation]['responses'][$statusCode]['schema']['items']['$ref'] = $this->normalizeRef($response['schema']['items']['$ref']); } if (isset($response['schema']['$ref']) && $this->isLocalRef($response['schema']['$ref'])) { - $data['paths'][$path][$operation]['responses'][$statusCode]['schema']['$ref'] = $this->normalizeRef($response['schema']['$ref']); + $normalizedData['paths'][$path][$operation]['responses'][$statusCode]['schema']['$ref'] = $this->normalizeRef($response['schema']['$ref']); } } } } } - foreach ($data['components']['schemas'] as $definition => $options) { + foreach ($normalizedData['components']['schemas'] as $definition => $options) { if (!isset($options['properties'])) { continue; } foreach ($options['properties'] as $property => $propertyOptions) { if (isset($propertyOptions['readOnly'])) { - unset($data['components']['schemas'][$definition]['properties'][$property]['readOnly']); + unset($normalizedData['components']['schemas'][$definition]['properties'][$property]['readOnly']); } if (isset($propertyOptions['$ref']) && $this->isLocalRef($propertyOptions['$ref'])) { - $data['components']['schemas'][$definition]['properties'][$property]['$ref'] = $this->normalizeRef($propertyOptions['$ref']); + $normalizedData['components']['schemas'][$definition]['properties'][$property]['$ref'] = $this->normalizeRef($propertyOptions['$ref']); } if (isset($propertyOptions['items']['$ref']) && $this->isLocalRef($propertyOptions['items']['$ref'])) { - $data['components']['schemas'][$definition]['properties'][$property]['items']['$ref'] = $this->normalizeRef($propertyOptions['items']['$ref']); + $normalizedData['components']['schemas'][$definition]['properties'][$property]['items']['$ref'] = $this->normalizeRef($propertyOptions['items']['$ref']); } } } - // $data['definitions'] is an instance of \ArrayObject - foreach (array_keys($data['components']['schemas']) as $definition) { + // $normalizedData['definitions'] is an instance of \ArrayObject + foreach (array_keys($normalizedData['components']['schemas']) as $definition) { if (!preg_match('/^[0-9A-Za-z]+$/', (string) $definition)) { - $data['components']['schemas'][preg_replace('/[^0-9A-Za-z]/', '', (string) $definition)] = $data['components']['schemas'][$definition]; - unset($data['components']['schemas'][$definition]); + $normalizedData['components']['schemas'][preg_replace('/[^0-9A-Za-z]/', '', (string) $definition)] = $normalizedData['components']['schemas'][$definition]; + unset($normalizedData['components']['schemas'][$definition]); } } - return $data; + return $normalizedData; } /** @@ -120,9 +120,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return $this->documentationNormalizer->getSupportedTypes($format); } diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index d306821..7f47f73 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -27,9 +27,12 @@ public function __construct(private readonly NormalizerInterface $decorated, arr $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - public function normalize(mixed $object, ?string $format = null, array $context = []): array + /** + * {@inheritdoc} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $openapi = $this->decorated->normalize($object, $format, $context); + $openapi = $this->decorated->normalize($data, $format, $context); if ('3.0.0' !== ($context['spec_version'] ?? null)) { return $openapi; @@ -65,9 +68,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return $this->decorated->getSupportedTypes($format); } diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index 21c2638..4da46fb 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -36,7 +36,7 @@ public function __construct(private readonly NormalizerInterface $decorated) /** * {@inheritdoc} */ - public function normalize(mixed $object, ?string $format = null, array $context = []): array + public function normalize(mixed $data, ?string $format = null, array $context = []): array { $pathsCallback = $this->getPathsCallBack(); $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; @@ -45,7 +45,7 @@ public function normalize(mixed $object, ?string $format = null, array $context 'paths' => $pathsCallback, ]; - return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); + return $this->recursiveClean($this->decorated->normalize($data, $format, $context)); } private function recursiveClean(array $data): array @@ -77,9 +77,9 @@ public function supportsNormalization(mixed $data, ?string $format = null, array } /** - * @param string|null $format + * {@inheritdoc} */ - public function getSupportedTypes($format): array + public function getSupportedTypes(?string $format): array { return (self::FORMAT === $format || self::JSON_FORMAT === $format || self::YAML_FORMAT === $format) ? [OpenApi::class => true] : []; } From 3417415125f3ebbcb620f84ef9dd1cd07e2b2621 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 8 Jan 2026 19:17:35 +0100 Subject: [PATCH 124/148] fix(doctrine): Handling of parameter description (#7656) Co-authored-by: soyuka --- Factory/OpenApiFactory.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 272feae..3759fa4 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -936,11 +936,16 @@ private function hasParameter(Operation $operation, Parameter $parameter): ?arra private function mergeParameter(Parameter $actual, Parameter $defined): Parameter { + // Handle description separately: only override if the new value is non-empty + $newDescription = $defined->getDescription(); + if ('' !== $newDescription && $actual->getDescription() !== $newDescription) { + $actual = $actual->withDescription($newDescription); + } + foreach ( [ 'name', 'in', - 'description', 'required', 'deprecated', 'allowEmptyValue', From 0fe79e17aed9f25c85c6403b9c19b32a9a079677 Mon Sep 17 00:00:00 2001 From: Patrick Kuijvenhoven Date: Mon, 12 Jan 2026 22:12:45 +0100 Subject: [PATCH 125/148] fix(openapi): phpdoc operation response as array (#7660) --- Model/Operation.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Model/Operation.php b/Model/Operation.php index 5ee455d..20c5557 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -17,15 +17,16 @@ final class Operation { use ExtensionTrait; + /** + * @param ?string[] $tags + * @param ?Response[] $responses + */ public function __construct(private ?string $operationId = null, private ?array $tags = null, private ?array $responses = null, private ?string $summary = null, private ?string $description = null, private ?ExternalDocumentation $externalDocs = null, private ?array $parameters = null, private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private ?bool $deprecated = null, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) { $this->extensionProperties = $extensionProperties; } - /** - * @param string $status - */ - public function addResponse(Response $response, $status = 'default'): self + public function addResponse(Response $response, int|string $status = 'default'): self { $this->responses[$status] = $response; @@ -62,6 +63,9 @@ public function getExternalDocs(): ?ExternalDocumentation return $this->externalDocs; } + /** + * @return ?Parameter[] + */ public function getParameters(): ?array { return $this->parameters; @@ -151,6 +155,9 @@ public function withExternalDocs(ExternalDocumentation $externalDocs): self return $clone; } + /** + * @param Parameter[] $parameters + */ public function withParameters(array $parameters): self { $clone = clone $this; From 3600d57bf2729c8fa82d831edbdd21244037ee1b Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 16 Jan 2026 16:43:25 +0100 Subject: [PATCH 126/148] fix(metadata): losing content on error reponse with no output (#7674) fixes #7664 --- Factory/OpenApiFactory.php | 6 ++--- Tests/Factory/OpenApiFactoryTest.php | 38 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 3759fa4..18db510 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -494,9 +494,9 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection /** * @param array $existingResponses */ - private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Operation $openapiOperation, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null): Operation + private function buildOpenApiResponse(array $existingResponses, int|string $status, string $description, Operation $openapiOperation, ?HttpOperation $operation = null, ?array $responseMimeTypes = null, ?array $operationOutputSchemas = null, ?ResourceMetadataCollection $resourceMetadataCollection = null, bool $isErrorResponse = false): Operation { - $noOutput = \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; + $noOutput = !$isErrorResponse && \is_array($operation?->getOutput()) && null === $operation->getOutput()['class']; $response = $existingResponses[$status] ?? new Response($description); if (null === $response->getDescription()) { @@ -1011,7 +1011,7 @@ private function addOperationErrors( 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); + $operation = $this->buildOpenApiResponse($operation->getResponses() ?: [], $status, $errorResource->getDescription() ?? '', $operation, $originalOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection, true); } return $operation; diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index e5cbd0d..e63c3be 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -275,6 +275,7 @@ public function testInvoke(): void requestBody: new RequestBody(''), )), 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), + 'postDummyItemWithoutOutput' => (new Post())->withUriTemplate('/dummyitem/nooutput')->withOperation($baseOperation)->withOutput(false), 'getDummyCollectionWithErrors' => (new GetCollection())->withUriTemplate('erroredDummies')->withErrors([DummyErrorResource::class])->withOperation($baseOperation), ]) ); @@ -1288,6 +1289,43 @@ public function testInvoke(): void null ), $emptyRequestBodyPath->getPost()); + $emptyResponsePath = $paths->getPath('/dummyitem/nooutput'); + $this->assertEquals(new Operation( + 'postDummyItemWithoutOutput', + ['Dummy'], + [ + '201' => new Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([]))), + ]), + null, + new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '400' => new Response( + 'Invalid input', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + '422' => new Response( + 'Unprocessable entity', + content: new \ArrayObject(['application/problem+json' => new MediaType(schema: new \ArrayObject(['$ref' => '#/components/schemas/Error']))]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Creates a Dummy resource.', + 'Creates a Dummy resource.', + null, + [], + new RequestBody( + 'The new Dummy resource', + content: new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), + ]), + required: true + ) + ), $emptyResponsePath->getPost()); + $parameter = $paths->getPath('/uri_variable_uuid')->getGet()->getParameters()[0]; $this->assertEquals(['type' => 'string', 'format' => 'uuid'], $parameter->getSchema()); From 39ed78187a4a8e7c1c1fc9b5a3ef3913e3e914e3 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sat, 17 Jan 2026 20:34:53 +0100 Subject: [PATCH 127/148] chore: array deprecations --- Tests/Factory/OpenApiFactoryTest.php | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index e63c3be..bebcace 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -696,10 +696,10 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response('Dummy collection', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], - ]))), + ])), ])), ], 'Retrieves the collection of Dummy resources.', @@ -728,7 +728,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -751,7 +751,7 @@ public function testInvoke(): void new RequestBody( 'The new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), true ) @@ -770,7 +770,7 @@ public function testInvoke(): void '200' => new Response( 'Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]) ), '404' => new Response( @@ -1023,7 +1023,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1074,7 +1074,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1097,7 +1097,7 @@ public function testInvoke(): void new RequestBody( 'Extended description for the new Dummy resource', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.jsonld'])), ]), false ), @@ -1266,7 +1266,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld']))), + 'application/ld+json' => new MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1297,7 +1297,7 @@ public function testInvoke(): void '201' => new Response( 'Dummy resource created', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([]))), + 'application/ld+json' => new MediaType(new \ArrayObject([])), ]), null, new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) @@ -1339,17 +1339,17 @@ public function testInvoke(): void ['Dummy'], [ '200' => new Response('Dummy collection', new \ArrayObject([ - 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject([ 'type' => 'array', 'items' => ['$ref' => '#/components/schemas/Dummy.OutputDto.jsonld'], - ]))), + ])), ])), '418' => new Response( 'A Teapot Exception', new \ArrayObject([ - 'application/problem+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'application/problem+json' => new MediaType(new \ArrayObject([ '$ref' => '#/components/schemas/DummyErrorResource', - ]))), + ])), ]), links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) ), From 5d128298c9ce0487153bce42d9bf98b4ab4f122a Mon Sep 17 00:00:00 2001 From: Gregor Harlan Date: Mon, 26 Jan 2026 07:26:55 +0100 Subject: [PATCH 128/148] chore: add `phpunit.baseline.xml` to .gitattributes (#7692) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index 801f208..531589d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,5 @@ /.gitattributes export-ignore /.gitignore export-ignore /Tests export-ignore +/phpunit.baseline.xml export-ignore /phpunit.xml.dist export-ignore From 59c13717f63e21f98d4ed4e4d7122b0bade72e2e Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 26 Jan 2026 16:38:30 +0100 Subject: [PATCH 129/148] cs: static fn fixes --- Factory/OpenApiFactory.php | 2 +- Serializer/OpenApiNormalizer.php | 2 +- Tests/Factory/OpenApiFactoryTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 18db510..33e4b20 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -1042,7 +1042,7 @@ private function getErrorResource(string $error, ?int $status = null, ?string $d try { $errorResource = $this->resourceMetadataFactory->create($error)[0] ?? new ErrorResource(status: $status, description: $description, class: $defaultErrorResourceClass); - if (!($errorResource instanceof ErrorResource)) { + if (!$errorResource instanceof ErrorResource) { throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error)); } diff --git a/Serializer/OpenApiNormalizer.php b/Serializer/OpenApiNormalizer.php index 21c2638..72841a3 100644 --- a/Serializer/OpenApiNormalizer.php +++ b/Serializer/OpenApiNormalizer.php @@ -91,7 +91,7 @@ private function getPathsCallBack(): \Closure $paths = $decoratedObject->getPaths(); // sort paths by tags, then by path for each tag - uksort($paths, function ($keyA, $keyB) use ($paths) { + uksort($paths, static function ($keyA, $keyB) use ($paths) { $a = $paths[$keyA]; $b = $paths[$keyB]; diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index bebcace..d8773f8 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -1403,7 +1403,7 @@ public function testGetExtensionPropertiesWithFalseValue(): void $resourceCollectionMetadataFactory ->method('create') - ->willReturnCallback(fn (string $resourceClass): ResourceMetadataCollection => match ($resourceClass) { + ->willReturnCallback(static fn (string $resourceClass): ResourceMetadataCollection => match ($resourceClass) { default => new ResourceMetadataCollection($resourceClass, []), Dummy::class => $resourceCollectionMetadata, }); From 83aa5d04b2dd205180e6c417bcf208bc33c9c014 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 20 Feb 2026 21:30:08 +0100 Subject: [PATCH 130/148] fix(doctrine): enforce api-platform/serializer dependency (#7781) --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 6e23450..8ae8308 100644 --- a/composer.json +++ b/composer.json @@ -43,6 +43,7 @@ "api-platform/doctrine-common": "^4.2", "api-platform/doctrine-orm": "^4.2", "api-platform/doctrine-odm": "^4.2", + "api-platform/serializer": "^4.2", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { From dcab93037834665f16cd226dbd867022057c3a7e Mon Sep 17 00:00:00 2001 From: James Isaac Date: Sun, 1 Mar 2026 17:00:49 +0000 Subject: [PATCH 131/148] fix(openapi): allow Operations to override global config in getPaginationParameters (#7807) --- Factory/OpenApiFactory.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 33e4b20..fa554a8 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -798,10 +798,6 @@ private function getFilterParameter(string $name, array $description, string $sh private function getPaginationParameters(CollectionOperationInterface|HttpOperation $operation): array { - if (!$this->paginationOptions->isPaginationEnabled()) { - return []; - } - $parameters = []; if ($operation->getPaginationEnabled() ?? $this->paginationOptions->isPaginationEnabled()) { From c9012b05f19513212f4b70f16c1d5a678a78c385 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Mar 2026 10:30:10 +0100 Subject: [PATCH 132/148] chore: bump sub-components dependencies to ^4.3 (#7820) --- composer.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 8ae8308..bf2797a 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.2", - "api-platform/metadata": "^4.3.x-dev", - "api-platform/state": "^4.2.4", + "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", @@ -40,10 +40,10 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^12.2", - "api-platform/doctrine-common": "^4.2", - "api-platform/doctrine-orm": "^4.2", - "api-platform/doctrine-odm": "^4.2", - "api-platform/serializer": "^4.2", + "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-orm": "^4.3", + "api-platform/doctrine-odm": "^4.3", + "api-platform/serializer": "^4.3", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -81,5 +81,7 @@ }, "scripts": { "test": "./vendor/bin/phpunit" - } + }, + "minimum-stability": "beta", + "prefer-stable": true } From afc45db74d474e43f8533b9c497102c699299dad Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 6 Mar 2026 16:07:49 +0100 Subject: [PATCH 133/148] chore: switch dev-main branch alias to 4.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index bf2797a..6b1b364 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.3.x-dev", + "dev-main": "4.4.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From 79935ac1b2c0bb2d94205140f8b31a516341c67f Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 25 Mar 2026 07:05:31 +0100 Subject: [PATCH 134/148] fix(openapi): fallback description on summary (#7874) --- Factory/OpenApiFactory.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 351b9d6..745692f 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -231,13 +231,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $openapiAttribute; } + $summary = null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface); + // Complete with defaults $openapiOperation = new Operation( operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName), tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName], responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [], - summary: null !== $openapiOperation->getSummary() ? $openapiOperation->getSummary() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), - description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $this->getPathDescription($resourceShortName, $method, $operation instanceof CollectionOperationInterface), + summary: $summary, + description: null !== $openapiOperation->getDescription() ? $openapiOperation->getDescription() : $summary, externalDocs: $openapiOperation->getExternalDocs(), parameters: null !== $openapiOperation->getParameters() ? $openapiOperation->getParameters() : [], requestBody: $openapiOperation->getRequestBody(), From 64268bd78e43c64672ab279d1cc767b75a868671 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 25 Mar 2026 20:19:41 +0100 Subject: [PATCH 135/148] fix(openapi): uri variable default description (#7884) --- Factory/OpenApiFactory.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 745692f..76217d7 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -281,10 +281,22 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } + $description = $uriVariable->getDescription(); + if (null === $description) { + $fromClass = $uriVariable->getFromClass(); + if (null !== $fromClass && $fromClass !== $resourceClass) { + $uriResourceName = (new \ReflectionClass($fromClass))->getShortName(); + } else { + $uriResourceName = $resourceShortName; + } + + $description = "$uriResourceName identifier"; + } + $parameter = new Parameter( $parameterName, 'path', - $uriVariable->getDescription() ?? "$resourceShortName identifier", + $description, $uriVariable->getRequired() ?? true, false, null, From 6ae725b4d2cee60972c18c4bd6148b63ccf16785 Mon Sep 17 00:00:00 2001 From: Roemer Blom Date: Sun, 29 Mar 2026 09:20:23 +0200 Subject: [PATCH 136/148] =?UTF-8?q?fix(openapi):=20default=20explode=20to?= =?UTF-8?q?=20true=20for=20form=20and=20cookie=20style=20param=E2=80=A6=20?= =?UTF-8?q?(#7891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Model/Parameter.php | 6 ++- Tests/Factory/OpenApiFactoryTest.php | 4 +- Tests/Model/ParameterTest.php | 74 ++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 Tests/Model/ParameterTest.php diff --git a/Model/Parameter.php b/Model/Parameter.php index 414bb35..ab8af92 100644 --- a/Model/Parameter.php +++ b/Model/Parameter.php @@ -17,7 +17,7 @@ final class Parameter { use ExtensionTrait; - public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private ?bool $allowEmptyValue = null, private array $schema = [], private ?string $style = null, private bool $explode = false, private ?bool $allowReserved = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) + public function __construct(private string $name, private string $in, private string $description = '', private bool $required = false, private bool $deprecated = false, private ?bool $allowEmptyValue = null, private array $schema = [], private ?string $style = null, private ?bool $explode = null, private ?bool $allowReserved = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $content = null) { if (null === $style) { if ('query' === $in || 'cookie' === $in) { @@ -26,6 +26,10 @@ public function __construct(private string $name, private string $in, private st $this->style = 'simple'; } } + + if (null === $explode) { + $this->explode = \in_array($this->style, ['form', 'cookie'], true); + } } public function getName(): string diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index d8773f8..c956e10 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -968,7 +968,7 @@ public function testInvoke(): void ], 'form', true, true, 'bar'), new Parameter('ha', 'query', '', false, false, null, [ 'type' => 'integer', - ]), + ], 'form', false), new Parameter('toto', 'query', '', true, false, null, [ 'type' => 'array', 'items' => ['type' => 'string'], @@ -976,7 +976,7 @@ public function testInvoke(): void new Parameter('order[name]', 'query', '', false, false, null, [ 'type' => 'string', 'enum' => ['asc', 'desc'], - ]), + ], 'form', false), ], ), $filteredPath->getGet()); diff --git a/Tests/Model/ParameterTest.php b/Tests/Model/ParameterTest.php new file mode 100644 index 0000000..e3e3218 --- /dev/null +++ b/Tests/Model/ParameterTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Tests\Model; + +use ApiPlatform\OpenApi\Model\Parameter; +use PHPUnit\Framework\TestCase; + +class ParameterTest extends TestCase +{ + public function testExplodeDefaultsTrueForFormStyle(): void + { + $parameter = new Parameter('test', 'query'); + $this->assertSame('form', $parameter->getStyle()); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsTrueForCookieStyle(): void + { + $parameter = new Parameter('test', 'cookie'); + $this->assertSame('form', $parameter->getStyle()); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsFalseForPathStyle(): void + { + $parameter = new Parameter('test', 'path'); + $this->assertSame('simple', $parameter->getStyle()); + $this->assertFalse($parameter->getExplode()); + } + + public function testExplodeDefaultsFalseForHeaderStyle(): void + { + $parameter = new Parameter('test', 'header'); + $this->assertSame('simple', $parameter->getStyle()); + $this->assertFalse($parameter->getExplode()); + } + + public function testExplicitExplodeFalseOverridesDefault(): void + { + $parameter = new Parameter('test', 'query', explode: false); + $this->assertSame('form', $parameter->getStyle()); + $this->assertFalse($parameter->getExplode()); + } + + public function testExplicitExplodeTrueOnSimpleStyle(): void + { + $parameter = new Parameter('test', 'path', explode: true); + $this->assertSame('simple', $parameter->getStyle()); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsTrueForExplicitFormStyle(): void + { + $parameter = new Parameter('test', 'path', style: 'form'); + $this->assertTrue($parameter->getExplode()); + } + + public function testExplodeDefaultsFalseForExplicitDeepObjectStyle(): void + { + $parameter = new Parameter('test', 'query', style: 'deepObject'); + $this->assertFalse($parameter->getExplode()); + } +} From 1562617e7500a50c2b6e6f43a0fb29a6a47e83a2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 30 Apr 2026 14:21:24 +0200 Subject: [PATCH 137/148] chore: allow phpunit ^11.5 for PHP 8.2 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHPUnit 12 requires PHP 8.3+. Widen the constraint in the root and each component composer.json so PHP 8.2 resolves to 11.5 (LTS) and 8.3+ to 12.x. Removes the per-job "Force PHPUnit 11.5" workaround steps from CI. | Q | A | ------------- | --- | Branch? | 4.3 | Tickets | ∅ | License | MIT | Doc PR | ∅ --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 6b1b364..c4d5142 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "phpunit/phpunit": "^12.2", + "phpunit/phpunit": "^11.5 || ^12.2", "api-platform/doctrine-common": "^4.3", "api-platform/doctrine-orm": "^4.3", "api-platform/doctrine-odm": "^4.3", From b168f33f139d13c7afaec3109f5065cb36efef61 Mon Sep 17 00:00:00 2001 From: cay89 Date: Fri, 22 May 2026 14:58:02 +0200 Subject: [PATCH 138/148] feat(symfony,laravel): `withCredentials` option to Swagger UI (#8197) --- Options.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Options.php b/Options.php index e91976a..a22904b 100644 --- a/Options.php +++ b/Options.php @@ -47,6 +47,7 @@ public function __construct( private ?string $errorResourceClass = null, private ?string $validationErrorResourceClass = null, private ?string $licenseIdentifier = null, + private bool $withCredentials = false, ) { } @@ -178,4 +179,9 @@ public function getLicenseIdentifier(): ?string { return $this->licenseIdentifier; } + + public function getWithCredentials(): bool + { + return $this->withCredentials; + } } From f5c5b6ad330d3dd56f5f881cb8a39823ac07b677 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 2 Jun 2026 17:01:45 +0200 Subject: [PATCH 139/148] fix(openapi): emit Draft 4 boolean exclusive bounds for spec 3.0.0 (#8222) Closes #7936 Closes #8176 --- Command/OpenApiCommand.php | 8 ++++++-- Factory/OpenApiFactory.php | 22 ++++++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Command/OpenApiCommand.php b/Command/OpenApiCommand.php index 30802a4..26573d8 100644 --- a/Command/OpenApiCommand.php +++ b/Command/OpenApiCommand.php @@ -56,10 +56,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); + $specVersion = $input->getOption('spec-version'); $data = $this->normalizer->normalize( - $this->openApiFactory->__invoke(['filter_tags' => $input->getOption('filter-tags')]), + $this->openApiFactory->__invoke([ + 'filter_tags' => $input->getOption('filter-tags'), + 'spec_version' => $specVersion, + ]), 'json', - ['spec_version' => $input->getOption('spec-version')] + ['spec_version' => $specVersion] ); if ($input->getOption('yaml') && !class_exists(Yaml::class)) { diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 76217d7..cc9fc43 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\OpenApi\Factory; +use ApiPlatform\JsonSchema\BackwardCompatibleSchemaFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\ApiResource; @@ -167,6 +168,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection return; } + $schemaSerializerContext = '3.0.0' === ($context['spec_version'] ?? null) + ? [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true] + : null; + $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class); $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity'); @@ -268,7 +273,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection foreach ($responseMimeTypes as $operationFormat) { $operationOutputSchema = null; - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); $operationOutputSchemas[$operationFormat] = $operationOutputSchema; @@ -409,7 +414,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $errorOperations[$error] = $this->getErrorResource($error); } - $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation); + $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } if ($overrideResponses || !$existingResponses) { @@ -427,7 +432,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(400)->withDescription('Invalid input'), $defaultValidationError, - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } break; case 'PATCH': @@ -439,7 +444,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(400)->withDescription('Invalid input'), $defaultValidationError, - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } break; case 'DELETE': @@ -452,13 +457,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(403)->withDescription('Forbidden'), - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(404)->withDescription('Not found'), - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } if (!$openapiOperation->getResponses()) { @@ -474,7 +479,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = null; - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); $operationInputSchemas[$operationFormat] = $operationInputSchema; @@ -997,6 +1002,7 @@ private function addOperationErrors( Schema $schema, \ArrayObject $schemas, HttpOperation $originalOperation, + ?array $serializerContext = null, ): Operation { foreach ($errors as $errorResource) { $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $this->errorFormats); @@ -1017,7 +1023,7 @@ private function addOperationErrors( $operationErrorSchemas = []; foreach ($responseMimeTypes as $operationFormat) { $operationErrorSchema = null; - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema, $serializerContext); $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); $operationErrorSchemas[$operationFormat] = $operationErrorSchema; } From b1df171310b70eacf9dd416fe6bd3ce3cc479b36 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 3 Jun 2026 07:58:09 +0200 Subject: [PATCH 140/148] fix(openapi): emit valid 3.0 schemas when downgrading from 3.1 (#8225) --- Serializer/LegacyOpenApiNormalizer.php | 134 +++++-- .../LegacyOpenApiNormalizerTest.php | 356 ++++++++++++++++++ 2 files changed, 467 insertions(+), 23 deletions(-) create mode 100644 Tests/Serializer/LegacyOpenApiNormalizerTest.php diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index 7f47f73..747b2d2 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -18,6 +18,11 @@ final class LegacyOpenApiNormalizer implements NormalizerInterface { public const SPEC_VERSION = 'spec_version'; + + private const SCHEMA_BRANCH_KEYS = ['properties', 'patternProperties']; + private const SCHEMA_LIST_KEYS = ['allOf', 'oneOf', 'anyOf']; + private const SCHEMA_NESTED_KEYS = ['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else']; + private array $defaultContext = [ self::SPEC_VERSION => '3.1.0', ]; @@ -27,9 +32,6 @@ public function __construct(private readonly NormalizerInterface $decorated, arr $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - /** - * {@inheritdoc} - */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { $openapi = $this->decorated->normalize($data, $format, $context); @@ -38,40 +40,126 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $openapi; } - $schemas = &$openapi['components']['schemas']; $openapi['openapi'] = '3.0.0'; - foreach ($openapi['components']['schemas'] as $name => $component) { - foreach ($component['properties'] ?? [] as $property => $value) { - if (\is_array($value['type'] ?? false)) { - foreach ($value['type'] as $type) { - $schemas[$name]['properties'][$property]['anyOf'][] = ['type' => $type]; - } - unset($schemas[$name]['properties'][$property]['type']); - } - if (\is_array($value['examples'] ?? false)) { - $schemas[$name]['properties'][$property]['example'] = $value['examples']; - unset($schemas[$name]['properties'][$property]['examples']); - } - } + foreach ($openapi['components']['schemas'] ?? [] as $name => $component) { + $openapi['components']['schemas'][$name] = $this->downgradeSchema($component); + } + + foreach ($openapi['paths'] ?? [] as $path => $operations) { + $openapi['paths'][$path] = $this->downgradePathItem($operations); } return $openapi; } - /** - * {@inheritdoc} - */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->decorated->supportsNormalization($data, $format, $context); } - /** - * {@inheritdoc} - */ public function getSupportedTypes(?string $format): array { return $this->decorated->getSupportedTypes($format); } + + private function downgradePathItem(mixed $pathItem): mixed + { + if (!\is_array($pathItem)) { + return $pathItem; + } + + foreach ($pathItem as $method => $operation) { + if (!\is_array($operation)) { + continue; + } + + if (isset($operation['requestBody']['content']) && \is_array($operation['requestBody']['content'])) { + foreach ($operation['requestBody']['content'] as $mediaType => $media) { + if (isset($media['schema'])) { + $pathItem[$method]['requestBody']['content'][$mediaType]['schema'] = $this->downgradeSchema($media['schema']); + } + } + } + + foreach ($operation['responses'] ?? [] as $status => $response) { + if (!\is_array($response)) { + continue; + } + foreach ($response['content'] ?? [] as $mediaType => $media) { + if (isset($media['schema'])) { + $pathItem[$method]['responses'][$status]['content'][$mediaType]['schema'] = $this->downgradeSchema($media['schema']); + } + } + } + + foreach ($operation['parameters'] ?? [] as $index => $parameter) { + if (isset($parameter['schema'])) { + $pathItem[$method]['parameters'][$index]['schema'] = $this->downgradeSchema($parameter['schema']); + } + } + } + + return $pathItem; + } + + private function downgradeSchema(mixed $schema): mixed + { + if (!\is_array($schema)) { + return $schema; + } + + if (\is_array($schema['type'] ?? null)) { + $types = array_values($schema['type']); + $nullable = \in_array('null', $types, true); + $nonNull = array_values(array_filter($types, static fn ($t) => 'null' !== $t)); + + if (1 === \count($nonNull)) { + $schema['type'] = $nonNull[0]; + } elseif ([] === $nonNull) { + unset($schema['type']); + } else { + unset($schema['type']); + $schema['anyOf'] = array_map(static fn ($t) => ['type' => $t], $nonNull); + } + + if ($nullable) { + $schema['nullable'] = true; + } + } + + if (\array_key_exists('examples', $schema)) { + $schema['example'] = $schema['examples']; + unset($schema['examples']); + } + + foreach (self::SCHEMA_BRANCH_KEYS as $key) { + if (!isset($schema[$key]) || !\is_array($schema[$key])) { + continue; + } + foreach ($schema[$key] as $name => $child) { + $schema[$key][$name] = $this->downgradeSchema($child); + } + } + + foreach (self::SCHEMA_LIST_KEYS as $key) { + if (!isset($schema[$key]) || !\is_array($schema[$key])) { + continue; + } + foreach ($schema[$key] as $index => $child) { + $schema[$key][$index] = $this->downgradeSchema($child); + } + } + + foreach (self::SCHEMA_NESTED_KEYS as $key) { + if (!isset($schema[$key])) { + continue; + } + if (\is_array($schema[$key])) { + $schema[$key] = $this->downgradeSchema($schema[$key]); + } + } + + return $schema; + } } diff --git a/Tests/Serializer/LegacyOpenApiNormalizerTest.php b/Tests/Serializer/LegacyOpenApiNormalizerTest.php new file mode 100644 index 0000000..26899b3 --- /dev/null +++ b/Tests/Serializer/LegacyOpenApiNormalizerTest.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\OpenApi\Tests\Serializer; + +use ApiPlatform\OpenApi\Serializer\LegacyOpenApiNormalizer; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class LegacyOpenApiNormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testReturnsUntouchedWhenSpecVersionIsNot30(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($document, $normalizer->normalize(new \stdClass(), null, [])); + } + + public function testConvertsNullableScalarUsingNullableFlag(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'nullable' => true], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testKeepsItemsWhenArrayTypeIsNullable(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => ['array', 'null'], + 'items' => ['type' => 'string'], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'nullable' => true, + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoNestedProperties(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'TestResource' => [ + 'properties' => [ + 'testEmbeddable' => [ + 'type' => ['object', 'null'], + 'properties' => [ + 'testArrayOrNull' => [ + 'type' => ['array', 'null'], + 'items' => ['type' => 'string'], + ], + ], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'TestResource' => [ + 'properties' => [ + 'testEmbeddable' => [ + 'type' => 'object', + 'properties' => [ + 'testArrayOrNull' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'nullable' => true, + ], + ], + 'nullable' => true, + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoItems(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => ['string', 'null']], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'nullable' => true], + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoAllOfOneOfAnyOf(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'a' => ['allOf' => [['type' => ['string', 'null']]]], + 'b' => ['oneOf' => [['type' => ['integer', 'null']]]], + 'c' => ['anyOf' => [['type' => ['boolean', 'null']]]], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'a' => ['allOf' => [['type' => 'string', 'nullable' => true]]], + 'b' => ['oneOf' => [['type' => 'integer', 'nullable' => true]]], + 'c' => ['anyOf' => [['type' => 'boolean', 'nullable' => true]]], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testRecursesIntoAdditionalProperties(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => ['string', 'null']], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string', 'nullable' => true], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testFallsBackToAnyOfForMultipleNonNullTypes(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'mixed' => ['type' => ['string', 'integer', 'null']], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + $result = $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0']); + + $property = $result['components']['schemas']['Dummy']['properties']['mixed']; + $this->assertArrayNotHasKey('type', $property); + $this->assertSame([ + ['type' => 'string'], + ['type' => 'integer'], + ], $property['anyOf']); + $this->assertTrue($property['nullable']); + } + + public function testConvertsExamplesToExampleRecursively(): void + { + $document = [ + 'openapi' => '3.1.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'examples' => ['Alice', 'Bob']], + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'inner' => ['type' => 'string', 'examples' => ['x']], + ], + ], + ], + ], + ]], + ]; + + $expected = [ + 'openapi' => '3.0.0', + 'components' => ['schemas' => [ + 'Dummy' => [ + 'properties' => [ + 'name' => ['type' => 'string', 'example' => ['Alice', 'Bob']], + 'nested' => [ + 'type' => 'object', + 'properties' => [ + 'inner' => ['type' => 'string', 'example' => ['x']], + ], + ], + ], + ], + ]], + ]; + + $normalizer = $this->buildNormalizer($document); + + $this->assertSame($expected, $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0'])); + } + + public function testWalksPathOperationSchemas(): void + { + $document = [ + 'openapi' => '3.1.0', + 'paths' => [ + '/dummies' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => ['string', 'null']], + ], + ], + ], + ], + ], + ], + ], + ], + 'components' => ['schemas' => []], + ]; + + $normalizer = $this->buildNormalizer($document); + $result = $normalizer->normalize(new \stdClass(), null, ['spec_version' => '3.0.0']); + + $schema = $result['paths']['/dummies']['post']['requestBody']['content']['application/json']['schema']; + $this->assertSame('object', $schema['type']); + $this->assertSame(['type' => 'string', 'nullable' => true], $schema['properties']['name']); + } + + private function buildNormalizer(array $document): LegacyOpenApiNormalizer + { + $decorated = $this->prophesize(NormalizerInterface::class); + $decorated->normalize(\Prophecy\Argument::any(), \Prophecy\Argument::any(), \Prophecy\Argument::any())->willReturn($document); + + return new LegacyOpenApiNormalizer($decorated->reveal()); + } +} From 21e22f4d74fafc4b01ee5790be7a264387445dcf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 3 Jun 2026 16:37:29 +0200 Subject: [PATCH 141/148] fix(openapi): coerce metadata parameters in user-supplied openapi operation (#8229) Fixes #8182 --- Factory/OpenApiFactory.php | 5 +++ Tests/Factory/OpenApiFactoryTest.php | 60 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index cc9fc43..6273ea8 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; @@ -946,6 +947,10 @@ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $de private function hasParameter(Operation $operation, Parameter $parameter): ?array { foreach ($operation->getParameters() as $key => $existingParameter) { + if (!$existingParameter instanceof Parameter) { + throw new InvalidArgumentException(\sprintf('OpenAPI operation parameters must be instances of "%s", "%s" given.', Parameter::class, get_debug_type($existingParameter))); + } + if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) { return [$key, $existingParameter]; } diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index c956e10..89463d4 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\ErrorResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\HeaderParameter; @@ -33,6 +34,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -1438,4 +1440,62 @@ public function testGetExtensionPropertiesWithFalseValue(): void $openApi = $factory->__invoke(); } + + public function testMetadataParameterInOpenApiOperationParametersThrows(): void + { + $resourceNameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); + $definitionNameFactory = new DefinitionNameFactory([]); + + $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ + (new GetCollection()) + ->withClass(Dummy::class) + ->withShortName('Dummy') + ->withName('api_dummies_get_collection') + ->withUriTemplate('/dummies') + ->withOpenapi(new Operation(parameters: [new QueryParameter(key: 'bar')])), + ]))->withClass(Dummy::class)]); + + $resourceCollectionMetadataFactory + ->method('create') + ->willReturnCallback(static fn (string $resourceClass): ResourceMetadataCollection => match ($resourceClass) { + default => new ResourceMetadataCollection($resourceClass, []), + Dummy::class => $resourceCollectionMetadata, + }); + + $resourceNameCollectionFactory->expects($this->once()) + ->method('create') + ->willReturn(new ResourceNameCollection([Dummy::class])); + + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([])); + + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceCollectionMetadataFactory, + propertyNameCollectionFactory: $propertyNameCollectionFactory, + propertyMetadataFactory: $propertyMetadataFactory, + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactory, + $resourceCollectionMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + null, + [], + new Options('Test API', 'This is a test API.', '1.2.3'), + new PaginationOptions(), + null, + ['json' => ['application/problem+json']] + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(Parameter::class); + + $factory->__invoke(); + } } From c72470132f2eb35a4f8f252e60342f0f7c487704 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 16 Jun 2026 12:01:53 +0200 Subject: [PATCH 142/148] fix(openapi): serialize Reference objects with $ref in the generated document (#8306) Fixes #8143 --- Model/Components.php | 2 +- Model/Operation.php | 4 +-- Tests/Serializer/OpenApiNormalizerTest.php | 31 ++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Model/Components.php b/Model/Components.php index cc420cf..04c6ef1 100644 --- a/Model/Components.php +++ b/Model/Components.php @@ -21,7 +21,7 @@ final class Components /** * @param \ArrayObject|\ArrayObject $schemas - * @param \ArrayObject|\ArrayObject $responses + * @param \ArrayObject|\ArrayObject $responses * @param \ArrayObject|\ArrayObject $parameters * @param \ArrayObject|\ArrayObject $examples * @param \ArrayObject|\ArrayObject $requestBodies diff --git a/Model/Operation.php b/Model/Operation.php index 20c5557..e2716ae 100644 --- a/Model/Operation.php +++ b/Model/Operation.php @@ -18,8 +18,8 @@ final class Operation use ExtensionTrait; /** - * @param ?string[] $tags - * @param ?Response[] $responses + * @param ?string[] $tags + * @param array|null $responses */ public function __construct(private ?string $operationId = null, private ?array $tags = null, private ?array $responses = null, private ?string $summary = null, private ?string $description = null, private ?ExternalDocumentation $externalDocs = null, private ?array $parameters = null, private ?RequestBody $requestBody = null, private ?\ArrayObject $callbacks = null, private ?bool $deprecated = null, private ?array $security = null, private ?array $servers = null, array $extensionProperties = []) { diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index be36a20..efe1f25 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -37,6 +37,7 @@ use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Parameter; use ApiPlatform\OpenApi\Model\Paths; +use ApiPlatform\OpenApi\Model\Reference; use ApiPlatform\OpenApi\Model\Schema; use ApiPlatform\OpenApi\Model\Server; use ApiPlatform\OpenApi\OpenApi; @@ -51,6 +52,9 @@ use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\TypeInfo\Type; @@ -95,6 +99,33 @@ public function testNormalizeWithEmptySchemas(): void $this->assertCount(0, $array['components']['schemas']); } + public function testNormalizeReferenceUsesDollarRef(): void + { + $openApi = new OpenApi( + new Info('My API', '1.0.0', 'An amazing API'), + [new Server('https://example.com')], + new Paths(), + new Components(responses: new \ArrayObject([ + '401' => new Reference(ref: '#/components/responses/401'), + ])) + ); + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory))]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $array = $normalizer->normalize($openApi); + + $this->assertArrayHasKey('$ref', $array['components']['responses']['401']); + $this->assertSame('#/components/responses/401', $array['components']['responses']['401']['$ref']); + $this->assertArrayNotHasKey('ref', $array['components']['responses']['401']); + } + public function testNormalize(): void { $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); From 0586754863f36bf1433d2bc3bd7a527ca1a91879 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 25 Jun 2026 09:28:04 +0200 Subject: [PATCH 143/148] feat(openapi): support OpenAPI 3.2.0 (#8350) --- Model/Components.php | 16 ++++++++- Model/Encoding.php | 50 +++++++++++++++++++++++++- Model/Example.php | 28 ++++++++++++++- Model/MediaType.php | 50 +++++++++++++++++++++++++- Model/OAuthFlow.php | 15 +++++++- Model/OAuthFlows.php | 15 +++++++- Model/PathItem.php | 34 +++++++++++++++++- Model/Response.php | 15 +++++++- Model/SecurityScheme.php | 28 ++++++++++++++- Model/Server.php | 15 +++++++- Model/Tag.php | 41 ++++++++++++++++++++- OpenApi.php | 19 ++++++++-- Serializer/LegacyOpenApiNormalizer.php | 2 +- 13 files changed, 314 insertions(+), 14 deletions(-) diff --git a/Model/Components.php b/Model/Components.php index 04c6ef1..2ecec74 100644 --- a/Model/Components.php +++ b/Model/Components.php @@ -30,8 +30,9 @@ final class Components * @param \ArrayObject|\ArrayObject $links * @param \ArrayObject>|\ArrayObject> $callbacks * @param \ArrayObject|\ArrayObject $pathItems + * @param \ArrayObject|\ArrayObject $mediaTypes */ - public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) + public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null, private ?\ArrayObject $mediaTypes = null) { $schemas?->ksort(); @@ -88,6 +89,11 @@ public function getPathItems(): ?\ArrayObject return $this->pathItems; } + public function getMediaTypes(): ?\ArrayObject + { + return $this->mediaTypes; + } + public function withSchemas(\ArrayObject $schemas): self { $clone = clone $this; @@ -167,4 +173,12 @@ public function withPathItems(\ArrayObject $pathItems): self return $clone; } + + public function withMediaTypes(\ArrayObject $mediaTypes): self + { + $clone = clone $this; + $clone->mediaTypes = $mediaTypes; + + return $clone; + } } diff --git a/Model/Encoding.php b/Model/Encoding.php index d56ee0e..4ec8f7d 100644 --- a/Model/Encoding.php +++ b/Model/Encoding.php @@ -17,7 +17,10 @@ final class Encoding { use ExtensionTrait; - public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false) + /** + * @param array|null $prefixEncoding + */ + public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false, private ?\ArrayObject $encoding = null, private ?array $prefixEncoding = null, private ?self $itemEncoding = null) { } @@ -56,6 +59,24 @@ public function getAllowReserved(): bool return $this->allowReserved; } + public function getEncoding(): ?\ArrayObject + { + return $this->encoding; + } + + /** + * @return array|null + */ + public function getPrefixEncoding(): ?array + { + return $this->prefixEncoding; + } + + public function getItemEncoding(): ?self + { + return $this->itemEncoding; + } + public function withContentType(string $contentType): self { $clone = clone $this; @@ -95,4 +116,31 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } + + public function withEncoding(?\ArrayObject $encoding): self + { + $clone = clone $this; + $clone->encoding = $encoding; + + return $clone; + } + + /** + * @param array|null $prefixEncoding + */ + public function withPrefixEncoding(?array $prefixEncoding): self + { + $clone = clone $this; + $clone->prefixEncoding = $prefixEncoding; + + return $clone; + } + + public function withItemEncoding(self $itemEncoding): self + { + $clone = clone $this; + $clone->itemEncoding = $itemEncoding; + + return $clone; + } } diff --git a/Model/Example.php b/Model/Example.php index 4b2f190..583820d 100644 --- a/Model/Example.php +++ b/Model/Example.php @@ -17,7 +17,7 @@ final class Example { use ExtensionTrait; - public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null) + public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null, private mixed $dataValue = null, private ?string $serializedValue = null) { } @@ -72,4 +72,30 @@ public function withExternalValue(string $externalValue): self return $clone; } + + public function getDataValue(): mixed + { + return $this->dataValue; + } + + public function withDataValue(mixed $dataValue): self + { + $clone = clone $this; + $clone->dataValue = $dataValue; + + return $clone; + } + + public function getSerializedValue(): ?string + { + return $this->serializedValue; + } + + public function withSerializedValue(string $serializedValue): self + { + $clone = clone $this; + $clone->serializedValue = $serializedValue; + + return $clone; + } } diff --git a/Model/MediaType.php b/Model/MediaType.php index ea50465..10d9c1d 100644 --- a/Model/MediaType.php +++ b/Model/MediaType.php @@ -17,7 +17,10 @@ final class MediaType { use ExtensionTrait; - public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) + /** + * @param array|null $prefixEncoding + */ + public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null, private ?\ArrayObject $itemSchema = null, private ?array $prefixEncoding = null, private ?Encoding $itemEncoding = null) { } @@ -41,6 +44,24 @@ public function getEncoding(): ?Encoding return $this->encoding; } + public function getItemSchema(): ?\ArrayObject + { + return $this->itemSchema; + } + + /** + * @return array|null + */ + public function getPrefixEncoding(): ?array + { + return $this->prefixEncoding; + } + + public function getItemEncoding(): ?Encoding + { + return $this->itemEncoding; + } + public function withSchema(\ArrayObject $schema): self { $clone = clone $this; @@ -72,4 +93,31 @@ public function withEncoding(Encoding $encoding): self return $clone; } + + public function withItemSchema(\ArrayObject $itemSchema): self + { + $clone = clone $this; + $clone->itemSchema = $itemSchema; + + return $clone; + } + + /** + * @param array|null $prefixEncoding + */ + public function withPrefixEncoding(?array $prefixEncoding): self + { + $clone = clone $this; + $clone->prefixEncoding = $prefixEncoding; + + return $clone; + } + + public function withItemEncoding(Encoding $itemEncoding): self + { + $clone = clone $this; + $clone->itemEncoding = $itemEncoding; + + return $clone; + } } diff --git a/Model/OAuthFlow.php b/Model/OAuthFlow.php index 2c2e356..479d185 100644 --- a/Model/OAuthFlow.php +++ b/Model/OAuthFlow.php @@ -17,7 +17,7 @@ final class OAuthFlow { use ExtensionTrait; - public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null) + public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null, private ?string $deviceAuthorizationUrl = null) { } @@ -41,6 +41,11 @@ public function getScopes(): \ArrayObject return $this->scopes; } + public function getDeviceAuthorizationUrl(): ?string + { + return $this->deviceAuthorizationUrl; + } + public function withAuthorizationUrl(string $authorizationUrl): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withScopes(\ArrayObject $scopes): self return $clone; } + + public function withDeviceAuthorizationUrl(string $deviceAuthorizationUrl): self + { + $clone = clone $this; + $clone->deviceAuthorizationUrl = $deviceAuthorizationUrl; + + return $clone; + } } diff --git a/Model/OAuthFlows.php b/Model/OAuthFlows.php index ad0f9fb..d677d10 100644 --- a/Model/OAuthFlows.php +++ b/Model/OAuthFlows.php @@ -17,7 +17,7 @@ final class OAuthFlows { use ExtensionTrait; - public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null) + public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null, private ?OAuthFlow $deviceAuthorization = null) { } @@ -41,6 +41,11 @@ public function getAuthorizationCode(): ?OAuthFlow return $this->authorizationCode; } + public function getDeviceAuthorization(): ?OAuthFlow + { + return $this->deviceAuthorization; + } + public function withImplicit(OAuthFlow $implicit): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withAuthorizationCode(OAuthFlow $authorizationCode): self return $clone; } + + public function withDeviceAuthorization(OAuthFlow $deviceAuthorization): self + { + $clone = clone $this; + $clone->deviceAuthorization = $deviceAuthorization; + + return $clone; + } } diff --git a/Model/PathItem.php b/Model/PathItem.php index e481e75..8ff59cb 100644 --- a/Model/PathItem.php +++ b/Model/PathItem.php @@ -19,7 +19,7 @@ final class PathItem public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; - public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null) + public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null, private ?Operation $query = null, private ?array $additionalOperations = null) { } @@ -88,6 +88,19 @@ public function getParameters(): ?array return $this->parameters; } + public function getQuery(): ?Operation + { + return $this->query; + } + + /** + * @return array|null + */ + public function getAdditionalOperations(): ?array + { + return $this->additionalOperations; + } + public function withRef(string $ref): self { $clone = clone $this; @@ -191,4 +204,23 @@ public function withParameters(?array $parameters = null): self return $clone; } + + public function withQuery(?Operation $query): self + { + $clone = clone $this; + $clone->query = $query; + + return $clone; + } + + /** + * @param array|null $additionalOperations + */ + public function withAdditionalOperations(?array $additionalOperations = null): self + { + $clone = clone $this; + $clone->additionalOperations = $additionalOperations; + + return $clone; + } } diff --git a/Model/Response.php b/Model/Response.php index 187e8be..b417b9d 100644 --- a/Model/Response.php +++ b/Model/Response.php @@ -17,7 +17,7 @@ final class Response { use ExtensionTrait; - public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null, private ?string $summary = null) { } @@ -41,6 +41,11 @@ public function getLinks(): ?\ArrayObject return $this->links; } + public function getSummary(): ?string + { + return $this->summary; + } + public function withDescription(string $description): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withLinks(\ArrayObject $links): self return $clone; } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } } diff --git a/Model/SecurityScheme.php b/Model/SecurityScheme.php index 52ed63f..b2ab6ed 100644 --- a/Model/SecurityScheme.php +++ b/Model/SecurityScheme.php @@ -17,7 +17,7 @@ final class SecurityScheme { use ExtensionTrait; - public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null) + public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null, private ?string $oauth2MetadataUrl = null, private ?bool $deprecated = null) { } @@ -61,6 +61,16 @@ public function getOpenIdConnectUrl(): ?string return $this->openIdConnectUrl; } + public function getOauth2MetadataUrl(): ?string + { + return $this->oauth2MetadataUrl; + } + + public function getDeprecated(): ?bool + { + return $this->deprecated; + } + public function withType(string $type): self { $clone = clone $this; @@ -124,4 +134,20 @@ public function withOpenIdConnectUrl(string $openIdConnectUrl): self return $clone; } + + public function withOauth2MetadataUrl(string $oauth2MetadataUrl): self + { + $clone = clone $this; + $clone->oauth2MetadataUrl = $oauth2MetadataUrl; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } } diff --git a/Model/Server.php b/Model/Server.php index e5a50a7..8b5d9ed 100644 --- a/Model/Server.php +++ b/Model/Server.php @@ -17,7 +17,7 @@ final class Server { use ExtensionTrait; - public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null) + public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null, private ?string $name = null) { } @@ -36,6 +36,11 @@ public function getVariables(): ?\ArrayObject return $this->variables; } + public function getName(): ?string + { + return $this->name; + } + public function withUrl(string $url): self { $clone = clone $this; @@ -59,4 +64,12 @@ public function withVariables(\ArrayObject $variables): self return $clone; } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } } diff --git a/Model/Tag.php b/Model/Tag.php index c079352..82e8e70 100644 --- a/Model/Tag.php +++ b/Model/Tag.php @@ -17,7 +17,7 @@ final class Tag { use ExtensionTrait; - public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null) + public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null, private ?string $summary = null, private ?string $parent = null, private ?string $kind = null) { } @@ -59,4 +59,43 @@ public function withExternalDocs(string $externalDocs): self return $clone; } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function getParent(): ?string + { + return $this->parent; + } + + public function withParent(string $parent): self + { + $clone = clone $this; + $clone->parent = $parent; + + return $clone; + } + + public function getKind(): ?string + { + return $this->kind; + } + + public function withKind(string $kind): self + { + $clone = clone $this; + $clone->kind = $kind; + + return $clone; + } } diff --git a/OpenApi.php b/OpenApi.php index 61a43d7..ac17632 100644 --- a/OpenApi.php +++ b/OpenApi.php @@ -17,12 +17,13 @@ use ApiPlatform\OpenApi\Model\ExtensionTrait; use ApiPlatform\OpenApi\Model\Info; use ApiPlatform\OpenApi\Model\Paths; +use Symfony\Component\Serializer\Attribute\SerializedName; final class OpenApi { use ExtensionTrait; - public const VERSION = '3.1.0'; + public const VERSION = '3.2.0'; private string $openapi = self::VERSION; private Components $components; @@ -30,7 +31,7 @@ final class OpenApi /** * @param array|null $externalDocs */ - public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) + public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null, private ?string $self = null) { $this->components = $components ?? new Components(); } @@ -85,6 +86,12 @@ public function getWebhooks(): ?\ArrayObject return $this->webhooks; } + #[SerializedName('$self')] + public function getSelf(): ?string + { + return $this->self; + } + public function withOpenapi(string $openapi): self { $clone = clone $this; @@ -156,4 +163,12 @@ public function withJsonSchemaDialect(?string $jsonSchemaDialect): self return $clone; } + + public function withSelf(?string $self): self + { + $clone = clone $this; + $clone->self = $self; + + return $clone; + } } diff --git a/Serializer/LegacyOpenApiNormalizer.php b/Serializer/LegacyOpenApiNormalizer.php index 747b2d2..e978743 100644 --- a/Serializer/LegacyOpenApiNormalizer.php +++ b/Serializer/LegacyOpenApiNormalizer.php @@ -24,7 +24,7 @@ final class LegacyOpenApiNormalizer implements NormalizerInterface private const SCHEMA_NESTED_KEYS = ['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else']; private array $defaultContext = [ - self::SPEC_VERSION => '3.1.0', + self::SPEC_VERSION => '3.2.0', ]; public function __construct(private readonly NormalizerInterface $decorated, array $defaultContext = []) From 0ec594758b7f4f0594b2773ec8ed1a69d7e76045 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 17:18:03 +0200 Subject: [PATCH 144/148] chore: open 5.0 development line Bump dev-main branch alias 4.4.x-dev -> 5.0.x-dev across root and all 21 subpackage composer.json files. Update COMPOSER_ROOT_VERSION to 5.0.x-dev (was stale at 4.3.x-dev). The 4.4 branch was cut from the prior 4.4.x-dev HEAD before this bump to carry the 4.4 maintenance line. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 11ea83c..a711f6b 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From 9293319b3c581f7b6bdda98817e9b009e1cb28b7 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 18:15:00 +0200 Subject: [PATCH 145/148] chore: require ^4.4@alpha for inter-package dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7115 added cross-package calls new in 4.4 — getStateOptionsRepositoryMethod() in api-platform/state and the repositoryMethod constructor argument in api-platform/doctrine-common — that 4.4 provider code invokes. The old ^4.2/^4.3 sibling floors let `composer update --prefer-lowest` pull releases lacking these symbols, so the per-component lowest CI jobs failed with "undefined method" / "unknown named parameter". Floor all api-platform/* inter-package constraints at ^4.4. The @alpha stability flag is required because the subpackages set minimum-stability:beta, under which a plain ^4.4 would not match the 4.4.0-alpha prereleases. Revert to plain ^4.4 once 4.4.0 stable ships. --- composer.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 11ea83c..d0bd2f8 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", @@ -40,10 +40,10 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/serializer": "^4.3.12", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/doctrine-orm": "^4.4@alpha", + "api-platform/doctrine-odm": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { From d6b23fbdd281ef98c1a3daa179060884b5d2dd51 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 29 Jun 2026 13:22:12 +0200 Subject: [PATCH 146/148] chore: bump inter-package constraints to ^5.0@alpha Completes 22ece5199 (open 5.0 dev line): root is 5.0.x-dev but the self-referential api-platform/* constraints were left at ^4.4@alpha, which is unresolvable in the component split-tests (published 4.4 alphas require metadata ^4.4, conflicting with the 5.0 root). --- composer.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 8b8ab72..da97fb5 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", @@ -40,10 +40,10 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/doctrine-orm": "^4.4@alpha", - "api-platform/doctrine-odm": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { From 9804203d0d2f15bcd66b001e65559da34aa6412a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:15:47 +0200 Subject: [PATCH 147/148] feat!: remove legacy PropertyInfo Type system (#8364) --- Factory/OpenApiFactory.php | 48 ++++++++++++------------------------ Factory/TypeFactoryTrait.php | 42 +++---------------------------- 2 files changed, 19 insertions(+), 71 deletions(-) diff --git a/Factory/OpenApiFactory.php b/Factory/OpenApiFactory.php index 6273ea8..1612d6e 100644 --- a/Factory/OpenApiFactory.php +++ b/Factory/OpenApiFactory.php @@ -58,8 +58,6 @@ use ApiPlatform\State\Util\StateOptionsTrait; use ApiPlatform\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\TypeInfo\Type; @@ -728,28 +726,21 @@ private function getFilterParameter(string $name, array $description, string $sh if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { $schema = $description['schema'] ?? []; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { - $type = Type::builtin($description['type']); - if ($description['is_collection'] ?? false) { - $type = Type::array($type, Type::int()); - } - - $schema += $this->getType($type); - } - // TODO: remove in 5.x - } else { - if (isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)); + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, Type::int()); } + + $schema += $this->getType($type); } if (!isset($schema['type'])) { $schema['type'] = 'string'; } - $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; - $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $arrayValueType = TypeIdentifier::ARRAY->value; + $objectValueType = TypeIdentifier::OBJECT->value; $isArraySchema = 'array' === ($schema['type'] ?? null); $style = $isArraySchema && \in_array( @@ -776,26 +767,19 @@ private function getFilterParameter(string $name, array $description, string $sh $schema = $description['schema'] ?? null; if (!$schema) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { - $type = Type::builtin($description['type']); - if ($description['is_collection'] ?? false) { - $type = Type::array($type, key: Type::int()); - } - $schema = $this->getType($type); - } else { - $schema = ['type' => 'string']; + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, key: Type::int()); } - // TODO: remove in 5.x + $schema = $this->getType($type); } else { - $schema = isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) - ? $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)) - : ['type' => 'string']; + $schema = ['type' => 'string']; } } - $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; - $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $arrayValueType = TypeIdentifier::ARRAY->value; + $objectValueType = TypeIdentifier::OBJECT->value; $isArraySchema = 'array' === $schema['type']; diff --git a/Factory/TypeFactoryTrait.php b/Factory/TypeFactoryTrait.php index f711e92..d538624 100644 --- a/Factory/TypeFactoryTrait.php +++ b/Factory/TypeFactoryTrait.php @@ -14,7 +14,6 @@ namespace ApiPlatform\OpenApi\Factory; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type as NativeType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -30,30 +29,9 @@ trait TypeFactoryTrait /** * @return array */ - private function getType(LegacyType|NativeType $type): array + private function getType(NativeType $type): array { - if ($type instanceof NativeType) { - return $this->getNativeType($type); - } - - if ($type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - - if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { - return $this->addNullabilityToTypeDefinition([ - 'type' => 'object', - 'additionalProperties' => $this->getType($subType), - ], $type); - } - - return $this->addNullabilityToTypeDefinition([ - 'type' => 'array', - 'items' => $this->getType($subType), - ], $type); - } - - return $this->addNullabilityToTypeDefinition($this->makeLegacyBasicType($type), $type); + return $this->getNativeType($type); } /** @@ -81,20 +59,6 @@ private function getNativeType(NativeType $type): array return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); } - /** - * @return array - */ - private function makeLegacyBasicType(LegacyType $type): array - { - return match ($type->getBuiltinType()) { - LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], - LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], - LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - LegacyType::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), - default => ['type' => 'string'], - }; - } - /** * @return array */ @@ -182,7 +146,7 @@ private function getClassType(?string $className, bool $nullable): array * * @return array */ - private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType|NativeType $type): array + private function addNullabilityToTypeDefinition(array $jsonSchema, NativeType $type): array { if (!$type->isNullable()) { return $jsonSchema; From d6484b631b222a9eef66462a5cfbe9344f2e8e43 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 1 Jul 2026 09:50:13 +0200 Subject: [PATCH 148/148] feat!: remove deprecated APIs scheduled for 5.0 (#8367) --- Tests/Factory/OpenApiFactoryTest.php | 6 +++--- Tests/Serializer/OpenApiNormalizerTest.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/Factory/OpenApiFactoryTest.php b/Tests/Factory/OpenApiFactoryTest.php index 89463d4..c556998 100644 --- a/Tests/Factory/OpenApiFactoryTest.php +++ b/Tests/Factory/OpenApiFactoryTest.php @@ -531,7 +531,7 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceCollectionMetadataFactory, @@ -1397,7 +1397,7 @@ public function testGetExtensionPropertiesWithFalseValue(): void $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory([]); + $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'), @@ -1447,7 +1447,7 @@ public function testMetadataParameterInOpenApiOperationParametersThrows(): void $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ (new GetCollection()) diff --git a/Tests/Serializer/OpenApiNormalizerTest.php b/Tests/Serializer/OpenApiNormalizerTest.php index efe1f25..4e5000e 100644 --- a/Tests/Serializer/OpenApiNormalizerTest.php +++ b/Tests/Serializer/OpenApiNormalizerTest.php @@ -239,7 +239,7 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactory,