diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 040ac4f0ef..6e1e52b970 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -28,6 +28,7 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; +use Symfony\Bridge\Doctrine\Serializer\Normalizer\CollectionDenormalizer; use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; @@ -1110,6 +1111,19 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value unset($context['resource_class'], $context['uri_variables']); try { + // When the collection is declared with a class, such as a Doctrine collection, + // ask the Serializer to build that class and pass the element and key types + // through the context, the same way the core Serializer does. The class is + // checked so older Symfony versions, which cannot build the collection, keep + // the array type. A plain array or iterable keeps the "Foo[]" type too. + $collectionClass = self::getCollectionContainerClass($t); + if (null !== $collectionClass && class_exists(CollectionDenormalizer::class)) { + $context['value_type'] = $unwrappedCollectionValueType; + $context['key_type'] = $t->getCollectionKeyType(); + + return $this->serializer->denormalize($value, $collectionClass, $format, $context); + } + return $this->serializer->denormalize($value, $className.'[]', $format, $context); } catch (NotNormalizableValueException $e) { // union/intersect types: try the next type, if not valid, an exception will be thrown at the end @@ -1272,6 +1286,20 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value return $value; } + /** + * Returns the class of the container of a collection type, or null when the container is a plain array or iterable. + */ + private static function getCollectionContainerClass(CollectionType $type): ?string + { + $containerType = $type->getWrappedType(); + + while ($containerType instanceof WrappingTypeInterface) { + $containerType = $containerType->getWrappedType(); + } + + return $containerType instanceof ObjectType ? $containerType->getClassName() : null; + } + /** * Sets a value of the object using the PropertyAccess component. */ diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 3a44cbd322..2f75480dca 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -50,6 +50,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bridge\Doctrine\Serializer\Normalizer\CollectionDenormalizer; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; @@ -59,6 +60,8 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * @author Amrouche Hamza @@ -1242,6 +1245,68 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void $this->assertInstanceOf(Dummy::class, $actual); } + public function testDenormalizeCollectionWithAClassContainer(): void + { + $data = ['relatedDummies' => [['name' => 'foo']]]; + + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['relatedDummies'])); + + // The property declares a Doctrine collection class and a value type that is not an API + // resource, so the element type has to travel through the context instead of the type. + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn( + (new ApiProperty()) + ->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())) + ->withWritable(true) + ); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnMap([ + [Dummy::class, true], + [RelatedDummy::class, false], + [ArrayCollection::class, false], + ]); + $resourceClassResolver->method('getResourceClass')->willReturnMap([ + [null, Dummy::class, Dummy::class], + ]); + + $relatedDummy = new RelatedDummy(); + $collection = new ArrayCollection([$relatedDummy]); + + // Symfony 8.2 and later ship the Doctrine collection denormalizer, so the declared + // collection class is handed to the Serializer with the element and key types in the + // context. Earlier versions keep the "Foo[]" type and build a plain array. + $supportsCollectionClass = class_exists(CollectionDenormalizer::class); + + $propertyAccessor = $this->createMock(PropertyAccessorInterface::class); + $propertyAccessor->expects($this->once()) + ->method('setValue') + ->with($this->isInstanceOf(Dummy::class), 'relatedDummies', $supportsCollectionClass ? $collection : [$relatedDummy]); + + $iriConverter = $this->createStub(IriConverterInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + if ($supportsCollectionClass) { + $serializerProphecy->denormalize($data['relatedDummies'], ArrayCollection::class, null, Argument::that(static function (array $context): bool { + return $context['value_type'] instanceof ObjectType + && RelatedDummy::class === $context['value_type']->getClassName() + && $context['key_type']->isIdentifiedBy(TypeIdentifier::INT); + }))->willReturn($collection); + } else { + $serializerProphecy->denormalize($data['relatedDummies'], RelatedDummy::class.'[]', null, Argument::type('array'))->willReturn([$relatedDummy]); + } + + $normalizer = new class($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $actual = $normalizer->denormalize($data, Dummy::class); + + $this->assertInstanceOf(Dummy::class, $actual); + } + public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void { // What Symfony's DateTimeNormalizer throws for a value it cannot parse.