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/src/JsonApi/Tests/Serializer/ErrorNormalizerTest.php b/src/JsonApi/Tests/Serializer/ErrorNormalizerTest.php new file mode 100644 index 00000000000..834ec4c72b7 --- /dev/null +++ b/src/JsonApi/Tests/Serializer/ErrorNormalizerTest.php @@ -0,0 +1,226 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ErrorNormalizer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Tests for the JSON API ErrorNormalizer. + */ +final class ErrorNormalizerTest extends TestCase +{ + /** + * Test normalization when attributes are missing from the normalized structure. + * This can occur with ItemNotFoundException or similar exceptions. + * The normalizer should handle this gracefully and return a valid JSON:API error. + */ + public function testNormalizeWithMissingAttributes(): void + { + $itemNormalizer = $this->createMock(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn([ + 'data' => [ + 'id' => 'error-1', + 'type' => 'errors', + ], + ]); + + $errorNormalizer = new ErrorNormalizer($itemNormalizer); + $exception = new \Exception('Test error'); + + $result = $errorNormalizer->normalize($exception, 'jsonapi'); + + $this->assertArrayHasKey('errors', $result); + $this->assertIsArray($result['errors']); + $this->assertCount(1, $result['errors']); + $this->assertEquals('error-1', $result['errors'][0]['id']); + $this->assertEquals('Test error', $result['errors'][0]['title']); + $this->assertArrayHasKey('status', $result['errors'][0]); + } + + public function testNormalizeWithValidStructure(): void + { + $itemNormalizer = $this->createMock(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn([ + 'data' => [ + 'type' => 'errors', + 'id' => 'error-1', + 'attributes' => [ + 'title' => 'An error occurred', + 'detail' => 'Something went wrong', + 'status' => '500', + ], + ], + ]); + + $errorNormalizer = new ErrorNormalizer($itemNormalizer); + $result = $errorNormalizer->normalize(new \Exception('Test error'), 'jsonapi'); + + $this->assertArrayHasKey('errors', $result); + $this->assertCount(1, $result['errors']); + $this->assertEquals('error-1', $result['errors'][0]['id']); + $this->assertEquals('An error occurred', $result['errors'][0]['title']); + $this->assertEquals('Something went wrong', $result['errors'][0]['detail']); + $this->assertIsString($result['errors'][0]['status']); + } + + public function testNormalizeWithViolations(): void + { + $itemNormalizer = $this->createMock(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn([ + 'data' => [ + 'type' => 'errors', + 'id' => 'validation-error', + 'attributes' => [ + 'title' => 'Validation failed', + 'detail' => 'Invalid input', + 'status' => 422, + 'violations' => [ + [ + 'message' => 'This field is required', + 'propertyPath' => 'name', + ], + [ + 'message' => 'Invalid email format', + 'propertyPath' => 'email', + ], + ], + ], + ], + ]); + + $errorNormalizer = new ErrorNormalizer($itemNormalizer); + $result = $errorNormalizer->normalize(new \Exception('Validation error'), 'jsonapi'); + + $this->assertArrayHasKey('errors', $result); + $this->assertCount(2, $result['errors']); + $this->assertEquals('This field is required', $result['errors'][0]['detail']); + $this->assertEquals('Invalid email format', $result['errors'][1]['detail']); + $this->assertFalse(isset($result['errors'][0]['violations'])); + $this->assertIsInt($result['errors'][0]['status']); + $this->assertEquals(422, $result['errors'][0]['status']); + } + + /** + * Test with type and links generation. + */ + public function testNormalizeWithTypeGeneratesLinks(): void + { + $itemNormalizer = $this->createMock(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn([ + 'data' => [ + 'type' => 'errors', + 'id' => 'about:blank/errors/validation', + 'attributes' => [ + 'type' => 'about:blank/errors/validation', + 'title' => 'Validation Error', + 'detail' => 'Input validation failed', + 'status' => '422', + 'violations' => [ + [ + 'message' => 'Must be a number', + 'propertyPath' => 'age', + ], + ], + ], + ], + ]); + + $errorNormalizer = new ErrorNormalizer($itemNormalizer); + $result = $errorNormalizer->normalize(new \Exception('Validation'), 'jsonapi'); + + $this->assertArrayHasKey('errors', $result); + $this->assertCount(1, $result['errors']); + $this->assertArrayHasKey('links', $result['errors'][0]); + $this->assertStringContainsString('age', $result['errors'][0]['links']['type']); + } + + public function testJsonApiComplianceForMissingAttributesCase(): void + { + $itemNormalizer = $this->createMock(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn([ + 'data' => [ + 'id' => 'error-123', + 'type' => 'errors', + ], + ]); + + $errorNormalizer = new ErrorNormalizer($itemNormalizer); + $result = $errorNormalizer->normalize(new \Exception('Not found'), 'jsonapi'); + + $this->assertArrayHasKey('errors', $result, 'Response must have "errors" key at top level'); + $this->assertIsArray($result['errors'], '"errors" must be an array'); + $this->assertNotEmpty($result['errors'], '"errors" array must not be empty'); + + $error = $result['errors'][0]; + $this->assertIsArray($error, 'Each error must be an object/array'); + + $hasAtLeastOneMember = isset($error['id']) || isset($error['links']) || isset($error['status']) + || isset($error['code']) || isset($error['title']) || isset($error['detail']) + || isset($error['source']) || isset($error['meta']); + + $this->assertTrue($hasAtLeastOneMember, 'Error object must contain at least one of: id, links, status, code, title, detail, source, meta'); + + if (isset($error['status'])) { + $this->assertIsString($error['status'], '"status" must be a string value'); + } + + if (isset($error['code'])) { + $this->assertIsString($error['code'], '"code" must be a string value'); + } + + if (isset($error['links'])) { + $this->assertIsArray($error['links'], '"links" must be an object'); + } + } + + public function testJsonApiComplianceForNormalCase(): void + { + $itemNormalizer = $this->createMock(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn([ + 'data' => [ + 'type' => 'errors', + 'id' => 'error-456', + 'attributes' => [ + 'title' => 'Validation Failed', + 'detail' => 'The request body is invalid', + 'status' => '422', + 'code' => 'validation_error', + ], + ], + ]); + + $errorNormalizer = new ErrorNormalizer($itemNormalizer); + $result = $errorNormalizer->normalize(new \Exception('Validation error'), 'jsonapi'); + + $this->assertArrayHasKey('errors', $result); + $this->assertIsArray($result['errors']); + + $error = $result['errors'][0]; + $this->assertIsArray($error); + + $hasAtLeastOneMember = isset($error['id']) || isset($error['links']) || isset($error['status']) + || isset($error['code']) || isset($error['title']) || isset($error['detail']) + || isset($error['source']) || isset($error['meta']); + + $this->assertTrue($hasAtLeastOneMember, 'Error object must contain at least one required member'); + + $this->assertEquals('error-456', $error['id']); + $this->assertEquals('Validation Failed', $error['title']); + $this->assertEquals('The request body is invalid', $error['detail']); + $this->assertEquals('422', $error['status']); + $this->assertEquals('validation_error', $error['code']); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php new file mode 100644 index 00000000000..342eac8d490 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiErrorTestResource.php @@ -0,0 +1,40 @@ + + * + * 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; + +/** + * Resource for testing JSON:API error normalization. + * Used to verify that ItemNotFoundException is properly serialized in JSON:API format. + */ +#[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..c43417e35e8 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/JsonApiErrorTestProvider.php @@ -0,0 +1,46 @@ + + * + * 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; + +/** + * Provider that throws ItemNotFoundException to test JSON:API error normalization. + * + * This provider is used to reproduce the bug where ErrorNormalizer + * failed when the normalized exception had no 'attributes' key. + */ +class JsonApiErrorTestProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $id = $uriVariables['id'] ?? null; + + // Return a valid resource for 'existing' ID + if ('existing' === $id) { + $resource = new JsonApiErrorTestResource(); + $resource->id = $id; + $resource->name = 'Existing Resource'; + + return $resource; + } + + // Throw ItemNotFoundException for any other ID + // This triggers the code path where 'attributes' may be missing + throw new ItemNotFoundException(\sprintf('Resource "%s" not found.', $id)); + } +}