diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 63ee797f426..63b5b068bde 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Error as ErrorOperation; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Util\AttributesExtractor; use ApiPlatform\State\SerializerContextBuilderInterface; @@ -70,6 +71,19 @@ public function createFromRequest(Request $request, bool $normalization, ?array // Special case as this is usually handled by our OperationContextTrait, here we want to force the IRI in the response if (!$operation instanceof CollectionOperationInterface && method_exists($operation, 'getItemUriTemplate') && $operation->getItemUriTemplate()) { $context['item_uri_template'] = $operation->getItemUriTemplate(); + } elseif ( + $normalization + && $operation instanceof HttpOperation + && !$operation instanceof CollectionOperationInterface + && !method_exists($operation, 'getItemUriTemplate') + && $operation->canMap() + && null !== ($context['output']['class'] ?? null) + && !\in_array($operation->getMethod(), ['GET', 'HEAD', 'OPTIONS'], true) + && $operation->getUriTemplate() + ) { + // A mapped output DTO on an item write operation (PUT/PATCH): the operation's own + // URI template is the item template, use it to generate the IRI of the DTO. + $context['item_uri_template'] = $operation->getUriTemplate(); } if ($types = $operation->getTypes()) { diff --git a/src/State/Processor/ObjectMapperOutputProcessor.php b/src/State/Processor/ObjectMapperOutputProcessor.php index 91a4fa169eb..7076acb00f8 100644 --- a/src/State/Processor/ObjectMapperOutputProcessor.php +++ b/src/State/Processor/ObjectMapperOutputProcessor.php @@ -48,7 +48,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $request = $context['request'] ?? null; $request?->attributes->set('persisted_data', $data); - $dto = $this->objectMapper->map($data, $operation->getClass()); + $dto = $this->objectMapper->map($data, $operation->getOutput()['class'] ?? $operation->getClass()); return $this->decorated ? $this->decorated->process($dto, $operation, $uriVariables, $context) : $dto; } diff --git a/src/State/Provider/ObjectMapperProvider.php b/src/State/Provider/ObjectMapperProvider.php index 9986f43954f..572a548e6ce 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,12 @@ 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(); + + // On write operations the provided data is the deserialization target (object to populate), + // it must stay an instance of the resource class; the output class is mapped to after + // persistence by the ObjectMapperOutputProcessor. + $isWrite = $operation instanceof HttpOperation && !\in_array($operation->getMethod(), ['GET', 'HEAD', 'OPTIONS'], true); + $class = $isWrite ? $operation->getClass() : ($operation->getOutput()['class'] ?? $operation->getClass()); if (!$this->objectMapper || !$operation->canMap()) { return $data; diff --git a/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php b/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php index 3ed28bf1f1e..8fea6acc90e 100644 --- a/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php +++ b/src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php @@ -107,6 +107,31 @@ public function testProcessMapsEntityToDto(): void $this->assertSame($result, $processor->process($entity, $operation)); } + public function testProcessMapsEntityToDefinedOutputClass(): void + { + $entity = new \stdClass(); + $entity->id = 1; + $dto = new \stdClass(); + $dto->id = 1; + $result = new \stdClass(); + $operation = new Post(class: ObjectMapperOutputDummy::class, output: ['class' => ObjectMapperOutputDtoDummy::class], map: true); + + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->once()) + ->method('map') + ->with($entity, ObjectMapperOutputDtoDummy::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(); @@ -136,3 +161,7 @@ public function testProcessSetsPersistedDataOnRequest(): void class ObjectMapperOutputDummy { } + +class ObjectMapperOutputDtoDummy +{ +} diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index 6b0190c50b6..e77c29d2f60 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Util; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -118,6 +119,9 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $iri = null; if ($hasData) { $iri = $this->iriConverter->getIriFromResource($originalData); + } elseif (\is_object($originalData) && ($itemUriTemplate = $this->getMappedOutputItemUriTemplate($operation))) { + // A mapped (non-resource) output DTO: derive the item IRI from the operation's item URI template + $iri = $this->iriConverter->getIriFromResource($originalData, UrlGeneratorInterface::ABS_PATH, null, ['item_uri_template' => $itemUriTemplate]); } elseif ($operation->getClass()) { $iri = $this->iriConverter->getIriFromResource($operation->getClass(), UrlGeneratorInterface::ABS_PATH, $operation); } @@ -148,6 +152,19 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c return $headers; } + private function getMappedOutputItemUriTemplate(HttpOperation $operation): ?string + { + if (!$operation->canMap() || null === ($operation->getOutput()['class'] ?? null)) { + return null; + } + + if (method_exists($operation, 'getItemUriTemplate')) { + return $operation->getItemUriTemplate(); + } + + return $operation instanceof CollectionOperationInterface ? null : $operation->getUriTemplate(); + } + private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void { if (!$this->resourceMetadataCollectionFactory) { diff --git a/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithOutput.php b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithOutput.php new file mode 100644 index 00000000000..10dc03380b3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/MappedResourceWithOutput.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\MappedOutputDto; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedOutputEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[ApiResource( + stateOptions: new Options(entityClass: MappedOutputEntity::class), + operations: [ + new Get(), + new Post(output: MappedOutputDto::class, itemUriTemplate: '/mapped_resource_with_outputs/{id}{._format}'), + new Patch(output: MappedOutputDto::class), + ], + normalizationContext: ['hydra_prefix' => false], +)] +#[Map(target: MappedOutputEntity::class)] +class MappedResourceWithOutput +{ + #[Map(if: false)] + public ?int $id = null; + + public ?string $name = null; + + public ?string $description = null; +} diff --git a/tests/Fixtures/TestBundle/Dto/MappedOutputDto.php b/tests/Fixtures/TestBundle/Dto/MappedOutputDto.php new file mode 100644 index 00000000000..57fb24ac402 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/MappedOutputDto.php @@ -0,0 +1,28 @@ + + * + * 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\MappedOutputEntity; +use Symfony\Component\ObjectMapper\Attribute\Map; + +/** + * Output DTO mapped from the entity: exposes only "name", not "description". + */ +#[Map(source: MappedOutputEntity::class)] +class MappedOutputDto +{ + public ?int $id = null; + + public ?string $name = null; +} diff --git a/tests/Fixtures/TestBundle/Entity/MappedOutputEntity.php b/tests/Fixtures/TestBundle/Entity/MappedOutputEntity.php new file mode 100644 index 00000000000..9ea0422fa49 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MappedOutputEntity.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 MappedOutputEntity +{ + #[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/MappedResourceOutputTest.php b/tests/Functional/MappedResourceOutputTest.php new file mode 100644 index 00000000000..97ffddf0a15 --- /dev/null +++ b/tests/Functional/MappedResourceOutputTest.php @@ -0,0 +1,120 @@ + + * + * 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\MappedResourceWithOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MappedOutputEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Verifies the behavior of write operations declaring an `output` DTO with the ObjectMapper. + */ +final class MappedResourceOutputTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MappedResourceWithOutput::class]; + } + + public function testPostWithOutputDtoReturnsOutputDto(): void + { + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested'); + } + + $this->recreateSchema([MappedOutputEntity::class]); + + $client = self::createClient(); + $response = $client->request('POST', '/mapped_resource_with_outputs', [ + 'json' => ['name' => 'a name', 'description' => 'a description'], + ]); + + fwrite(\STDERR, "\n=== POST status: ".$response->getStatusCode()."\n"); + fwrite(\STDERR, "=== POST response body ===\n".substr($response->getContent(false), 0, 2000)."\n"); + fwrite(\STDERR, '=== Location header: '.var_export($response->getHeaders(false)['location'][0] ?? null, true)."\n"); + fwrite(\STDERR, '=== Content-Location header: '.var_export($response->getHeaders(false)['content-location'][0] ?? null, true)."\n"); + + self::assertResponseStatusCodeSame(201); + $data = $response->toArray(false); + + // The output DTO exposes "name" but not "description" + $this->assertArrayHasKey('name', $data); + $this->assertArrayNotHasKey('description', $data, 'Response should be the output DTO, not the resource'); + + // Item IRI expected on a 201, not the collection IRI + $location = $response->getHeaders(false)['location'][0] ?? null; + $this->assertNotNull($location); + $this->assertMatchesRegularExpression('~^/mapped_resource_with_outputs/\d+$~', $location, 'Location must be the item IRI'); + $this->assertSame($location, $data['@id'] ?? null, '@id must be the item IRI'); + } + + public function testPatchWithOutputDtoPreservesUnsentFields(): void + { + if (!$this->getContainer()->has('api_platform.object_mapper')) { + $this->markTestSkipped('ObjectMapper not installed'); + } + + if ($this->isMongoDB()) { + $this->markTestSkipped('MongoDB not tested'); + } + + $this->recreateSchema([MappedOutputEntity::class]); + + $manager = $this->getManager(); + $entity = new MappedOutputEntity(); + $entity->name = 'original name'; + $entity->description = 'original description'; + $manager->persist($entity); + $manager->flush(); + $id = $entity->id; + $manager->clear(); + + $client = self::createClient(); + $response = $client->request('PATCH', '/mapped_resource_with_outputs/'.$id, [ + 'headers' => ['content-type' => 'application/merge-patch+json'], + 'json' => ['name' => 'updated name'], + ]); + + fwrite(\STDERR, "\n=== PATCH status: ".$response->getStatusCode()."\n"); + fwrite(\STDERR, "=== PATCH response body ===\n".substr($response->getContent(false), 0, 2000)."\n"); + + self::assertResponseIsSuccessful(); + $data = $response->toArray(false); + + // The response must be the output DTO with a proper item IRI + $this->assertArrayHasKey('name', $data); + $this->assertArrayNotHasKey('description', $data, 'Response should be the output DTO, not the resource'); + $this->assertSame('/mapped_resource_with_outputs/'.$id, $data['@id']); + + // PATCH semantics: unsent fields must be preserved on the entity + $manager = $this->getManager(); + $manager->clear(); + $persisted = $manager->getRepository(MappedOutputEntity::class)->find($id); + $this->assertSame('updated name', $persisted->name); + $this->assertSame('original description', $persisted->description, 'PATCH must not touch fields the client did not send'); + } +} diff --git a/tests/State/Provider/ObjectMapperProviderTest.php b/tests/State/Provider/ObjectMapperProviderTest.php index 672134c7d39..f005c8f98cc 100644 --- a/tests/State/Provider/ObjectMapperProviderTest.php +++ b/tests/State/Provider/ObjectMapperProviderTest.php @@ -169,22 +169,24 @@ public function testProvideMapsPaginator(): void $this->assertSame($targetResource2, $items[1]); } - public function testProvideIgnoresInputClassAndMapsToOutputClass(): void + public function testProvideIgnoresInputAndOutputClassesOnWrite(): void { $sourceEntity = new SourceEntity(); - $outputResource = new OutputResource(); + $targetResource = new TargetResource(); $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, OutputResource::class) - ->willReturn($outputResource); + ->with($sourceEntity, TargetResource::class) + ->willReturn($targetResource); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn($sourceEntity); $provider = new ObjectMapperProvider($objectMapper, $decorated); + // On a write operation the provided data is the deserialization target: it must be + // the resource class, the output class is only mapped to after persistence. $result = $provider->provide($operation); - $this->assertSame($outputResource, $result); + $this->assertSame($targetResource, $result); } public function testProvideMapsToOutputClassWhenNoInput(): void