Skip to content

Commit 266ab41

Browse files
committed
fix: exception to status on error resource
1 parent 231eba3 commit 266ab41

File tree

16 files changed

+135
-34
lines changed

16 files changed

+135
-34
lines changed

features/main/exception_to_status.feature

+7
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@ Feature: Using exception_to_status config
3131
Then the response status code should be 400
3232
And the response should be in JSON
3333
And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"
34+
35+
@!mongodb
36+
Scenario: Override validation exception status code from delete operation
37+
When I add "Content-Type" header equal to "application/ld+json"
38+
And I send a "DELETE" request to "/error_with_overriden_status/1"
39+
Then the response status code should be 403
40+
And the JSON node "status" should be equal to 403

src/Action/EntrypointAction.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public function __invoke(Request $request = null)
4242
{
4343
if ($this->provider && $this->processor) {
4444
$context = ['request' => $request];
45-
$operation = new Get(class: Entrypoint::class, provider: fn () => new Entrypoint($this->resourceNameCollectionFactory->create()));
45+
$operation = new Get(read: true, serialize: true, class: Entrypoint::class, provider: fn () => new Entrypoint($this->resourceNameCollectionFactory->create()));
4646
$body = $this->provider->provide($operation, [], $context);
4747

4848
return $this->processor->process($body, $operation, [], $context);

src/ApiResource/Error.php

+6-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ class Error extends \Exception implements ProblemExceptionInterface, HttpExcepti
4848
public function __construct(
4949
private readonly string $title,
5050
private readonly string $detail,
51-
#[ApiProperty(identifier: true)] private readonly int $status,
51+
#[ApiProperty(identifier: true)] private int $status,
5252
private readonly array $originalTrace,
5353
private ?string $instance = null,
5454
private string $type = 'about:blank',
@@ -132,6 +132,11 @@ public function getStatus(): ?int
132132
return $this->status;
133133
}
134134

135+
public function setStatus(int $status): void
136+
{
137+
$this->status = $status;
138+
}
139+
135140
#[Groups(['jsonld', 'jsonproblem', 'legacy_jsonproblem'])]
136141
public function getDetail(): ?string
137142
{

src/Documentation/Action/DocumentationAction.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ private function getOpenApiDocumentation(array $context, string $format, Request
7878
{
7979
if ($this->provider && $this->processor) {
8080
$context['request'] = $request;
81-
$operation = new Get(class: OpenApi::class, provider: fn () => $this->openApiFactory->__invoke($context), normalizationContext: [ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null], outputFormats: $this->documentationFormats);
81+
$operation = new Get(class: OpenApi::class, read: true, serialize: true, provider: fn () => $this->openApiFactory->__invoke($context), normalizationContext: [ApiGatewayNormalizer::API_GATEWAY => $context['api_gateway'] ?? null], outputFormats: $this->documentationFormats);
8282
if ('html' === $format) {
8383
$operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true);
8484
}
@@ -104,6 +104,8 @@ private function getHydraDocumentation(array $context, Request $request): Docume
104104
$context['request'] = $request;
105105
$operation = new Get(
106106
class: Documentation::class,
107+
read: true,
108+
serialize: true,
107109
provider: fn () => new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version)
108110
);
109111

src/Elasticsearch/State/CollectionProvider.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Elasticsearch\State;
1515

16+
use ApiPlatform\ApiResource\Error;
1617
use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface;
1718
use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata;
1819
use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface;
@@ -22,6 +23,7 @@
2223
use ApiPlatform\State\Pagination\Pagination;
2324
use ApiPlatform\State\ProviderInterface;
2425
use Elastic\Elasticsearch\Client;
26+
use Elastic\Elasticsearch\Exception\ClientResponseException;
2527
use Elastic\Elasticsearch\Response\Elasticsearch;
2628
use Elasticsearch\Client as LegacyClient;
2729
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
@@ -72,7 +74,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
7274
'body' => $body,
7375
];
7476

75-
$documents = $this->client->search($params); // @phpstan-ignore-line
77+
try {
78+
$documents = $this->client->search($params); // @phpstan-ignore-line
79+
} catch (ClientResponseException $e) {
80+
$response = $e->getResponse();
81+
throw new Error(status: $response->getStatusCode(), detail: (string) $response->getBody(), title: $response->getReasonPhrase(), originalTrace: $e->getTrace());
82+
}
7683

7784
if ($documents instanceof Elasticsearch) {
7885
$documents = $documents->asArray();

src/Elasticsearch/State/ItemProvider.php

+9-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Elasticsearch\State;
1515

16+
use ApiPlatform\ApiResource\Error;
1617
use ApiPlatform\Elasticsearch\Metadata\Document\DocumentMetadata;
1718
use ApiPlatform\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface;
1819
use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer;
@@ -65,8 +66,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
6566

6667
try {
6768
$document = $this->client->get($params); // @phpstan-ignore-line
68-
} catch (Missing404Exception|ClientResponseException) { // @phpstan-ignore-line
69+
} catch (Missing404Exception) { // @phpstan-ignore-line
6970
return null;
71+
} catch (ClientResponseException $e) {
72+
$response = $e->getResponse();
73+
if (404 === $response->getStatusCode()) {
74+
return null;
75+
}
76+
77+
throw new Error(status: $response->getStatusCode(), detail: (string) $response->getBody(), title: $response->getReasonPhrase(), originalTrace: $e->getTrace());
7078
}
7179

7280
if ($document instanceof Elasticsearch) {

src/JsonLd/Action/ContextAction.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ public function __invoke(string $shortName, Request $request = null): array|Resp
6161
outputFormats: ['jsonld' => ['application/ld+json']],
6262
validate: false,
6363
provider: fn () => $this->getContext($shortName),
64-
serialize: false
64+
serialize: false,
65+
read: true
6566
);
6667
$context = ['request' => $request];
6768
$jsonLdContext = $this->provider->provide($operation, [], $context);

src/Metadata/Exception/ProblemExceptionInterface.php

+2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public function getTitle(): ?string;
2828

2929
public function getStatus(): ?int;
3030

31+
public function setStatus(int $status): void;
32+
3133
public function getDetail(): ?string;
3234

3335
public function getInstance(): ?string;

src/State/Provider/ReadProvider.php

+2-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
4949
}
5050

5151
$request = ($context['request'] ?? null);
52-
if (!($operation->canRead() ?? true) || (!$operation->getUriVariables() && !$request?->isMethodSafe())) {
52+
53+
if (!$operation->canRead()) {
5354
return null;
5455
}
5556

src/Symfony/Controller/MainController.php

+13
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Api\UriVariablesConverterInterface;
1717
use ApiPlatform\Exception\InvalidIdentifierException;
1818
use ApiPlatform\Exception\InvalidUriVariableException;
19+
use ApiPlatform\Metadata\HttpOperation;
1920
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2021
use ApiPlatform\State\ProcessorInterface;
2122
use ApiPlatform\State\ProviderInterface;
@@ -62,6 +63,14 @@ public function __invoke(Request $request): Response
6263
$operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE'));
6364
}
6465

66+
if (null === $operation->canRead() && $operation instanceof HttpOperation) {
67+
$operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe());
68+
}
69+
70+
if (null === $operation->canDeserialize() && $operation instanceof HttpOperation) {
71+
$operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true));
72+
}
73+
6574
$body = $this->provider->provide($operation, $uriVariables, $context);
6675

