Skip to content

Commit e7502b6

Browse files
authored
fix(serializer): nested denormalization when allow_extra_attributes=false (#7270)
1 parent 4f717c1 commit e7502b6

File tree

4 files changed

+241
-0
lines changed

4 files changed

+241
-0
lines changed

src/Serializer/ItemNormalizer.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ public function denormalize(mixed $data, string $class, ?string $format = null,
6868
}
6969
}
7070

71+
// See https://github.com/api-platform/core/pull/7270 - id may be an allowed attribute due to being added in the
72+
// overridden getAllowedAttributes below, in order to allow updating a nested item via ID. But in this case it
73+
// may not "really" be an allowed attribute, ie we don't want to actually use it in denormalization. In this
74+
// scenario it will not be present in parent::getAllowedAttributes
75+
if (isset($data['id'], $context['resource_class'])) {
76+
$parentAllowedAttributes = parent::getAllowedAttributes($class, $context, true);
77+
if (\is_array($parentAllowedAttributes) && !\in_array('id', $parentAllowedAttributes, true)) {
78+
unset($data['id']);
79+
}
80+
}
81+
7182
return parent::denormalize($data, $class, $format, $context);
7283
}
7384

@@ -107,4 +118,14 @@ private function getContextUriVariables(array $data, $operation, array $context)
107118

108119
return $uriVariables;
109120
}
121+
122+
protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
123+
{
124+
$allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
125+
if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) {
126+
$allowedAttributes = array_merge($allowedAttributes, ['id']);
127+
}
128+
129+
return $allowedAttributes;
130+
}
110131
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use Doctrine\ORM\Mapping as ORM;
18+
use Symfony\Component\Serializer\Annotation\Groups;
19+
use Symfony\Component\Uid\Uuid;
20+
21+
#[ORM\Entity]
22+
#[ORM\Table(name: 'bar6225')]
23+
#[ApiResource]
24+
class Bar6225
25+
{
26+
public function __construct()
27+
{
28+
$this->id = Uuid::v7();
29+
}
30+
31+
#[ORM\Id]
32+
#[ORM\Column(type: 'symfony_uuid', unique: true)]
33+
#[Groups(['Foo:Read'])]
34+
private Uuid $id;
35+
36+
#[ORM\OneToOne(mappedBy: 'bar', cascade: ['persist', 'remove'])]
37+
private ?Foo6225 $foo;
38+
39+
#[ORM\Column(length: 255)]
40+
#[Groups(['Foo:Write', 'Foo:Read'])]
41+
private string $someProperty;
42+
43+
public function getId(): Uuid
44+
{
45+
return $this->id;
46+
}
47+
48+
public function getFoo(): Foo6225
49+
{
50+
return $this->foo;
51+
}
52+
53+
public function setFoo(Foo6225 $foo): static
54+
{
55+
$this->foo = $foo;
56+
57+
return $this;
58+
}
59+
60+
public function getSomeProperty(): string
61+
{
62+
return $this->someProperty;
63+
}
64+
65+
public function setSomeProperty(string $someProperty): static
66+
{
67+
$this->someProperty = $someProperty;
68+
69+
return $this;
70+
}
71+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Patch;
18+
use ApiPlatform\Metadata\Post;
19+
use Doctrine\ORM\Mapping as ORM;
20+
use Symfony\Component\Serializer\Annotation\Groups;
21+
use Symfony\Component\Uid\Uuid;
22+
23+
#[ORM\Entity()]
24+
#[ORM\Table(name: 'foo6225')]
25+
#[ApiResource(
26+
operations: [
27+
new Post(),
28+
new Patch(),
29+
],
30+
normalizationContext: [
31+
'groups' => ['Foo:Read'],
32+
],
33+
denormalizationContext: [
34+
'allow_extra_attributes' => false,
35+
'groups' => ['Foo:Write'],
36+
],
37+
)]
38+
class Foo6225
39+
{
40+
public function __construct()
41+
{
42+
$this->id = Uuid::v7();
43+
}
44+
45+
#[ORM\Id]
46+
#[ORM\Column(type: 'symfony_uuid', unique: true)]
47+
#[Groups(['Foo:Read'])]
48+
private Uuid $id;
49+
50+
#[ORM\OneToOne(inversedBy: 'foo', cascade: ['persist', 'remove'])]
51+
#[ORM\JoinColumn(nullable: false)]
52+
#[Groups(['Foo:Write', 'Foo:Read'])]
53+
private Bar6225 $bar;
54+
55+
public function getId(): Uuid
56+
{
57+
return $this->id;
58+
}
59+
60+
public function getBar(): Bar6225
61+
{
62+
return $this->bar;
63+
}
64+
65+
public function setBar(Bar6225 $bar): static
66+
{
67+
$this->bar = $bar;
68+
69+
return $this;
70+
}
71+
}

tests/Functional/NestedPatchTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225\Bar6225;
18+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225\Foo6225;
19+
use ApiPlatform\Tests\RecreateSchemaTrait;
20+
use ApiPlatform\Tests\SetupClassResourcesTrait;
21+
22+
class NestedPatchTest extends ApiTestCase
23+
{
24+
use RecreateSchemaTrait;
25+
use SetupClassResourcesTrait;
26+
27+
protected static ?bool $alwaysBootKernel = false;
28+
29+
public static function getResources(): array
30+
{
31+
return [Foo6225::class, Bar6225::class];
32+
}
33+
34+
public function testIssue6225(): void
35+
{
36+
if ($this->isMongoDB()) {
37+
$this->markTestSkipped();
38+
}
39+
40+
$this->recreateSchema(self::getResources());
41+
42+
$response = self::createClient()->request('POST', '/foo6225s', [
43+
'json' => [
44+
'bar' => [
45+
'someProperty' => 'abc',
46+
],
47+
],
48+
'headers' => [
49+
'accept' => 'application/json',
50+
],
51+
]);
52+
static::assertResponseIsSuccessful();
53+
$responseContent = json_decode($response->getContent(), true);
54+
$createdFooId = $responseContent['id'];
55+
$createdBarId = $responseContent['bar']['id'];
56+
57+
$patchResponse = self::createClient()->request('PATCH', '/foo6225s/'.$createdFooId, [
58+
'json' => [
59+
'bar' => [
60+
'id' => $createdBarId,
61+
'someProperty' => 'def',
62+
],
63+
],
64+
'headers' => [
65+
'accept' => 'application/json',
66+
'content-type' => 'application/merge-patch+json',
67+
],
68+
]);
69+
static::assertResponseIsSuccessful();
70+
static::assertEquals([
71+
'id' => $createdFooId,
72+
'bar' => [
73+
'id' => $createdBarId,
74+
'someProperty' => 'def',
75+
],
76+
], json_decode($patchResponse->getContent(), true));
77+
}
78+
}

0 commit comments

Comments
 (0)