Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions src/Serializer/Tests/AbstractItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion tests/Functional/ValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading