diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8eac85f2780..b187494e0c7 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -326,6 +326,7 @@ public function register(): void ), $app->make(ResourceClassResolverInterface::class) ), + $app->make(ResourceClassResolverInterface::class) ) ), true === $config->get('app.debug') ? 'array' : $config->get('api-platform.cache', 'file') diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 5c0db818d70..ca0b81e36d3 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -59,6 +59,7 @@ public function __construct( ?ResourceClassResolverInterface $resourceClassResolver = null, ) { $this->resourceClassResolver = $resourceClassResolver; + $this->resourceMetadataFactory = $this->decorated; } public function create(string $resourceClass): ResourceMetadataCollection diff --git a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 0921b7a7568..effab2aad34 100644 --- a/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/src/Metadata/Tests/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -213,4 +213,84 @@ public function testCreateWithIgnoredProperty(): void self::assertFalse($result->isReadable()); self::assertFalse($result->isWritable()); } + + public function testWithResourceClassResolverIdentifiesResourceClass(): void + { + $serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class); + $dummySerializerClassMetadata = new SerializerClassMetadata(Dummy::class); + $relatedDummySerializerAttributeMetadata = new SerializerAttributeMetadata('relatedDummy'); + $dummySerializerClassMetadata->addAttributeMetadata($relatedDummySerializerAttributeMetadata); + $serializerClassMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummySerializerClassMetadata); + $relatedDummySerializerClassMetadata = new SerializerClassMetadata(RelatedDummy::class); + $serializerClassMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedDummySerializerClassMetadata); + + $context = []; + + $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relatedDummyPropertyMetadata = (new ApiProperty()) + ->withNativeType(Type::nullable(Type::object(RelatedDummy::class))); + $decoratedProphecy->create(Dummy::class, 'relatedDummy', $context)->willReturn($relatedDummyPropertyMetadata); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(RelatedDummy::class)->willReturn(true); + $resourceClassResolverProphecy->getResourceClass(null, RelatedDummy::class)->willReturn(RelatedDummy::class); + + $serializerPropertyMetadataFactory = new SerializerPropertyMetadataFactory( + $serializerClassMetadataFactoryProphecy->reveal(), + $decoratedProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $actual = $serializerPropertyMetadataFactory->create(Dummy::class, 'relatedDummy', $context); + + $this->assertInstanceOf(ApiProperty::class, $actual); + $this->assertTrue($actual->isReadable()); + $this->assertTrue($actual->isWritable()); + } + + /** + * Test that isResourceClass() falls back to false when resourceClassResolver is NOT provided. + * This demonstrates the bug that occurs without the fix in ApiPlatformProvider. + * + * When resourceClassResolver is null and resourceMetadataFactory is null, + * isResourceClass() returns false, preventing proper link status detection. + */ + public function testWithoutResourceClassResolverFallsBackToFalse(): void + { + $serializerClassMetadataFactoryProphecy = $this->prophesize(SerializerClassMetadataFactoryInterface::class); + $dummySerializerClassMetadata = new SerializerClassMetadata(Dummy::class); + $relatedDummySerializerAttributeMetadata = new SerializerAttributeMetadata('relatedDummy'); + $dummySerializerClassMetadata->addAttributeMetadata($relatedDummySerializerAttributeMetadata); + $serializerClassMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($dummySerializerClassMetadata); + $relatedDummySerializerClassMetadata = new SerializerClassMetadata(RelatedDummy::class); + $serializerClassMetadataFactoryProphecy->getMetadataFor(RelatedDummy::class)->willReturn($relatedDummySerializerClassMetadata); + + $context = []; + + $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $relatedDummyPropertyMetadata = (new ApiProperty()) + ->withNativeType(Type::nullable(Type::object(RelatedDummy::class))); + $decoratedProphecy->create(Dummy::class, 'relatedDummy', $context)->willReturn($relatedDummyPropertyMetadata); + + // Create factory WITHOUT resourceClassResolver (passing null) + $serializerPropertyMetadataFactory = new SerializerPropertyMetadataFactory( + $serializerClassMetadataFactoryProphecy->reveal(), + $decoratedProphecy->reveal(), + null // No resourceClassResolver + ); + + $actual = $serializerPropertyMetadataFactory->create(Dummy::class, 'relatedDummy', $context); + + $this->assertInstanceOf(ApiProperty::class, $actual); + // Without resourceClassResolver, isResourceClass() falls back to false, + // so transformLinkStatus() skips the link status logic + // This results in null link statuses, which causes serializer to reject nested documents + $this->assertTrue($actual->isReadable()); + $this->assertTrue($actual->isWritable()); + // The key difference: link statuses remain null (not explicitly set) + // This is the root cause of the Laravel test failures + $this->assertNull($actual->isReadableLink(), 'readableLink is null when resourceClassResolver is missing'); + $this->assertNull($actual->isWritableLink(), 'writeableLink is null when resourceClassResolver is missing'); + } } diff --git a/src/Metadata/Tests/Util/ResourceClassInfoTraitTest.php b/src/Metadata/Tests/Util/ResourceClassInfoTraitTest.php new file mode 100644 index 00000000000..317956b833f --- /dev/null +++ b/src/Metadata/Tests/Util/ResourceClassInfoTraitTest.php @@ -0,0 +1,168 @@ + + * + * 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\Util; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\RelatedDummy; +use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; +use PHPUnit\Framework\TestCase; + +class ResourceClassInfoTraitTest extends TestCase +{ + private function getResourceClassInfoTraitImplementation( + ?ResourceClassResolverInterface $resourceClassResolver = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + ) { + return new class($resourceClassResolver, $resourceMetadataFactory) { + use ResourceClassInfoTrait { + ResourceClassInfoTrait::isResourceClass as public; + ResourceClassInfoTrait::getResourceClass as public; + } + + public function __construct( + ?ResourceClassResolverInterface $resourceClassResolver = null, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, + ) { + $this->resourceClassResolver = $resourceClassResolver; + $this->resourceMetadataFactory = $resourceMetadataFactory; + } + }; + } + + public function testIsResourceClassWithResolver(): void + { + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass') + ->willReturnMap([ + [Dummy::class, true], + [RelatedDummy::class, false], + ]); + + $classInfo = $this->getResourceClassInfoTraitImplementation($resourceClassResolver); + + $this->assertTrue($classInfo->isResourceClass(Dummy::class)); + $this->assertFalse($classInfo->isResourceClass(RelatedDummy::class)); + } + + public function testIsResourceClassWithMetadataFactoryWhenNoResolver(): void + { + $resourceMetadataFactory = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $dummyMetadata = new ResourceMetadataCollection(Dummy::class, [new \stdClass()]); + $emptyMetadata = new ResourceMetadataCollection(RelatedDummy::class, []); + + $resourceMetadataFactory->method('create') + ->willReturnMap([ + [Dummy::class, $dummyMetadata], + [RelatedDummy::class, $emptyMetadata], + ]); + + $classInfo = $this->getResourceClassInfoTraitImplementation(null, $resourceMetadataFactory); + + $this->assertTrue($classInfo->isResourceClass(Dummy::class)); + $this->assertFalse($classInfo->isResourceClass(RelatedDummy::class)); + } + + public function testIsResourceClassWithoutResolverOrFactoryReturnsFalse(): void + { + $classInfo = $this->getResourceClassInfoTraitImplementation(); + + $this->assertFalse($classInfo->isResourceClass(Dummy::class)); + $this->assertFalse($classInfo->isResourceClass(RelatedDummy::class)); + } + + public function testResourceClassResolverTakesPrecedenceOverResourceMetadataFactory(): void + { + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $resourceMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactory->expects($this->never())->method('create'); + + $classInfo = $this->getResourceClassInfoTraitImplementation($resourceClassResolver, $resourceMetadataFactory); + + $this->assertTrue($classInfo->isResourceClass(Dummy::class)); + } + + public function testGetResourceClassWhenStrictFalseAndIsResourceClassFalse(): void + { + $dummy = new Dummy(); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(false); + $resourceClassResolver->expects($this->never())->method('getResourceClass'); + + $classInfo = $this->getResourceClassInfoTraitImplementation($resourceClassResolver); + + $result = $classInfo->getResourceClass($dummy); + $this->assertNull($result); + } + + public function testGetResourceClassWhenStrictFalseAndIsResourceClassTrue(): void + { + $dummy = new Dummy(); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $resourceClassResolver->method('getResourceClass')->willReturn(Dummy::class); + + $classInfo = $this->getResourceClassInfoTraitImplementation($resourceClassResolver); + + $result = $classInfo->getResourceClass($dummy); + $this->assertSame(Dummy::class, $result); + } + + public function testGetResourceClassWhenStrictTrueAndIsResourceClassTrue(): void + { + $dummy = new Dummy(); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); + + $resourceClassResolver->method('getResourceClass')->willReturn(Dummy::class); + + $classInfo = $this->getResourceClassInfoTraitImplementation($resourceClassResolver); + + $result = $classInfo->getResourceClass($dummy, true); + $this->assertSame(Dummy::class, $result); + } + + public function testGetResourceClassWhenStrictTrueAndIsResourceClassFalse(): void + { + $dummy = new Dummy(); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(false); + + $resourceClassResolver->method('getResourceClass')->willReturn(Dummy::class); + + $classInfo = $this->getResourceClassInfoTraitImplementation($resourceClassResolver); + + $result = $classInfo->getResourceClass($dummy, true); + $this->assertSame(Dummy::class, $result); + } + + public function testGetResourceClassWithoutResolver(): void + { + $dummy = new Dummy(); + + $classInfo = $this->getResourceClassInfoTraitImplementation(); + + $result = $classInfo->getResourceClass($dummy); + $this->assertSame(Dummy::class, $result); + } +} diff --git a/src/Metadata/Util/ResourceClassInfoTrait.php b/src/Metadata/Util/ResourceClassInfoTrait.php index 037f96de7b3..1dac9174406 100644 --- a/src/Metadata/Util/ResourceClassInfoTrait.php +++ b/src/Metadata/Util/ResourceClassInfoTrait.php @@ -62,8 +62,7 @@ private function isResourceClass(string $class): bool return \count($this->resourceMetadataFactory->create($class)) > 0; } - // assume that it's a resource class - return true; + return false; } private function getTypeFromProperty(ApiProperty $propertyMetadata): ?Type