From 5b38deadeda69c342e386030bfb055f9c6550440 Mon Sep 17 00:00:00 2001 From: Florent Morselli Date: Tue, 16 Dec 2025 20:16:55 +0100 Subject: [PATCH] fix(metadata): enhance resource class determination before Object Mapper Processor return --- .../ObjectMapperMetadataCollectionFactory.php | 7 +- src/State/Processor/ObjectMapperProcessor.php | 61 +++++-- .../Processor/ObjectMapperProcessorTest.php | 160 ++++++++++++++++++ 3 files changed, 210 insertions(+), 18 deletions(-) diff --git a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php index d4026dd30fb..9850de72ffc 100644 --- a/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ObjectMapperMetadataCollectionFactory.php @@ -52,11 +52,14 @@ public function create(string $resourceClass): ResourceMetadataCollection $entityClass = $options->getDocumentClass(); } - $class = $operation->getInput()['class'] ?? $operation->getClass(); + $inputClass = $operation->getInput()['class'] ?? $operation->getClass(); + $outputClass = $operation->getOutput()['class'] ?? null; $entityMap = null; // Look for Mapping metadata - if ($this->canBeMapped($class) || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { + if ($this->canBeMapped($inputClass) + || ($outputClass && $this->canBeMapped($outputClass)) + || ($entityClass && ($entityMap = $this->canBeMapped($entityClass)))) { $found = true; if ($entityMap) { foreach ($entityMap as $mapping) { diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index 2f542d276d7..707065ed473 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -34,35 +34,64 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - $class = $operation->getInput()['class'] ?? $operation->getClass(); - if ( $data instanceof Response || !$this->objectMapper || !$operation->canWrite() || null === $data - || !is_a($data, $class, true) || !$operation->canMap() ) { return $this->decorated->process($data, $operation, $uriVariables, $context); } $request = $context['request'] ?? null; - $persisted = $this->decorated->process( - // maps the Resource to an Entity - $this->objectMapper->map($data, $request?->attributes->get('mapped_data')), - $operation, - $uriVariables, - $context, - ); + $resourceClass = $operation->getClass(); + $inputClass = $operation->getInput()['class'] ?? null; + $outputClass = $operation->getOutput()['class'] ?? null; + + // Get entity class from state options if available + $stateOptions = $operation->getStateOptions(); + $entityClass = null; + if ($stateOptions) { + if (method_exists($stateOptions, 'getEntityClass')) { + $entityClass = $stateOptions->getEntityClass(); + } elseif (method_exists($stateOptions, 'getDocumentClass')) { + $entityClass = $stateOptions->getDocumentClass(); + } + } + + $hasCustomInput = null !== $inputClass && $inputClass !== $resourceClass; + $hasCustomOutput = null !== $outputClass && $outputClass !== $resourceClass; + $hasEntityMapping = null !== $entityClass && $entityClass !== $resourceClass; + + // Skip mapping if no custom input/output and no entity mapping needed + if (!$hasCustomInput && !$hasCustomOutput && !$hasEntityMapping) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + // Map input to entity if we have custom input or entity mapping + if ($hasCustomInput || $hasEntityMapping) { + $expectedInputClass = $hasCustomInput ? $inputClass : $resourceClass; + if (!is_a($data, $expectedInputClass, true)) { + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + $data = $this->objectMapper->map($data, $request?->attributes->get('mapped_data')); + } + + $persisted = $this->decorated->process($data, $operation, $uriVariables, $context); $request?->attributes->set('persisted_data', $persisted); - // return the Resource representation of the persisted entity - return $this->objectMapper->map( - // persist the entity - $persisted, - $operation->getClass() - ); + // Map output back to resource or custom output class + if ($hasCustomOutput) { + return $this->objectMapper->map($persisted, $outputClass); + } + + // If we have entity mapping but no custom output, map back to resource class + if ($hasEntityMapping) { + return $this->objectMapper->map($persisted, $resourceClass); + } + + return $persisted; } } diff --git a/src/State/Tests/Processor/ObjectMapperProcessorTest.php b/src/State/Tests/Processor/ObjectMapperProcessorTest.php index 514a153cdfd..e5c3f9b9703 100644 --- a/src/State/Tests/Processor/ObjectMapperProcessorTest.php +++ b/src/State/Tests/Processor/ObjectMapperProcessorTest.php @@ -95,6 +95,151 @@ public function testProcessBypassesWithoutMapAttribute(): void $processor = new ObjectMapperProcessor($objectMapper, $decorated); $this->assertEquals($data, $processor->process($data, $operation)); } + + public function testProcessWithNoCustomInputAndNoCustomOutput(): void + { + $this->skipIfMapParameterNotAvailable(); + + $entity = new DummyEntity(); + $persisted = new DummyEntity(); + $operation = new Post(class: DummyEntity::class, map: true, write: true); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($entity, $operation, [], []) + ->willReturn($persisted); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $result = $processor->process($entity, $operation); + + $this->assertSame($persisted, $result); + } + + public function testProcessWithNoCustomInputAndCustomOutput(): void + { + $this->skipIfMapParameterNotAvailable(); + + $entity = new DummyEntity(); + $persisted = new DummyEntity(); + $output = new DummyOutput(); + $operation = new Post( + class: DummyEntity::class, + output: ['class' => DummyOutput::class], + map: true, + write: true + ); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($persisted, DummyOutput::class) + ->willReturn($output); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($entity, $operation, [], []) + ->willReturn($persisted); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $result = $processor->process($entity, $operation); + + $this->assertSame($output, $result); + } + + public function testProcessWithCustomInputAndNoCustomOutput(): void + { + $this->skipIfMapParameterNotAvailable(); + + $input = new DummyInput(); + $entity = new DummyEntity(); + $persisted = new DummyEntity(); + $operation = new Post( + class: DummyEntity::class, + input: ['class' => DummyInput::class], + map: true, + write: true + ); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($input, null) + ->willReturn($entity); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($entity, $operation, [], []) + ->willReturn($persisted); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $result = $processor->process($input, $operation); + + $this->assertSame($persisted, $result); + } + + public function testProcessWithCustomInputAndCustomOutput(): void + { + $this->skipIfMapParameterNotAvailable(); + + $input = new DummyInput(); + $entity = new DummyEntity(); + $persisted = new DummyEntity(); + $output = new DummyOutput(); + $operation = new Post( + class: DummyEntity::class, + input: ['class' => DummyInput::class], + output: ['class' => DummyOutput::class], + map: true, + write: true + ); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->exactly(2)) + ->method('map') + ->willReturnCallback(function ($data, $target) use ($input, $entity, $persisted, $output) { + if ($data === $input && null === $target) { + return $entity; + } + if ($data === $persisted && DummyOutput::class === $target) { + return $output; + } + throw new \Exception('Unexpected map call'); + }); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($entity, $operation, [], []) + ->willReturn($persisted); + + $processor = new ObjectMapperProcessor($objectMapper, $decorated); + $result = $processor->process($input, $operation); + + $this->assertSame($output, $result); + } + + private function skipIfMapParameterNotAvailable(): void + { + try { + $reflection = new \ReflectionClass(Post::class); + $constructor = $reflection->getConstructor(); + $parameters = $constructor->getParameters(); + foreach ($parameters as $parameter) { + if ('map' === $parameter->getName()) { + return; + } + } + $this->markTestSkipped('The "map" parameter is not available in this version'); + } catch (\ReflectionException $e) { + $this->markTestSkipped('Could not check for "map" parameter availability'); + } + } } class DummyResourceWithoutMap @@ -105,3 +250,18 @@ class DummyResourceWithoutMap class DummyResourceWithMap { } + +#[Map] +class DummyEntity +{ +} + +#[Map] +class DummyInput +{ +} + +#[Map] +class DummyOutput +{ +}