From 522679aa96ad4990f16c45b88956ea9da58d72fd Mon Sep 17 00:00:00 2001 From: Ben Davies Date: Wed, 8 Jul 2026 21:24:25 +0100 Subject: [PATCH] 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. --- src/Serializer/AbstractItemNormalizer.php | 27 +++++++ .../Tests/AbstractItemNormalizerTest.php | 78 +++++++++++++++++++ tests/Functional/ValidationTest.php | 4 +- 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 253f1a0b305..365bdbc4319 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -51,6 +51,7 @@ use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; +use Symfony\Component\TypeInfo\Type\EnumType; use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; @@ -1445,6 +1446,32 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($denormalizationException) { if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) { + // When the union contains an object member that is not a resource class (e.g. a nullable + // Uuid), the exception stashed while denormalizing it comes from its dedicated normalizer + // and carries the expected types actually accepted on the wire (e.g. ["string"]) plus a + // hint that is safe to expose to the user. Rebuilding the error from the PHP type union + // would report unactionable expected types such as "Uuid|null" instead, so re-throw it, + // only extending its expected types with "null" when the property also accepts null. + // Enum members are deliberately not re-thrown here: their violations are rendered from + // the rebuilt exception below, whose expected types are mapped to the enum backing type + // downstream (see DeserializeProvider). + foreach ($types as $memberType) { + while ($memberType instanceof WrappingTypeInterface && !$memberType instanceof CollectionType) { + $memberType = $memberType->getWrappedType(); + } + + if (!$memberType instanceof ObjectType || $memberType instanceof EnumType || $this->resourceClassResolver->isResourceClass($memberType->getClassName())) { + continue; + } + + $expectedTypes = $denormalizationException->getExpectedTypes(); + if ($type->isNullable() && $expectedTypes && !\in_array('null', $expectedTypes, true)) { + throw NotNormalizableValueException::createForUnexpectedDataType($denormalizationException->getMessage(), $value, [...$expectedTypes, 'null'], $denormalizationException->getPath() ?? $context['deserialization_path'] ?? null, $denormalizationException->canUseMessageForUser(), $denormalizationException->getCode(), $denormalizationException); + } + + throw $denormalizationException; + } + // If the exception came from object denormalization (e.g. BackedEnumNormalizer for a // nullable backed enum), preserve its more specific message and its user-facing hint flag; // the expected types still carry the object/enum class so the failure stays recognizable diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index e09e9f05a48..bc17aae5005 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1387,6 +1387,84 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void + { + // Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + // What Symfony's DateTimeNormalizer throws for a value it cannot parse. + $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); + + $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::nullable(Type::object(\DateTimeImmutable::class)), \DateTimeImmutable::class, $normalizerException); + + try { + $normalizer->denormalize(['dummyDate' => false], Dummy::class); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame(['string', 'null'], $exception->getExpectedTypes()); + $this->assertTrue($exception->canUseMessageForUser()); + $this->assertSame($normalizerException->getMessage(), $exception->getMessage()); + $this->assertSame('dummyDate', $exception->getPath()); + } + } + + public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + + $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); + + $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException); + + try { + $normalizer->denormalize(['dummyDate' => false], Dummy::class); + $this->fail('Expected a NotNormalizableValueException to be thrown.'); + } catch (NotNormalizableValueException $exception) { + $this->assertSame(['string'], $exception->getExpectedTypes()); + $this->assertTrue($exception->canUseMessageForUser()); + $this->assertSame($normalizerException->getMessage(), $exception->getMessage()); + $this->assertSame('dummyDate', $exception->getPath()); + } + } + + private function createNormalizerForObjectProperty(string $attribute, Type $nativeType, string $className, NotNormalizableValueException $normalizerException): AbstractItemNormalizer + { + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection([$attribute])); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType($nativeType) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [$className, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $propertyAccessor = $this->createStub(PropertyAccessorInterface::class); + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->denormalize(false, $className, null, Argument::type('array'))->willThrow($normalizerException); + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + return $normalizer; + } + public function testTypeConfusionGuardPreservesPathAndExpectedType(): void { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); diff --git a/tests/Functional/ValidationTest.php b/tests/Functional/ValidationTest.php index c82238d75d0..7c9f4c214a0 100644 --- a/tests/Functional/ValidationTest.php +++ b/tests/Functional/ValidationTest.php @@ -119,8 +119,10 @@ public function testPostWithDenormalizationErrorsCollected(): void if (!method_exists(PropertyInfoExtractor::class, 'getType')) { $this->assertSame('This value should be of type uuid.', $violationUuid['message']); } else { - $this->assertSame('This value should be of type UuidInterface|null.', $violationUuid['message']); + $this->assertSame('This value should be of type uuid|null.', $violationUuid['message']); } + $this->assertArrayHasKey('hint', $violationUuid); + $this->assertSame('Invalid UUID string: y', $violationUuid['hint']); $violationRelatedDummy = $findViolation('relatedDummy'); $this->assertNotNull($violationRelatedDummy);