Skip to content

Commit 3743ba6

Browse files
committed
fix(state): support output DTOs on write operations with ObjectMapper
1 parent 113aeb4 commit 3743ba6

10 files changed

Lines changed: 297 additions & 7 deletions

File tree

‎src/Serializer/SerializerContextBuilder.php‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Metadata\CollectionOperationInterface;
1717
use ApiPlatform\Metadata\Error as ErrorOperation;
1818
use ApiPlatform\Metadata\Exception\RuntimeException;
19+
use ApiPlatform\Metadata\HttpOperation;
1920
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2021
use ApiPlatform\Metadata\Util\AttributesExtractor;
2122
use ApiPlatform\State\SerializerContextBuilderInterface;
@@ -70,6 +71,19 @@ public function createFromRequest(Request $request, bool $normalization, ?array
7071
// Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response
7172
if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) {
7273
$context['item_uri_template'] = $operation->getItemUriTemplate();
74+
} elseif (
75+
$normalization
76+
&& $operation instanceof HttpOperation
77+
&& !$operation instanceof CollectionOperationInterface
78+
&& !method_exists($operation, 'getItemUriTemplate')
79+
&& $operation->canMap()
80+
&& null !== ($context['output']['class'] ?? null)
81+
&& !\in_array($operation->getMethod(), ['GET', 'HEAD', 'OPTIONS'], true)
82+
&& $operation->getUriTemplate()
83+
) {
84+
// A mapped output DTO on an item write operation (PUT/PATCH): the operation's own
85+
// URI template is the item template, use it to generate the IRI of the DTO.
86+
$context['item_uri_template'] = $operation->getUriTemplate();
7387
}
7488

7589
if ($types = $operation->getTypes()) {

‎src/State/Processor/ObjectMapperOutputProcessor.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
4848

4949
$request = $context['request'] ?? null;
5050
$request?->attributes->set('persisted_data', $data);
51-
$dto = $this->objectMapper->map($data, $operation->getClass());
51+
$dto = $this->objectMapper->map($data, $operation->getOutput()['class'] ?? $operation->getClass());
5252

5353
return $this->decorated ? $this->decorated->process($dto, $operation, $uriVariables, $context) : $dto;
5454
}

‎src/State/Provider/ObjectMapperProvider.php‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\State\Provider;
1515

16+
use ApiPlatform\Metadata\HttpOperation;
1617
use ApiPlatform\Metadata\Operation;
1718
use ApiPlatform\Metadata\Util\CloneTrait;
1819
use ApiPlatform\State\Pagination\MappedObjectPaginator;
@@ -41,7 +42,12 @@ public function __construct(
4142
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
4243
{
4344
$data = $this->decorated->provide($operation, $uriVariables, $context);
44-
$class = $operation->getOutput()['class'] ?? $operation->getClass();
45+
46+
// On write operations the provided data is the deserialization target (object to populate),
47+
// it must stay an instance of the resource class; the output class is mapped to after
48+
// persistence by the ObjectMapperOutputProcessor.
49+
$isWrite = $operation instanceof HttpOperation && !\in_array($operation->getMethod(), ['GET', 'HEAD', 'OPTIONS'], true);
50+
$class = $isWrite ? $operation->getClass() : ($operation->getOutput()['class'] ?? $operation->getClass());
4551

4652
if (!$this->objectMapper || !$operation->canMap()) {
4753
return $data;

‎src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,31 @@ public function testProcessMapsEntityToDto(): void
107107
$this->assertSame($result, $processor->process($entity, $operation));
108108
}
109109

110+
public function testProcessMapsEntityToDefinedOutputClass(): void
111+
{
112+
$entity = new \stdClass();
113+
$entity->id = 1;
114+
$dto = new \stdClass();
115+
$dto->id = 1;
116+
$result = new \stdClass();
117+
$operation = new Post(class: ObjectMapperOutputDummy::class, output: ['class' => ObjectMapperOutputDtoDummy::class], map: true);
118+
119+
$objectMapper = $this->createMock(ObjectMapperInterface::class);
120+
$objectMapper->expects($this->once())
121+
->method('map')
122+
->with($entity, ObjectMapperOutputDtoDummy::class)
123+
->willReturn($dto);
124+
125+
$decorated = $this->createMock(ProcessorInterface::class);
126+
$decorated->expects($this->once())
127+
->method('process')
128+
->with($dto, $operation, [], $this->anything())
129+
->willReturn($result);
130+
131+
$processor = new ObjectMapperOutputProcessor($objectMapper, $decorated);
132+
$this->assertSame($result, $processor->process($entity, $operation));
133+
}
134+
110135
public function testProcessSetsPersistedDataOnRequest(): void
111136
{
112137
$entity = new \stdClass();
@@ -136,3 +161,7 @@ public function testProcessSetsPersistedDataOnRequest(): void
136161
class ObjectMapperOutputDummy
137162
{
138163
}
164+
165+
class ObjectMapperOutputDtoDummy
166+
{
167+
}

‎src/State/Util/HttpResponseHeadersTrait.php‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\State\Util;
1515

16+
use ApiPlatform\Metadata\CollectionOperationInterface;
1617
use ApiPlatform\Metadata\Error;
1718
use ApiPlatform\Metadata\Exception\HttpExceptionInterface;
1819
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
@@ -118,6 +119,9 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
118119
$iri = null;
119120
if ($hasData) {
120121
$iri = $this->iriConverter->getIriFromResource($originalData);
122+
} elseif (\is_object($originalData) && ($itemUriTemplate = $this->getMappedOutputItemUriTemplate($operation))) {
123+
// A mapped (non-resource) output DTO: derive the item IRI from the operation's item URI template
124+
$iri = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, null, ['item_uri_template' => $itemUriTemplate]);
121125
} elseif ($operation->getClass()) {
122126
$iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation);
123127
}
@@ -148,6 +152,19 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
148152
return $headers;
149153
}
150154

