Skip to content

Commit 31d2079

Browse files
committed
fix(serializer): preserve denormalization errors for nullable object properties
A value of the wrong type for a nullable object property (?Uuid, ?DateTimeImmutable, ...) produced "This value should be of type Uuid|null." with no hint, while the non-nullable version of the same property produced the schema accurate "This value should be of type string." plus a hint. Nullability alone flipped the result: a nullable type is a composite, so the dedicated normalizer's exception was stashed, and once every union member failed the error was rebuilt from the PHP type union. The rebuild kept the message, but the visible violation is built by DeserializeProvider from getExpectedTypes(), and those were replaced with the stringified union members. Re-throw the stashed exception when the union contains an object member that is neither a resource class nor an enum, extending its expected types with "null" when the property accepts null. The label comes from whatever the dedicated normalizer reports as its accepted input types: a wrong value for a nullable Ulid property now renders "This value should be of type string|null." plus the UidNormalizer hint, matching the 4.1.x output extended with "null". Enums keep flowing through the rebuilt exception and are mapped to their backing type downstream; scalar unions and resource relations are untouched.
1 parent 39b278c commit 31d2079

3 files changed

Lines changed: 108 additions & 1 deletion

File tree

src/Serializer/AbstractItemNormalizer.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
use Symfony\Component\TypeInfo\Type\BuiltinType;
5252
use Symfony\Component\TypeInfo\Type\CollectionType;
5353
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
54+
use Symfony\Component\TypeInfo\Type\EnumType;
5455
use Symfony\Component\TypeInfo\Type\NullableType;
5556
use Symfony\Component\TypeInfo\Type\ObjectType;
5657
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
@@ -1445,6 +1446,32 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value
14451446

14461447
if ($denormalizationException) {
14471448
if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) {
1449+
// When the union contains an object member that is not a resource class (e.g. a nullable
1450+
// Uuid), the exception stashed while denormalizing it comes from its dedicated normalizer
1451+
// and carries the expected types actually accepted on the wire (e.g. ["string"]) plus a
1452+
// hint that is safe to expose to the user. Rebuilding the error from the PHP type union
1453+
// would report unactionable expected types such as "Uuid|null" instead, so re-throw it,
1454+
// only extending its expected types with "null" when the property also accepts null.
1455+
// Enum members are deliberately not re-thrown here: their violations are rendered from
1456+
// the rebuilt exception below, whose expected types are mapped to the enum backing type
1457+
// downstream (see DeserializeProvider).
1458+
foreach ($types as $memberType) {
1459+
while ($memberType instanceof WrappingTypeInterface && !$memberType instanceof CollectionType) {
1460+
$memberType = $memberType->getWrappedType();
1461+
}
1462+
1463+
if (!$memberType instanceof ObjectType || $memberType instanceof EnumType || $this->resourceClassResolver->isResourceClass($memberType->getClassName())) {
1464+
continue;
1465+
}
1466+
1467+
$expectedTypes = $denormalizationException->getExpectedTypes();
1468+
if ($type->isNullable() && $expectedTypes && !\in_array('null', $expectedTypes, true)) {
1469+
throw NotNormalizableValueException::createForUnexpectedDataType($denormalizationException->getMessage(), $value, [...$expectedTypes, 'null'], $denormalizationException->getPath() ?? $context['deserialization_path'] ?? null, $denormalizationException->canUseMessageForUser(), $denormalizationException->getCode(), $denormalizationException);
1470+
}
1471+
1472+
throw $denormalizationException;
1473+
}
1474+
14481475
// If the exception came from object denormalization (e.g. BackedEnumNormalizer for a
14491476
// nullable backed enum), preserve its more specific message and its user-facing hint flag;
14501477
// the expected types still carry the object/enum class so the failure stays recognizable

src/Serializer/Tests/AbstractItemNormalizerTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,6 +1387,84 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void
13871387
$this->assertInstanceOf(Dummy::class, $actual);
13881388
}
13891389

