From c535ae19b9c5923120bf0bd85b9022d9fd9526f2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 13 Nov 2026 15:52:51 +0100 Subject: [PATCH] fix(state): map to the resource on mapped write operations With a resource-level output, ObjectMapperProvider handed the output class to PATCH, PUT and DELETE: DELETE did not remove the entity and PATCH overwrote unsent fields. --- src/State/Provider/ObjectMapperProvider.php | 5 +- .../ApiResource/MappedResourceLevelOutput.php | 39 +++++++++ .../Dto/MappedResourceLevelOutputDto.php | 21 +++++ .../MappedResourceLevelOutputEntity.php | 61 ++++++++++++++ .../MappedResourceLevelOutputTest.php | 79 +++++++++++++++++++ .../Provider/ObjectMapperProviderTest.php | 2 +- 6 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/MappedResourceLevelOutput.php create mode 100644 tests/Fixtures/TestBundle/Dto/MappedResourceLevelOutputDto.php create mode 100644 tests/Fixtures/TestBundle/Entity/MappedResourceLevelOutputEntity.php create mode 100644 tests/Functional/MappedResourceLevelOutputTest.php diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index 9986f43954f..4f927a6ebfd 100644 --- a/src/State/Provider/ObjectMapperProvider.php +++ b/src/State/Provider/ObjectMapperProvider.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Provider; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\CloneTrait; use ApiPlatform\State\Pagination\MappedObjectPaginator; @@ -41,7 +42,9 @@ public function __construct( public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $data = $this->decorated->provide($operation, $uriVariables, $context); - $class = $operation->getOutput()['class'] ?? $operation->getClass(); + $class = $operation instanceof HttpOperation && !\in_array($operation->getMethod(), [HttpOperation::METHOD_GET, HttpOperation::METHOD_HEAD, HttpOperation::METHOD_OPTIONS, HttpOperation::METHOD_QUERY], true) + ? $operation->getClass() + : ($operation->getOutput()['class'] ?? $operation->getClass()); if (!$this->objectMapper || !$operation->canMap()) { return $data; diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceLevelOutput.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceLevelOutput.php new file mode 100644 index 00000000000..74e0bc4f19b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceLevelOutput.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; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\MappedResourceLevelOutputDto; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceLevelOutputEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(entityClass: MappedResourceLevelOutputEntity::class), + output: MappedResourceLevelOutputDto::class, + operations: [new Get(), new Patch(), new Delete()], +)] +#[Map(target: MappedResourceLevelOutputEntity::class)] +final class MappedResourceLevelOutput +{ + #[Map(if: false)] + public ?int $id = null; + + public ?string $name = null; + + public ?string $description = null; +} diff --git a/tests/Fixtures/TestBundle/Dto/MappedResourceLevelOutputDto.php b/tests/Fixtures/TestBundle/Dto/MappedResourceLevelOutputDto.php new file mode 100644 index 00000000000..2b2dad209fd --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/MappedResourceLevelOutputDto.php @@ -0,0 +1,21 @@ + + * + * 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; + +final class MappedResourceLevelOutputDto +{ + public ?int $id = null; + + public string $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedResourceLevelOutputEntity.php b/tests/Fixtures/TestBundle/Entity/MappedResourceLevelOutputEntity.php new file mode 100644 index 00000000000..60e71033779 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedResourceLevelOutputEntity.php @@ -0,0 +1,61 @@ + + * + * 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 ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceLevelOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Dto\MappedResourceLevelOutputDto; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ORM\Entity] +#[Map(target: MappedResourceLevelOutput::class)] +#[Map(target: MappedResourceLevelOutputDto::class)] +class MappedResourceLevelOutputEntity +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column] + private string $name; + + #[ORM\Column] + private string $description; + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } +} diff --git a/tests/Functional/MappedResourceLevelOutputTest.php b/tests/Functional/MappedResourceLevelOutputTest.php new file mode 100644 index 00000000000..3b0fe508d0c --- /dev/null +++ b/tests/Functional/MappedResourceLevelOutputTest.php @@ -0,0 +1,79 @@ + + * + * 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\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MappedResourceLevelOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedResourceLevelOutputEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MappedResourceLevelOutputTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MappedResourceLevelOutput::class]; + } + + public function testDeleteRemovesEntityWhenOutputIsDeclared(): void + { + $id = $this->persistEntity('before', 'kept'); + + self::createClient()->request('DELETE', '/mapped_resource_level_outputs/'.$id); + + $this->assertResponseIsSuccessful(); + $this->assertNull($this->getManager()->getRepository(MappedResourceLevelOutputEntity::class)->find($id)); + } + + public function testPatchKeepsUnsentFieldsWhenOutputIsDeclared(): void + { + $id = $this->persistEntity('before', 'kept'); + + self::createClient()->request('PATCH', '/mapped_resource_level_outputs/'.$id, [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'after'], + ]); + + $this->assertResponseIsSuccessful(); + $entity = $this->getManager()->getRepository(MappedResourceLevelOutputEntity::class)->find($id); + $this->assertSame('after', $entity->getName()); + $this->assertSame('kept', $entity->getDescription()); + } + + private function persistEntity(string $name, string $description): int + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested.'); + } + + $this->recreateSchema([MappedResourceLevelOutputEntity::class]); + $manager = $this->getManager(); + $entity = new MappedResourceLevelOutputEntity(); + $entity->setName($name); + $entity->setDescription($description); + $manager->persist($entity); + $manager->flush(); + $id = $entity->getId(); + $manager->clear(); + + return $id; + } +} diff --git a/tests/State/Provider/ObjectMapperProviderTest.php b/tests/State/Provider/ObjectMapperProviderTest.php index 29436fd75ab..be78479d004 100644 --- a/tests/State/Provider/ObjectMapperProviderTest.php +++ b/tests/State/Provider/ObjectMapperProviderTest.php @@ -180,7 +180,7 @@ public function testProvideIgnoresInputClassAndMapsToOutputClass(): void { $sourceEntity = new SourceEntity(); $outputResource = new OutputResource(); - $operation = new Patch(class: TargetResource::class, input: ['class' => InputResource::class], output: ['class' => OutputResource::class], map: true); + $operation = new Get(class: TargetResource::class, input: ['class' => InputResource::class], output: ['class' => OutputResource::class], map: true); $objectMapper = $this->createMock(ObjectMapperInterface::class); $objectMapper->expects($this->once()) ->method('map')