Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Serializer/SerializerContextBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
2 changes: 1 addition & 1 deletion src/State/Processor/ObjectMapperOutputProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 7 additions & 1 deletion src/State/Provider/ObjectMapperProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions src/State/Tests/Processor/ObjectMapperOutputProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -136,3 +161,7 @@ public function testProcessSetsPersistedDataOnRequest(): void
class ObjectMapperOutputDummy
{
}

class ObjectMapperOutputDtoDummy
{
}
17 changes: 17 additions & 0 deletions src/State/Util/HttpResponseHeadersTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/MappedResourceWithOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
28 changes: 28 additions & 0 deletions tests/Fixtures/TestBundle/Dto/MappedOutputDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
31 changes: 31 additions & 0 deletions tests/Fixtures/TestBundle/Entity/MappedOutputEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;
}
120 changes: 120 additions & 0 deletions tests/Functional/MappedResourceOutputTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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');
}
}
12 changes: 7 additions & 5 deletions tests/State/Provider/ObjectMapperProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading