diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 253f1a0b30..365bdbc431 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 e09e9f05a4..bc17aae500 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 c82238d75d..7c9f4c214a 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);