From 5938c2ba48c6dd8ccfd99a545ae6d7c861037eb0 Mon Sep 17 00:00:00 2001 From: daFish81 Date: Sun, 30 Nov 2025 17:20:06 +0100 Subject: [PATCH 1/2] fix(jsonapi): handle missing attributes in ErrorNormalizer Fixes undefined array key warning when normalizing exceptions that don't have attributes in their normalized structure. This typically occurs with ItemNotFoundException when invalid resource identifiers are provided. --- features/jsonapi/errors.feature | 10 +++++ src/JsonApi/Serializer/ErrorNormalizer.php | 9 +++++ .../ApiResource/JsonApiErrorTestResource.php | 36 ++++++++++++++++++ .../State/JsonApiErrorTestProvider.php | 37 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php create mode 100644 tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature index 24e99594478..a259e63ec83 100644 --- a/features/jsonapi/errors.feature +++ b/features/jsonapi/errors.feature @@ -61,3 +61,13 @@ Feature: JSON API error handling And the JSON node "errors[0].status" should be equal to 404 And the JSON node "errors[0].detail" should exist And the JSON node "errors[0].type" should exist + + Scenario: Get a proper error when ItemNotFoundException is thrown from a provider + When I send a "GET" request to "/jsonapi_error_test/nonexistent" + Then the response status code should be 404 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" + And the JSON node "errors" should exist + And the JSON node "errors[0].status" should exist + And the JSON node "errors[0].title" should exist + And the JSON node "errors[0].id" should exist diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index a9eba146f8f..0c0ae754fb2 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -35,6 +35,15 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) public function normalize(mixed $object, ?string $format = null, array $context = []): array { $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); + + if (!isset($jsonApiObject['data']['attributes'])) { + return ['errors' => [[ + 'id' => $jsonApiObject['data']['id'] ?? uniqid('error_', true), + 'status' => (string) (method_exists($object, 'getStatusCode') ? $object->getStatusCode() : 500), + 'title' => method_exists($object, 'getMessage') ? $object->getMessage() : 'An error occurred', + ]]]; + } + $error = $jsonApiObject['data']['attributes']; $error['id'] = $jsonApiObject['data']['id']; if (isset($error['type'])) { diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php new file mode 100644 index 00000000000..dc25beace70 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\State\JsonApiErrorTestProvider; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/jsonapi_error_test/{id}', + provider: JsonApiErrorTestProvider::class, + ), + ], + formats: ['jsonapi' => ['application/vnd.api+json']], +)] +class JsonApiErrorTestResource +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $name; +} diff --git a/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php b/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php new file mode 100644 index 00000000000..c70f203be7f --- /dev/null +++ b/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; + +class JsonApiErrorTestProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $id = $uriVariables['id'] ?? null; + + if ('existing' === $id) { + $resource = new JsonApiErrorTestResource(); + $resource->id = $id; + $resource->name = 'Existing Resource'; + + return $resource; + } + + throw new ItemNotFoundException(\sprintf('Resource "%s" not found.', $id)); + } +} From d6ea74973dd4651172fbeaea0358c7366c440f31 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 19 Dec 2025 14:17:51 +0100 Subject: [PATCH 2/2] suggest another approach --- features/jsonapi/errors.feature | 10 ---- src/JsonApi/Serializer/ErrorNormalizer.php | 16 +++--- .../ApiResource/JsonApiErrorTestResource.php | 34 +++++++----- .../State/JsonApiErrorTestProvider.php | 37 ------------- tests/Functional/JsonApiTest.php | 52 +++++++++++++++++++ 5 files changed, 79 insertions(+), 70 deletions(-) delete mode 100644 tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php create mode 100644 tests/Functional/JsonApiTest.php diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature index a259e63ec83..24e99594478 100644 --- a/features/jsonapi/errors.feature +++ b/features/jsonapi/errors.feature @@ -61,13 +61,3 @@ Feature: JSON API error handling And the JSON node "errors[0].status" should be equal to 404 And the JSON node "errors[0].detail" should exist And the JSON node "errors[0].type" should exist - - Scenario: Get a proper error when ItemNotFoundException is thrown from a provider - When I send a "GET" request to "/jsonapi_error_test/nonexistent" - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON node "errors" should exist - And the JSON node "errors[0].status" should exist - And the JSON node "errors[0].title" should exist - And the JSON node "errors[0].id" should exist diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 0c0ae754fb2..4875b28181e 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -35,16 +35,7 @@ public function __construct(private ?NormalizerInterface $itemNormalizer = null) public function normalize(mixed $object, ?string $format = null, array $context = []): array { $jsonApiObject = $this->itemNormalizer->normalize($object, $format, $context); - - if (!isset($jsonApiObject['data']['attributes'])) { - return ['errors' => [[ - 'id' => $jsonApiObject['data']['id'] ?? uniqid('error_', true), - 'status' => (string) (method_exists($object, 'getStatusCode') ? $object->getStatusCode() : 500), - 'title' => method_exists($object, 'getMessage') ? $object->getMessage() : 'An error occurred', - ]]]; - } - - $error = $jsonApiObject['data']['attributes']; + $error = $jsonApiObject['data']['attributes'] ?? []; $error['id'] = $jsonApiObject['data']['id']; if (isset($error['type'])) { $error['links'] = ['type' => $error['type']]; @@ -54,6 +45,11 @@ public function normalize(mixed $object, ?string $format = null, array $context $error['code'] = $object->getId(); } + // TODO: change this 5.x + // if (isset($error['status'])) { + // $error['status'] = (string) $error['status']; + // } + if (!isset($error['violations'])) { return ['errors' => [$error]]; } diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php index dc25beace70..a0899ac2f4e 100644 --- a/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php @@ -13,24 +13,32 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\Get; -use ApiPlatform\Tests\Fixtures\TestBundle\State\JsonApiErrorTestProvider; - -#[ApiResource( - operations: [ - new Get( - uriTemplate: '/jsonapi_error_test/{id}', - provider: JsonApiErrorTestProvider::class, - ), - ], +use ApiPlatform\Metadata\Operation; + +#[Get( + uriTemplate: '/jsonapi_error_test/{id}', + provider: [self::class, 'provide'], formats: ['jsonapi' => ['application/vnd.api+json']], )] class JsonApiErrorTestResource { - #[ApiProperty(identifier: true)] public string $id; - public string $name; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $id = $uriVariables['id'] ?? null; + + if ('existing' === $id) { + $resource = new self(); + $resource->id = $id; + $resource->name = 'Existing Resource'; + + return $resource; + } + + throw new ItemNotFoundException(\sprintf('Resource "%s" not found.', $id)); + } } diff --git a/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php b/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php deleted file mode 100644 index c70f203be7f..00000000000 --- a/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php +++ /dev/null @@ -1,37 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Fixtures\TestBundle\State; - -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; - -class JsonApiErrorTestProvider implements ProviderInterface -{ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - $id = $uriVariables['id'] ?? null; - - if ('existing' === $id) { - $resource = new JsonApiErrorTestResource(); - $resource->id = $id; - $resource->name = 'Existing Resource'; - - return $resource; - } - - throw new ItemNotFoundException(\sprintf('Resource "%s" not found.', $id)); - } -} diff --git a/tests/Functional/JsonApiTest.php b/tests/Functional/JsonApiTest.php new file mode 100644 index 00000000000..9e90a391b2a --- /dev/null +++ b/tests/Functional/JsonApiTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class JsonApiTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + JsonApiErrorTestResource::class, + ]; + } + + public function testError(): void + { + self::createClient()->request('GET', '/jsonapi_error_test/nonexistent', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'errors' => [ + [ + 'status' => '400', + 'detail' => 'Resource "nonexistent" not found.', + ], + ], + ]); + } +}