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..90a24f3cb10 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -21,13 +21,17 @@ 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 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,135 @@ 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()); + } + } + + /** + * 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); + + $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, + null, + null, + null, + 'suit', + 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 + { + $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(); diff --git a/tests/Functional/ValidationTest.php b/tests/Functional/ValidationTest.php index 18ba33fd123..2fa7ca46a22 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']); @@ -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);