Skip to content

Commit fa5b61e

Browse files
committed
fix(metadata): compute isWritable during updates
Doctrine should not assume an `id` is not writable during updates. PropertyInfo should not assume a property is not writable (for now only during updates) but as this has a direct impact on serialization groups maybe we should just skip readable/writable in the PropertyInfoPropertyMetadataFactory. SerializerPropertyMetadataFactory should run after AttributePropertyMetadataFactory or we can not override this behavior. Mayabe that we could just simplify these and never set `writable` (or `readable`) from Doctrine or PropertyInfo actually (and we won't need to add the api_allow_update to the getFactoryOptions...). fixes #7382
1 parent 949c3c9 commit fa5b61e

File tree

9 files changed

+126
-16
lines changed

9 files changed

+126
-16
lines changed

src/Doctrine/Odm/Metadata/Property/DoctrineMongoDbOdmPropertyMetadataFactory.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ public function create(string $resourceClass, string $property, array $options =
5757
break;
5858
}
5959

60+
if ($options['api_allow_update'] ?? false) {
61+
break;
62+
}
63+
6064
$propertyMetadata = $propertyMetadata->withWritable(false);
6165

6266
break;

src/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ public function create(string $resourceClass, string $property, array $options =
5656
break;
5757
}
5858

59+
if ($options['api_allow_update'] ?? false) {
60+
break;
61+
}
62+
5963
if ($doctrineClassMetadata instanceof ClassMetadata) {
6064
$writable = $doctrineClassMetadata->isIdentifierNatural();
6165
} else {

src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ public function create(string $resourceClass, string $property, array $options =
6666
$propertyMetadata = $propertyMetadata->withReadable($readable);
6767
}
6868

69-
if (null === $propertyMetadata->isWritable() && null !== $writable = $this->propertyInfo->isWritable($resourceClass, $property, $options)) {
69+
// A property might not be writable and we can still want to update it,
70+
// this leaves the choice to the SerializerPropertyMetadataFactory
71+
if (false === ($options['api_allow_update'] ?? false) && null === $propertyMetadata->isWritable() && null !== $writable = $this->propertyInfo->isWritable($resourceClass, $property, $options)) {
7072
$propertyMetadata = $propertyMetadata->withWritable($writable);
7173
}
7274

src/Serializer/AbstractItemNormalizer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,7 @@ protected function denormalizeRelation(string $attributeName, ApiProperty $prope
622622
*/
623623
protected function getFactoryOptions(array $context): array
624624
{
625-
$options = [];
625+
$options = ['api_allow_update' => $context['api_allow_update'] ?? false];
626626
if (isset($context[self::GROUPS])) {
627627
/* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */
628628
$options['serializer_groups'] = (array) $context[self::GROUPS];

src/Serializer/ItemNormalizer.php

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,4 @@ private function getContextUriVariables(array $data, $operation, array $context)
115115

116116
return $uriVariables;
117117
}
118-
119-
protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool
120-
{
121-
$allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString);
122-
// id is a special case handled above it causes issues not allowing it
123-
if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false) && !\in_array('id', $allowedAttributes, true)) {
124-
$allowedAttributes[] = 'id';
125-
}
126-
127-
return $allowedAttributes;
128-
}
129118
}

src/Symfony/Bundle/Resources/config/metadata/property.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<argument type="service" id="api_platform.metadata.property.metadata_factory.property_info.inner" />
1414
</service>
1515

16-
<service id="api_platform.metadata.property.metadata_factory.attribute" decorates="api_platform.metadata.property.metadata_factory" decoration-priority="20" class="ApiPlatform\Metadata\Property\Factory\AttributePropertyMetadataFactory" public="false">
16+
<service id="api_platform.metadata.property.metadata_factory.attribute" decorates="api_platform.metadata.property.metadata_factory" decoration-priority="35" class="ApiPlatform\Metadata\Property\Factory\AttributePropertyMetadataFactory" public="false">
1717
<argument type="service" id="api_platform.metadata.property.metadata_factory.attribute.inner" />
1818
</service>
1919

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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;
15+
16+
use ApiPlatform\Metadata\ApiProperty;
17+
use ApiPlatform\Metadata\ApiResource;
18+
use Doctrine\ORM\Mapping as ORM;
19+
20+
#[ApiResource(
21+
denormalizationContext: [
22+
'allow_extra_attributes' => false,
23+
],
24+
)]
25+
#[ORM\Entity]
26+
class Camp
27+
{
28+
#[ORM\Id]
29+
#[ORM\Column(type: 'integer')]
30+
#[ORM\GeneratedValue]
31+
#[ApiProperty(writable: false)]
32+
private ?int $id = null;
33+
34+
#[ORM\Column(type: 'string', length: 255)]
35+
private ?string $name = null;
36+
37+
public function getId(): ?int
38+
{
39+
return $this->id;
40+
}
41+
42+
public function getName(): ?string
43+
{
44+
return $this->name;
45+
}
46+
47+
public function setName(string $name): self
48+
{
49+
$this->name = $name;
50+
51+
return $this;
52+
}
53+
}

tests/Fixtures/TestBundle/Entity/Issue6225/Bar6225.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function __construct()
3030

3131
#[ORM\Id]
3232
#[ORM\Column(type: 'symfony_uuid', unique: true)]
33-
#[Groups(['Foo:Read'])]
33+
#[Groups(['Foo:Read', 'Foo:Write'])]
3434
private Uuid $id;
3535

3636
#[ORM\OneToOne(mappedBy: 'bar', cascade: ['persist', 'remove'])]

tests/Functional/NestedPatchTest.php

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Tests\Functional;
1515

1616
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Camp;
1718
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225\Bar6225;
1819
use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6225\Foo6225;
1920
use ApiPlatform\Tests\RecreateSchemaTrait;
@@ -28,7 +29,7 @@ class NestedPatchTest extends ApiTestCase
2829

2930
public static function getResources(): array
3031
{
31-
return [Foo6225::class, Bar6225::class];
32+
return [Foo6225::class, Bar6225::class, Camp::class];
3233
}
3334

3435
public function testIssue6225(): void
@@ -75,4 +76,61 @@ public function testIssue6225(): void
7576
],
7677
], json_decode($patchResponse->getContent(), true));
7778
}
79+
80+
public function testIdNotWriteable(): void
81+
{
82+
if ($this->isMongoDB()) {
83+
$this->markTestSkipped();
84+
}
85+
86+
$this->recreateSchema(self::getResources());
87+
88+
$camp = new Camp();
89+
$camp->setName('Test Camp');
90+
$manager = static::getContainer()->get('doctrine')->getManager();
91+
$manager->persist($camp);
92+
$manager->flush();
93+
94+
static::createClient()->request(
95+
'PATCH',
96+
'/camps/'.$camp->getId(),
97+
[
98+
'json' => [
99+
'id' => 39,
100+
],
101+
'headers' => ['Content-Type' => 'application/merge-patch+json'],
102+
]
103+
);
104+
105+
$this->assertResponseStatusCodeSame(400);
106+
$this->assertJsonContains([
107+
'detail' => 'Extra attributes are not allowed ("id" is unknown).',
108+
]);
109+
}
110+
111+
public function testIdIsNotWritable(): void
112+
{
113+
if ($this->isMongoDB()) {
114+
$this->markTestSkipped();
115+
}
116+
117+
$this->recreateSchema(self::getResources());
118+
119+
static::createClient()->request(
120+
'POST',
121+
'/camps',
122+
[
123+
'json' => [
124+
'id' => 39,
125+
'name' => 'New Camp',
126+
],
127+
'headers' => ['Content-Type' => 'application/json'],
128+
]
129+
);
130+
131+
$this->assertResponseStatusCodeSame(400);
132+
$this->assertJsonContains([
133+
'detail' => 'Update is not allowed for this operation.',
134+
]);
135+
}
78136
}

0 commit comments

Comments
 (0)