Skip to content

Commit 330d2ba

Browse files
committed
feat(symfony): object mapper with state options
1 parent 4ecde01 commit 330d2ba

File tree

10 files changed

+383
-12
lines changed

10 files changed

+383
-12
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
"symfony/cache": "^6.4 || ^7.0",
166166
"symfony/config": "^6.4 || ^7.0",
167167
"symfony/console": "^6.4 || ^7.0",
168+
"symfony/object-mapper": "^7.3",
168169
"symfony/css-selector": "^6.4 || ^7.0",
169170
"symfony/dependency-injection": "^6.4 || ^7.0",
170171
"symfony/doctrine-bridge": "^6.4.2 || ^7.1",

features/doctrine/issue6039/entity_class_option.feature

Lines changed: 0 additions & 12 deletions
This file was deleted.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\State\Processor;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\State\ProcessorInterface;
18+
use Symfony\Component\ObjectMapper\Attribute\Map;
19+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
20+
21+
/**
22+
* @implements ProcessorInterface<mixed,mixed>
23+
*/
24+
final class ObjectMapperProcessor implements ProcessorInterface
25+
{
26+
/**
27+
* @param ProcessorInterface<mixed,mixed> $decorated
28+
*/
29+
public function __construct(
30+
private readonly ObjectMapperInterface $objectMapper,
31+
private readonly ProcessorInterface $decorated,
32+
) {
33+
}
34+
35+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null
36+
{
37+
if (!$operation->canWrite()) {
38+
return $this->decorated->process($data, $operation, $uriVariables, $context);
39+
}
40+
41+
if (!(new \ReflectionClass($operation->getClass()))->getAttributes(Map::class)) {
42+
return $this->decorated->process($data, $operation, $uriVariables, $context);
43+
}
44+
45+
return $this->objectMapper->map($this->decorated->process($this->objectMapper->map($data), $operation, $uriVariables, $context));
46+
}
47+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\State\Provider;
15+
16+
use ApiPlatform\Doctrine\Odm\State\Options as OdmOptions;
17+
use ApiPlatform\Doctrine\Orm\State\Options;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\State\Pagination\ArrayPaginator;
20+
use ApiPlatform\State\Pagination\PaginatorInterface;
21+
use ApiPlatform\State\ProviderInterface;
22+
use Symfony\Component\ObjectMapper\Attribute\Map;
23+
use Symfony\Component\ObjectMapper\ObjectMapperInterface;
24+
25+
/**
26+
* @implements ProviderInterface<object>
27+
*/
28+
final class ObjectMapperProvider implements ProviderInterface
29+
{
30+
/**
31+
* @param ProviderInterface<mixed> $decorated
32+
*/
33+
public function __construct(
34+
private readonly ObjectMapperInterface $objectMapper,
35+
private readonly ProviderInterface $decorated,
36+
) {
37+
}
38+
39+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
40+
{
41+
$data = $this->decorated->provide($operation, $uriVariables, $context);
42+
43+
if (!\is_object($data)) {
44+
return $data;
45+
}
46+
47+
$entityClass = null;
48+
if (($options = $operation->getStateOptions()) && $options instanceof Options && $options->getEntityClass()) {
49+
$entityClass = $options->getEntityClass();
50+
}
51+
52+
if (($options = $operation->getStateOptions()) && $options instanceof OdmOptions && $options->getDocumentClass()) {
53+
$entityClass = $options->getDocumentClass();
54+
}
55+
56+
if (!$entityClass || !(new \ReflectionClass($entityClass))->getAttributes(Map::class)) {
57+
return $data;
58+
}
59+
60+
if ($data instanceof PaginatorInterface) {
61+
return new ArrayPaginator(array_map(fn ($v) => $this->objectMapper->map($v), iterator_to_array($data)), 0, \count($data));
62+
}
63+
64+
return $this->objectMapper->map($data);
65+
}
66+
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
use Symfony\Component\DependencyInjection\Reference;
6060
use Symfony\Component\Finder\Finder;
6161
use Symfony\Component\HttpClient\ScopingHttpClient;
62+
use Symfony\Component\ObjectMapper\Attribute\Map;
6263
use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter;
6364
use Symfony\Component\Uid\AbstractUid;
6465
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -169,6 +170,9 @@ public function load(array $configs, ContainerBuilder $container): void
169170
$this->registerArgumentResolverConfiguration($loader);
170171
$this->registerLinkSecurityConfiguration($loader, $config);
171172

173+
if (class_exists(Map::class)) {
174+
$loader->load('state/object_mapper.xml');
175+
}
172176
$container->registerForAutoconfiguration(FilterInterface::class)
173177
->addTag('api_platform.filter');
174178
$container->registerForAutoconfiguration(ProviderInterface::class)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?xml version="1.0" ?>
2+
3+
<container xmlns="http://symfony.com/schema/dic/services"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6+
<services>
7+
<service id="api_platform.state_provider.object_mapper" class="ApiPlatform\State\Provider\ObjectMapperProvider" decorates="api_platform.state_provider.read">
8+
<argument type="service" id="object_mapper" />
9+
<argument type="service" id="api_platform.state_provider.object_mapper.inner" />
10+
</service>
11+
12+
<service id="api_platform.state_processor.object_mapper" class="ApiPlatform\State\Processor\ObjectMapperProcessor" decorates="api_platform.state_processor.locator">
13+
<argument type="service" id="object_mapper" />
14+
<argument type="service" id="api_platform.state_processor.object_mapper.inner" />
15+
</service>
16+
</services>
17+
</container>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\JsonLd\ContextBuilder;
18+
use ApiPlatform\Metadata\ApiResource;
19+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity;
20+
use Symfony\Component\ObjectMapper\Attribute\Map;
21+
22+
#[ApiResource(
23+
stateOptions: new Options(entityClass: MappedEntity::class),
24+
normalizationContext: [ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false],
25+
)]
26+
#[Map(target: MappedEntity::class)]
27+
final class MappedResource
28+
{
29+
#[Map(if: false)]
30+
public ?string $id = null;
31+
32+
#[Map(target: 'firstName', transform: [self::class, 'toFirstName'])]
33+
#[Map(target: 'lastName', transform: [self::class, 'toLastName'])]
34+
public string $username;
35+
36+
public static function toFirstName(string $v): string
37+
{
38+
return explode(' ', $v)[0] ?? null;
39+
}
40+
41+
public static function toLastName(string $v): string
42+
{
43+
return explode(' ', $v)[1] ?? null;
44+
}
45+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Symfony\Component\ObjectMapper\Attribute\Map;
19+
20+
/**
21+
* MappedEntity to MappedResource.
22+
*/
23+
#[ORM\Entity]
24+
#[Map(target: MappedResource::class)]
25+
class MappedEntity
26+
{
27+
#[ORM\Column(type: 'integer')]
28+
#[ORM\Id]
29+
#[ORM\GeneratedValue(strategy: 'AUTO')]
30+
private ?int $id = null;
31+
32+
#[ORM\Column]
33+
#[Map(if: false)]
34+
private string $firstName;
35+
36+
#[Map(target: 'username', transform: [self::class, 'toUsername'])]
37+
#[ORM\Column]
38+
private string $lastName;
39+
40+
public static function toUsername($value, $object): string
41+
{
42+
return $object->getFirstName().' '.$object->getLastName();
43+
}
44+
45+
public function getId(): ?int
46+
{
47+
return $this->id;
48+
}
49+
50+
public function setLastName(string $name): void
51+
{
52+
$this->lastName = $name;
53+
}
54+
55+
public function getLastName(): string
56+
{
57+
return $this->lastName;
58+
}
59+
60+
public function setFirstName(string $name): void
61+
{
62+
$this->firstName = $name;
63+
}
64+
65+
public function getFirstName(): string
66+
{
67+
return $this->firstName;
68+
}
69+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\Doctrine;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6039\UserApi;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6039\Issue6039EntityUser;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
final class StateOptionTest extends ApiTestCase
23+
{
24+
use RecreateSchemaTrait;
25+
use SetupClassResourcesTrait;
26+
27+
protected static ?bool $alwaysBootKernel = false;
28+
29+
/**
30+
* @return class-string[]
31+
*/
32+
public static function getResources(): array
33+
{
34+
return [UserApi::class];
35+
}
36+
37+
public function testDtoWithEntityClassOptionCollection(): void
38+
{
39+
if ($this->isMongoDB()) {
40+
$this->markTestSkipped('This test is not for MongoDB.');
41+
}
42+
43+
$this->recreateSchema([Issue6039EntityUser::class]);
44+
$manager = static::getContainer()->get('doctrine')->getManager();
45+
46+
$user = new Issue6039EntityUser();
47+
$user->name = 'name';
48+
$user->bar = 'bar';
49+
$manager->persist($user);
50+
$manager->flush();
51+
52+
$response = static::createClient()->request('GET', '/issue6039_user_apis', ['headers' => ['Accept' => 'application/ld+json']]);
53+
54+
$this->assertResponseStatusCodeSame(200);
55+
$this->assertArrayNotHasKey('bar', $response->toArray()['hydra:member'][0]);
56+
}
57+
}

0 commit comments

Comments
 (0)