Skip to content

Commit

Permalink
feat(symfony): object mapper with state options
Browse files Browse the repository at this point in the history
  • Loading branch information
soyuka committed Nov 15, 2024
1 parent fb47197 commit 6e954d5
Show file tree
Hide file tree
Showing 10 changed files with 306 additions and 12 deletions.
8 changes: 7 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
6 changes: 3 additions & 3 deletions src/Serializer/SerializerContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand Down
45 changes: 45 additions & 0 deletions src/State/Processor/ObjectMapperProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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));
}
}

61 changes: 61 additions & 0 deletions src/State/Provider/ObjectMapperProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions src/Symfony/Bundle/Resources/config/state/object_mapper.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="api_platform.state_provider.object_mapper" class="ApiPlatform\State\Provider\ObjectMapperProvider" decorates="api_platform.state_provider.read">
<argument type="service" id="object_mapper" />
<argument type="service" id="api_platform.state_provider.object_mapper.inner" />
</service>

<service id="api_platform.state_processor.object_mapper" class="ApiPlatform\State\Processor\ObjectMapperProcessor" decorates="api_platform.state_processor.locator">
<argument type="service" id="object_mapper" />
<argument type="service" id="api_platform.state_processor.object_mapper.inner" />
</service>
</services>
</container>
16 changes: 8 additions & 8 deletions src/Symfony/Controller/MainController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
}
29 changes: 29 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/MappedResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

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] ?? null;
}

public static function toLastName(string $v): string {
return explode(' ', $v)[1] ?? null;
}
}
68 changes: 68 additions & 0 deletions tests/Fixtures/TestBundle/Entity/MappedEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* 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;
}
}
64 changes: 64 additions & 0 deletions tests/Functional/MappingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace ApiPlatform\Tests\Functional;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource;
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity;
use ApiPlatform\Tests\RecreateSchemaTrait;
use ApiPlatform\Tests\SetupClassResourcesTrait;

final class MappingTest extends ApiTestCase
{
use SetupClassResourcesTrait;
use RecreateSchemaTrait;

/**
* @return class-string[]
*/
public static function getResources(): array
{
return [MappedResource::class];
}

public function testShouldMapBetweenResourceAndEntity(): void
{
$this->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();
}
}

0 comments on commit 6e954d5

Please sign in to comment.