From 957624bd1219ae3dcff1b9cd1c5812f82031ff93 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 22 Oct 2026 09:24:18 +0200 Subject: [PATCH] fix(jsonapi): add status and title, fix pointer Fixes #8748 --- .../ConstraintViolationListNormalizer.php | 13 ++-- .../ConstraintViolationNormalizerTest.php | 20 ++++-- .../ApiResource/JsonApi/ErrorProblem.php | 5 ++ .../JsonApi/ErrorObjectMembersTest.php | 65 +++++++++++++++++++ tests/Functional/JsonApi/ErrorTest.php | 4 +- 5 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/Functional/JsonApi/ErrorObjectMembersTest.php diff --git a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php index ce0c728c12f..af13a683df4 100644 --- a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php +++ b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonApi\Serializer; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -38,9 +39,13 @@ public function __construct(private readonly PropertyMetadataFactoryInterface $p */ public function normalize(mixed $data, ?string $format = null, array $context = []): array { + $operation = $context['operation'] ?? null; + $status = (string) ($operation instanceof HttpOperation ? $operation->getStatus() : null) ?: '422'; $violations = []; foreach ($data as $violation) { $violations[] = [ + 'status' => $status, + 'title' => 'Validation Failed', 'detail' => $violation->getMessage(), 'source' => [ 'pointer' => $this->getSourcePointerFromViolation($violation), @@ -72,13 +77,13 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $violation->getPropertyPath(); if (!$fieldName) { - return 'data'; + return '/data'; } $root = $violation->getRoot(); if (!\is_object($root)) { - return "data/attributes/$fieldName"; + return "/data/attributes/$fieldName"; } $class = $root::class; @@ -94,9 +99,9 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio } if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { - return "data/relationships/$fieldName"; + return "/data/relationships/$fieldName"; } - return "data/attributes/$fieldName"; + return "/data/attributes/$fieldName"; } } diff --git a/src/JsonApi/Tests/Serializer/ConstraintViolationNormalizerTest.php b/src/JsonApi/Tests/Serializer/ConstraintViolationNormalizerTest.php index 5b36848cede..2f2bd4bb7aa 100644 --- a/src/JsonApi/Tests/Serializer/ConstraintViolationNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ConstraintViolationNormalizerTest.php @@ -71,21 +71,27 @@ public function testNormalize(): void [ 'errors' => [ [ + 'status' => '422', + 'title' => 'Validation Failed', 'detail' => 'This value should not be null.', 'source' => [ - 'pointer' => 'data/relationships/relatedDummy', + 'pointer' => '/data/relationships/relatedDummy', ], ], [ + 'status' => '422', + 'title' => 'Validation Failed', 'detail' => 'This value should not be null.', 'source' => [ - 'pointer' => 'data/attributes/name', + 'pointer' => '/data/attributes/name', ], ], [ + 'status' => '422', + 'title' => 'Validation Failed', 'detail' => 'Unknown violation.', 'source' => [ - 'pointer' => 'data', + 'pointer' => '/data', ], ], ], @@ -111,9 +117,11 @@ public function testNormalizeWithStringRoot(): void [ 'errors' => [ [ + 'status' => '422', + 'title' => 'Validation Failed', 'detail' => 'Invalid page value.', 'source' => [ - 'pointer' => 'data/attributes/page', + 'pointer' => '/data/attributes/page', ], ], ], @@ -140,9 +148,11 @@ public function testNormalizeWithNullRoot(): void [ 'errors' => [ [ + 'status' => '422', + 'title' => 'Validation Failed', 'detail' => 'Invalid value.', 'source' => [ - 'pointer' => 'data/attributes/field', + 'pointer' => '/data/attributes/field', ], ], ], diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php index cd8e73e65b0..76175e61299 100644 --- a/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php @@ -28,6 +28,11 @@ uriTemplate: '/jsonapi_validation_problem', processor: [self::class, 'processValidation'], ), + new Post( + uriTemplate: '/jsonapi_validation_problem_bad_request', + processor: [self::class, 'processValidation'], + exceptionToStatus: [ValidationException::class => 400], + ), new Post( uriTemplate: '/jsonapi_exception_problem', processor: [self::class, 'processBadRequest'], diff --git a/tests/Functional/JsonApi/ErrorObjectMembersTest.php b/tests/Functional/JsonApi/ErrorObjectMembersTest.php new file mode 100644 index 00000000000..8f11ea943f2 --- /dev/null +++ b/tests/Functional/JsonApi/ErrorObjectMembersTest.php @@ -0,0 +1,65 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ErrorProblem; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ErrorObjectMembersTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ErrorProblem::class]; + } + + public function testValidationErrorHasStatusAndTitle(): void + { + $response = self::createClient()->request('POST', '/jsonapi_validation_problem', [ + 'headers' => ['accept' => 'application/vnd.api+json', 'content-type' => 'application/vnd.api+json'], + 'json' => ['data' => ['type' => 'JsonApiErrorProblem', 'attributes' => new \stdClass()]], + ]); + + $this->assertResponseStatusCodeSame(422); + $error = $response->toArray(false)['errors'][0]; + $this->assertSame('422', $error['status'] ?? null); + $this->assertArrayHasKey('title', $error); + } + + public function testValidationErrorPointerIsAJsonPointer(): void + { + $response = self::createClient()->request('POST', '/jsonapi_validation_problem', [ + 'headers' => ['accept' => 'application/vnd.api+json', 'content-type' => 'application/vnd.api+json'], + 'json' => ['data' => ['type' => 'JsonApiErrorProblem', 'attributes' => new \stdClass()]], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertSame('/data/attributes/name', $response->toArray(false)['errors'][0]['source']['pointer']); + } + + public function testValidationErrorStatusFollowsTheResponseStatus(): void + { + $response = self::createClient()->request('POST', '/jsonapi_validation_problem_bad_request', [ + 'headers' => ['accept' => 'application/vnd.api+json', 'content-type' => 'application/vnd.api+json'], + 'json' => ['data' => ['type' => 'JsonApiErrorProblem', 'attributes' => new \stdClass()]], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertSame('400', $response->toArray(false)['errors'][0]['status'] ?? null); + } +} diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php index 8833e5e73fb..1562f015ce2 100644 --- a/tests/Functional/JsonApi/ErrorTest.php +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -69,8 +69,10 @@ public function testValidationErrorRendersJsonApiPointer(): void $this->assertJsonEquals([ 'errors' => [ [ + 'status' => '422', + 'title' => 'Validation Failed', 'detail' => 'This value should not be blank.', - 'source' => ['pointer' => 'data/attributes/name'], + 'source' => ['pointer' => '/data/attributes/name'], ], ], ]);