Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/State/Provider/ObjectMapperProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions tests/Fixtures/TestBundle/ApiResource/MappedPatchResource.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\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;
}
29 changes: 29 additions & 0 deletions tests/Fixtures/TestBundle/Dto/MappedPatchInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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\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;
}
31 changes: 31 additions & 0 deletions tests/Fixtures/TestBundle/Entity/MappedPatchEntity.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 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;
}
41 changes: 41 additions & 0 deletions tests/Functional/MappingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -61,6 +63,7 @@ public static function getResources(): array
MappedResourceNoMap::class,
BookDto::class,
BookStoreResource::class,
MappedPatchResource::class,
];
}

Expand Down Expand Up @@ -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',
]);
}
}
28 changes: 23 additions & 5 deletions tests/State/Provider/ObjectMapperProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
Loading