From aa8a16fdf6d92e177847c9dcc4c46398d5032a48 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:17:46 +0200 Subject: [PATCH 1/5] fix(state): use exception message for user-facing violation when available When a NotNormalizableValueException has canUseMessageForUser() = true, use its message directly as the constraint violation message instead of the generic Type constraint message. This fixes issues with Symfony 8.x where expectedTypes can be null/empty, producing broken messages. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/State/Provider/DeserializeProvider.php | 7 +- .../Provider/DeserializeProviderTest.php | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 6390c86bcd0..e0b3d1ad331 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -112,12 +112,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c continue; } $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); - $message = (new Type($expectedTypes))->message; $parameters = []; if ($exception->canUseMessageForUser()) { $parameters['hint'] = $exception->getMessage(); + $violationMessage = $exception->getMessage(); + $violations->add(new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); + } else { + $message = (new Type($expectedTypes))->message; + $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); } - $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); } if (0 !== \count($violations)) { throw new ValidationException($violations); diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index b0f29c11413..85e3494119c 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -24,10 +24,14 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; +use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Constraints\Type; class DeserializeProviderTest extends TestCase { @@ -203,6 +207,84 @@ public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void $provider->provide($operation, ['id' => 1], ['request' => $request]); } + #[IgnoreDeprecations] + public function testDeserializeUsesExceptionMessageWhenCanUseMessageForUser(): void + { + $operation = new Post(deserialize: true, class: \stdClass::class); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $exception = NotNormalizableValueException::createForUnexpectedDataType( + 'The data must belong to a backed enumeration of type Suit.', + 'invalid', + ['string'], + 'status', + true, + ); + $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->method('createFromRequest')->willReturn([]); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->method('deserialize')->willThrowException($partialException); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: '{"status":"invalid"}'); + $request->headers->set('CONTENT_TYPE', 'application/json'); + $request->attributes->set('input_format', 'json'); + + try { + $provider->provide($operation, [], ['request' => $request]); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violations = $e->getConstraintViolationList(); + $this->assertCount(1, $violations); + $this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getMessage()); + $this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getMessageTemplate()); + $this->assertSame('status', $violations[0]->getPropertyPath()); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); + } + } + + #[IgnoreDeprecations] + public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void + { + $operation = new Post(deserialize: true, class: \stdClass::class); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $exception = NotNormalizableValueException::createForUnexpectedDataType( + 'Internal error detail', + 42, + ['string'], + 'name', + false, + ); + $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->method('createFromRequest')->willReturn([]); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->method('deserialize')->willThrowException($partialException); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: '{"name":42}'); + $request->headers->set('CONTENT_TYPE', 'application/json'); + $request->attributes->set('input_format', 'json'); + + try { + $provider->provide($operation, [], ['request' => $request]); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violations = $e->getConstraintViolationList(); + $this->assertCount(1, $violations); + $this->assertStringContainsString('string', $violations[0]->getMessage()); + $this->assertSame('name', $violations[0]->getPropertyPath()); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); + $this->assertArrayNotHasKey('hint', $violations[0]->getParameters()); + } + } + public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void { $objectToPopulate = new \stdClass(); From d40b7fd0bca5ec161e8f3fd05f5408d124baeb3c Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Sun, 29 Mar 2026 21:25:16 +0200 Subject: [PATCH 2/5] test(state): add test for Symfony 8.1 BackedEnumNormalizer with null expectedTypes Simulates the behavior from symfony/serializer PR #62574 where an invalid enum value produces an exception with expectedTypes=null and a user-friendly message listing valid values. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Provider/DeserializeProviderTest.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index 85e3494119c..edc927ecde9 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -246,6 +246,48 @@ public function testDeserializeUsesExceptionMessageWhenCanUseMessageForUser(): v } } + /** + * Simulates Symfony 8.1 BackedEnumNormalizer behavior (symfony/serializer PR #62574): + * when a value has the right type but is not a valid enum case, the exception + * is created with expectedTypes=null and a user-friendly message listing valid values. + */ + #[IgnoreDeprecations] + public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): void + { + $operation = new Post(deserialize: true, class: \stdClass::class); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $exception = new NotNormalizableValueException( + message: "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", + path: 'suit', + useMessageForUser: true, + ); + $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->method('createFromRequest')->willReturn([]); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->method('deserialize')->willThrowException($partialException); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $request = new Request(content: '{"suit":"invalid"}'); + $request->headers->set('CONTENT_TYPE', 'application/json'); + $request->attributes->set('input_format', 'json'); + + try { + $provider->provide($operation, [], ['request' => $request]); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violations = $e->getConstraintViolationList(); + $this->assertCount(1, $violations); + $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessage()); + $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessageTemplate()); + $this->assertSame('suit', $violations[0]->getPropertyPath()); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); + } + } + #[IgnoreDeprecations] public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void { From ff43999b7e7c15519739140d37dd7cb5c5f98bf0 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:03:29 +0200 Subject: [PATCH 3/5] fix(ci): fix CS import order, functional test assertion, and lowest Symfony compat - Sort ValidationException import alphabetically in DeserializeProviderTest - Update ValidationTest to expect exception message when canUseMessageForUser() is true - Use positional params for NotNormalizableValueException ctor (lowest Symfony compat) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/State/Tests/Provider/DeserializeProviderTest.php | 12 ++++++++---- tests/Functional/ValidationTest.php | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index edc927ecde9..ccdd70f3599 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -21,10 +21,10 @@ use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; +use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; -use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -259,9 +259,13 @@ public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): vo $decorated->method('provide')->willReturn(null); $exception = new NotNormalizableValueException( - message: "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", - path: 'suit', - useMessageForUser: true, + "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", + 0, + null, + null, + null, + 'suit', + true, ); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); diff --git a/tests/Functional/ValidationTest.php b/tests/Functional/ValidationTest.php index 18ba33fd123..e1c954a8f7d 100644 --- a/tests/Functional/ValidationTest.php +++ b/tests/Functional/ValidationTest.php @@ -85,7 +85,7 @@ public function testPostWithDenormalizationErrorsCollected(): void $violationBaz = $findViolation('baz'); $this->assertNotNull($violationBaz, 'Violation for "baz" not found.'); - $this->assertSame('This value should be of type string.', $violationBaz['message']); + $this->assertSame('Failed to create object because the class misses the "baz" property.', $violationBaz['message']); $this->assertArrayHasKey('hint', $violationBaz); $this->assertSame('Failed to create object because the class misses the "baz" property.', $violationBaz['hint']); From f06a7045496e13a77c9f81eb919185611170c5ba Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:12:40 +0200 Subject: [PATCH 4/5] fix(test): update ValidationTest assertions for relatedDummy and uuid violations These violations have canUseMessageForUser()=true, so the new behavior uses the exception message directly instead of the generic type message. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Functional/ValidationTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Functional/ValidationTest.php b/tests/Functional/ValidationTest.php index e1c954a8f7d..2fa7ca46a22 100644 --- a/tests/Functional/ValidationTest.php +++ b/tests/Functional/ValidationTest.php @@ -116,16 +116,15 @@ public function testPostWithDenormalizationErrorsCollected(): void $violationUuid = $findViolation('uuid'); $this->assertNotNull($violationUuid); - $this->assertNotNull($violationUuid); if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->assertSame('This value should be of type uuid.', $violationUuid['message']); + $this->assertSame('Invalid UUID string: y', $violationUuid['message']); } else { $this->assertSame('This value should be of type UuidInterface|null.', $violationUuid['message']); } $violationRelatedDummy = $findViolation('relatedDummy'); $this->assertNotNull($violationRelatedDummy); - $this->assertSame('This value should be of type array|string.', $violationRelatedDummy['message']); + $this->assertSame('The type of the "relatedDummy" attribute must be "array" (nested document) or "string" (IRI), "integer" given.', $violationRelatedDummy['message']); $violationRelatedDummies = $findViolation('relatedDummies'); $this->assertNotNull($violationRelatedDummies); From cac703bf9bd7814eeb1c4559af37d324080e00b6 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:42:14 +0200 Subject: [PATCH 5/5] fix(test): skip null expectedTypes test on lowest Symfony The extended NotNormalizableValueException constructor (with path and useMessageForUser params) doesn't exist on lowest Symfony versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/State/Tests/Provider/DeserializeProviderTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index ccdd70f3599..90a24f3cb10 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -258,6 +258,11 @@ public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): vo $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); + $ctor = new \ReflectionMethod(NotNormalizableValueException::class, '__construct'); + if ($ctor->getNumberOfParameters() <= 3) { + $this->markTestSkipped('NotNormalizableValueException does not support extended constructor parameters.'); + } + $exception = new NotNormalizableValueException( "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", 0,