diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 8cd4bcc9c2b..abe2eb0178b 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -127,6 +128,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, @@ -143,6 +145,7 @@ class: $class, description: $description, normalizationContext: $normalizationContext, denormalizationContext: $denormalizationContext, + collectDenormalizationErrors: $collectDenormalizationErrors, security: $security, securityMessage: $securityMessage, securityPostDenormalize: $securityPostDenormalize, @@ -169,10 +172,9 @@ class: $class, name: $name, provider: $provider, processor: $processor, - extraProperties: $extraProperties, - collectDenormalizationErrors: $collectDenormalizationErrors, - parameters: $parameters, stateOptions: $stateOptions, + parameters: $parameters, + extraProperties: $extraProperties, ); } } diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index 34a2c3c3f71..1be8995f779 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -125,6 +126,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 50d51adf282..a1cc716c1ce 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -126,6 +127,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index d6e48716e65..957cdebe9ae 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -127,6 +128,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, @@ -170,9 +172,9 @@ class: $class, name: $name, provider: $provider, processor: $processor, + stateOptions: $stateOptions, parameters: $parameters, extraProperties: $extraProperties, - stateOptions: $stateOptions, ); } diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 99373140d26..bff00d26612 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -73,11 +75,12 @@ class HttpOperation extends Operation * class?: string|null, * name?: string, * }|string|false|null $output {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation} - * @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure} - * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} - * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} - * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} - * @param WebLink[]|null $links + * @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure} + * @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus} + * @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers} + * @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors} + * @param WebLink[]|null $links + * @param array>|null $errors */ public function __construct( protected string $method = 'GET', @@ -155,6 +158,7 @@ public function __construct( protected ?array $exceptionToStatus = null, protected ?bool $queryParameterValidationEnabled = null, protected ?array $links = null, + protected ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -203,6 +207,13 @@ public function __construct( array|Parameters|null $parameters = null, array $extraProperties = [], ) { + if (null !== $this->errors) { + foreach ($this->errors as $error) { + if (!(new $error()) instanceof ProblemExceptionInterface) { + throw new InvalidArgumentException(sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class)); + } + } + } parent::__construct( shortName: $shortName, class: $class, @@ -635,4 +646,20 @@ public function withLinks(array $links): self return $self; } + + public function getErrors(): ?array + { + return $this->errors; + } + + /** + * @param class-string[] $errors + */ + public function withErrors(array $errors): self + { + $self = clone $this; + $self->errors = $errors; + + return $self; + } } diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 94de193d1fc..84fcbe45a2c 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -62,6 +62,7 @@ public function __construct( ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -139,6 +140,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 06adec587fa..956427148a2 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -127,6 +128,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index e79ff5f3cb2..9c041ced954 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -128,6 +129,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 176ca17fa32..9056130b216 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -49,6 +49,7 @@ public function __construct( ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, + ?array $errors = null, ?string $shortName = null, ?string $class = null, @@ -128,6 +129,7 @@ public function __construct( exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, links: $links, + errors: $errors, shortName: $shortName, class: $class, paginationEnabled: $paginationEnabled, diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index b059d9c183d..4bc1a06b503 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\JsonSchema\TypeFactoryInterface; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\HeaderParameterInterface; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -328,6 +329,21 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $existingResponses = $openapiOperation?->getResponses() ?: []; $overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses(); + if ($operation instanceof HttpOperation && null !== $operation->getErrors()) { + foreach ($operation->getErrors() as $error) { + /** @var ProblemExceptionInterface $exception */ + $exception = new $error(); + + $operationErrorSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($error, $operationFormat, Schema::TYPE_OUTPUT, null, $schema, null, $forceSchemaCollection); + $operationErrorSchemas[$operationFormat] = $operationErrorSchema; + $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); + } + + $openapiOperation = $this->buildOpenApiResponse($existingResponses, $exception->getStatus(), $exception->getType(), $openapiOperation, $operation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection); + } + } if ($overrideResponses || !$existingResponses) { // Create responses switch ($method) { diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 7be076f3043..fd715edbbed 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -56,6 +56,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; @@ -252,6 +253,7 @@ public function testInvoke(): void ], )), 'postDummyItemWithoutInput' => (new Post())->withUriTemplate('/dummyitem/noinput')->withOperation($baseOperation)->withInput(false), + 'getDummyCollectionWithErrors' => (new GetCollection())->withUriTemplate('erroredDummies')->withErrors([DummyErrorResource::class])->withOperation($baseOperation), ]) ); @@ -268,10 +270,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, [])); $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); @@ -387,6 +391,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 = [ @@ -505,6 +562,35 @@ public function testInvoke(): void ]), ], ])); + $dummyErrorSchema = new Schema('openapi'); + $dummyErrorSchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => '', + '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/']); @@ -529,6 +615,7 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject([ 'Dummy' => $dummySchema->getDefinitions(), 'Dummy.OutputDto' => $dummySchema->getDefinitions(), + 'DummyErrorResource' => $dummyErrorSchema->getDefinitions(), 'Parameter' => $parameterSchema, ])); @@ -1005,5 +1092,44 @@ 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('Teapot', new \ArrayObject([ + 'application/ld+json' => new MediaType(new \ArrayObject(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/DummyErrorResource'], + ]))), + ]), + links: new \ArrayObject(['getDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), null, 'This is a dummy')]) + ), + ], + 'Retrieves the collection of Dummy resources.', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ], + deprecated: false + ), $paths->getPath('/erroredDummies')->getGet()); } } diff --git a/src/OpenApi/Tests/Fixtures/DummyErrorResource.php b/src/OpenApi/Tests/Fixtures/DummyErrorResource.php new file mode 100644 index 00000000000..53b091ef955 --- /dev/null +++ b/src/OpenApi/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 null; + } + + public function getStatus(): ?int + { + return 418; + } + + public function getDetail(): ?string + { + return 'I am not a coffee maker'; + } + + public function getInstance(): ?string + { + return null; + } +}