155+
private function getMappedOutputItemUriTemplate(HttpOperation $operation): ?string
156+
{
157+
if (!$operation->canMap() || null === ($operation->getOutput()['class'] ?? null)) {
158+
return null;
159+
}
160+
161+
if (method_exists($operation, 'getItemUriTemplate')) {
162+
return $operation->getItemUriTemplate();
163+
}
164+
165+
return $operation instanceof CollectionOperationInterface ? null : $operation->getUriTemplate();
166+
}
167+
151168
private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void
152169
{
153170
if (!$this->resourceMetadataCollectionFactory) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Doctrine\Orm\State\Options;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use ApiPlatform\Metadata\Get;
19+
use ApiPlatform\Metadata\Patch;
20+
use ApiPlatform\Metadata\Post;
21+
use ApiPlatform\Tests\Fixtures\TestBundle\Dto\MappedOutputDto;
22+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedOutputEntity;
23+
use Symfony\Component\ObjectMapper\Attribute\Map;
24+
25+
#[ApiResource(
26+
stateOptions: new Options(entityClass: MappedOutputEntity::class),
27+
operations: [
28+
new Get(),
29+
new Post(output: MappedOutputDto::class, itemUriTemplate: '/mapped_resource_with_outputs/{id}{._format}'),
30+
new Patch(output: MappedOutputDto::class),
31+
],
32+
normalizationContext: ['hydra_prefix' => false],
33+
)]
34+
#[Map(target: MappedOutputEntity::class)]
35+
class MappedResourceWithOutput
36+
{
37+
#[Map(if: false)]
38+
public ?int $id = null;
39+
40+
public ?string $name = null;
41+
42+
public ?string $description = null;
43+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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\Tests\Fixtures\TestBundle\Dto;
15+
16+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedOutputEntity;
17+
use Symfony\Component\ObjectMapper\Attribute\Map;
18+
19+
/**
20+
* Output DTO mapped from the entity: exposes only "name", not "description".
21+
*/
22+
#[Map(source: MappedOutputEntity::class)]
23+
class MappedOutputDto
24+
{
25+
public ?int $id = null;
26+
27+
public ?string $name = null;
28+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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\Tests\Fixtures\TestBundle\Entity;
15+
16+
use Doctrine\ORM\Mapping as ORM;
17+
18+
#[ORM\Entity]
19+
class MappedOutputEntity
20+
{
21+
#[ORM\Column(type: 'integer')]
22+
#[ORM\Id]
23+
#[ORM\GeneratedValue(strategy: 'AUTO')]
24+
public ?int $id = null;
25+
26+
#[ORM\Column]
27+
public string $name;
28+
29+
#[ORM\Column]
30+
public string $description;
31+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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\Tests\Functional;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceWithOutput;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedOutputEntity;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
/**
23+
* Verifies the behavior of write operations declaring an `output` DTO with the ObjectMapper.
24+
*/
25+
final class MappedResourceOutputTest extends ApiTestCase
26+
{
27+
use RecreateSchemaTrait;
28+
use SetupClassResourcesTrait;
29+
protected static ?bool $alwaysBootKernel = false;
30+
31+
/**
32+
* @return class-string[]
33+
*/
34+
public static function getResources(): array
35+
{
36+
return [MappedResourceWithOutput::class];
37+
}
38+
39+
public function testPostWithOutputDtoReturnsOutputDto(): void
40+
{
41+
if (!$this->getContainer()->has('api_platform.object_mapper')) {
42+
$this->markTestSkipped('ObjectMapper not installed');
43+
}
44+
45+
if ($this->isMongoDB()) {
46+
$this->markTestSkipped('MongoDB not tested');
47+
}
48+
49+
$this->recreateSchema([MappedOutputEntity::class]);
50+
51+
$client = self::createClient();
52+
$response = $client->request('POST', '/mapped_resource_with_outputs', [
53+
'json' => ['name' => 'a name', 'description' => 'a description'],
54+
]);
55+
56+
fwrite(\STDERR, "\n=== POST status: ".$response->getStatusCode()."\n");
57+
fwrite(\STDERR, "=== POST response body ===\n".substr($response->getContent(false), 0, 2000)."\n");
58+
fwrite(\STDERR, '=== Location header: '.var_export($response->getHeaders(false)['location'][0] ?? null, true)."\n");
59+
fwrite(\STDERR, '=== Content-Location header: '.var_export($response->getHeaders(false)['content-location'][0] ?? null, true)."\n");
60+
61+
self::assertResponseStatusCodeSame(201);
62+
$data = $response->toArray(false);
63+
64+
// The output DTO exposes "name" but not "description"
65+
$this->assertArrayHasKey('name', $data);
66+
$this->assertArrayNotHasKey('description', $data, 'Response should be the output DTO, not the resource');
67+
68+
// Item IRI expected on a 201, not the collection IRI
69+
$location = $response->getHeaders(false)['location'][0] ?? null;
70+
$this->assertNotNull($location);
71+
$this->assertMatchesRegularExpression('~^/mapped_resource_with_outputs/\d+$~', $location, 'Location must be the item IRI');
72+
$this->assertSame($location, $data['@id'] ?? null, '@id must be the item IRI');
73+
}
74+
75+
public function testPatchWithOutputDtoPreservesUnsentFields(): void
76+
{
77+
if (!$this->getContainer()->has('api_platform.object_mapper')) {
78+
$this->markTestSkipped('ObjectMapper not installed');
79+
}
80+
81+
if ($this->isMongoDB()) {
82+
$this->markTestSkipped('MongoDB not tested');
83+
}
84+
85+
$this->recreateSchema([MappedOutputEntity::class]);
86+
87+
$manager = $this->getManager();
88+
$entity = new MappedOutputEntity();
89+
$entity->name = 'original name';
90+
$entity->description = 'original description';
91+
$manager->persist($entity);
92+
$manager->flush();
93+
$id = $entity->id;
94+
$manager->clear();
95+
96+
$client = self::createClient();
97+
$response = $client->request('PATCH', '/mapped_resource_with_outputs/'.$id, [
98+
'headers' => ['content-type' => 'application/merge-patch+json'],
99+
'json' => ['name' => 'updated name'],
100+
]);
101+
102+
fwrite(\STDERR, "\n=== PATCH status: ".$response->getStatusCode()."\n");
103+
fwrite(\STDERR, "=== PATCH response body ===\n".substr($response->getContent(false), 0, 2000)."\n");
104+
105+
self::assertResponseIsSuccessful();
106+
$data = $response->toArray(false);
107+
108+
// The response must be the output DTO with a proper item IRI
109+
$this->assertArrayHasKey('name', $data);
110+
$this->assertArrayNotHasKey('description', $data, 'Response should be the output DTO, not the resource');
111+
$this->assertSame('/mapped_resource_with_outputs/'.$id, $data['@id']);
112+
113+
// PATCH semantics: unsent fields must be preserved on the entity
114+
$manager = $this->getManager();
115+
$manager->clear();
116+
$persisted = $manager->getRepository(MappedOutputEntity::class)->find($id);
117+
$this->assertSame('updated name', $persisted->name);
118+
$this->assertSame('original description', $persisted->description, 'PATCH must not touch fields the client did not send');
119+
}
120+
}

‎tests/State/Provider/ObjectMapperProviderTest.php‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,22 +169,24 @@ public function testProvideMapsPaginator(): void
169169
$this->assertSame($targetResource2, $items[1]);
170170
}
171171

172-
public function testProvideIgnoresInputClassAndMapsToOutputClass(): void
172+
public function testProvideIgnoresInputAndOutputClassesOnWrite(): void
173173
{
174174
$sourceEntity = new SourceEntity();
175-
$outputResource = new OutputResource();
175+
$targetResource = new TargetResource();
176176
$operation = new Patch(class: TargetResource::class, input: ['class' => InputResource::class], output: ['class' => OutputResource::class], map: true);
177177
$objectMapper = $this->createMock(ObjectMapperInterface::class);
178178
$objectMapper->expects($this->once())
179179
->method('map')
180-
->with($sourceEntity, OutputResource::class)
181-
->willReturn($outputResource);
180+
->with($sourceEntity, TargetResource::class)
181+
->willReturn($targetResource);
182182
$decorated = $this->createStub(ProviderInterface::class);
183183
$decorated->method('provide')->willReturn($sourceEntity);
184184
$provider = new ObjectMapperProvider($objectMapper, $decorated);
185185

186+
// On a write operation the provided data is the deserialization target: it must be
187+
// the resource class, the output class is only mapped to after persistence.
186188
$result = $provider->provide($operation);
187-
$this->assertSame($outputResource, $result);
189+
$this->assertSame($targetResource, $result);
188190
}
189191

190192
public function testProvideMapsToOutputClassWhenNoInput(): void

0 commit comments

Comments
 (0)