diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index 8eef0c15f30..9986f43954f 100644 --- a/src/State/Provider/ObjectMapperProvider.php +++ b/src/State/Provider/ObjectMapperProvider.php @@ -41,7 +41,7 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $data = $this->decorated->provide($operation, $uriVariables, $context); - $class = $operation->getInput()['class'] ?? $operation->getOutput()['class'] ?? $operation->getClass(); + $class = $operation->getOutput()['class'] ?? $operation->getClass(); if (!$this->objectMapper || !$operation->canMap()) { return $data; diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedPatchResource.php b/tests/Fixtures/TestBundle/ApiResource/MappedPatchResource.php new file mode 100644 index 00000000000..21052cfff24 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedPatchResource.php @@ -0,0 +1,43 @@ + + * + * 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; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\MappedPatchInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedPatchEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(entityClass: MappedPatchEntity::class), + operations: [ + new Get(), + new Post(input: MappedPatchInput::class), + new Patch(input: MappedPatchInput::class), + ], + normalizationContext: ['hydra_prefix' => false], +)] +#[Map(source: MappedPatchEntity::class)] +class MappedPatchResource +{ + #[Map(if: false)] + public ?int $id = null; + + public string $name; + + public string $description; +} diff --git a/tests/Fixtures/TestBundle/Dto/MappedPatchInput.php b/tests/Fixtures/TestBundle/Dto/MappedPatchInput.php new file mode 100644 index 00000000000..ff2b2ce7460 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/MappedPatchInput.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\MappedPatchEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +/** + * Input DTO for PATCH — maps directly to entity. + * Uses uninitialized properties so ObjectMapper skips unsent fields. + */ +#[Map(target: MappedPatchEntity::class)] +class MappedPatchInput +{ + public string $name; + + public string $description; +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedPatchEntity.php b/tests/Fixtures/TestBundle/Entity/MappedPatchEntity.php new file mode 100644 index 00000000000..a1aaa7c7db8 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedPatchEntity.php @@ -0,0 +1,31 @@ + + * + * 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; + +#[ORM\Entity] +class MappedPatchEntity +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null; + + #[ORM\Column] + public string $name; + + #[ORM\Column] + public string $description; +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 5babb70fdb5..353153b9f69 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BookStoreResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FirstResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7563\BookDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedPatchResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceOdm; @@ -31,6 +32,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntityNoMap; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedEntitySourceOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedPatchEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceWithRelationRelatedEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SameEntity; @@ -61,6 +63,7 @@ public static function getResources(): array MappedResourceNoMap::class, BookDto::class, BookStoreResource::class, + MappedPatchResource::class, ]; } @@ -477,4 +480,42 @@ public function testOutputDtoForCollectionRead(): void 'author' => 'John Doe', ]); } + + /** + * Test PATCH with input DTO + ObjectMapper: only client-sent fields should be updated. + * The input DTO uses uninitialized properties so ObjectMapper skips unsent fields. + */ + public function testPatchWithInputDtoOnlyUpdatessentFields(): void + { + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested'); + } + + $this->recreateSchema([MappedPatchEntity::class]); + + $manager = $this->getManager(); + $entity = new MappedPatchEntity(); + $entity->name = 'original name'; + $entity->description = 'original description'; + $manager->persist($entity); + $manager->flush(); + + $id = $entity->id; + + // PATCH only the name — description should remain unchanged + self::createClient()->request('PATCH', '/mapped_patch_resources/'.$id, [ + 'headers' => ['content-type' => 'application/merge-patch+json'], + 'json' => ['name' => 'updated name'], + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'name' => 'updated name', + 'description' => 'original description', + ]); + } } diff --git a/tests/State/Provider/ObjectMapperProviderTest.php b/tests/State/Provider/ObjectMapperProviderTest.php index 1cf360040ef..672134c7d39 100644 --- a/tests/State/Provider/ObjectMapperProviderTest.php +++ b/tests/State/Provider/ObjectMapperProviderTest.php @@ -169,22 +169,22 @@ public function testProvideMapsPaginator(): void $this->assertSame($targetResource2, $items[1]); } - public function testProvideMapsToInputClassWhenInputIsSet(): void + public function testProvideIgnoresInputClassAndMapsToOutputClass(): void { $sourceEntity = new SourceEntity(); - $inputResource = new InputResource(); + $outputResource = new OutputResource(); $operation = new Patch(class: TargetResource::class, input: ['class' => InputResource::class], output: ['class' => OutputResource::class], map: true); $objectMapper = $this->createMock(ObjectMapperInterface::class); $objectMapper->expects($this->once()) ->method('map') - ->with($sourceEntity, InputResource::class) - ->willReturn($inputResource); + ->with($sourceEntity, OutputResource::class) + ->willReturn($outputResource); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn($sourceEntity); $provider = new ObjectMapperProvider($objectMapper, $decorated); $result = $provider->provide($operation); - $this->assertSame($inputResource, $result); + $this->assertSame($outputResource, $result); } public function testProvideMapsToOutputClassWhenNoInput(): void @@ -205,6 +205,24 @@ public function testProvideMapsToOutputClassWhenNoInput(): void $this->assertSame($outputResource, $result); } + public function testProvideMapsToResourceClassWhenInputSetButNoOutput(): void + { + $sourceEntity = new SourceEntity(); + $targetResource = new TargetResource(); + $operation = new Patch(class: TargetResource::class, input: ['class' => InputResource::class], map: true); + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($sourceEntity, TargetResource::class) + ->willReturn($targetResource); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($sourceEntity); + $provider = new ObjectMapperProvider($objectMapper, $decorated); + + $result = $provider->provide($operation); + $this->assertSame($targetResource, $result); + } + public function testProvideMapsEmptyArray(): void { $operation = new Get(class: TargetResource::class, map: true);