Skip to content

Commit 4717bd5

Browse files
authored
Allow defining exception_to_status per operation (#3519)
1 parent 7d95337 commit 4717bd5

File tree

6 files changed

+195
-3
lines changed

6 files changed

+195
-3
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
* **BC**: Change `api_platform.listener.request.add_format` priority from 7 to 28 to execute it before firewall (priority 8) (#3599)
66
* **BC**: Use `@final` annotation in ORM filters (#4109)
7+
* Allow defining `exception_to_status` per operation (#3519)
78
* Doctrine: Better exception to find which resource is linked to an exception (#3965)
89
* Doctrine: Allow mixed type value for date filter (notice if invalid) (#3870)
910
* Doctrine: Add `nulls_always_first` and `nulls_always_last` to `nulls_comparison` in order filter (#4103)

src/Action/ExceptionAction.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313

1414
namespace ApiPlatform\Core\Action;
1515

16+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
1617
use ApiPlatform\Core\Util\ErrorFormatGuesser;
18+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
1719
use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException;
1820
use Symfony\Component\ErrorHandler\Exception\FlattenException;
1921
use Symfony\Component\HttpFoundation\Request;
@@ -31,16 +33,18 @@ final class ExceptionAction
3133
private $serializer;
3234
private $errorFormats;
3335
private $exceptionToStatus;
36+
private $resourceMetadataFactory;
3437

3538
/**
3639
* @param array $errorFormats A list of enabled error formats
3740
* @param array $exceptionToStatus A list of exceptions mapped to their HTTP status code
3841
*/
39-
public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = [])
42+
public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = [], ?ResourceMetadataFactoryInterface $resourceMetadataFactory = null)
4043
{
4144
$this->serializer = $serializer;
4245
$this->errorFormats = $errorFormats;
4346
$this->exceptionToStatus = $exceptionToStatus;
47+
$this->resourceMetadataFactory = $resourceMetadataFactory;
4448
}
4549

4650
/**
@@ -53,7 +57,12 @@ public function __invoke($exception, Request $request): Response
5357
$exceptionClass = $exception->getClass();
5458
$statusCode = $exception->getStatusCode();
5559

56-
foreach ($this->exceptionToStatus as $class => $status) {
60+
$exceptionToStatus = array_merge(
61+
$this->exceptionToStatus,
62+
$this->getOperationExceptionToStatus($request)
63+
);
64+
65+
foreach ($exceptionToStatus as $class => $status) {
5766
if (is_a($exceptionClass, $class, true)) {
5867
$statusCode = $status;
5968

@@ -69,4 +78,26 @@ public function __invoke($exception, Request $request): Response
6978

7079
return new Response($this->serializer->serialize($exception, $format['key'], ['statusCode' => $statusCode]), $statusCode, $headers);
7180
}
81+
82+
private function getOperationExceptionToStatus(Request $request): array
83+
{
84+
$attributes = RequestAttributesExtractor::extractAttributes($request);
85+
86+
if ([] === $attributes || null === $this->resourceMetadataFactory) {
87+
return [];
88+
}
89+
90+
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
91+
$operationExceptionToStatus = $resourceMetadata->getOperationAttribute($attributes, 'exception_to_status', [], false);
92+
$resourceExceptionToStatus = $resourceMetadata->getAttribute('exception_to_status', []);
93+
94+
if (!\is_array($operationExceptionToStatus) || !\is_array($resourceExceptionToStatus)) {
95+
throw new \LogicException('"exception_to_status" attribute should be an array.');
96+
}
97+
98+
return array_merge(
99+
$resourceExceptionToStatus,
100+
$operationExceptionToStatus
101+
);
102+
}
72103
}

src/Annotation/ApiResource.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
* @Attribute("swaggerContext", type="array"),
7171
* @Attribute("urlGenerationStrategy", type="int"),
7272
* @Attribute("validationGroups", type="mixed"),
73+
* @Attribute("exceptionToStatus", type="array"),
7374
* )
7475
*/
7576
#[\Attribute(\Attribute::TARGET_CLASS)]
@@ -170,6 +171,7 @@ final class ApiResource
170171
* @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts
171172
* @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups
172173
* @param int $urlGenerationStrategy
174+
* @param array $exceptionToStatus https://api-platform.com/docs/core/errors/#fine-grained-configuration
173175
*
174176
* @throws InvalidArgumentException
175177
*/
@@ -219,7 +221,8 @@ public function __construct(
219221
?array $swaggerContext = null,
220222
?array $validationGroups = null,
221223
?int $urlGenerationStrategy = null,
222-
?bool $compositeIdentifier = null
224+
?bool $compositeIdentifier = null,
225+
?array $exceptionToStatus = null
223226
) {
224227
if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support
225228
[$publicProperties, $configurableAttributes] = self::getConfigMetadata();

src/Bridge/Symfony/Bundle/Resources/config/api.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@
249249
<argument type="service" id="api_platform.serializer" />
250250
<argument>%api_platform.error_formats%</argument>
251251
<argument>%api_platform.exception_to_status%</argument>
252+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
252253
</service>
253254

254255
<!-- Identifiers -->

tests/Action/ExceptionActionTest.php

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515

1616
use ApiPlatform\Core\Action\ExceptionAction;
1717
use ApiPlatform\Core\Exception\InvalidArgumentException;
18+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
19+
use ApiPlatform\Core\Metadata\Resource\ResourceMetadata;
1820
use ApiPlatform\Core\Tests\ProphecyTrait;
21+
use DomainException;
1922
use PHPUnit\Framework\TestCase;
2023
use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException;
2124
use Symfony\Component\ErrorHandler\Exception\FlattenException;
@@ -57,6 +60,153 @@ public function testActionWithCatchableException()
5760
$this->assertTrue($response->headers->contains('X-Frame-Options', 'deny'));
5861
}
5962

63+
/**
64+
* @dataProvider provideOperationExceptionToStatusCases
65+
*/
66+
public function testActionWithOperationExceptionToStatus(
67+
array $globalExceptionToStatus,
68+
?array $resourceExceptionToStatus,
69+
?array $operationExceptionToStatus,
70+
int $expectedStatusCode
71+
) {
72+
$exception = new DomainException();
73+
$flattenException = FlattenException::create($exception);
74+
75+
$serializer = $this->prophesize(SerializerInterface::class);
76+
$serializer->serialize($flattenException, 'jsonproblem', ['statusCode' => $expectedStatusCode])->willReturn();
77+
78+
$resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class);
79+
$resourceMetadataFactory->create('Foo')->willReturn(new ResourceMetadata(
80+
'Foo',
81+
null,
82+
null,
83+
[
84+
'operation' => null !== $operationExceptionToStatus ? ['exception_to_status' => $operationExceptionToStatus] : [],
85+
],
86+
null,
87+
null !== $resourceExceptionToStatus ? ['exception_to_status' => $resourceExceptionToStatus] : []
88+
));
89+
90+
$exceptionAction = new ExceptionAction(
91+
$serializer->reveal(),
92+
[
93+
'jsonproblem' => ['application/problem+json'],
94+
'jsonld' => ['application/ld+json'],
95+
],
96+
$globalExceptionToStatus,
97+
$resourceMetadataFactory->reveal()
98+
);
99+
100+
$request = new Request();
101+
$request->setFormat('jsonproblem', 'application/problem+json');
102+
$request->attributes->replace([
103+
'_api_resource_class' => 'Foo',
104+
'_api_item_operation_name' => 'operation',
105+
]);
106+
107+
$response = $exceptionAction($flattenException, $request);
108+
109+
$this->assertSame('', $response->getContent());
110+
$this->assertSame($expectedStatusCode, $response->getStatusCode());
111+
$this->assertTrue($response->headers->contains('Content-Type', 'application/problem+json; charset=utf-8'));
112+
$this->assertTrue($response->headers->contains('X-Content-Type-Options', 'nosniff'));
113+
$this->assertTrue($response->headers->contains('X-Frame-Options', 'deny'));
114+
}
115+
116+
public function provideOperationExceptionToStatusCases()
117+
{
118+
yield 'no mapping' => [
119+
[],
120+
null,
121+
null,
122+
500,
123+
];
124+
125+
yield 'on global attributes' => [
126+
[DomainException::class => 100],
127+
null,
128+
null,
129+
100,
130+
];
131+
132+
yield 'on global attributes with empty resource and operation attributes' => [
133+
[DomainException::class => 100],
134+
[],
135+
[],
136+
100,
137+
];
138+
139+
yield 'on global attributes and resource attributes' => [
140+
[DomainException::class => 100],
141+
[DomainException::class => 200],
142+
null,
143+
200,
144+
];
145+
146+
yield 'on global attributes and resource attributes with empty operation attributes' => [
147+
[DomainException::class => 100],
148+
[DomainException::class => 200],
149+
[],
150+
200,
151+
];
152+
153+
yield 'on global attributes and operation attributes' => [
154+
[DomainException::class => 100],
155+
null,
156+
[DomainException::class => 300],
157+
300,
158+
];
159+
160+
yield 'on global attributes and operation attributes with empty resource attributes' => [
161+
[DomainException::class => 100],
162+
[],
163+
[DomainException::class => 300],
164+
300,
165+
];
166+
167+
yield 'on global, resource and operation attributes' => [
168+
[DomainException::class => 100],
169+
[DomainException::class => 200],
170+
[DomainException::class => 300],
171+
300,
172+
];
173+
174+
yield 'on resource attributes' => [
175+
[],
176+
[DomainException::class => 200],
177+
null,
178+
200,
179+
];
180+
181+
yield 'on resource attributes with empty operation attributes' => [
182+
[],
183+
[DomainException::class => 200],
184+
[],
185+
200,
186+
];
187+
188+
yield 'on resource and operation attributes' => [
189+
[],
190+
[DomainException::class => 200],
191+
[DomainException::class => 300],
192+
300,
193+
];
194+
195+
yield 'on operation attributes' => [
196+
[],
197+
null,
198+
[DomainException::class => 300],
199+
300,
200+
];
201+
202+
yield 'on operation attributes with empty resource attributes' => [
203+
[],
204+
[],
205+
[DomainException::class => 300],
206+
300,
207+
];
208+
}
209+
60210
public function testActionWithUncatchableException()
61211
{
62212
$serializerException = $this->prophesize(ExceptionInterface::class);

tests/Annotation/ApiResourceTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,9 @@ public function testConstructAttribute()
159159
hydraContext: ['hydra' => 'foo'],
160160
paginationViaCursor: ['foo'],
161161
stateless: true,
162+
exceptionToStatus: [
163+
\DomainException::class => 400,
164+
],
162165
);
163166
PHP
164167
);
@@ -208,6 +211,9 @@ public function testConstructAttribute()
208211
'pagination_via_cursor' => ['foo'],
209212
'stateless' => true,
210213
'composite_identifier' => null,
214+
'exception_to_status' => [
215+
\DomainException::class => 400,
216+
],
211217
], $resource->attributes);
212218
}
213219

0 commit comments

Comments
 (0)