diff --git a/composer.json b/composer.json index ec324f12a30..011f40c2a91 100644 --- a/composer.json +++ b/composer.json @@ -180,6 +180,7 @@ "symfony/maker-bundle": "^1.24", "symfony/mercure-bundle": "*", "symfony/messenger": "^6.4 || ^7.0", + "symfony/object-mapper": "^7.3", "symfony/routing": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/security-core": "^6.4 || ^7.0", diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php new file mode 100644 index 00000000000..2c379a68bc2 --- /dev/null +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -0,0 +1,47 @@ + + * + * 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\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * @implements ProcessorInterface + */ +final class ObjectMapperProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $decorated + */ + public function __construct( + private readonly ?ObjectMapperInterface $objectMapper, + private readonly ProcessorInterface $decorated, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if (!$this->objectMapper || !$operation->canWrite()) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + if (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class)) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + return $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context)); + } +} diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php new file mode 100644 index 00000000000..bd9747e11ac --- /dev/null +++ b/src/State/Provider/ObjectMapperProvider.php @@ -0,0 +1,77 @@ + + * + * 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\State\Provider; + +use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * @implements ProviderInterface + */ +final class ObjectMapperProvider implements ProviderInterface +{ + use CloneTrait; + + /** + * @param ProviderInterface $decorated + */ + public function __construct( + private readonly ?ObjectMapperInterface $objectMapper, + private readonly ProviderInterface $decorated, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $data = $this->decorated->provide($operation, $uriVariables, $context); + + if (!$this->objectMapper || !\is_object($data)) { + return $data; + } + + $request = $context['request'] ?? null; + $entityClass = null; + if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { + $entityClass = $options->getEntityClass(); + } + + if (($options = $operation->getStateOptions()) && $options instanceof OdmOptions && $options->getDocumentClass()) { + $entityClass = $options->getDocumentClass(); + } + + $entityClass ??= $data::class; + + if (!(new \ReflectionClass($entityClass))->getAttributes(Map::class)) { + return $data; + } + + if ($data instanceof PaginatorInterface) { + $data = new ArrayPaginator(array_map(fn ($v) => $this->objectMapper->map($v), iterator_to_array($data)), 0, \count($data)); + } else { + $data = $this->objectMapper->map($data); + } + + $request?->attributes->set('data', $data); + $request?->attributes->set('previous_data', $this->clone($data)); + + return $data; + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 677beabf303..7f3b15c3150 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -59,6 +59,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Finder\Finder; use Symfony\Component\HttpClient\ScopingHttpClient; +use Symfony\Component\ObjectMapper\ObjectMapper; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Validator\Validator\ValidatorInterface; @@ -169,6 +170,9 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerArgumentResolverConfiguration($loader); $this->registerLinkSecurityConfiguration($loader, $config); + if (class_exists(ObjectMapper::class)) { + $loader->load('state/object_mapper.xml'); + } $container->registerForAutoconfiguration(FilterInterface::class) ->addTag('api_platform.filter'); $container->registerForAutoconfiguration(ProviderInterface::class) diff --git a/src/Symfony/Bundle/Resources/config/state/object_mapper.xml b/src/Symfony/Bundle/Resources/config/state/object_mapper.xml new file mode 100644 index 00000000000..7d2f0f24266 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResource.php b/tests/Fixtures/TestBundle/ApiResource/MappedResource.php new file mode 100644 index 00000000000..8b4092eabab --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResource.php @@ -0,0 +1,45 @@ + + * + * 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; + +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(entityClass: MappedEntity::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], +)] +#[Map(target: MappedEntity::class)] +final class MappedResource +{ + #[Map(if: false)] + public ?string $id = null; + + #[Map(target: 'firstName', transform: [self::class, 'toFirstName'])] + #[Map(target: 'lastName', transform: [self::class, 'toLastName'])] + public string $username; + + public static function toFirstName(string $v): string + { + return explode(' ', $v)[0]; + } + + public static function toLastName(string $v): string + { + return explode(' ', $v)[1]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceOdm.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceOdm.php new file mode 100644 index 00000000000..2a61cabab7b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceOdm.php @@ -0,0 +1,45 @@ + + * + * 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; + +use ApiPlatform\Doctrine\Odm\State\Options; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(documentClass: MappedDocument::class), + normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false], +)] +#[Map(target: MappedDocument::class)] +final class MappedResourceOdm +{ + #[Map(if: false)] + public ?string $id = null; + + #[Map(target: 'firstName', transform: [self::class, 'toFirstName'])] + #[Map(target: 'lastName', transform: [self::class, 'toLastName'])] + public string $username; + + public static function toFirstName(string $v): string + { + return explode(' ', $v)[0]; + } + + public static function toLastName(string $v): string + { + return explode(' ', $v)[1]; + } +} diff --git a/tests/Fixtures/TestBundle/Document/MappedDocument.php b/tests/Fixtures/TestBundle/Document/MappedDocument.php new file mode 100644 index 00000000000..143e66f2e54 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MappedDocument.php @@ -0,0 +1,67 @@ + + * + * 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\Document; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\ObjectMapper\Attribute\Map; + +/** + * MappedEntity to MappedResource. + */ +#[ODM\Document] +#[Map(target: MappedResourceOdm::class)] +class MappedDocument +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + #[Map(if: false)] + private string $firstName; + + #[Map(target: 'username', transform: [self::class, 'toUsername'])] + #[ODM\Field(type: 'string')] + private string $lastName; + + public static function toUsername($value, $object): string + { + return $object->getFirstName().' '.$object->getLastName(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setLastName(string $name): void + { + $this->lastName = $name; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function setFirstName(string $name): void + { + $this->firstName = $name; + } + + public function getFirstName(): string + { + return $this->firstName; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedEntity.php b/tests/Fixtures/TestBundle/Entity/MappedEntity.php new file mode 100644 index 00000000000..e58eda80279 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedEntity.php @@ -0,0 +1,69 @@ + + * + * 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\Entity; + +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\ObjectMapper\Attribute\Map; + +/** + * MappedEntity to MappedResource. + */ +#[ORM\Entity] +#[Map(target: MappedResource::class)] +class MappedEntity +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + #[Map(if: false)] + private string $firstName; + + #[Map(target: 'username', transform: [self::class, 'toUsername'])] + #[ORM\Column] + private string $lastName; + + public static function toUsername($value, $object): string + { + return $object->getFirstName().' '.$object->getLastName(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function setLastName(string $name): void + { + $this->lastName = $name; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function setFirstName(string $name): void + { + $this->firstName = $name; + } + + public function getFirstName(): string + { + return $this->firstName; + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php new file mode 100644 index 00000000000..c032e6822cd --- /dev/null +++ b/tests/Functional/MappingTest.php @@ -0,0 +1,84 @@ + + * + * 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; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MappedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\DocumentManager; + +final class MappingTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MappedResource::class, MappedResourceOdm::class]; + } + + public function testShouldMapBetweenResourceAndEntity(): void + { + if (!$this->getContainer()->has('object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + $this->recreateSchema([MappedEntity::class]); + $this->loadFixtures(); + $r = self::createClient()->request('GET', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources'); + $this->assertJsonContains(['member' => [ + ['username' => 'B0 A0'], + ['username' => 'B1 A1'], + ['username' => 'B2 A2'], + ]]); + + $r = self::createClient()->request('POST', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources', ['json' => ['username' => 'so yuka']]); + $this->assertJsonContains(['username' => 'so yuka']); + + $manager = $this->getManager(); + $repo = $manager->getRepository($this->isMongoDB() ? MappedDocument::class : MappedEntity::class); + $persisted = $repo->findOneBy(['id' => $r->toArray()['id']]); + $this->assertSame('so', $persisted->getFirstName()); + $this->assertSame('yuka', $persisted->getLastName()); + + $uri = $r->toArray()['@id']; + self::createClient()->request('GET', $uri); + $this->assertJsonContains(['username' => 'so yuka']); + + $r = self::createClient()->request('PATCH', $uri, ['json' => ['username' => 'ba zar'], 'headers' => ['content-type' => 'application/merge-patch+json']]); + $this->assertJsonContains(['username' => 'ba zar']); + } + + private function loadFixtures(): void + { + $manager = $this->getManager(); + + for ($i = 0; $i < 10; ++$i) { + $e = $manager instanceof DocumentManager ? new MappedDocument() : new MappedEntity(); + $e->setLastName('A'.$i); + $e->setFirstName('B'.$i); + $manager->persist($e); + } + + $manager->flush(); + } +}