1390+
public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void
1391+
{
1392+
// Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system.
1393+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
1394+
$this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).');
1395+
}
1396+
1397+
// What Symfony's DateTimeNormalizer throws for a value it cannot parse.
1398+
$normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true);
1399+
1400+
$normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::nullable(Type::object(\DateTimeImmutable::class)), \DateTimeImmutable::class, $normalizerException);
1401+
1402+
try {
1403+
$normalizer->denormalize(['dummyDate' => false], Dummy::class);
1404+
$this->fail('Expected a NotNormalizableValueException to be thrown.');
1405+
} catch (NotNormalizableValueException $exception) {
1406+
$this->assertSame(['string', 'null'], $exception->getExpectedTypes());
1407+
$this->assertTrue($exception->canUseMessageForUser());
1408+
$this->assertSame($normalizerException->getMessage(), $exception->getMessage());
1409+
$this->assertSame('dummyDate', $exception->getPath());
1410+
}
1411+
}
1412+
1413+
public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void
1414+
{
1415+
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
1416+
$this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).');
1417+
}
1418+
1419+
$normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true);
1420+
1421+
$normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException);
1422+
1423+
try {
1424+
$normalizer->denormalize(['dummyDate' => false], Dummy::class);
1425+
$this->fail('Expected a NotNormalizableValueException to be thrown.');
1426+
} catch (NotNormalizableValueException $exception) {
1427+
$this->assertSame(['string'], $exception->getExpectedTypes());
1428+
$this->assertTrue($exception->canUseMessageForUser());
1429+
$this->assertSame($normalizerException->getMessage(), $exception->getMessage());
1430+
$this->assertSame('dummyDate', $exception->getPath());
1431+
}
1432+
}
1433+
1434+
private function createNormalizerForObjectProperty(string $attribute, Type $nativeType, string $className, NotNormalizableValueException $normalizerException): AbstractItemNormalizer
1435+
{
1436+
$propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class);
1437+
$propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([$attribute]));
1438+
1439+
$propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class);
1440+
$propertyMetadataFactory->method('create')->willReturn(
1441+
(new ApiProperty())
1442+
->withNativeType($nativeType)
1443+
->withWritable(true)
1444+
);
1445+
1446+
$resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class);
1447+
$resourceClassResolver->method('isResourceClass')->willReturnMap([
1448+
[Dummy::class, true],
1449+
[$className, false],
1450+
]);
1451+
$resourceClassResolver->method('getResourceClass')->willReturnMap([
1452+
[null, Dummy::class, Dummy::class],
1453+
]);
1454+
1455+
$propertyAccessor = $this->createStub(PropertyAccessorInterface::class);
1456+
$iriConverter = $this->createStub(IriConverterInterface::class);
1457+
1458+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
1459+
$serializerProphecy->willImplement(DenormalizerInterface::class);
1460+
$serializerProphecy->denormalize(false, $className, null, Argument::type('array'))->willThrow($normalizerException);
1461+
1462+
$normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {};
1463+
$normalizer->setSerializer($serializerProphecy->reveal());
1464+
1465+
return $normalizer;
1466+
}
1467+
13901468
public function testTypeConfusionGuardPreservesPathAndExpectedType(): void
13911469
{
13921470
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);

tests/Functional/ValidationTest.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,10 @@ public function testPostWithDenormalizationErrorsCollected(): void
119119
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
120120
$this->assertSame('This value should be of type uuid.', $violationUuid['message']);
121121
} else {
122-
$this->assertSame('This value should be of type UuidInterface|null.', $violationUuid['message']);
122+
$this->assertSame('This value should be of type uuid|null.', $violationUuid['message']);
123123
}
124+
$this->assertArrayHasKey('hint', $violationUuid);
125+
$this->assertSame('Invalid UUID string: y', $violationUuid['hint']);
124126

125127
$violationRelatedDummy = $findViolation('relatedDummy');
126128
$this->assertNotNull($violationRelatedDummy);

0 commit comments

Comments
 (0)