Skip to content
Draft
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
28 changes: 28 additions & 0 deletions src/Serializer/AbstractItemNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down
65 changes: 65 additions & 0 deletions src/Serializer/Tests/AbstractItemNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 <hamza.simperfit@gmail.com>
Expand Down Expand Up @@ -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.
Expand Down
Loading