Skip to content

Commit 9a2ab42

Browse files
committed
feat: add a hook for doctrine entity or document to api resource transformation
1 parent d3b4b7b commit 9a2ab42

23 files changed

+612
-24
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
Feature: Use an entity or document transformer to return the correct ressource
2+
3+
@createSchema
4+
@!mongodb
5+
Scenario: Get transformed collection from entities
6+
Given there is a TransformedDummy for date '2025-01-01'
7+
When I send a "GET" request to "/transformed_dummy_entity_ressources"
8+
Then the response status code should be 200
9+
And the response should be in JSON
10+
And the JSON node "hydra:totalItems" should be equal to 1
11+
12+
@!mongodb
13+
Scenario: Get transform item from entity
14+
Given there is a TransformedDummy for date '2025-01-01'
15+
When I send a "GET" request to "/transformed_dummy_entity_ressources/1"
16+
Then the response status code should be 200
17+
And the response should be in JSON
18+
And the JSON node "year" should exist
19+
And the JSON node year should be equal to "2025"
20+
21+
@!mongodb
22+
Scenario: Post new entity from transformed resource
23+
Given I add "Content-type" header equal to "application/ld+json"
24+
When I send a "POST" request to "/transformed_dummy_entity_ressources" with body:
25+
"""
26+
{
27+
"year" : 2020
28+
}
29+
"""
30+
Then the response status code should be 201
31+
And the response should be in JSON
32+
And the JSON node "year" should be equal to "2020"
33+
34+
@!mongodb
35+
Scenario: Patch entity from transformed resource
36+
Given there is a TransformedDummy for date '2025-01-01'
37+
And I add "Content-type" header equal to "application/merge-patch+json"
38+
When I send a "PATCH" request to "/transformed_dummy_entity_ressources/1" with body:
39+
"""
40+
{
41+
"year" : 2020
42+
}
43+
"""
44+
Then the response status code should be 200
45+
And the response should be in JSON
46+
And the JSON node "year" should be equal to "2020"
47+
48+
@createSchema
49+
@mongodb
50+
Scenario: Get collection from documents
51+
Given there is a TransformedDummy for date '2025-01-01'
52+
When I send a "GET" request to "/transformed_dummy_document_ressources"
53+
Then the response status code should be 200
54+
And the response should be in JSON
55+
And the JSON node "hydra:totalItems" should be equal to 1
56+
57+
@mongodb
58+
Scenario: Get item from document
59+
Given there is a TransformedDummy for date '2025-01-01'
60+
When I send a "GET" request to "/transformed_dummy_document_ressources/1"
61+
Then the response status code should be 200
62+
And the response should be in JSON
63+
And the JSON node "year" should exist
64+
And the JSON node year should be equal to "2025"

features/hydra/error.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Feature: Error handling
1616
And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8"
1717
And the header "Link" should contain '<http://www.w3.org/ns/hydra/error>; rel="http://www.w3.org/ns/json-ld#error"'
1818
And the JSON node "type" should exist
19-
And the JSON node "title" should not exists
19+
And the JSON node "title" should not exist
2020
And the JSON node "hydra:title" should be equal to "An error occurred"
2121
And the JSON node "detail" should exist
2222
And the JSON node "description" should not exist

src/Doctrine/Common/State/Options.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@
1818
class Options implements OptionsInterface
1919
{
2020
/**
21-
* @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future
21+
* @param mixed $handleLinks experimental callable, typed mixed as we may want a service name in the future
22+
* @param mixed $toResourceTransformer experimental callable, typed mixed as we may want a service name in the future
23+
* @param mixed $fromResourceTransformer experimental callable, typed mixed as we may want a service name in the future
2224
*/
2325
public function __construct(
2426
protected mixed $handleLinks = null,
27+
protected mixed $toResourceTransformer = null,
28+
protected mixed $fromResourceTransformer = null,
2529
) {
2630
}
2731

@@ -37,4 +41,30 @@ public function withHandleLinks(mixed $handleLinks): self
3741

3842
return $self;
3943
}
44+
45+
public function getToResourceTransformer(): mixed
46+
{
47+
return $this->toResourceTransformer;
48+
}
49+
50+
public function withToResourceTransformer(mixed $toResourceTransformer): self
51+
{
52+
$self = clone $this;
53+
$self->toResourceTransformer = $toResourceTransformer;
54+
55+
return $self;
56+
}
57+
58+
public function getFromResourceTransformer(): mixed
59+
{
60+
return $this->fromResourceTransformer;
61+
}
62+
63+
public function withFromResourceTransformer(mixed $fromResourceTransformer): self
64+
{
65+
$self = clone $this;
66+
$self->fromResourceTransformer = $fromResourceTransformer;
67+
68+
return $self;
69+
}
4070
}