6776
// The provider can change the Operation, extract it again from the Request attributes
@@ -84,6 +93,10 @@ public function __invoke(Request $request): Response
8493
$operation = $operation->withWrite(!$request->isMethodSafe());
8594
}
8695

96+
if (null === $operation->canSerialize()) {
97+
$operation = $operation->withSerialize(true);
98+
}
99+
87100
return $this->processor->process($body, $operation, $uriVariables, $context);
88101
}
89102
}

src/Symfony/EventListener/ErrorListener.php

+34-21
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Api\IdentifiersExtractorInterface;
1717
use ApiPlatform\ApiResource\Error;
1818
use ApiPlatform\Metadata\Error as ErrorOperation;
19+
use ApiPlatform\Metadata\Exception\ProblemExceptionInterface;
1920
use ApiPlatform\Metadata\HttpOperation;
2021
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2122
use ApiPlatform\Metadata\ResourceClassResolverInterface;
@@ -64,32 +65,44 @@ protected function duplicateRequest(\Throwable $exception, Request $request): Re
6465
$apiOperation = $this->initializeOperation($request);
6566
$format = $this->getRequestFormat($request, $this->errorFormats, false);
6667

67-
if ($this->resourceClassResolver?->isResourceClass($exception::class)) {
68-
$resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class);
69-
70-
$operation = null;
71-
foreach ($resourceCollection as $resource) {
72-
foreach ($resource->getOperations() as $op) {
73-
foreach ($op->getOutputFormats() as $key => $value) {
74-
if ($key === $format) {
75-
$operation = $op;
76-
break 3;
68+
if ($this->resourceMetadataCollectionFactory) {
69+
if ($this->resourceClassResolver?->isResourceClass($exception::class)) {
70+
$resourceCollection = $this->resourceMetadataCollectionFactory->create($exception::class);
71+
72+
$operation = null;
73+
foreach ($resourceCollection as $resource) {
74+
foreach ($resource->getOperations() as $op) {
75+
foreach ($op->getOutputFormats() as $key => $value) {
76+
if ($key === $format) {
77+
$operation = $op;
78+
break 3;
79+
}
7780
}
7881
}
7982
}
80-
}
8183

82-
// No operation found for the requested format, we take the first available
83-
if (!$operation) {
84-
$operation = $resourceCollection->getOperation();
84+
// No operation found for the requested format, we take the first available
85+
if (!$operation) {
86+
$operation = $resourceCollection->getOperation();
87+
}
88+
$errorResource = $exception;
89+
if ($errorResource instanceof ProblemExceptionInterface && $operation instanceof HttpOperation) {
90+
$statusCode = $this->getStatusCode($apiOperation, $request, $operation, $exception);
91+
$operation = $operation->withStatus($statusCode);
92+
$errorResource->setStatus($statusCode);
93+
}
94+
} else {
95+
// Create a generic, rfc7807 compatible error according to the wanted format
96+
$operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format));
97+
// status code may be overriden by the exceptionToStatus option
98+
$statusCode = 500;
99+
if ($operation instanceof HttpOperation) {
100+
$statusCode = $this->getStatusCode($apiOperation, $request, $operation, $exception);
101+
$operation = $operation->withStatus($statusCode);
102+
}
103+
104+
$errorResource = Error::createFromException($exception, $statusCode);
85105
}
86-
$errorResource = $exception;
87-
} elseif ($this->resourceMetadataCollectionFactory) {
88-
// Create a generic, rfc7807 compatible error according to the wanted format
89-
/** @var HttpOperation $operation */
90-
$operation = $this->resourceMetadataCollectionFactory->create(Error::class)->getOperation($this->getFormatOperation($format));
91-
$operation = $operation->withStatus($this->getStatusCode($apiOperation, $request, $operation, $exception));
92-
$errorResource = Error::createFromException($exception, $operation->getStatus());
93106
} else {
94107
/** @var HttpOperation $operation */
95108
$operation = new ErrorOperation(name: '_api_errors_problem', class: Error::class, outputFormats: ['jsonld' => ['application/ld+json']], normalizationContext: ['groups' => ['jsonld'], 'skip_null_values' => true]);

src/Symfony/State/DeserializeProvider.php

+3-4
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
5151
return $data;
5252
}
5353

54-
if (
55-
!($operation->canDeserialize() ?? true)
56-
|| !\in_array($method = $operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)
57-
) {
54+
if (!$operation->canDeserialize()) {
5855
return $data;
5956
}
6057

@@ -74,6 +71,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
7471
throw new UnsupportedMediaTypeHttpException('Format not supported.');
7572
}
7673

74+
$method = $operation->getMethod();
75+
7776
if (
7877
null !== $data
7978
&& (

src/Symfony/State/SerializeProcessor.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function __construct(private readonly ProcessorInterface $processor, priv
3636

3737
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
3838
{
39-
if ($data instanceof Response || !($operation->canSerialize() ?? true) || !($request = $context['request'] ?? null)) {
39+
if ($data instanceof Response || !$operation->canSerialize() || !($request = $context['request'] ?? null)) {
4040
return $this->processor->process($data, $operation, $uriVariables, $context);
4141
}
4242

src/Symfony/Validator/Exception/ValidationException.php

+8-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
)]
4242
final class ValidationException extends BaseValidationException implements ConstraintViolationListAwareExceptionInterface, \Stringable, ProblemExceptionInterface
4343
{
44+
private int $status = 422;
45+
4446
public function __construct(private readonly ConstraintViolationListInterface $constraintViolationList, string $message = '', int $code = 0, \Throwable $previous = null, string $errorTitle = null)
4547
{
4648
parent::__construct($message ?: $this->__toString(), $code, $previous, $errorTitle);
@@ -119,7 +121,12 @@ public function getDetail(): ?string
119121
#[Groups(['jsonld', 'json', 'legacy_jsonproblem', 'legacy_json'])]
120122
public function getStatus(): ?int
121123
{
122-
return 422;
124+
return $this->status;
125+
}
126+
127+
public function setStatus(int $status): void
128+
{
129+
$this->status = $status;
123130
}
124131

125132
#[Groups(['jsonld', 'json'])]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\Delete;
17+
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
18+
use Symfony\Component\Validator\ConstraintViolationList;
19+
20+
#[Delete(
21+
uriTemplate: '/error_with_overriden_status/{id}',
22+
read: true,
23+
// To make it work with 3.1, remove in 4
24+
uriVariables: ['id'],
25+
provider: [ErrorWithOverridenStatus::class, 'throw'],
26+
exceptionToStatus: [ValidationException::class => 403]
27+
)]
28+
class ErrorWithOverridenStatus
29+
{
30+
public static function throw(): void
31+
{
32+
throw new ValidationException(new ConstraintViolationList());
33+
}
34+
}

tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php

+2
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
use ApiPlatform\Metadata\Put;
2121
use ApiPlatform\Tests\Fixtures\TestBundle\Exception\NotFoundException;
2222
use ApiPlatform\Tests\Fixtures\TestBundle\Filter\RequiredFilter;
23+
use ApiPlatform\Tests\Fixtures\TestBundle\State\DummyExceptionToStatusProvider;
2324
use Doctrine\ORM\Mapping as ORM;
2425
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
2526

2627
#[ApiResource(
2728
exceptionToStatus: [NotFoundHttpException::class => 400],
29+
provider: DummyExceptionToStatusProvider::class,
2830
operations: [
2931
new Get(uriTemplate: '/dummy_exception_to_statuses/{id}', exceptionToStatus: [NotFoundException::class => 404]),
3032
new Put(uriTemplate: '/dummy_exception_to_statuses/{id}'),

0 commit comments

Comments
 (0)