From 70985295d3f5984d19f49f59d35b956d2610a351 Mon Sep 17 00:00:00 2001 From: Ali Ben Macha Date: Sun, 1 Nov 2026 08:58:53 +0100 Subject: [PATCH 1/2] fix(serializer): unwrap the collection key type, not the collection type When the key type of an object collection was a wrapping type (e.g. a nullable int), denormalizeObjectCollection() unwrapped the collection type instead of the key type, getting the same generic type back forever and hanging the request in an infinite loop. --- src/Serializer/AbstractItemNormalizer.php | 2 +- .../Tests/AbstractItemNormalizerTest.php | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index e8695e1e79..226d6853fa 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -652,7 +652,7 @@ protected function denormalizeObjectCollection(string $attribute, ApiProperty $p $collectionKeyType = $type->getCollectionKeyType(); while ($collectionKeyType instanceof WrappingTypeInterface) { - $collectionKeyType = $type->getWrappedType(); + $collectionKeyType = $collectionKeyType->getWrappedType(); } if (!$collectionKeyType->accepts($index)) { diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 3a44cbd322..1a047eec13 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1780,6 +1780,38 @@ public function testDenormalizeBadKeyType(): void $normalizer->denormalize($data, Dummy::class); } + public function testDenormalizeBadWrappedKeyType(): void + { + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Wrapping key types require symfony/type-info.'); + } + + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('The type of the key "a" must be'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummies'])); + + $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::nullable(Type::int())); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $this->prophesize(IriConverterInterface::class)->reveal(), $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + // the nullable key type is unwrapped instead of looping forever + $normalizer->denormalize(['relatedDummies' => ['a' => ['bar' => 'baz']]], Dummy::class); + } + public function testNullable(): void { $data = [ From a278714566df9dbf896decb1130b5afca71e8802 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 3 Nov 2026 14:30:38 +0100 Subject: [PATCH 2/2] test(serializer): use stubs for wrapped key type --- .../Tests/AbstractItemNormalizerTest.php | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 1a047eec13..e1c796e579 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1782,33 +1782,25 @@ public function testDenormalizeBadKeyType(): void public function testDenormalizeBadWrappedKeyType(): void { - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Wrapping key types require symfony/type-info.'); - } - $this->expectException(NotNormalizableValueException::class); $this->expectExceptionMessage('The type of the key "a" must be'); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummies'])); + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['relatedDummies'])); $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::nullable(Type::int())); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->willReturnArgument(1); + $resourceClassResolver->method('isResourceClass')->willReturn(true); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializer = self::createStubForIntersectionOfInterfaces([SerializerInterface::class, DenormalizerInterface::class]); - $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $this->prophesize(IriConverterInterface::class)->reveal(), $resourceClassResolverProphecy->reveal(), $this->prophesize(PropertyAccessorInterface::class)->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; - $normalizer->setSerializer($serializerProphecy->reveal()); + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $this->createStub(IriConverterInterface::class), $resourceClassResolver, $this->createStub(PropertyAccessorInterface::class), null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializer); - // the nullable key type is unwrapped instead of looping forever $normalizer->denormalize(['relatedDummies' => ['a' => ['bar' => 'baz']]], Dummy::class); }