From e46c551fdbcfb647578bc51b9e9f7054bf9b2857 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 10:27:31 +0200 Subject: [PATCH 1/3] 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 --- src/JsonApi/Serializer/ErrorNormalizer.php | 25 ++++++++++-- tests/Functional/JsonApi/ErrorTest.php | 44 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 2076a1c1d8..7dd268ee62 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -34,9 +34,14 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { - $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); - $error = $jsonApiObject['data']['attributes'] ?? []; - $error['id'] = $jsonApiObject['data']['id']; + if ($data instanceof FlattenException) { + $error = $this->flattenExceptionToError($data, $context); + } else { + $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); + $error = $jsonApiObject['data']['attributes'] ?? []; + $error['id'] = $jsonApiObject['data']['id']; + } + if (isset($error['type'])) { $error['links'] = ['type' => $error['type']]; } @@ -71,6 +76,20 @@ public function normalize(mixed $data, ?string $format = null, array $context = return ['errors' => $errors]; } + private function flattenExceptionToError(FlattenException $exception, array $context): array + { + $status = $exception->getStatusCode(); + + return [ + 'id' => "/errors/$status", + 'title' => 'An error occurred', + 'detail' => ($context['debug'] ?? false) ? $exception->getMessage() : $exception->getStatusText(), + 'status' => $status, + 'type' => "/errors/$status", + 'code' => (string) $status, + ]; + } + /** * {@inheritdoc} */ diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php index 8833e5e73f..64a6a96abb 100644 --- a/tests/Functional/JsonApi/ErrorTest.php +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ErrorProblem; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Contracts\HttpClient\ResponseInterface; class ErrorTest extends ApiTestCase { @@ -113,4 +114,47 @@ public function testNotFoundRouteRendersJsonApiFormat(): void $this->assertArrayHasKey('detail', $body['errors'][0]); $this->assertArrayHasKey('type', $body['errors'][0]); } + + public function testRouterNotFoundRendersJsonApiErrorObject(): void + { + $response = $this->requestWithSymfonyErrorHandling('GET', '/does_not_exist'); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json'); + $error = $response->toArray(false)['errors'][0]; + $this->assertSame('/errors/404', $error['id']); + $this->assertSame('An error occurred', $error['title']); + $this->assertStringContainsString('No route found', $error['detail']); + $this->assertSame(404, $error['status']); + $this->assertSame('404', $error['code']); + $this->assertSame('/errors/404', $error['type']); + $this->assertSame(['type' => '/errors/404'], $error['links']); + } + + public function testMethodNotAllowedRendersJsonApiErrorObject(): void + { + $response = $this->requestWithSymfonyErrorHandling('DELETE', '/jsonapi_error_test/existing'); + + $this->assertResponseStatusCodeSame(405); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json'); + $error = $response->toArray(false)['errors'][0]; + $this->assertSame('/errors/405', $error['id']); + $this->assertSame('An error occurred', $error['title']); + $this->assertStringContainsString('Method Not Allowed', $error['detail']); + $this->assertSame(405, $error['status']); + $this->assertSame('405', $error['code']); + $this->assertSame('/errors/405', $error['type']); + $this->assertSame(['type' => '/errors/405'], $error['links']); + } + + private function requestWithSymfonyErrorHandling(string $method, string $uri): ResponseInterface + { + $client = self::createClient(); + $client->disableReboot(); + static::getContainer()->get('api_platform.listener.exception')->handleSymfonyErrors = false; + + return $client->request($method, $uri, [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + } } From 8125afb77174972c5a775aea05e9ac077fccf3b8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 14:10:33 +0200 Subject: [PATCH 2/3] fix(jsonapi): normalize flatten exceptions through the problem normalizer --- src/JsonApi/Serializer/ErrorNormalizer.php | 26 ++++++++++--------- .../Bundle/Resources/config/jsonapi.php | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 7dd268ee62..78c8270ffa 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -25,7 +25,7 @@ final class ErrorNormalizer implements NormalizerInterface { public const FORMAT = 'jsonapi'; - public function __construct(private ?NormalizerInterface $itemNormalizer = null) + public function __construct(private ?NormalizerInterface $itemNormalizer = null, private ?NormalizerInterface $problemNormalizer = null) { } @@ -35,7 +35,7 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) public function normalize(mixed $data, ?string $format = null, array $context = []): array { if ($data instanceof FlattenException) { - $error = $this->flattenExceptionToError($data, $context); + $error = $this->flattenExceptionToError($data, $format, $context); } else { $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); $error = $jsonApiObject['data']['attributes'] ?? []; @@ -76,17 +76,19 @@ public function normalize(mixed $data, ?string $format = null, array $context = return ['errors' => $errors]; } - private function flattenExceptionToError(FlattenException $exception, array $context): array + private function flattenExceptionToError(FlattenException $exception, ?string $format, array $context): array { - $status = $exception->getStatusCode(); + $type = \sprintf('/errors/%d', $exception->getStatusCode()); + unset($context['exception']); + $problem = $this->problemNormalizer->normalize($exception, $format, ['type' => $type] + $context); return [ - 'id' => "/errors/$status", - 'title' => 'An error occurred', - 'detail' => ($context['debug'] ?? false) ? $exception->getMessage() : $exception->getStatusText(), - 'status' => $status, - 'type' => "/errors/$status", - 'code' => (string) $status, + 'id' => $problem['type'], + 'title' => $problem['title'], + 'detail' => $problem['detail'], + 'status' => $problem['status'], + 'type' => $problem['type'], + 'code' => (string) $problem['status'], ]; } @@ -95,7 +97,7 @@ private function flattenExceptionToError(FlattenException $exception, array $con */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return self::FORMAT === $format && ($data instanceof \Exception || $data instanceof FlattenException); + return self::FORMAT === $format && ($data instanceof \Exception || ($data instanceof FlattenException && null !== $this->problemNormalizer)); } /** @@ -106,7 +108,7 @@ public function getSupportedTypes(?string $format): array if (self::FORMAT === $format) { return [ \Exception::class => true, - FlattenException::class => true, + FlattenException::class => null !== $this->problemNormalizer, ]; } diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index 4e6f610c44..b2ae1f2845 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -119,6 +119,6 @@ ->tag('serializer.normalizer', ['priority' => -780]); $services->set('api_platform.jsonapi.normalizer.error', ErrorNormalizer::class) - ->args([service('api_platform.jsonapi.normalizer.item')]) + ->args([service('api_platform.jsonapi.normalizer.item'), service('serializer.normalizer.problem')->nullOnInvalid()]) ->tag('serializer.normalizer', ['priority' => -790]); }; From 45c297c2852e74175e78cc6af9370425a171da46 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 19 Oct 2026 14:11:13 +0200 Subject: [PATCH 3/3] fix(jsonapi): inline flatten exception mapping --- src/JsonApi/Serializer/ErrorNormalizer.php | 28 +++++++++------------- tests/Functional/JsonApi/ErrorTest.php | 4 ++-- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 78c8270ffa..098ca8931d 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -35,7 +35,17 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null, public function normalize(mixed $data, ?string $format = null, array $context = []): array { if ($data instanceof FlattenException) { - $error = $this->flattenExceptionToError($data, $format, $context); + $type = \sprintf('/errors/%d', $data->getStatusCode()); + unset($context['exception']); + $problem = $this->problemNormalizer->normalize($data, $format, ['type' => $type] + $context); + $error = [ + 'id' => $problem['type'], + 'title' => $problem['title'], + 'detail' => $problem['detail'], + 'status' => $problem['status'], + 'type' => $problem['type'], + 'code' => (string) $problem['status'], + ]; } else { $jsonApiObject = $this->itemNormalizer->normalize($data, $format, $context); $error = $jsonApiObject['data']['attributes'] ?? []; @@ -76,22 +86,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return ['errors' => $errors]; } - private function flattenExceptionToError(FlattenException $exception, ?string $format, array $context): array - { - $type = \sprintf('/errors/%d', $exception->getStatusCode()); - unset($context['exception']); - $problem = $this->problemNormalizer->normalize($exception, $format, ['type' => $type] + $context); - - return [ - 'id' => $problem['type'], - 'title' => $problem['title'], - 'detail' => $problem['detail'], - 'status' => $problem['status'], - 'type' => $problem['type'], - 'code' => (string) $problem['status'], - ]; - } - /** * {@inheritdoc} */ diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php index 64a6a96abb..ca3b6a5962 100644 --- a/tests/Functional/JsonApi/ErrorTest.php +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -125,7 +125,7 @@ public function testRouterNotFoundRendersJsonApiErrorObject(): void $this->assertSame('/errors/404', $error['id']); $this->assertSame('An error occurred', $error['title']); $this->assertStringContainsString('No route found', $error['detail']); - $this->assertSame(404, $error['status']); + $this->assertSame('404', $error['status']); $this->assertSame('404', $error['code']); $this->assertSame('/errors/404', $error['type']); $this->assertSame(['type' => '/errors/404'], $error['links']); @@ -141,7 +141,7 @@ public function testMethodNotAllowedRendersJsonApiErrorObject(): void $this->assertSame('/errors/405', $error['id']); $this->assertSame('An error occurred', $error['title']); $this->assertStringContainsString('Method Not Allowed', $error['detail']); - $this->assertSame(405, $error['status']); + $this->assertSame('405', $error['status']); $this->assertSame('405', $error['code']); $this->assertSame('/errors/405', $error['type']); $this->assertSame(['type' => '/errors/405'], $error['links']);