diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index 3f0074d744f..2f542d276d7 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -15,8 +15,8 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; -use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; /** * @implements ProcessorInterface diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index 89f1c332fce..6504b8fbb70 100644 --- a/src/State/Provider/ObjectMapperProvider.php +++ b/src/State/Provider/ObjectMapperProvider.php @@ -40,7 +40,11 @@ public function provide(Operation $operation, array $uriVariables = [], array $c { $data = $this->decorated->provide($operation, $uriVariables, $context); - if (!$this->objectMapper || !\is_object($data) || !$operation->canMap()) { + if (!$this->objectMapper || !$operation->canMap()) { + return $data; + } + + if (!\is_object($data) && !\is_array($data)) { return $data; } @@ -49,6 +53,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($data instanceof PaginatorInterface) { $data = new ArrayPaginator(array_map(fn ($v) => $this->objectMapper->map($v, $operation->getClass()), iterator_to_array($data)), 0, \count($data)); + } elseif (\is_array($data)) { + $data = array_map(fn ($v) => $this->objectMapper->map($v, $operation->getClass()), $data); } else { $data = $this->objectMapper->map($data, $operation->getClass()); } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7563/BookDto.php b/tests/Fixtures/TestBundle/ApiResource/Issue7563/BookDto.php new file mode 100644 index 00000000000..63b83bfa8c2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7563/BookDto.php @@ -0,0 +1,41 @@ + + * + * 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\Issue7563; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\ObjectMapper\IsbnToCustomIsbnTransformer; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Get( + stateOptions: new Options(entityClass: Book::class) +)] +#[GetCollection( + stateOptions: new Options(entityClass: Book::class) +)] +#[Map(source: Book::class)] +class BookDto +{ + public function __construct( + #[Map(source: 'id')] + public ?int $id = null, + #[Map(source: 'name')] + public ?string $name = null, + #[Map(source: 'isbn', transform: IsbnToCustomIsbnTransformer::class)] + public ?string $customIsbn = null, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ObjectMapper/IsbnToCustomIsbnTransformer.php b/tests/Fixtures/TestBundle/ObjectMapper/IsbnToCustomIsbnTransformer.php new file mode 100644 index 00000000000..8372483dac8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ObjectMapper/IsbnToCustomIsbnTransformer.php @@ -0,0 +1,29 @@ + + * + * 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\ObjectMapper; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7563\BookDto; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; +use Symfony\Component\ObjectMapper\TransformCallableInterface; + +/** + * @implements TransformCallableInterface + */ +final readonly class IsbnToCustomIsbnTransformer implements TransformCallableInterface +{ + public function __invoke(mixed $value, object $source, ?object $target): mixed + { + return 'custom'.$value; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index f47386188b4..eb58c616ae8 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -271,6 +271,10 @@ services: tags: - name: 'api_platform.state_processor' + ApiPlatform\Tests\Fixtures\TestBundle\ObjectMapper\IsbnToCustomIsbnTransformer: + tags: + - name: 'object_mapper.transform_callable' + app.messenger_handler.messenger_with_response: class: 'ApiPlatform\Tests\Fixtures\TestBundle\MessengerHandler\MessengerWithResponseHandler' tags: diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 232298b0209..d697ae202d2 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FirstResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7563\BookDto; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm; @@ -24,6 +25,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithRelationRelated; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SecondResource; use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntityNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntitySourceOnly; @@ -55,6 +57,7 @@ public static function getResources(): array MappedResourceWithInput::class, MappedResourceSourceOnly::class, MappedResourceNoMap::class, + BookDto::class, ]; } @@ -277,4 +280,75 @@ private function loadFixtures(): void $manager->flush(); } + + private function loadBookFixtures(): void + { + $manager = $this->getManager(); + + for ($i = 1; $i <= 5; ++$i) { + $book = new Book(); + $book->name = 'Book '.$i; + $book->isbn = 'ISBN-'.$i; + $manager->persist($book); + } + + $manager->flush(); + } + + public function testGetSingleBookDto(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested.'); + } + + $this->recreateSchema([Book::class]); + $this->loadBookFixtures(); + + self::createClient()->request('GET', '/book_dtos/1'); + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + '@type' => 'BookDto', + 'id' => 1, + 'name' => 'Book 1', + 'customIsbn' => 'customISBN-1', + ]); + } + + public function testGetCollectionBookDtoPaginated(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested.'); + } + + $this->recreateSchema([Book::class]); + $this->loadBookFixtures(); + + $response = self::createClient()->request('GET', '/book_dtos'); + self::assertResponseIsSuccessful(); + + $json = $response->toArray(); + self::assertCount(3, $json['hydra:member']); + foreach ($response->toArray()['hydra:member'] as $member) { + self::assertStringStartsWith('customISBN-', $member['customIsbn']); + } + } + + public function testGetCollectionBookDtoUnpaginated(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested.'); + } + + $this->recreateSchema([Book::class]); + $this->loadBookFixtures(); + + $response = self::createClient()->request('GET', '/book_dtos?pagination=false'); + self::assertResponseIsSuccessful(); + + $json = $response->toArray(); + self::assertCount(5, $json['hydra:member']); + foreach ($json['hydra:member'] as $member) { + self::assertStringStartsWith('customISBN-', $member['customIsbn']); + } + } } diff --git a/tests/State/Provider/ObjectMapperProviderTest.php b/tests/State/Provider/ObjectMapperProviderTest.php new file mode 100644 index 00000000000..8b5b3d456dd --- /dev/null +++ b/tests/State/Provider/ObjectMapperProviderTest.php @@ -0,0 +1,208 @@ + + * + * 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\State\Provider; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Provider\ObjectMapperProvider; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ObjectMapperProviderTest extends TestCase +{ + public function testProvideBypassesWhenNoObjectMapper(): void + { + $data = new SourceEntity(); + $operation = new Get(class: TargetResource::class); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + $provider = new ObjectMapperProvider(null, $decorated); + + $result = $provider->provide($operation); + $this->assertSame($data, $result); + } + + public function testProvideBypassesWhenOperationCannotMap(): void + { + $data = new SourceEntity(); + $operation = new Get(class: TargetResource::class, map: false); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($data); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertSame($data, $result); + } + + public function testProvideBypassesWhenDataIsNull(): void + { + $operation = new Get(class: TargetResource::class, map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertNull($result); + } + + public function testProvideMapsObject(): void + { + $sourceEntity = new SourceEntity(); + $targetResource = new TargetResource(); + $operation = new Get(class: TargetResource::class, map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($sourceEntity, TargetResource::class) + ->willReturn($targetResource); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($sourceEntity); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertSame($targetResource, $result); + } + + public function testProvideMapsObjectAndSetsRequestAttributes(): void + { + $sourceEntity = new SourceEntity(); + $targetResource = new TargetResource(); + $operation = new Get(class: TargetResource::class, map: true); + $request = new Request(); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($sourceEntity, TargetResource::class) + ->willReturn($targetResource); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($sourceEntity); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation, [], ['request' => $request]); + $this->assertSame($targetResource, $result); + $this->assertSame($sourceEntity, $request->attributes->get('mapped_data')); + $this->assertSame($targetResource, $request->attributes->get('data')); + $this->assertInstanceOf(TargetResource::class, $request->attributes->get('previous_data')); + $this->assertNotSame($targetResource, $request->attributes->get('previous_data')); + } + + public function testProvideMapsArray(): void + { + $sourceEntity1 = new SourceEntity(); + $sourceEntity2 = new SourceEntity(); + $targetResource1 = new TargetResource(); + $targetResource2 = new TargetResource(); + $operation = new Get(class: TargetResource::class, map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->exactly(2)) + ->method('map') + ->willReturnCallback(function ($source, $target) use ($sourceEntity1, $sourceEntity2, $targetResource1, $targetResource2) { + if ($source === $sourceEntity1) { + return $targetResource1; + } + if ($source === $sourceEntity2) { + return $targetResource2; + } + + return null; + }); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn([$sourceEntity1, $sourceEntity2]); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame($targetResource1, $result[0]); + $this->assertSame($targetResource2, $result[1]); + } + + public function testProvideMapsPaginator(): void + { + $sourceEntity1 = new SourceEntity(); + $sourceEntity2 = new SourceEntity(); + $targetResource1 = new TargetResource(); + $targetResource2 = new TargetResource(); + $operation = new Get(class: TargetResource::class, map: true); + $paginator = new ArrayPaginator([$sourceEntity1, $sourceEntity2], 0, 10); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->exactly(2)) + ->method('map') + ->willReturnCallback(function ($source, $target) use ($sourceEntity1, $sourceEntity2, $targetResource1, $targetResource2) { + if ($source === $sourceEntity1) { + return $targetResource1; + } + if ($source === $sourceEntity2) { + return $targetResource2; + } + + return null; + }); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($paginator); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertInstanceOf(ArrayPaginator::class, $result); + $items = iterator_to_array($result); + $this->assertCount(2, $items); + $this->assertSame($targetResource1, $items[0]); + $this->assertSame($targetResource2, $items[1]); + } + + public function testProvideMapsEmptyArray(): void + { + $operation = new Get(class: TargetResource::class, map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn([]); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testProvideMapsEmptyPaginator(): void + { + $operation = new Get(class: TargetResource::class, map: true); + $paginator = new ArrayPaginator([], 0, 10); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($paginator); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertInstanceOf(ArrayPaginator::class, $result); + $this->assertCount(0, iterator_to_array($result)); + } +} + +class SourceEntity +{ + public string $name = 'source'; +} + +class TargetResource +{ + public string $name = 'target'; +}