diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8404741c22f..e7f8219c560 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -151,7 +151,8 @@ use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; -use ApiPlatform\State\Processor\ObjectMapperProcessor; +use ApiPlatform\State\Processor\ObjectMapperInputProcessor; +use ApiPlatform\State\Processor\ObjectMapperOutputProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; use ApiPlatform\State\Processor\WriteProcessor; @@ -470,7 +471,13 @@ public function register(): void }); $this->app->singleton(WriteProcessor::class, static function (Application $app) { - return new WriteProcessor($app->make(SerializeProcessor::class), $app->make(CallableProcessor::class)); + $inner = $app->make(SerializeProcessor::class); + + if (interface_exists(ObjectMapperInterface::class)) { + $inner = new ObjectMapperOutputProcessor($app->make(ObjectMapper::class), $inner); + } + + return new WriteProcessor($inner, $app->make(CallableProcessor::class)); }); $this->app->singleton(SerializerContextBuilder::class, static function (Application $app) { @@ -504,10 +511,10 @@ public function register(): void return $app->make(WriteProcessor::class); }); - // ObjectMapperProcessor wraps the base processor if available + // ObjectMapperInputProcessor wraps the base processor if available if (interface_exists(ObjectMapperInterface::class)) { $this->app->extend(ProcessorInterface::class, static function (ProcessorInterface $inner, Application $app) { - return new ObjectMapperProcessor($app->make(ObjectMapper::class), $inner); + return new ObjectMapperInputProcessor($app->make(ObjectMapper::class), $inner); }); } diff --git a/src/State/Processor/ObjectMapperInputProcessor.php b/src/State/Processor/ObjectMapperInputProcessor.php new file mode 100644 index 00000000000..a99128e9aca --- /dev/null +++ b/src/State/Processor/ObjectMapperInputProcessor.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * Maps the API resource (DTO) to the entity before persistence. + * + * @implements ProcessorInterface + */ +final class ObjectMapperInputProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface|null $decorated + */ + public function __construct( + private readonly ?ObjectMapperInterface $objectMapper, + private readonly ?ProcessorInterface $decorated = null, + ) { + } + + 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() ?? true) + || null === $data + || null === $class + || !is_a($data, $class, true) + || !$operation->canMap() + ) { + return $this->decorated ? $this->decorated->process($data, $operation, $uriVariables, $context) : $data; + } + + $request = $context['request'] ?? null; + $mapped = $this->objectMapper->map($data, $request?->attributes->get('mapped_data')); + + return $this->decorated ? $this->decorated->process($mapped, $operation, $uriVariables, $context) : $mapped; + } +} diff --git a/src/State/Processor/ObjectMapperOutputProcessor.php b/src/State/Processor/ObjectMapperOutputProcessor.php new file mode 100644 index 00000000000..91a4fa169eb --- /dev/null +++ b/src/State/Processor/ObjectMapperOutputProcessor.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Processor; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +/** + * Maps the persisted entity back to the API resource (DTO) after persistence. + * + * @implements ProcessorInterface + */ +final class ObjectMapperOutputProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface|null $decorated + */ + public function __construct( + private readonly ?ObjectMapperInterface $objectMapper, + private readonly ?ProcessorInterface $decorated = null, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if ( + $data instanceof Response + || !$this->objectMapper + || !($operation->canWrite() ?? true) + || null === $data + || !$operation->canMap() + ) { + return $this->decorated ? $this->decorated->process($data, $operation, $uriVariables, $context) : $data; + } + + $request = $context['request'] ?? null; + $request?->attributes->set('persisted_data', $data); + $dto = $this->objectMapper->map($data, $operation->getClass()); + + return $this->decorated ? $this->decorated->process($dto, $operation, $uriVariables, $context) : $dto; + } +} diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php index 7c71d3e3f0f..80f2ed4b7b3 100644 --- a/src/State/Processor/ObjectMapperProcessor.php +++ b/src/State/Processor/ObjectMapperProcessor.php @@ -19,6 +19,8 @@ use Symfony\Component\ObjectMapper\ObjectMapperInterface; /** + * @deprecated since API Platform 4.2, use {@see ObjectMapperInputProcessor} and {@see ObjectMapperOutputProcessor} instead + * * @implements ProcessorInterface */ final class ObjectMapperProcessor implements ProcessorInterface @@ -30,6 +32,7 @@ public function __construct( private readonly ?ObjectMapperInterface $objectMapper, private readonly ProcessorInterface $decorated, ) { + trigger_deprecation('api-platform/core', '4.2', 'The "%s" class is deprecated, use "%s" and "%s" instead.', self::class, ObjectMapperInputProcessor::class, ObjectMapperOutputProcessor::class); } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null diff --git a/src/State/Tests/Processor/ObjectMapperInputProcessorTest.php b/src/State/Tests/Processor/ObjectMapperInputProcessorTest.php new file mode 100644 index 00000000000..fa29a057dd4 --- /dev/null +++ b/src/State/Tests/Processor/ObjectMapperInputProcessorTest.php @@ -0,0 +1,156 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\State\Processor\ObjectMapperInputProcessor; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ObjectMapperInputProcessorTest extends TestCase +{ + public function testProcessBypassesWhenNoObjectMapper(): void + { + $data = new \stdClass(); + $operation = new Post(class: \stdClass::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperInputProcessor(null, $decorated); + $this->assertSame($data, $processor->process($data, $operation)); + } + + public function testProcessBypassesOnNonWriteOperation(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperInputProcessor($objectMapper, $decorated); + $this->assertSame($data, $processor->process($data, $operation)); + } + + public function testProcessBypassesWithNullData(): void + { + $operation = new Post(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with(null, $operation, [], []) + ->willReturn(null); + + $processor = new ObjectMapperInputProcessor($objectMapper, $decorated); + $this->assertNull($processor->process(null, $operation)); + } + + public function testProcessBypassesWithResponseData(): void + { + $response = new Response(); + $operation = new Post(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($response, $operation, [], []) + ->willReturn($response); + + $processor = new ObjectMapperInputProcessor($objectMapper, $decorated); + $this->assertSame($response, $processor->process($response, $operation)); + } + + public function testProcessBypassesWithMismatchedDataType(): void + { + $data = new \stdClass(); + $operation = new Post(class: ObjectMapperInputDummy::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperInputProcessor($objectMapper, $decorated); + $this->assertSame($data, $processor->process($data, $operation)); + } + + public function testProcessMapsInputToEntity(): void + { + $dto = new \stdClass(); + $entity = new \stdClass(); + $entity->id = 1; + $result = new \stdClass(); + $operation = new Post(class: \stdClass::class, map: true); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($dto, null) + ->willReturn($entity); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($entity, $operation, [], $this->anything()) + ->willReturn($result); + + $processor = new ObjectMapperInputProcessor($objectMapper, $decorated); + $this->assertSame($result, $processor->process($dto, $operation)); + } + + public function testProcessMapsWithExistingMappedData(): void + { + $dto = new \stdClass(); + $existingEntity = new \stdClass(); + $existingEntity->id = 42; + $mappedEntity = new \stdClass(); + $mappedEntity->id = 42; + $result = new \stdClass(); + $operation = new Post(class: \stdClass::class, map: true); + + $request = new Request(); + $request->attributes->set('mapped_data', $existingEntity); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($dto, $existingEntity) + ->willReturn($mappedEntity); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($mappedEntity, $operation, [], $this->anything()) + ->willReturn($result); + + $processor = new ObjectMapperInputProcessor($objectMapper, $decorated); + $this->assertSame($result, $processor->process($dto, $operation, [], ['request' => $request])); + } +} + +class ObjectMapperInputDummy +{ +} diff --git a/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php b/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php new file mode 100644 index 00000000000..3ed28bf1f1e --- /dev/null +++ b/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php @@ -0,0 +1,138 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\State\Processor\ObjectMapperOutputProcessor; +use ApiPlatform\State\ProcessorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ObjectMapperOutputProcessorTest extends TestCase +{ + public function testProcessBypassesWhenNoObjectMapper(): void + { + $data = new \stdClass(); + $operation = new Post(class: \stdClass::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperOutputProcessor(null, $decorated); + $this->assertSame($data, $processor->process($data, $operation)); + } + + public function testProcessBypassesOnNonWriteOperation(): void + { + $data = new \stdClass(); + $operation = new Get(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($data, $operation, [], []) + ->willReturn($data); + + $processor = new ObjectMapperOutputProcessor($objectMapper, $decorated); + $this->assertSame($data, $processor->process($data, $operation)); + } + + public function testProcessBypassesWithNullData(): void + { + $operation = new Post(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with(null, $operation, [], []) + ->willReturn(null); + + $processor = new ObjectMapperOutputProcessor($objectMapper, $decorated); + $this->assertNull($processor->process(null, $operation)); + } + + public function testProcessBypassesWithResponseData(): void + { + $response = new Response(); + $operation = new Post(class: \stdClass::class); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($response, $operation, [], []) + ->willReturn($response); + + $processor = new ObjectMapperOutputProcessor($objectMapper, $decorated); + $this->assertSame($response, $processor->process($response, $operation)); + } + + public function testProcessMapsEntityToDto(): void + { + $entity = new \stdClass(); + $entity->id = 1; + $dto = new \stdClass(); + $dto->id = 1; + $result = new \stdClass(); + $operation = new Post(class: ObjectMapperOutputDummy::class, map: true); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($entity, ObjectMapperOutputDummy::class) + ->willReturn($dto); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->with($dto, $operation, [], $this->anything()) + ->willReturn($result); + + $processor = new ObjectMapperOutputProcessor($objectMapper, $decorated); + $this->assertSame($result, $processor->process($entity, $operation)); + } + + public function testProcessSetsPersistedDataOnRequest(): void + { + $entity = new \stdClass(); + $entity->id = 1; + $dto = new \stdClass(); + $operation = new Post(class: ObjectMapperOutputDummy::class, map: true); + $request = new Request(); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($entity, ObjectMapperOutputDummy::class) + ->willReturn($dto); + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once()) + ->method('process') + ->willReturn($dto); + + $processor = new ObjectMapperOutputProcessor($objectMapper, $decorated); + $processor->process($entity, $operation, [], ['request' => $request]); + + $this->assertSame($entity, $request->attributes->get('persisted_data')); + } +} + +class ObjectMapperOutputDummy +{ +} diff --git a/src/State/Tests/Processor/ObjectMapperProcessorTest.php b/src/State/Tests/Processor/ObjectMapperProcessorTest.php deleted file mode 100644 index 514a153cdfd..00000000000 --- a/src/State/Tests/Processor/ObjectMapperProcessorTest.php +++ /dev/null @@ -1,107 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\State\Tests\Processor; - -use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\Post; -use ApiPlatform\State\Processor\ObjectMapperProcessor; -use ApiPlatform\State\ProcessorInterface; -use PHPUnit\Framework\TestCase; -use Symfony\Component\ObjectMapper\Attribute\Map; -use Symfony\Component\ObjectMapper\ObjectMapperInterface; - -class ObjectMapperProcessorTest extends TestCase -{ - public function testProcessBypassesWhenNoObjectMapper(): void - { - $data = new DummyResourceWithoutMap(); - $operation = new Post(class: DummyResourceWithoutMap::class); - $decorated = $this->createMock(ProcessorInterface::class); - $decorated->expects($this->once()) - ->method('process') - ->with($data, $operation, [], []) - ->willReturn($data); - - $processor = new ObjectMapperProcessor(null, $decorated); - $this->assertEquals($data, $processor->process($data, $operation)); - } - - public function testProcessBypassesOnNonWriteOperation(): void - { - $data = new DummyResourceWithoutMap(); - $operation = new Get(class: DummyResourceWithoutMap::class); - $objectMapper = $this->createMock(ObjectMapperInterface::class); - $decorated = $this->createMock(ProcessorInterface::class); - $decorated->expects($this->once()) - ->method('process') - ->with($data, $operation, [], []) - ->willReturn($data); - - $processor = new ObjectMapperProcessor($objectMapper, $decorated); - $this->assertEquals($data, $processor->process($data, $operation)); - } - - public function testProcessBypassesWithNullData(): void - { - $operation = new Post(class: DummyResourceWithoutMap::class); - $objectMapper = $this->createMock(ObjectMapperInterface::class); - $decorated = $this->createMock(ProcessorInterface::class); - $decorated->expects($this->once()) - ->method('process') - ->with(null, $operation, [], []) - ->willReturn(null); - - $processor = new ObjectMapperProcessor($objectMapper, $decorated); - $this->assertNull($processor->process(null, $operation)); - } - - public function testProcessBypassesWithMismatchedDataType(): void - { - $data = new \stdClass(); - $operation = new Post(class: DummyResourceWithMap::class); - $objectMapper = $this->createMock(ObjectMapperInterface::class); - $decorated = $this->createMock(ProcessorInterface::class); - $decorated->expects($this->once()) - ->method('process') - ->with($data, $operation, [], []) - ->willReturn($data); - - $processor = new ObjectMapperProcessor($objectMapper, $decorated); - $this->assertEquals($data, $processor->process($data, $operation)); - } - - public function testProcessBypassesWithoutMapAttribute(): void - { - $data = new DummyResourceWithoutMap(); - $operation = new Post(class: DummyResourceWithoutMap::class); - $objectMapper = $this->createMock(ObjectMapperInterface::class); - $decorated = $this->createMock(ProcessorInterface::class); - $decorated->expects($this->once()) - ->method('process') - ->with($data, $operation, [], []) - ->willReturn($data); - - $processor = new ObjectMapperProcessor($objectMapper, $decorated); - $this->assertEquals($data, $processor->process($data, $operation)); - } -} - -class DummyResourceWithoutMap -{ -} - -#[Map] -class DummyResourceWithMap -{ -} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index e664c3beb3a..1f2f6689b96 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -199,6 +199,7 @@ public function load(array $configs, ContainerBuilder $container): void // TranslationExtractCommand was introduced in framework-bundle/7.3 with the object mapper service if (class_exists(ObjectMapper::class) && class_exists(TranslationExtractCommand::class)) { $loader->load('state/object_mapper.php'); + $loader->load($config['use_symfony_listeners'] ? 'symfony/object_mapper.php' : 'state/object_mapper_processor.php'); } if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class)) { diff --git a/src/Symfony/Bundle/Resources/config/state/object_mapper.php b/src/Symfony/Bundle/Resources/config/state/object_mapper.php index 24efc954d53..ad970c8d2d2 100644 --- a/src/Symfony/Bundle/Resources/config/state/object_mapper.php +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper.php @@ -15,7 +15,6 @@ use ApiPlatform\Metadata\Resource\Factory\ObjectMapperMetadataCollectionFactory; use ApiPlatform\State\ObjectMapper\ObjectMapper; -use ApiPlatform\State\Processor\ObjectMapperProcessor; use ApiPlatform\State\Provider\ObjectMapperProvider; use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; @@ -38,14 +37,6 @@ service('api_platform.object_mapper.metadata_factory'), ]); - $services->set('api_platform.state_processor.object_mapper', ObjectMapperProcessor::class) - ->decorate('api_platform.state_processor.locator', null, 0) - ->args([ - service('api_platform.object_mapper')->nullOnInvalid(), - service('api_platform.state_processor.object_mapper.inner'), - service('api_platform.object_mapper.metadata_factory'), - ]); - $services->set('api_platform.metadata.resource.metadata_collection_factory.object_mapper', ObjectMapperMetadataCollectionFactory::class) ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 100) ->args([ diff --git a/src/Symfony/Bundle/Resources/config/state/object_mapper_processor.php b/src/Symfony/Bundle/Resources/config/state/object_mapper_processor.php new file mode 100644 index 00000000000..40de78b6169 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/state/object_mapper_processor.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\State\Processor\ObjectMapperInputProcessor; +use ApiPlatform\State\Processor\ObjectMapperOutputProcessor; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.state_processor.object_mapper_input', ObjectMapperInputProcessor::class) + ->decorate('api_platform.state_processor.main', null, 50) + ->args([ + service('api_platform.object_mapper')->nullOnInvalid(), + service('api_platform.state_processor.object_mapper_input.inner'), + ]); + + $services->set('api_platform.state_processor.object_mapper_output', ObjectMapperOutputProcessor::class) + ->decorate('api_platform.state_processor.main', null, 150) + ->args([ + service('api_platform.object_mapper')->nullOnInvalid(), + service('api_platform.state_processor.object_mapper_output.inner'), + ]); +}; diff --git a/src/Symfony/Bundle/Resources/config/symfony/object_mapper.php b/src/Symfony/Bundle/Resources/config/symfony/object_mapper.php new file mode 100644 index 00000000000..b1ed3ca5b74 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/object_mapper.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\State\Processor\ObjectMapperInputProcessor; +use ApiPlatform\State\Processor\ObjectMapperOutputProcessor; +use ApiPlatform\Symfony\EventListener\ObjectMapperInputListener; +use ApiPlatform\Symfony\EventListener\ObjectMapperOutputListener; +use ApiPlatform\Symfony\EventListener\ValidateProcessorListener; +use ApiPlatform\Symfony\Validator\State\ValidateProcessor; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.state_processor.object_mapper_input', ObjectMapperInputProcessor::class) + ->args([ + service('api_platform.object_mapper')->nullOnInvalid(), + ]); + + $services->set('api_platform.state_processor.object_mapper_output', ObjectMapperOutputProcessor::class) + ->args([ + service('api_platform.object_mapper')->nullOnInvalid(), + ]); + + $services->set('api_platform.listener.view.object_mapper_input', ObjectMapperInputListener::class) + ->args([ + service('api_platform.state_processor.object_mapper_input'), + service('api_platform.metadata.resource.metadata_collection_factory'), + ]) + ->tag('kernel.event_listener', ['event' => 'kernel.view', 'method' => 'onKernelView', 'priority' => 48]); + + if (interface_exists(ValidatorInterface::class)) { + $services->set('api_platform.state_processor.validate', ValidateProcessor::class) + ->args([ + null, + service('api_platform.validator'), + ]); + + $services->set('api_platform.listener.view.validate_processor', ValidateProcessorListener::class) + ->args([ + service('api_platform.state_processor.validate'), + service('api_platform.metadata.resource.metadata_collection_factory'), + ]) + ->tag('kernel.event_listener', ['event' => 'kernel.view', 'method' => 'onKernelView', 'priority' => 40]); + } + + $services->set('api_platform.listener.view.object_mapper_output', ObjectMapperOutputListener::class) + ->args([ + service('api_platform.state_processor.object_mapper_output'), + service('api_platform.metadata.resource.metadata_collection_factory'), + ]) + ->tag('kernel.event_listener', ['event' => 'kernel.view', 'method' => 'onKernelView', 'priority' => 24]); +}; diff --git a/src/Symfony/Bundle/Resources/config/validator/state.php b/src/Symfony/Bundle/Resources/config/validator/state.php index 30c31f43e57..af407f9184c 100644 --- a/src/Symfony/Bundle/Resources/config/validator/state.php +++ b/src/Symfony/Bundle/Resources/config/validator/state.php @@ -14,6 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ApiPlatform\Symfony\Validator\State\ParameterValidatorProvider; +use ApiPlatform\Symfony\Validator\State\ValidateProcessor; use ApiPlatform\Symfony\Validator\State\ValidateProvider; return static function (ContainerConfigurator $container) { @@ -33,4 +34,11 @@ service('validator'), service('api_platform.state_provider.parameter_validator.inner'), ]); + + $services->set('api_platform.state_processor.validate', ValidateProcessor::class) + ->decorate('api_platform.state_processor.main', null, 80) + ->args([ + service('api_platform.state_processor.validate.inner'), + service('api_platform.validator'), + ]); }; diff --git a/src/Symfony/EventListener/ObjectMapperInputListener.php b/src/Symfony/EventListener/ObjectMapperInputListener.php new file mode 100644 index 00000000000..34998a4f041 --- /dev/null +++ b/src/Symfony/EventListener/ObjectMapperInputListener.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\ViewEvent; + +/** + * Maps the API resource (DTO) to the entity before persistence. + */ +final class ObjectMapperInputListener +{ + use OperationRequestInitiatorTrait; + + /** + * @param ProcessorInterface $processor + */ + public function __construct( + private readonly ProcessorInterface $processor, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + + public function onKernelView(ViewEvent $event): void + { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + if (!$operation || !($attributes = RequestAttributesExtractor::extractAttributes($request)) || !$attributes['persist']) { + return; + } + + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + $data = $this->processor->process($event->getControllerResult(), $operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + ]); + + $event->setControllerResult($data); + } +} diff --git a/src/Symfony/EventListener/ObjectMapperOutputListener.php b/src/Symfony/EventListener/ObjectMapperOutputListener.php new file mode 100644 index 00000000000..577a9df38d9 --- /dev/null +++ b/src/Symfony/EventListener/ObjectMapperOutputListener.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\ViewEvent; + +/** + * Maps the persisted entity back to the API resource (DTO) after persistence. + */ +final class ObjectMapperOutputListener +{ + use OperationRequestInitiatorTrait; + + /** + * @param ProcessorInterface $processor + */ + public function __construct( + private readonly ProcessorInterface $processor, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + + public function onKernelView(ViewEvent $event): void + { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + if (!$operation || !($attributes = RequestAttributesExtractor::extractAttributes($request)) || !$attributes['persist']) { + return; + } + + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + $data = $this->processor->process($event->getControllerResult(), $operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + ]); + + $event->setControllerResult($data); + } +} diff --git a/src/Symfony/EventListener/ValidateProcessorListener.php b/src/Symfony/EventListener/ValidateProcessorListener.php new file mode 100644 index 00000000000..0a127b06989 --- /dev/null +++ b/src/Symfony/EventListener/ValidateProcessorListener.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\ViewEvent; + +/** + * Validates data after ObjectMapper transformation (entity-level validation). + */ +final class ValidateProcessorListener +{ + use OperationRequestInitiatorTrait; + + /** + * @param ProcessorInterface $processor + */ + public function __construct( + private readonly ProcessorInterface $processor, + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + } + + public function onKernelView(ViewEvent $event): void + { + $request = $event->getRequest(); + $operation = $this->initializeOperation($request); + + if (!$operation || !($attributes = RequestAttributesExtractor::extractAttributes($request)) || !$attributes['persist']) { + return; + } + + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + if (null === $operation->canValidate()) { + $operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE')); + } + + $this->processor->process($event->getControllerResult(), $operation, $request->attributes->get('_api_uri_variables') ?? [], [ + 'request' => $request, + ]); + } +} diff --git a/src/Symfony/Validator/State/ValidateProcessor.php b/src/Symfony/Validator/State/ValidateProcessor.php new file mode 100644 index 00000000000..c979cd0091c --- /dev/null +++ b/src/Symfony/Validator/State/ValidateProcessor.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Symfony\Validator\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Validator\ValidatorInterface; +use Symfony\Component\HttpFoundation\Response; + +/** + * Validates data in the processor chain (after ObjectMapper transformations). + * + * @author Saif Eddin Gmati + */ +final class ValidateProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface|null $decorated + */ + public function __construct( + private readonly ?ProcessorInterface $decorated, + private readonly ValidatorInterface $validator, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ($data instanceof Response || !$data || false === ($operation->canWrite() ?? true)) { + return $this->decorated ? $this->decorated->process($data, $operation, $uriVariables, $context) : $data; + } + + if (false === ($operation->canValidate() ?? true)) { + return $this->decorated ? $this->decorated->process($data, $operation, $uriVariables, $context) : $data; + } + + $this->validator->validate($data, $operation->getValidationContext() ?? []); + + return $this->decorated ? $this->decorated->process($data, $operation, $uriVariables, $context) : $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/UniqueBookResource.php b/tests/Fixtures/TestBundle/ApiResource/UniqueBookResource.php new file mode 100644 index 00000000000..1474b78a74c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/UniqueBookResource.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Doctrine\Orm\State\Options as OrmOptions; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\CreateUniqueBookDto; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UniqueBook; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + operations: [ + new Post(input: CreateUniqueBookDto::class), + new Get(), + new GetCollection(), + ], + stateOptions: new OrmOptions(entityClass: UniqueBook::class) +)] +#[Map(source: UniqueBook::class)] +class UniqueBookResource +{ + public int $id; + public string $isbn = ''; + public string $title = ''; +} diff --git a/tests/Fixtures/TestBundle/Dto/CreateUniqueBookDto.php b/tests/Fixtures/TestBundle/Dto/CreateUniqueBookDto.php new file mode 100644 index 00000000000..2bb5b06cf3f --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/CreateUniqueBookDto.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Dto; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UniqueBook; +use Symfony\Component\ObjectMapper\Attribute\Map; +use Symfony\Component\Validator\Constraints as Assert; + +#[Map(target: UniqueBook::class)] +final class CreateUniqueBookDto +{ + #[Assert\NotBlank] + #[Assert\Isbn] + public string $isbn = ''; + + #[Assert\NotBlank] + public string $title = ''; +} diff --git a/tests/Fixtures/TestBundle/Entity/UniqueBook.php b/tests/Fixtures/TestBundle/Entity/UniqueBook.php new file mode 100644 index 00000000000..e546e95f7ce --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/UniqueBook.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Validator\Constraints as Assert; + +#[ORM\Entity] +#[UniqueEntity(fields: ['isbn'], message: 'This ISBN already exists.')] +class UniqueBook +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + #[ORM\Column(unique: true)] + #[Assert\NotBlank] + #[Assert\Isbn] + public string $isbn = ''; + + #[ORM\Column] + #[Assert\NotBlank] + public string $title = ''; + + public function getId(): ?int + { + return $this->id; + } + + public function getIsbn(): string + { + return $this->isbn; + } + + public function setIsbn(string $isbn): void + { + $this->isbn = $isbn; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): void + { + $this->title = $title; + } +} diff --git a/tests/Functional/ObjectMapperValidationTest.php b/tests/Functional/ObjectMapperValidationTest.php new file mode 100644 index 00000000000..8726a3a4476 --- /dev/null +++ b/tests/Functional/ObjectMapperValidationTest.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UniqueBookResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UniqueBook; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Test entity-level validation after ObjectMapper DTO transformation. + * + * @group issue-7725 + */ +class ObjectMapperValidationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [UniqueBookResource::class]; + } + + /** + * Core test for issue #7725: UniqueEntity constraint should be validated + * after ObjectMapper transforms DTO to Entity, preventing database errors. + */ + public function testUniqueEntityValidationOnCreate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UniqueEntity validation is not supported with MongoDB.'); + } + + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + $this->recreateSchema([UniqueBook::class]); + + $client = self::createClient(); + + // First book creation should succeed + $client->request('POST', '/unique_book_resources', [ + 'json' => [ + 'isbn' => '978-0-13-468599-1', + 'title' => 'Clean Code', + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + + // Ensure the first book is actually persisted + $entityManager = $this->getContainer()->get('doctrine')->getManager(); + $entityManager->clear(); // Clear to force fresh queries + + // Verify first book exists in database + $bookCount = $entityManager->getRepository(UniqueBook::class)->count(['isbn' => '978-0-13-468599-1']); + $this->assertEquals(1, $bookCount, 'First book should be in database'); + + // Second book with same ISBN should return 422 validation error, NOT 500 database error + $client->request('POST', '/unique_book_resources', [ + 'json' => [ + 'isbn' => '978-0-13-468599-1', + 'title' => 'Another Book', + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + // Should return 422 validation error, NOT 500 database error + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + + // Verify we got the UniqueEntity validation error + $this->assertJsonContains([ + 'status' => 422, + 'hydra:title' => 'An error occurred', + ]); + + $content = $client->getResponse()->getContent(false); + $this->assertStringContainsString('This ISBN already exists', $content); + } + + /** + * Ensure DTO validation still works (input DTO constraints are checked). + */ + public function testDtoValidationStillWorks(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UniqueEntity validation is not supported with MongoDB.'); + } + + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + $this->recreateSchema([UniqueBook::class]); + + $client = self::createClient(); + + // POST with blank ISBN should trigger DTO validation + $client->request('POST', '/unique_book_resources', [ + 'json' => [ + 'isbn' => '', + 'title' => 'Some Book', + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(422); + + // Should have ISBN violation + $content = $client->getResponse()->getContent(false); + $this->assertStringContainsString('isbn', $content); + } + + /** + * Verify entity-level constraints (not just UniqueEntity) are validated. + */ + public function testEntityConstraintsValidated(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UniqueEntity validation is not supported with MongoDB.'); + } + + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + $this->recreateSchema([UniqueBook::class]); + + $client = self::createClient(); + + // Invalid ISBN format should be caught by entity validation + $client->request('POST', '/unique_book_resources', [ + 'json' => [ + 'isbn' => 'not-a-valid-isbn', + 'title' => 'Test Book', + ], + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(422); + + // Should have ISBN format violation + $content = $client->getResponse()->getContent(false); + $this->assertStringContainsString('isbn', $content); + $this->assertStringContainsString('ISBN', $content); + } +} diff --git a/tests/Symfony/Validator/State/ValidateProcessorTest.php b/tests/Symfony/Validator/State/ValidateProcessorTest.php new file mode 100644 index 00000000000..f3164836a93 --- /dev/null +++ b/tests/Symfony/Validator/State/ValidateProcessorTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\Validator\State; + +use ApiPlatform\Metadata\Post; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\Symfony\Validator\State\ValidateProcessor; +use ApiPlatform\Validator\ValidatorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Response; + +class ValidateProcessorTest extends TestCase +{ + public function testValidate(): void + { + $obj = new \stdClass(); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($obj)->willReturn($obj); + $validationContext = ['test']; + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once())->method('validate')->with($obj, $validationContext); + $processor = new ValidateProcessor($decorated, $validator); + $processor->process($obj, new Post(validationContext: $validationContext)); + } + + public function testNoValidate(): void + { + $obj = new \stdClass(); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($obj)->willReturn($obj); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->never())->method('validate'); + $processor = new ValidateProcessor($decorated, $validator); + $processor->process($obj, new Post(validate: false)); + } + + public function testSkipsResponseObjects(): void + { + $response = new Response(); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($response)->willReturn($response); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->never())->method('validate'); + $processor = new ValidateProcessor($decorated, $validator); + $processor->process($response, new Post()); + } + + public function testSkipsNullData(): void + { + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with(null)->willReturn(null); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->never())->method('validate'); + $processor = new ValidateProcessor($decorated, $validator); + $processor->process(null, new Post()); + } + + public function testPassesValidationContext(): void + { + $obj = new \stdClass(); + $validationContext = ['groups' => ['create', 'strict']]; + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($obj)->willReturn($obj); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once())->method('validate')->with($obj, $validationContext); + $processor = new ValidateProcessor($decorated, $validator); + $processor->process($obj, new Post(validationContext: $validationContext)); + } + + public function testDecoratorChainContinues(): void + { + $obj = new \stdClass(); + $result = new \stdClass(); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($obj)->willReturn($result); + $validator = $this->createMock(ValidatorInterface::class); + $processor = new ValidateProcessor($decorated, $validator); + $this->assertSame($result, $processor->process($obj, new Post())); + } + + public function testDeleteReturnsNull(): void + { + $obj = new \stdClass(); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($obj)->willReturn(null); + $validator = $this->createMock(ValidatorInterface::class); + $processor = new ValidateProcessor($decorated, $validator); + $result = $processor->process($obj, new Post()); + $this->assertNull($result); + } + + public function testSkipsWhenCanWriteIsFalse(): void + { + $obj = new \stdClass(); + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->expects($this->once())->method('process')->with($obj)->willReturn($obj); + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->never())->method('validate'); + $processor = new ValidateProcessor($decorated, $validator); + // Delete operations typically have write: false + $processor->process($obj, new Post(write: false)); + } +}