diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 4ef86f8e24..7c7b5dae38 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -852,6 +852,10 @@ protected function getAttributeValue(object $object, string $attribute, ?string $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + if (null === $attributeValue && $type->isNullable()) { + return null; + } + if (!is_iterable($attributeValue)) { throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); } @@ -983,6 +987,10 @@ protected function getAttributeValue(object $object, string $attribute, ?string $attributeValue = $this->propertyAccessor->getValue($object, $attribute); + if ($nullable && null === $attributeValue) { + return null; + } + if (!is_iterable($attributeValue)) { throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); } diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index c9c7c097a4..55eb5dcdad 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -168,6 +168,61 @@ public function testNormalize(): void ])); } + public function testNormalizeNullableToManyRelationReturnsNull(): void + { + $dummy = new Dummy(); + $dummy->setName('foo'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummies'])); + + // BC layer for api-platform/metadata < 4.1 + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); + $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); + } else { + $relatedDummiesType = Type::nullable(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); + } + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('foo'); + $propertyAccessorProphecy->getValue($dummy, 'relatedDummies')->willReturn(null); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('foo', null, Argument::type('array'))->willReturn('foo'); + $serializerProphecy->normalize(null, null, Argument::type('array'))->willReturn(null); + + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'name' => 'foo', + 'relatedDummies' => null, + ]; + $this->assertSame($expected, $normalizer->normalize($dummy, null, [ + 'resources' => [], + 'skip_null_values' => false, + ])); + } + public function testNormalizeWithSecuredProperty(): void { $dummy = new SecuredDummy(); diff --git a/tests/Fixtures/TestBundle/ApiResource/NullableToManyRelation/NullableToManyChild.php b/tests/Fixtures/TestBundle/ApiResource/NullableToManyRelation/NullableToManyChild.php new file mode 100644 index 0000000000..d0106077fa --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/NullableToManyRelation/NullableToManyChild.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[Get( + shortName: 'NullableToManyChild', + uriTemplate: '/nullable_to_many_children/{id}', + provider: [self::class, 'provide'], +)] +class NullableToManyChild +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $c = new self(); + $c->id = (int) ($uriVariables['id'] ?? 1); + $c->name = 'child'; + + return $c; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/NullableToManyRelation/NullableToManyParent.php b/tests/Fixtures/TestBundle/ApiResource/NullableToManyRelation/NullableToManyParent.php new file mode 100644 index 0000000000..8b2bc82d0f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/NullableToManyRelation/NullableToManyParent.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Doctrine\Common\Collections\Collection; + +#[Get( + shortName: 'NullableToManyParent', + uriTemplate: '/nullable_to_many_parents/{id}', + provider: [self::class, 'provide'], +)] +class NullableToManyParent +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + /** @var Collection|null */ + #[ApiProperty(readableLink: true)] + public ?Collection $children = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $p = new self(); + $p->id = (int) ($uriVariables['id'] ?? 1); + $p->name = 'parent'; + $p->children = null; + + return $p; + } +} diff --git a/tests/Functional/Serializer/NullableToManyRelationTest.php b/tests/Functional/Serializer/NullableToManyRelationTest.php new file mode 100644 index 0000000000..0c6955fbc5 --- /dev/null +++ b/tests/Functional/Serializer/NullableToManyRelationTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation\NullableToManyChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullableToManyRelation\NullableToManyParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NullableToManyRelationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [NullableToManyParent::class, NullableToManyChild::class]; + } + + public function testNullableToManyRelationNormalizesAsNull(): void + { + $response = self::createClient()->request('GET', '/nullable_to_many_parents/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertArrayHasKey('children', $body); + $this->assertNull($body['children']); + } +}