diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index c426584aa8..1cc8452a98 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -68,6 +68,17 @@ public function denormalize(mixed $data, string $class, ?string $format = null, } } + // See https://github.com/api-platform/core/pull/7270 - id may be an allowed attribute due to being added in the + // overridden getAllowedAttributes below, in order to allow updating a nested item via ID. But in this case it + // may not "really" be an allowed attribute, ie we don't want to actually use it in denormalization. In this + // scenario it will not be present in parent::getAllowedAttributes + if (isset($data['id'], $context['resource_class'])) { + $parentAllowedAttributes = parent::getAllowedAttributes($class, $context, true); + if (\is_array($parentAllowedAttributes) && !\in_array('id', $parentAllowedAttributes, true)) { + unset($data['id']); + } + } + return parent::denormalize($data, $class, $format, $context); } @@ -107,4 +118,14 @@ private function getContextUriVariables(array $data, $operation, array $context) return $uriVariables; } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { + $allowedAttributes = array_merge($allowedAttributes, ['id']); + } + + return $allowedAttributes; + } } diff --git a/tests/Fixtures/TestBundle/Entity/Issue6225/Bar6225.php b/tests/Fixtures/TestBundle/Entity/Issue6225/Bar6225.php new file mode 100644 index 0000000000..6a5b06df56 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue6225/Bar6225.php @@ -0,0 +1,71 @@ + + * + * 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\Issue6225; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Uid\Uuid; + +#[ORM\Entity] +#[ORM\Table(name: 'bar6225')] +#[ApiResource] +class Bar6225 +{ + public function __construct() + { + $this->id = Uuid::v7(); + } + + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid', unique: true)] + #[Groups(['Foo:Read'])] + private Uuid $id; + + #[ORM\OneToOne(mappedBy: 'bar', cascade: ['persist', 'remove'])] + private ?Foo6225 $foo; + + #[ORM\Column(length: 255)] + #[Groups(['Foo:Write', 'Foo:Read'])] + private string $someProperty; + + public function getId(): Uuid + { + return $this->id; + } + + public function getFoo(): Foo6225 + { + return $this->foo; + } + + public function setFoo(Foo6225 $foo): static + { + $this->foo = $foo; + + return $this; + } + + public function getSomeProperty(): string + { + return $this->someProperty; + } + + public function setSomeProperty(string $someProperty): static + { + $this->someProperty = $someProperty; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Issue6225/Foo6225.php b/tests/Fixtures/TestBundle/Entity/Issue6225/Foo6225.php new file mode 100644 index 0000000000..7f625901db --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue6225/Foo6225.php @@ -0,0 +1,71 @@ + + * + * 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\Issue6225; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Uid\Uuid; + +#[ORM\Entity()] +#[ORM\Table(name: 'foo6225')] +#[ApiResource( + operations: [ + new Post(), + new Patch(), + ], + normalizationContext: [ + 'groups' => ['Foo:Read'], + ], + denormalizationContext: [ + 'allow_extra_attributes' => false, + 'groups' => ['Foo:Write'], + ], +)] +class Foo6225 +{ + public function __construct() + { + $this->id = Uuid::v7(); + } + + #[ORM\Id] + #[ORM\Column(type: 'symfony_uuid', unique: true)] + #[Groups(['Foo:Read'])] + private Uuid $id; + + #[ORM\OneToOne(inversedBy: 'foo', cascade: ['persist', 'remove'])] + #[ORM\JoinColumn(nullable: false)] + #[Groups(['Foo:Write', 'Foo:Read'])] + private Bar6225 $bar; + + public function getId(): Uuid + { + return $this->id; + } + + public function getBar(): Bar6225 + { + return $this->bar; + } + + public function setBar(Bar6225 $bar): static + { + $this->bar = $bar; + + return $this; + } +} diff --git a/tests/Functional/NestedPatchTest.php b/tests/Functional/NestedPatchTest.php new file mode 100644 index 0000000000..52fc1c756c --- /dev/null +++ b/tests/Functional/NestedPatchTest.php @@ -0,0 +1,78 @@ + + * + * 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\Entity\Issue6225\Bar6225; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225\Foo6225; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class NestedPatchTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Foo6225::class, Bar6225::class]; + } + + public function testIssue6225(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema(self::getResources()); + + $response = self::createClient()->request('POST', '/foo6225s', [ + 'json' => [ + 'bar' => [ + 'someProperty' => 'abc', + ], + ], + 'headers' => [ + 'accept' => 'application/json', + ], + ]); + static::assertResponseIsSuccessful(); + $responseContent = json_decode($response->getContent(), true); + $createdFooId = $responseContent['id']; + $createdBarId = $responseContent['bar']['id']; + + $patchResponse = self::createClient()->request('PATCH', '/foo6225s/'.$createdFooId, [ + 'json' => [ + 'bar' => [ + 'id' => $createdBarId, + 'someProperty' => 'def', + ], + ], + 'headers' => [ + 'accept' => 'application/json', + 'content-type' => 'application/merge-patch+json', + ], + ]); + static::assertResponseIsSuccessful(); + static::assertEquals([ + 'id' => $createdFooId, + 'bar' => [ + 'id' => $createdBarId, + 'someProperty' => 'def', + ], + ], json_decode($patchResponse->getContent(), true)); + } +}