src/Doctrine/Common/State/PersistProcessor.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@
1919
use ApiPlatform\State\ProcessorInterface;
2020
use Doctrine\Persistence\ManagerRegistry;
2121
use Doctrine\Persistence\ObjectManager as DoctrineObjectManager;
22+
use Psr\Container\ContainerInterface;
2223

2324
final class PersistProcessor implements ProcessorInterface
2425
{
2526
use ClassInfoTrait;
2627
use LinksHandlerTrait;
28+
use ResourceTransformerLocatorTrait;
2729

28-
public function __construct(private readonly ManagerRegistry $managerRegistry)
30+
public function __construct(private readonly ManagerRegistry $managerRegistry,
31+
?ContainerInterface $resourceTransformerLocator = null)
2932
{
33+
$this->resourceTransformerLocator = $resourceTransformerLocator;
3034
}
3135

3236
/**
@@ -38,6 +42,12 @@ public function __construct(private readonly ManagerRegistry $managerRegistry)
3842
*/
3943
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
4044
{
45+
// if a transformer is defined, start with that
46+
$data = match ($transformer = $this->getFromResourceTransformer($operation)) {
47+
null => $data,
48+
default => $transformer($data, $this->getManager($operation, $this->managerRegistry)),
49+
};
50+
4151
if (
4252
!\is_object($data)
4353
|| !$manager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($data))
@@ -104,7 +114,10 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
104114
$manager->flush();
105115
$manager->refresh($data);
106116

107-
return $data;
117+
return match ($transformer = $this->getToResourceTransformer($operation)) {
118+
null => $data,
119+
default => $transformer($data, $operation, $uriVariables, $context),
120+
};
108121
}
109122

110123
/**
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Doctrine\Common\State;
15+
16+
use Doctrine\Persistence\ObjectManager;
17+
18+
interface ResourceTransformerInterface
19+
{
20+
/**
21+
* @param object $entityOrDocument the doctrine entity or document to make a resource from
22+
*
23+
* @return object the resulting ApiResource
24+
*/
25+
public function toResource(object $entityOrDocument): object;
26+
27+
/**
28+
* @param object $resource the resource you want to persist
29+
* @param ObjectManager $objectManager the object manager to handle this kind of resources
30+
*
31+
* @return object the existing or new entity or document
32+
*/
33+
public function fromResource(object $resource, ObjectManager $objectManager): object;
34+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Doctrine\Common\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use Doctrine\Persistence\ManagerRegistry;
18+
use Doctrine\Persistence\ObjectManager;
19+
use Psr\Container\ContainerInterface;
20+
21+
/**
22+
* Maybe merge this and LinksHandlerLocatorTrait into a OptionsHooksLocatorTrait or something similar?
23+
*/
24+
trait ResourceTransformerLocatorTrait
25+
{
26+
private ?ContainerInterface $resourceTransformerLocator;
27+
28+
protected function getToResourceTransformer(Operation $operation): ?callable
29+
{
30+
if (!($options = $operation->getStateOptions()) || !$options instanceof Options) {
31+
return null;
32+
}
33+
34+
$transformer = $options->getToResourceTransformer();
35+
if (\is_callable($transformer)) {
36+
return $transformer;
37+
}
38+
39+
if ($this->resourceTransformerLocator && \is_string($transformer) && $this->resourceTransformerLocator->has($transformer)) {
40+
return [$this->resourceTransformerLocator->get($transformer), 'toResource'];
41+
}
42+
43+
return null;
44+
}
45+
46+
protected function getFromResourceTransformer(Operation $operation): ?callable
47+
{
48+
if (!($options = $operation->getStateOptions()) || !$options instanceof Options) {
49+
return null;
50+
}
51+
52+
$transformer = $options->getFromResourceTransformer();
53+
if (\is_callable($transformer)) {
54+
return $transformer;
55+
}
56+
57+
if ($this->resourceTransformerLocator && \is_string($transformer) && $this->resourceTransformerLocator->has($transformer)) {
58+
return [$this->resourceTransformerLocator->get($transformer), 'fromResource'];
59+
}
60+
61+
return null;
62+
}
63+
64+
protected function getManager(Operation $operation, ManagerRegistry $managerRegistry): ObjectManager
65+
{
66+
$options = $operation->getStateOptions();
67+
$entityClass = match (true) {
68+
$options instanceof \ApiPlatform\Doctrine\Orm\State\Options => $options->getEntityClass(),
69+
$options instanceof \ApiPlatform\Doctrine\Odm\State\Options => $options->getDocumentClass(),
70+
default => null,
71+
};
72+
73+
if ($entityClass) {
74+
return $managerRegistry->getManagerForClass($entityClass);
75+
}
76+
77+
throw new \RuntimeException('This should not be called on an operation without StateOptions');
78+
}
79+
}

src/Doctrine/Odm/State/CollectionProvider.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Doctrine\Odm\State;
1515

1616
use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait;
17+
use ApiPlatform\Doctrine\Common\State\ResourceTransformerLocatorTrait;
1718
use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface;
1819
use ApiPlatform\Doctrine\Odm\Extension\AggregationResultCollectionExtensionInterface;
1920
use ApiPlatform\Metadata\Exception\RuntimeException;
@@ -33,15 +34,20 @@ final class CollectionProvider implements ProviderInterface
3334
{
3435
use LinksHandlerLocatorTrait;
3536
use LinksHandlerTrait;
37+
use ResourceTransformerLocatorTrait;
3638
use StateOptionsTrait;
3739

3840
/**
3941
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
4042
*/
41-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [], ?ContainerInterface $handleLinksLocator = null)
42-
{
43+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry,
44+
private readonly iterable $collectionExtensions = [],
45+
?ContainerInterface $handleLinksLocator = null,
46+
?ContainerInterface $resourceTransformerLocator = null,
47+
) {
4348
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
4449
$this->handleLinksLocator = $handleLinksLocator;
50+
$this->resourceTransformerLocator = $resourceTransformerLocator;
4551
$this->managerRegistry = $managerRegistry;
4652
}
4753

@@ -69,13 +75,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
6975
$extension->applyToCollection($aggregationBuilder, $documentClass, $operation, $context);
7076

7177
if ($extension instanceof AggregationResultCollectionExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) {
72-
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
78+
$result = $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
79+
break;
7380
}
7481
}
7582

