From 9c9883d12ae423de0fc3c423d6e5b3e9249386ee Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Thu, 15 Oct 2026 17:48:19 +0200 Subject: [PATCH] fix(metadata): keep explicit readableLink and writableLink on collections of non-resource classes --- .../SerializerPropertyMetadataFactory.php | 2 +- .../Tests/Fixtures/RelatedDummiesOutput.php | 24 +++++++++++++++++ .../SerializerPropertyMetadataFactoryTest.php | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/Metadata/Tests/Fixtures/RelatedDummiesOutput.php diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index 05b3c605180..8ca14d359a3 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -67,7 +67,7 @@ public function create(string $resourceClass, string $property, array $options = $type = $propertyMetadata->getNativeType(); if (null !== $type && !$this->isResourceClass($resourceClass) && $type->isSatisfiedBy(static fn (Type $t): bool => $t instanceof CollectionType)) { - return $propertyMetadata->withReadableLink(true)->withWritableLink(true); + return $propertyMetadata->withReadableLink($propertyMetadata->isReadableLink() ?? true)->withWritableLink($propertyMetadata->isWritableLink() ?? true); } return $this->transformLinkStatus($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $type); diff --git a/src/Metadata/Tests/Fixtures/RelatedDummiesOutput.php b/src/Metadata/Tests/Fixtures/RelatedDummiesOutput.php new file mode 100644 index 00000000000..faa2ac2503d --- /dev/null +++ b/src/Metadata/Tests/Fixtures/RelatedDummiesOutput.php @@ -0,0 +1,24 @@ + + * + * 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\Metadata\Tests\Fixtures; + +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\RelatedDummy; + +class RelatedDummiesOutput +{ + /** + * @var RelatedDummy[] + */ + public array $relatedDummies = []; +} diff --git a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php index effab2aad34..603853b7a24 100644 --- a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\DummyCar; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\RelatedDummy; use ApiPlatform\Metadata\Tests\Fixtures\DummyIgnoreProperty; +use ApiPlatform\Metadata\Tests\Fixtures\RelatedDummiesOutput; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -293,4 +294,30 @@ public function testWithoutResourceClassResolverFallsBackToFalse(): void $this->assertNull($actual->isReadableLink(), 'readableLink is null when resourceClassResolver is missing'); $this->assertNull($actual->isWritableLink(), 'writeableLink is null when resourceClassResolver is missing'); } + + #[DataProvider('provideCollectionOnNonResourceClass')] + public function testCollectionOnNonResourceClass(ApiProperty $propertyMetadata, bool $expectedLink): void + { + $serializerClassMetadataFactory = $this->createStub(SerializerClassMetadataFactoryInterface::class); + $serializerClassMetadataFactory->method('getMetadataFor')->willReturn(new SerializerClassMetadata(RelatedDummiesOutput::class)); + + $decorated = $this->createStub(PropertyMetadataFactoryInterface::class); + $decorated->method('create')->willReturn($propertyMetadata->withNativeType(Type::list(Type::object(RelatedDummy::class)))); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturnCallback(static fn (string $class): bool => RelatedDummy::class === $class); + + $serializerPropertyMetadataFactory = new SerializerPropertyMetadataFactory($serializerClassMetadataFactory, $decorated, $resourceClassResolver); + + $actual = $serializerPropertyMetadataFactory->create(RelatedDummiesOutput::class, 'relatedDummies'); + + $this->assertSame($expectedLink, $actual->isReadableLink()); + $this->assertSame($expectedLink, $actual->isWritableLink()); + } + + public static function provideCollectionOnNonResourceClass(): iterable + { + yield 'embedded by default' => [new ApiProperty(), true]; + yield 'explicit links are kept' => [new ApiProperty(readableLink: false, writableLink: false), false]; + } }