Skip to content

Commit 5974d94

Browse files
authored
fix(jsonapi): normalize symfony-rendered errors (#8731)
* fix(jsonapi): normalize symfony-rendered errors Symfony's error renderer hands a FlattenException to the JSON:API ErrorNormalizer, which delegated to the item normalizer and kept only a generated id. Build the error members from the FlattenException and run them through the same tail as the item path. Refs #8704 * fix(jsonapi): normalize flatten exceptions through the problem normalizer * fix(jsonapi): inline flatten exception mapping
1 parent c810012 commit 5974d94

3 files changed

Lines changed: 66 additions & 7 deletions

File tree

‎src/JsonApi/Serializer/ErrorNormalizer.php‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ final class ErrorNormalizer implements NormalizerInterface
2525
{
2626
public const FORMAT = 'jsonapi';
2727

28-
public function __construct(private ?NormalizerInterface $itemNormalizer = null)
28+
public function __construct(private ?NormalizerInterface $itemNormalizer = null, private ?NormalizerInterface $problemNormalizer = null)
2929
{
3030
}
3131

@@ -34,9 +34,24 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null)
3434
*/
3535
public function normalize(mixed $data, ?string $format = null, array $context = []): array
3636
{
37-
$jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context);
38-
$error = $jsonApiObject['data']['attributes'] ?? [];
39-
$error['id'] = $jsonApiObject['data']['id'];
37+
if ($data instanceof FlattenException) {
38+
$type = \sprintf('/errors/%d', $data->getStatusCode());
39+
unset($context['exception']);
40+
$problem = $this->problemNormalizer->normalize($data, $format, ['type' => $type] + $context);
41+
$error = [
42+
'id' => $problem['type'],
43+
'title' => $problem['title'],
44+
'detail' => $problem['detail'],
45+
'status' => $problem['status'],
46+
'type' => $problem['type'],
47+
'code' => (string) $problem['status'],
48+
];
49+
} else {
50+
$jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context);
51+
$error = $jsonApiObject['data']['attributes'] ?? [];
52+
$error['id'] = $jsonApiObject['data']['id'];
53+
}
54+
4055
if (isset($error['type'])) {
4156
$error['links'] = ['type' => $error['type']];
4257
}
@@ -76,7 +91,7 @@ public function normalize(mixed $data, ?string $format = null, array $context =
7691
*/
7792
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
7893
{
79-
return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException);
94+
return self::FORMAT === $format && ($data instanceof \Exception || ($data instanceof FlattenException && null !== $this->problemNormalizer));
8095
}
8196

8297
/**
@@ -87,7 +102,7 @@ public function getSupportedTypes(?string $format): array
87102
if (self::FORMAT === $format) {
88103
return [
89104
\Exception::class => true,
90-
FlattenException::class => true,
105+
FlattenException::class => null !== $this->problemNormalizer,
91106
];
92107
}
93108

‎src/Symfony/Bundle/Resources/config/jsonapi.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,6 @@
119119
->tag('serializer.normalizer', ['priority' => -780]);
120120

121121
$services->set('api_platform.jsonapi.normalizer.error', ErrorNormalizer::class)
122-
->args([service('api_platform.jsonapi.normalizer.item')])
122+
->args([service('api_platform.jsonapi.normalizer.item'), service('serializer.normalizer.problem')->nullOnInvalid()])
123123
->tag('serializer.normalizer', ['priority' => -790]);
124124
};

‎tests/Functional/JsonApi/ErrorTest.php‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ErrorProblem;
1818
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource;
1919
use ApiPlatform\Tests\SetupClassResourcesTrait;
20+
use Symfony\Contracts\HttpClient\ResponseInterface;
2021

2122
class ErrorTest extends ApiTestCase
2223
{
@@ -113,4 +114,47 @@ public function testNotFoundRouteRendersJsonApiFormat(): void
113114
$this->assertArrayHasKey('detail', $body['errors'][0]);
114115
$this->assertArrayHasKey('type', $body['errors'][0]);
115116
}
117+
118+
public function testRouterNotFoundRendersJsonApiErrorObject(): void
119+
{
120+
$response = $this->requestWithSymfonyErrorHandling('GET', '/does_not_exist');
121+
122+
$this->assertResponseStatusCodeSame(404);
123+
$this->assertResponseHeaderSame('content-type', 'application/vnd.api+json');
124+
$error = $response->toArray(false)['errors'][0];
125+
$this->assertSame('/errors/404', $error['id']);
126+
$this->assertSame('An error occurred', $error['title']);
127+
$this->assertStringContainsString('No route found', $error['detail']);
128+
$this->assertSame('404', $error['status']);
129+
$this->assertSame('404', $error['code']);
130+
$this->assertSame('/errors/404', $error['type']);
131+
$this->assertSame(['type' => '/errors/404'], $error['links']);
132+
}
133+
134+
public function testMethodNotAllowedRendersJsonApiErrorObject(): void
135+
{
136+
$response = $this->requestWithSymfonyErrorHandling('DELETE', '/jsonapi_error_test/existing');
137+
138+
$this->assertResponseStatusCodeSame(405);
139+
$this->assertResponseHeaderSame('content-type', 'application/vnd.api+json');
140+
$error = $response->toArray(false)['errors'][0];
141+
$this->assertSame('/errors/405', $error['id']);
142+
$this->assertSame('An error occurred', $error['title']);
143+
$this->assertStringContainsString('Method Not Allowed', $error['detail']);
144+
$this->assertSame('405', $error['status']);
145+
$this->assertSame('405', $error['code']);
146+
$this->assertSame('/errors/405', $error['type']);
147+
$this->assertSame(['type' => '/errors/405'], $error['links']);
148+
}
149+
150+
private function requestWithSymfonyErrorHandling(string $method, string $uri): ResponseInterface
151+
{
152+
$client = self::createClient();
153+
$client->disableReboot();
154+
static::getContainer()->get('api_platform.listener.exception')->handleSymfonyErrors = false;
155+
156+
return $client->request($method, $uri, [
157+
'headers' => ['accept' => 'application/vnd.api+json'],
158+
]);
159+
}
116160
}

0 commit comments

Comments
 (0)