7683
$attribute = $operation->getExtraProperties()['doctrine_mongodb'] ?? [];
7784
$executeOptions = $attribute['execute_options'] ?? [];
7885

79-
return $aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator();
86+
$result = $result ?? $aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator();
87+
88+
return match ($transformer = $this->getToResourceTransformer($operation)) {
89+
null => $result,
90+
default => array_map($transformer, iterator_to_array($result)),
91+
};
8092
}
8193
}

src/Doctrine/Odm/State/ItemProvider.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Doctrine\Odm\State;
1515

1616
use ApiPlatform\Doctrine\Common\State\LinksHandlerLocatorTrait;
17+
use ApiPlatform\Doctrine\Common\State\ResourceTransformerLocatorTrait;
1718
use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface;
1819
use ApiPlatform\Doctrine\Odm\Extension\AggregationResultItemExtensionInterface;
1920
use ApiPlatform\Metadata\Exception\RuntimeException;
@@ -36,15 +37,20 @@ final class ItemProvider implements ProviderInterface
3637
{
3738
use LinksHandlerLocatorTrait;
3839
use LinksHandlerTrait;
40+
use ResourceTransformerLocatorTrait;
3941
use StateOptionsTrait;
4042

4143
/**
4244
* @param AggregationItemExtensionInterface[] $itemExtensions
4345
*/
44-
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [], ?ContainerInterface $handleLinksLocator = null)
46+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ManagerRegistry $managerRegistry,
47+
private readonly iterable $itemExtensions = [],
48+
?ContainerInterface $handleLinksLocator = null,
49+
?ContainerInterface $resourceTransformerLocator = null)
4550
{
4651
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
4752
$this->handleLinksLocator = $handleLinksLocator;
53+
$this->resourceTransformerLocator = $resourceTransformerLocator;
4854
$this->managerRegistry = $managerRegistry;
4955
}
5056

@@ -77,12 +83,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
7783
$extension->applyToItem($aggregationBuilder, $documentClass, $uriVariables, $operation, $context);
7884

7985
if ($extension instanceof AggregationResultItemExtensionInterface && $extension->supportsResult($documentClass, $operation, $context)) {
80-
return $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
86+
$result = $extension->getResult($aggregationBuilder, $documentClass, $operation, $context);
87+
break;
8188
}
8289
}
8390

8491
$executeOptions = $operation->getExtraProperties()['doctrine_mongodb']['execute_options'] ?? [];
8592

86-
return $aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator()->current() ?: null;
93+
$result = $result ?? ($aggregationBuilder->hydrate($documentClass)->getAggregation($executeOptions)->getIterator()->current() ?: null);
94+
95+
return match ($transformer = $this->getToResourceTransformer($operation)) {
96+
null => $result,
97+
default => (null !== $result) ? $transformer($result) : null,
98+
};
8799
}
88100
}

0 commit comments

Comments
 (0)