Skip to content

Commit 5b38dea

Browse files
committed
fix(metadata): enhance resource class determination before Object Mapper Processor return
1 parent 2a34498 commit 5b38dea

File tree

3 files changed

+210
-18
lines changed

3 files changed

+210
-18
lines changed

src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,14 @@ public function create(string $resourceClass): ResourceMetadataCollection
5252
$entityClass = $options->getDocumentClass();
5353
}
5454

55-
$class = $operation->getInput()['class'] ?? $operation->getClass();
55+
$inputClass = $operation->getInput()['class'] ?? $operation->getClass();
56+
$outputClass = $operation->getOutput()['class'] ?? null;
5657
$entityMap = null;
5758

5859
// Look for Mapping metadata
59-
if ($this->canBeMapped($class) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) {
60+
if ($this->canBeMapped($inputClass)
61+
|| ($outputClass && $this->canBeMapped($outputClass))
62+
|| ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) {
6063
$found = true;
6164
if ($entityMap) {
6265
foreach ($entityMap as $mapping) {

src/State/Processor/ObjectMapperProcessor.php

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,35 +34,64 @@ public function __construct(
3434

3535
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null
3636
{
37-
$class = $operation->getInput()['class'] ?? $operation->getClass();
38-
3937
if (
4038
$data instanceof Response
4139
|| !$this->objectMapper
4240
|| !$operation->canWrite()
4341
|| null === $data
44-
|| !is_a($data, $class, true)
4542
|| !$operation->canMap()
4643
) {
4744
return $this->decorated->process($data, $operation, $uriVariables, $context);
4845
}
4946

5047
$request = $context['request'] ?? null;
51-
$persisted = $this->decorated->process(
52-
// maps the Resource to an Entity
53-
$this->objectMapper->map($data, $request?->attributes->get('mapped_data')),
54-
$operation,
55-
$uriVariables,
56-
$context,
57-
);
48+
$resourceClass = $operation->getClass();
49+
$inputClass = $operation->getInput()['class'] ?? null;
50+
$outputClass = $operation->getOutput()['class'] ?? null;
51+
52+
// Get entity class from state options if available
53+
$stateOptions = $operation->getStateOptions();
54+
$entityClass = null;
55+
if ($stateOptions) {
56+
if (method_exists($stateOptions, 'getEntityClass')) {
57+
$entityClass = $stateOptions->getEntityClass();
58+
} elseif (method_exists($stateOptions, 'getDocumentClass')) {
59+
$entityClass = $stateOptions->getDocumentClass();
60+
}
61+
}
62+
63+
$hasCustomInput = null !== $inputClass && $inputClass !== $resourceClass;
64+
$hasCustomOutput = null !== $outputClass && $outputClass !== $resourceClass;
65+
$hasEntityMapping = null !== $entityClass && $entityClass !== $resourceClass;
66+
67+
// Skip mapping if no custom input/output and no entity mapping needed
68+
if (!$hasCustomInput && !$hasCustomOutput && !$hasEntityMapping) {
69+
return $this->decorated->process($data, $operation, $uriVariables, $context);
70+
}
5871

72+
// Map input to entity if we have custom input or entity mapping
73+
if ($hasCustomInput || $hasEntityMapping) {
74+
$expectedInputClass = $hasCustomInput ? $inputClass : $resourceClass;
75+
if (!is_a($data, $expectedInputClass, true)) {
76+
return $this->decorated->process($data, $operation, $uriVariables, $context);
77+
}
78+
79+
$data = $this->objectMapper->map($data, $request?->attributes->get('mapped_data'));
80+
}
81+
82+
$persisted = $this->decorated->process($data, $operation, $uriVariables, $context);
5983
$request?->attributes->set('persisted_data', $persisted);
6084

61-
// return the Resource representation of the persisted entity
62-
return $this->objectMapper->map(
63-
// persist the entity
64-
$persisted,
65-
$operation->getClass()
66-
);
85+
// Map output back to resource or custom output class
86+
if ($hasCustomOutput) {
87+
return $this->objectMapper->map($persisted, $outputClass);
88+
}
89+
90+
// If we have entity mapping but no custom output, map back to resource class
91+
if ($hasEntityMapping) {
92+
return $this->objectMapper->map($persisted, $resourceClass);
93+
}
94+
95+
return $persisted;
6796
}
6897
}

src/State/Tests/Processor/ObjectMapperProcessorTest.php

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,151 @@ public function testProcessBypassesWithoutMapAttribute(): void
9595
$processor = new ObjectMapperProcessor($objectMapper, $decorated);
9696
$this->assertEquals($data, $processor->process($data, $operation));
9797
}
98+
99+
public function testProcessWithNoCustomInputAndNoCustomOutput(): void
100+
{
101+
$this->skipIfMapParameterNotAvailable();
102+
103+
$entity = new DummyEntity();
104+
$persisted = new DummyEntity();
105+
$operation = new Post(class: DummyEntity::class, map: true, write: true);
106+
107+
$objectMapper = $this->createMock(ObjectMapperInterface::class);
108+
$objectMapper->expects($this->never())->method('map');
109+
110+
$decorated = $this->createMock(ProcessorInterface::class);
111+
$decorated->expects($this->once())
112+
->method('process')
113+
->with($entity, $operation, [], [])
114+
->willReturn($persisted);
115+
116+
$processor = new ObjectMapperProcessor($objectMapper, $decorated);
117+
$result = $processor->process($entity, $operation);
118+
119+
$this->assertSame($persisted, $result);
120+
}
121+
122+
public function testProcessWithNoCustomInputAndCustomOutput(): void
123+
{
124+
$this->skipIfMapParameterNotAvailable();
125+
126+
$entity = new DummyEntity();
127+
$persisted = new DummyEntity();
128+
$output = new DummyOutput();
129+
$operation = new Post(
130+
class: DummyEntity::class,
131+
output: ['class' => DummyOutput::class],
132+
map: true,
133+
write: true
134+
);
135+
136+
$objectMapper = $this->createMock(ObjectMapperInterface::class);
137+
$objectMapper->expects($this->once())
138+
->method('map')
139+
->with($persisted, DummyOutput::class)
140+
->willReturn($output);
141+
142+
$decorated = $this->createMock(ProcessorInterface::class);
143+
$decorated->expects($this->once())
144+
->method('process')
145+
->with($entity, $operation, [], [])
146+
->willReturn($persisted);
147+
148+
$processor = new ObjectMapperProcessor($objectMapper, $decorated);
149+
$result = $processor->process($entity, $operation);
150+
151+
$this->assertSame($output, $result);
152+
}
153+
154+
public function testProcessWithCustomInputAndNoCustomOutput(): void
155+
{
156+
$this->skipIfMapParameterNotAvailable();
157+
158+
$input = new DummyInput();
159+
$entity = new DummyEntity();
160+
$persisted = new DummyEntity();
161+
$operation = new Post(
162+
class: DummyEntity::class,
163+
input: ['class' => DummyInput::class],
164+
map: true,
165+
write: true
166+
);
167+
168+
$objectMapper = $this->createMock(ObjectMapperInterface::class);
169+
$objectMapper->expects($this->once())
170+
->method('map')
171+
->with($input, null)
172+
->willReturn($entity);
173+
174+
$decorated = $this->createMock(ProcessorInterface::class);
175+
$decorated->expects($this->once())
176+
->method('process')
177+
->with($entity, $operation, [], [])
178+
->willReturn($persisted);
179+
180+
$processor = new ObjectMapperProcessor($objectMapper, $decorated);
181+
$result = $processor->process($input, $operation);
182+
183+
$this->assertSame($persisted, $result);
184+
}
185+
186+
public function testProcessWithCustomInputAndCustomOutput(): void
187+
{
188+
$this->skipIfMapParameterNotAvailable();
189+
190+
$input = new DummyInput();
191+
$entity = new DummyEntity();
192+
$persisted = new DummyEntity();
193+
$output = new DummyOutput();
194+
$operation = new Post(
195+
class: DummyEntity::class,
196+
input: ['class' => DummyInput::class],
197+
output: ['class' => DummyOutput::class],
198+
map: true,
199+
write: true
200+
);
201+
202+
$objectMapper = $this->createMock(ObjectMapperInterface::class);
203+
$objectMapper->expects($this->exactly(2))
204+
->method('map')
205+
->willReturnCallback(function ($data, $target) use ($input, $entity, $persisted, $output) {
206+
if ($data === $input && null === $target) {
207+
return $entity;
208+
}
209+
if ($data === $persisted && DummyOutput::class === $target) {
210+
return $output;
211+
}
212+
throw new \Exception('Unexpected map call');
213+
});
214+
215+
$decorated = $this->createMock(ProcessorInterface::class);
216+
$decorated->expects($this->once())
217+
->method('process')
218+
->with($entity, $operation, [], [])
219+
->willReturn($persisted);
220+
221+
$processor = new ObjectMapperProcessor($objectMapper, $decorated);
222+
$result = $processor->process($input, $operation);
223+
224+
$this->assertSame($output, $result);
225+
}
226+
227+
private function skipIfMapParameterNotAvailable(): void
228+
{
229+
try {
230+
$reflection = new \ReflectionClass(Post::class);
231+
$constructor = $reflection->getConstructor();
232+
$parameters = $constructor->getParameters();
233+
foreach ($parameters as $parameter) {
234+
if ('map' === $parameter->getName()) {
235+
return;
236+
}
237+
}
238+
$this->markTestSkipped('The "map" parameter is not available in this version');
239+
} catch (\ReflectionException $e) {
240+
$this->markTestSkipped('Could not check for "map" parameter availability');
241+
}
242+
}
98243
}
99244

100245
class DummyResourceWithoutMap
@@ -105,3 +250,18 @@ class DummyResourceWithoutMap
105250
class DummyResourceWithMap
106251
{
107252
}
253+
254+
#[Map]
255+
class DummyEntity
256+
{
257+
}
258+
259+
#[Map]
260+
class DummyInput
261+
{
262+
}
263+
264+
#[Map]
265+
class DummyOutput
266+
{
267+
}

0 commit comments

Comments
 (0)