Skip to content

Commit 48267c9

Browse files
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 <[email protected]>
1 parent 7c89f66 commit 48267c9

File tree

12 files changed

+456
-166
lines changed

12 files changed

+456
-166
lines changed

src/Metadata/Delete.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -127,6 +128,7 @@ public function __construct(
127128
exceptionToStatus: $exceptionToStatus,
128129
queryParameterValidationEnabled: $queryParameterValidationEnabled,
129130
links: $links,
131+
errors: $errors,
130132
shortName: $shortName,
131133
class: $class,
132134
paginationEnabled: $paginationEnabled,

src/Metadata/Error.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -125,6 +126,7 @@ public function __construct(
125126
exceptionToStatus: $exceptionToStatus,
126127
queryParameterValidationEnabled: $queryParameterValidationEnabled,
127128
links: $links,
129+
errors: $errors,
128130
shortName: $shortName,
129131
class: $class,
130132
paginationEnabled: $paginationEnabled,

src/Metadata/Get.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -126,6 +127,7 @@ public function __construct(
126127
exceptionToStatus: $exceptionToStatus,
127128
queryParameterValidationEnabled: $queryParameterValidationEnabled,
128129
links: $links,
130+
errors: $errors,
129131
shortName: $shortName,
130132
class: $class,
131133
paginationEnabled: $paginationEnabled,

src/Metadata/GetCollection.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -127,6 +128,7 @@ public function __construct(
127128
exceptionToStatus: $exceptionToStatus,
128129
queryParameterValidationEnabled: $queryParameterValidationEnabled,
129130
links: $links,
131+
errors: $errors,
130132
shortName: $shortName,
131133
class: $class,
132134
paginationEnabled: $paginationEnabled,

src/Metadata/HttpOperation.php

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Metadata;
1515

16+
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
1617
use ApiPlatform\OpenApi\Attributes\Webhook;
1718
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
1819
use ApiPlatform\State\OptionsInterface;
@@ -73,11 +74,12 @@ class HttpOperation extends Operation
7374
* class?: string|null,
7475
* name?: string,
7576
* }|string|false|null $output {@see https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation}
76-
* @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure}
77-
* @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus}
78-
* @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers}
79-
* @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors}
80-
* @param WebLink[]|null $links
77+
* @param string|array|bool|null $mercure {@see https://api-platform.com/docs/core/mercure}
78+
* @param string|bool|null $messenger {@see https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus}
79+
* @param string|callable|null $provider {@see https://api-platform.com/docs/core/state-providers/#state-providers}
80+
* @param string|callable|null $processor {@see https://api-platform.com/docs/core/state-processors/#state-processors}
81+
* @param WebLink[]|null $links
82+
* @param array<class-string<ProblemExceptionInterface>>|null $errors
8183
*/
8284
public function __construct(
8385
protected string $method = 'GET',
@@ -154,6 +156,7 @@ public function __construct(
154156
protected bool|OpenApiOperation|Webhook|null $openapi = null,
155157
protected ?array $exceptionToStatus = null,
156158
protected ?array $links = null,
159+
protected ?array $errors = null,
157160

158161
?string $shortName = null,
159162
?string $class = null,
@@ -623,4 +626,20 @@ public function withLinks(array $links): self
623626

624627
return $self;
625628
}
629+
630+
public function getErrors(): ?array
631+
{
632+
return $this->errors;
633+
}
634+
635+
/**
636+
* @param class-string<ProblemExceptionInterface>[] $errors
637+
*/
638+
public function withErrors(array $errors): self
639+
{
640+
$self = clone $this;
641+
$self->errors = $errors;
642+
643+
return $self;
644+
}
626645
}

src/Metadata/NotExposed.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public function __construct(
6262

6363
?bool $queryParameterValidationEnabled = null,
6464
?array $links = null,
65+
?array $errors = null,
6566

6667
?string $shortName = null,
6768
?string $class = null,
@@ -139,6 +140,7 @@ public function __construct(
139140
exceptionToStatus: $exceptionToStatus,
140141
queryParameterValidationEnabled: $queryParameterValidationEnabled,
141142
links: $links,
143+
errors: $errors,
142144
shortName: $shortName,
143145
class: $class,
144146
paginationEnabled: $paginationEnabled,

src/Metadata/Patch.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -127,6 +128,7 @@ public function __construct(
127128
exceptionToStatus: $exceptionToStatus,
128129
queryParameterValidationEnabled: $queryParameterValidationEnabled,
129130
links: $links,
131+
errors: $errors,
130132
shortName: $shortName,
131133
class: $class,
132134
paginationEnabled: $paginationEnabled,

src/Metadata/Post.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -128,6 +129,7 @@ public function __construct(
128129
exceptionToStatus: $exceptionToStatus,
129130
queryParameterValidationEnabled: $queryParameterValidationEnabled,
130131
links: $links,
132+
errors: $errors,
131133
shortName: $shortName,
132134
class: $class,
133135
paginationEnabled: $paginationEnabled,

src/Metadata/Put.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
?array $exceptionToStatus = null,
5050
?bool $queryParameterValidationEnabled = null,
5151
?array $links = null,
52+
?array $errors = null,
5253

5354
?string $shortName = null,
5455
?string $class = null,
@@ -128,6 +129,7 @@ public function __construct(
128129
exceptionToStatus: $exceptionToStatus,
129130
queryParameterValidationEnabled: $queryParameterValidationEnabled,
130131
links: $links,
132+
errors: $errors,
131133
shortName: $shortName,
132134
class: $class,
133135
paginationEnabled: $paginationEnabled,

src/OpenApi/Factory/OpenApiFactory.php

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
use ApiPlatform\JsonSchema\TypeFactoryInterface;
2121
use ApiPlatform\Metadata\ApiResource;
2222
use ApiPlatform\Metadata\CollectionOperationInterface;
23+
use ApiPlatform\Metadata\Error;
24+
use ApiPlatform\Metadata\Exception\OperationNotFoundException;
25+
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
26+
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
27+
use ApiPlatform\Metadata\Exception\RuntimeException;
2328
use ApiPlatform\Metadata\HeaderParameterInterface;
2429
use ApiPlatform\Metadata\HttpOperation;
2530
use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
@@ -38,6 +43,7 @@
3843
use ApiPlatform\OpenApi\Model\MediaType;
3944
use ApiPlatform\OpenApi\Model\OAuthFlow;
4045
use ApiPlatform\OpenApi\Model\OAuthFlows;
46+
use ApiPlatform\OpenApi\Model\Operation;
4147
use ApiPlatform\OpenApi\Model\Parameter;
4248
use ApiPlatform\OpenApi\Model\PathItem;
4349
use ApiPlatform\OpenApi\Model\Paths;
@@ -75,8 +81,19 @@ final class OpenApiFactory implements OpenApiFactoryInterface
7581
*/
7682
public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name';
7783

78-
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)
79-
{
84+
public function __construct(
85+
private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory,
86+
private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory,
87+
private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory,
88+
private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory,
89+
private readonly SchemaFactoryInterface $jsonSchemaFactory,
90+
?TypeFactoryInterface $jsonSchemaTypeFactory,
91+
ContainerInterface $filterLocator,
92+
private readonly array $formats = [],
93+
?Options $openApiOptions = null,
94+
?PaginationOptions $paginationOptions = null,
95+
private readonly ?RouterInterface $router = null,
96+
) {
8097
$this->filterLocator = $filterLocator;
8198
$this->openApiOptions = $openApiOptions ?: new Options('API Platform');
8299
$this->paginationOptions = $paginationOptions ?: new PaginationOptions();
@@ -181,15 +198,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
181198

182199
if ($openapiAttribute instanceof Webhook) {
183200
$pathItem = $openapiAttribute->getPathItem() ?: new PathItem();
184-
$openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Model\Operation();
201+
$openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation();
185202
} elseif (!\is_object($openapiAttribute)) {
186-
$openapiOperation = new Model\Operation();
203+
$openapiOperation = new Operation();
187204
} else {
188205
$openapiOperation = $openapiAttribute;
189206
}
190207

191208
// Complete with defaults
192-
$openapiOperation = new Model\Operation(
209+
$openapiOperation = new Operation(
193210
operationId: null !== $openapiOperation->getOperationId() ? $openapiOperation->getOperationId() : $this->normalizeOperationName($operationName),
194211
tags: null !== $openapiOperation->getTags() ? $openapiOperation->getTags() : [$operation->getShortName() ?: $resourceShortName],
195212
responses: null !== $openapiOperation->getResponses() ? $openapiOperation->getResponses() : [],
@@ -339,6 +356,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
339356

340357
$existingResponses = $openapiOperation?->getResponses() ?: [];
341358
$overrideResponses = $operation->getExtraProperties()[self::OVERRIDE_OPENAPI_RESPONSES] ?? $this->openApiOptions->getOverrideResponses();
359+
if ($operation instanceof HttpOperation && null !== ($errors = $operation->getErrors())) {
360+
$openapiOperation = $this->addOperationErrors($openapiOperation, $errors, $responseMimeTypes, $resourceMetadataCollection, $schema, $schemas);
361+
}
362+
342363
if ($overrideResponses || !$existingResponses) {
343364
// Create responses
344365
switch ($method) {
@@ -433,7 +454,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
433454
'3.1',
434455
'The "openapiContext" option is deprecated, use "openapi" instead.'
435456
);
436-
$allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(Model\Operation::class))->getProperties());
457+
$allowedProperties = array_map(fn (\ReflectionProperty $reflProperty): string => $reflProperty->getName(), (new \ReflectionClass(Operation::class))->getProperties());
437458
foreach ($operation->getOpenapiContext() as $key => $value) {
438459
$value = match ($key) {
439460
'externalDocs' => new ExternalDocumentation(description: $value['description'] ?? '', url: $value['url'] ?? ''),
@@ -460,7 +481,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection
460481
}
461482
}
462483

463-
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
484+
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
464485
{
465486
if (isset($existingResponses[$status])) {
466487
return $openapiOperation;
@@ -491,6 +512,9 @@ private function buildContent(array $responseMimeTypes, array $operationSchemas)
491512
return $content;
492513
}
493514

515+
/**
516+
* @return array[array<string, string>, array<string, string>]
517+
*/
494518
private function getMimeTypes(HttpOperation $operation): array
495519
{
496520
$requestFormats = $operation->getInputFormats() ?: [];
@@ -502,6 +526,11 @@ private function getMimeTypes(HttpOperation $operation): array
502526
return [$requestMimeTypes, $responseMimeTypes];
503527
}
504528

529+
/**
530+
* @param array<string, string[]> $responseFormats
531+
*
532+
* @return array<string, string>
533+
*/
505534
private function flattenMimeTypes(array $responseFormats): array
506535
{
507536
$responseMimeTypes = [];
@@ -803,7 +832,7 @@ private function appendSchemaDefinitions(\ArrayObject $schemas, \ArrayObject $de
803832
/**
804833
* @return array{0: int, 1: Parameter}|null
805834
*/
806-
private function hasParameter(Model\Operation $operation, Parameter $parameter): ?array
835+
private function hasParameter(Operation $operation, Parameter $parameter): ?array
807836
{
808837
foreach ($operation->getParameters() as $key => $existingParameter) {
809838
if ($existingParameter->getName() === $parameter->getName() && $existingParameter->getIn() === $parameter->getIn()) {
@@ -843,4 +872,55 @@ private function mergeParameter(Parameter $actual, Parameter $defined): Paramete
843872

844873
return $actual;
845874
}
875+
876+
/**
877+
* @param string[] $errors
878+
* @param array<string, string> $responseMimeTypes
879+
*/
880+
private function addOperationErrors(Operation $operation, array $errors, array $responseMimeTypes, ResourceMetadataCollection $resourceMetadataCollection, Schema $schema, \ArrayObject $schemas): Operation
881+
{
882+
$existingResponses = null;
883+
foreach ($errors as $error) {
884+
if (!is_a($error, ProblemExceptionInterface::class, true)) {
885+
throw new RuntimeException(\sprintf('The error class "%s" does not implement "%s". Did you forget a use statement?', $error, ProblemExceptionInterface::class));
886+
}
887+
888+
$status = null;
889+
$description = null;
890+
891+
try {
892+
/** @var ProblemExceptionInterface $exception */
893+
$exception = new $error();
894+
$status = $exception->getStatus();
895+
$description = $exception->getTitle();
896+
} catch (\TypeError) {
897+
}
898+
899+
try {
900+
$errorOperation = $this->resourceMetadataFactory->create($error)->getOperation();
901+
if (!is_a($errorOperation, Error::class)) {
902+
throw new RuntimeException(\sprintf('The error class %s is not an ErrorResource', $error));
903+
}
904+
} catch (ResourceClassNotFoundException|OperationNotFoundException) {
905+
$errorOperation = null;
906+
}
907+
$status ??= $errorOperation?->getStatus();
908+
$description ??= $errorOperation?->getDescription();
909+
910+
if (!$status) {
911+
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));
912+
}
913+
914+
$operationErrorSchemas = [];
915+
foreach ($responseMimeTypes as $operationFormat) {
916+
$operationErrorSchema = $this->jsonSchemaFactory->buildSchema($error, $operationFormat, Schema::TYPE_OUTPUT, null, $schema);
917+
$operationErrorSchemas[$operationFormat] = $operationErrorSchema;
918+
$this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions());
919+
}
920+
921+
$operation = $this->buildOpenApiResponse($existingResponses ??= $operation->getResponses() ?: [], $status, $description ?? '', $operation, $errorOperation, $responseMimeTypes, $operationErrorSchemas, $resourceMetadataCollection);
922+
}
923+
924+
return $operation;
925+
}
846926
}

0 commit comments

Comments
 (0)