From 6e954d5053f41c4d5147c6751316150921214a94 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 15 Nov 2024 16:34:57 +0100 Subject: [PATCH] feat(symfony): object mapper with state options --- composer.json | 8 ++- src/Serializer/SerializerContextBuilder.php | 6 +- src/State/Processor/ObjectMapperProcessor.php | 45 ++++++++++++ src/State/Provider/ObjectMapperProvider.php | 61 +++++++++++++++++ .../ApiPlatformExtension.php | 4 ++ .../Resources/config/state/object_mapper.xml | 17 +++++ src/Symfony/Controller/MainController.php | 16 ++--- .../TestBundle/ApiResource/MappedResource.php | 29 ++++++++ .../TestBundle/Entity/MappedEntity.php | 68 +++++++++++++++++++ tests/Functional/MappingTest.php | 64 +++++++++++++++++ 10 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 src/State/Processor/ObjectMapperProcessor.php create mode 100644 src/State/Provider/ObjectMapperProvider.php create mode 100644 src/Symfony/Bundle/Resources/config/state/object_mapper.xml create mode 100644 tests/Fixtures/TestBundle/ApiResource/MappedResource.php create mode 100644 tests/Fixtures/TestBundle/Entity/MappedEntity.php create mode 100644 tests/Functional/MappingTest.php diff --git a/composer.json b/composer.json index 290828ac782..54b777b091e 100644 --- a/composer.json +++ b/composer.json @@ -162,6 +162,7 @@ "symfony/cache": "^6.4 || ^7.0", "symfony/config": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", + "symfony/object-mapper": "dev-feat/automapper-frameworkbundle", "symfony/css-selector": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", "symfony/doctrine-bridge": "^6.4.2 || ^7.0.2", @@ -208,5 +209,10 @@ "symfony/web-profiler-bundle": "To use the data collector.", "webonyx/graphql-php": "To support GraphQL." }, - "type": "library" + "type": "library", + "repositories": [ + { "type": "path", "url": "../symfony/src/Symfony/Component/ObjectMapper" }, + { "type": "path", "url": "../symfony/src/Symfony/Bundle/FrameworkBundle" } + ], + "minimum-stability": "dev" } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 1393a3c07b8..3689899211d 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -82,9 +82,9 @@ public function createFromRequest(Request $request, bool $normalization, ?array } } - if (null === $context['output'] && ($options = $operation?->getStateOptions()) && class_exists(Options::class) && $options instanceof Options && $options->getEntityClass()) { - $context['force_resource_class'] = $operation->getClass(); - } + // if (null === $context['output'] && ($options = $operation?->getStateOptions()) && class_exists(Options::class) && $options instanceof Options && $options->getEntityClass()) { + // $context['force_resource_class'] = $operation->getClass(); + // } if ($this->debug && isset($context['groups']) && $operation instanceof ErrorOperation) { if (!\is_array($context['groups'])) { diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php new file mode 100644 index 00000000000..21679aee3ba --- /dev/null +++ b/src/State/Processor/ObjectMapperProcessor.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\State\Processor; + +use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +final class ObjectMapperProcessor implements ProcessorInterface +{ + 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 (!$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..88b64aa2929 --- /dev/null +++ b/src/State/Provider/ObjectMapperProvider.php @@ -0,0 +1,61 @@ + + * + * 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\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +final class ObjectMapperProvider implements ProviderInterface +{ + 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(!is_object($data)) { + return $data; + } + + $entityClass = $operation->getClass(); + if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) { + $entityClass = $options->getEntityClass(); + } + + if (($options = $operation->getStateOptions()) && $options instanceof OdmOptions && $options->getDocumentClass()) { + $entityClass = $options->getDocumentClass(); + } + + if (!(new \ReflectionClass($entityClass))->getAttributes(Map::class)) { + return $data; + } + + if ($data instanceof PaginatorInterface) { + return new ArrayPaginator(array_map(fn($v) => $this->objectMapper->map($v), iterator_to_array($data)), 0, \count($data)); + } + + return $this->objectMapper->map($data); + } +} + diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 5c83b24b099..75fb50d4a6e 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -43,6 +43,7 @@ use ApiPlatform\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Validator\Exception\ValidationException; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\ObjectMapper\Attribute\Map; use phpDocumentor\Reflection\DocBlockFactoryInterface; use PHPStan\PhpDocParser\Parser\PhpDocParser; use Ramsey\Uuid\Uuid; @@ -158,6 +159,9 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerArgumentResolverConfiguration($loader); $this->registerLinkSecurityConfiguration($loader, $config); + if (class_exists(Map::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..6b269b99345 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index beafc6922cd..73867f92283 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -80,6 +80,14 @@ public function __invoke(Request $request): Response $operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)); } + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + if (null === $operation->canSerialize()) { + $operation = $operation->withSerialize(true); + } + $body = $this->provider->provide($operation, $uriVariables, $context); // The provider can change the Operation, extract it again from the Request attributes @@ -101,14 +109,6 @@ public function __invoke(Request $request): Response $context['previous_data'] = $request->attributes->get('previous_data'); $context['data'] = $request->attributes->get('data'); - if (null === $operation->canWrite()) { - $operation = $operation->withWrite(!$request->isMethodSafe()); - } - - if (null === $operation->canSerialize()) { - $operation = $operation->withSerialize(true); - } - return $this->processor->process($body, $operation, $uriVariables, $context); } } diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResource.php b/tests/Fixtures/TestBundle/ApiResource/MappedResource.php new file mode 100644 index 00000000000..274c4933f15 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResource.php @@ -0,0 +1,29 @@ + 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] ?? null; + } + + public static function toLastName(string $v): string { + return explode(' ', $v)[1] ?? null; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedEntity.php b/tests/Fixtures/TestBundle/Entity/MappedEntity.php new file mode 100644 index 00000000000..a4066d8abba --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedEntity.php @@ -0,0 +1,68 @@ + + * + * 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..5a3bcfa7e2d --- /dev/null +++ b/tests/Functional/MappingTest.php @@ -0,0 +1,64 @@ +recreateSchema([MappedEntity::class]); + $this->loadFixtures(); + self::createClient()->request('GET', 'mapped_resources'); + $this->assertJsonContains(['member' => [ + ['username' => 'B0 A0'], + ['username' => 'B1 A1'], + ['username' => 'B2 A2'], + ]]); + + $r = self::createClient()->request('POST', 'mapped_resources', ['json' => ['username' => 'so yuka']]); + $this->assertJsonContains(['username' => 'so yuka']); + + $manager = $this->getManager(); + $repo = $manager->getRepository(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 = new MappedEntity; + $e->setLastName('A'.$i); + $e->setFirstName('B'.$i); + $manager->persist($e); + } + + $manager->flush(); + } +}