Skip to content

Commit bde59ba

Browse files
dunglassoyuka
andauthored
feat: spec-compliant PUT method (#4996)
Co-authored-by: soyuka <[email protected]>
1 parent 9e69e9d commit bde59ba

35 files changed

+453
-48
lines changed

features/graphql/subscription.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ Feature: GraphQL subscription support
184184
And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/2"
185185
And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks/hub\?topic=http://example.com/subscriptions/[a-f0-9]+$@"
186186

187-
Scenario: Receive Mercure updates with different payloads from subscriptions
187+
Scenario: Receive Mercure updates with different payloads from subscriptions (legacy PUT in non-standard mode)
188188
When I add "Accept" header equal to "application/ld+json"
189189
And I add "Content-Type" header equal to "application/ld+json"
190190
And I send a "PUT" request to "/dummy_mercures/1" with body:

features/hal/hal.feature

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ Feature: HAL support
8484
}
8585
"""
8686

87-
Scenario: Update a resource
87+
Scenario: Update a resource (legacy PUT as standard_put: false)
8888
When I add "Accept" header equal to "application/hal+json"
8989
And I add "Content-Type" header equal to "application/json"
9090
And I send a "PUT" request to "/dummies/1" with body:
@@ -127,6 +127,50 @@ Feature: HAL support
127127
}
128128
"""
129129

130+
Scenario: Update a resource
131+
When I add "Accept" header equal to "application/hal+json"
132+
When I add "Content-Type" header equal to "application/merge-patch+json"
133+
And I send a "PATCH" request to "/dummies/1" with body:
134+
"""
135+
{"name": "A nice dummy"}
136+
"""
137+
Then the response status code should be 200
138+
And the response should be in JSON
139+
And the JSON should be valid according to the JSON HAL schema
140+
And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8"
141+
And the JSON should be equal to:
142+
"""
143+
{
144+
"_links": {
145+
"self": {
146+
"href": "/dummies/1"
147+
},
148+
"relatedDummy": {
149+
"href": "/related_dummies/1"
150+
},
151+
"relatedDummies": [
152+
{
153+
"href": "/related_dummies/1"
154+
}
155+
]
156+
},
157+
"description": null,
158+
"dummy": null,
159+
"dummyBoolean": null,
160+
"dummyDate": "2015-03-01T10:00:00+00:00",
161+
"dummyFloat": null,
162+
"dummyPrice": null,
163+
"jsonData": [],
164+
"arrayData": [],
165+
"name_converted": null,
166+
"id": 1,
167+
"name": "A nice dummy",
168+
"alias": null,
169+
"foo": null
170+
}
171+
"""
172+
173+
130174
Scenario: Embed a relation in a parent object
131175
When I add "Content-Type" header equal to "application/json"
132176
And I send a "POST" request to "/relation_embedders" with body:

features/http_cache/tags.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Feature: Cache invalidation through HTTP Cache tags
121121
Then the response status code should be 200
122122
And the header "Cache-Tags" should be equal to "/relation3s/1,/relation2s/1,/relation2s/2,/relation3s"
123123

124-
Scenario: Update a collection member only
124+
Scenario: Update a collection member only (legacy non-standard PUT)
125125
When I add "Content-Type" header equal to "application/ld+json"
126126
And I send a "PUT" request to "/relation3s/1" with body:
127127
"""

features/main/custom_normalized.feature

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Feature: Using custom normalized entity
147147
}
148148
"""
149149

150-
Scenario: Update a resource
150+
Scenario: Update a resource (legacy non-standard PUT)
151151
When I add "Content-Type" header equal to "application/ld+json"
152152
And I send a "PUT" request to "/custom_normalized_dummies/1" with body:
153153
"""
@@ -171,6 +171,30 @@ Feature: Using custom normalized entity
171171
}
172172
"""
173173

174+
Scenario: Update a resource
175+
When I add "Content-Type" header equal to "application/merge-patch+json"
176+
And I send a "PATCH" request to "/custom_normalized_dummies/1" with body:
177+
"""
178+
{
179+
"name": "My Dummy modified"
180+
}
181+
"""
182+
Then the response status code should be 200
183+
And the response should be in JSON
184+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
185+
And the header "Content-Location" should be equal to "/custom_normalized_dummies/1"
186+
And the JSON should be equal to:
187+
"""
188+
{
189+
"@context": "/contexts/CustomNormalizedDummy",
190+
"@id": "/custom_normalized_dummies/1",
191+
"@type": "CustomNormalizedDummy",
192+
"id": 1,
193+
"name": "My Dummy modified",
194+
"alias": "My alias"
195+
}
196+
"""
197+
174198
Scenario: API doc is correctly generated
175199
When I send a "GET" request to "/docs.jsonld"
176200
Then the response status code should be 200

features/main/custom_writable_identifier.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Feature: Using custom writable identifier on resource
6969
"""
7070

7171
@!mongodb
72-
Scenario: Update a resource
72+
Scenario: Update a resource (legacy non-standard PUT)
7373
When I add "Content-Type" header equal to "application/ld+json"
7474
And I send a "PUT" request to "/custom_writable_identifier_dummies/my_slug" with body:
7575
"""

features/main/standard_put.feature

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
Feature: Spec-compliant PUT support
2+
As a client software developer
3+
I need to be able to create or replace resources using the PUT HTTP method
4+
5+
@createSchema
6+
Scenario: Create a new resource
7+
When I add "Content-Type" header equal to "application/ld+json"
8+
And I send a "PUT" request to "/standard_puts/5" with body:
9+
"""
10+
{
11+
"foo": "a",
12+
"bar": "b"
13+
}
14+
"""
15+
Then the response status code should be 201
16+
And the response should be in JSON
17+
And the JSON should be equal to:
18+
"""
19+
{
20+
"@context": "/contexts/StandardPut",
21+
"@id": "/standard_puts/5",
22+
"@type": "StandardPut",
23+
"id": 5,
24+
"foo": "a",
25+
"bar": "b"
26+
}
27+
"""
28+
29+
Scenario: Replace an existing resource
30+
When I add "Content-Type" header equal to "application/ld+json"
31+
And I send a "PUT" request to "/standard_puts/5" with body:
32+
"""
33+
{
34+
"foo": "c"
35+
}
36+
"""
37+
Then the response status code should be 200
38+
And the response should be in JSON
39+
And the JSON should be equal to:
40+
"""
41+
{
42+
"@context": "/contexts/StandardPut",
43+
"@id": "/standard_puts/5",
44+
"@type": "StandardPut",
45+
"id": 5,
46+
"foo": "c",
47+
"bar": ""
48+
}
49+
"""

features/serializer/vo_relations.feature

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Feature: Value object as ApiResource
8080
}
8181
"""
8282

83-
Scenario: Update Value object with writable and non writable property
83+
Scenario: Update Value object with writable and non writable property (legacy non-standard PUT)
8484
When I add "Content-Type" header equal to "application/ld+json"
8585
And I send a "PUT" request to "/vo_dummy_inspections/1" with body:
8686
"""
@@ -102,6 +102,29 @@ Feature: Value object as ApiResource
102102
}
103103
"""
104104

105+
Scenario: Update Value object with writable and non writable property
106+
When I add "Content-Type" header equal to "application/merge-patch+json"
107+
And I send a "PATCH" request to "/vo_dummy_inspections/1" with body:
108+
"""
109+
{
110+
"performed": "2018-08-24 00:00:00",
111+
"accepted": false
112+
}
113+
"""
114+
Then the response status code should be 200
115+
And the JSON should be equal to:
116+
"""
117+
{
118+
"@context": "/contexts/VoDummyInspection",
119+
"@id": "/vo_dummy_inspections/1",
120+
"@type": "VoDummyInspection",
121+
"accepted": true,
122+
"car": "/vo_dummy_cars/1",
123+
"performed": "2018-08-24T00:00:00+00:00"
124+
}
125+
"""
126+
127+
105128
@createSchema
106129
Scenario: Create Value object without required params
107130
When I add "Content-Type" header equal to "application/ld+json"

phpstan.neon.dist

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,5 +85,3 @@ parameters:
8585
-
8686
message: '#^Property .+ is unused.$#'
8787
path: tests/Doctrine/Odm/PropertyInfo/Fixtures/DoctrineDummy.php
88-
# Waiting for https://github.com/laminas/laminas-code/pull/150
89-
- '#Call to an undefined method ReflectionEnum::.+#'

src/Doctrine/Common/State/LinksHandlerTrait.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@
2020
use ApiPlatform\Metadata\HttpOperation;
2121
use ApiPlatform\Metadata\Link;
2222
use ApiPlatform\Metadata\Operation;
23+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2324

2425
trait LinksHandlerTrait
2526
{
27+
private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory;
28+
2629
/**
2730
* @return Link[]
2831
*/
@@ -48,6 +51,10 @@ private function getLinks(string $resourceClass, Operation $operation, array $co
4851
return [$newLink];
4952
}
5053

54+
if (!$this->resourceMetadataCollectionFactory) {
55+
return [];
56+
}
57+
5158
// Using GraphQL, it's possible that we won't find a GraphQL Operation of the same type (e.g. it is disabled).
5259
try {
5360
$resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass);

src/Doctrine/Common/State/PersistProcessor.php

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Doctrine\Common\State;
1515

16+
use ApiPlatform\Metadata\HttpOperation;
1617
use ApiPlatform\Metadata\Operation;
1718
use ApiPlatform\State\ProcessorInterface;
1819
use ApiPlatform\Util\ClassInfoTrait;
@@ -24,6 +25,7 @@
2425
final class PersistProcessor implements ProcessorInterface
2526
{
2627
use ClassInfoTrait;
28+
use LinksHandlerTrait;
2729

2830
public function __construct(private readonly ManagerRegistry $managerRegistry)
2931
{
@@ -38,10 +40,65 @@ public function __construct(private readonly ManagerRegistry $managerRegistry)
3840
*/
3941
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
4042
{
41-
if (!$manager = $this->getManager($data)) {
43+
if (
44+
!\is_object($data) ||
45+
!$manager = $this->managerRegistry->getManagerForClass($class = $this->getObjectClass($data))
46+
) {
4247
return $data;
4348
}
4449

50+
// PUT: reset the existing object managed by Doctrine and merge data sent by the user in it
51+
// This custom logic is needed because EntityManager::merge() has been deprecated and UPSERT isn't supported:
52+
// https://github.com/doctrine/orm/issues/8461#issuecomment-1250233555
53+
if ($operation instanceof HttpOperation && HttpOperation::METHOD_PUT === $operation->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? false)) {
54+
\assert(method_exists($manager, 'getReference'));
55+
// TODO: the call to getReference is most likely to fail with complex identifiers
56+
$newData = $data;
57+
if (isset($context['previous_data'])) {
58+
$newData = 1 === \count($uriVariables) ? $manager->getReference($class, current($uriVariables)) : clone $context['previous_data'];
59+
}
60+
61+
$identifiers = array_reverse($uriVariables);
62+
$links = $this->getLinks($class, $operation, $context);
63+
$reflectionProperties = $this->getReflectionProperties($data);
64+
65+
if (!isset($context['previous_data'])) {
66+
foreach (array_reverse($links) as $link) {
67+
if ($link->getExpandedValue() || !$link->getFromClass()) {
68+
continue;
69+
}
70+
71+
$identifierProperties = $link->getIdentifiers();
72+
$hasCompositeIdentifiers = 1 < \count($identifierProperties);
73+
74+
foreach ($identifierProperties as $identifierProperty) {
75+
$reflectionProperty = $reflectionProperties[$identifierProperty];
76+
$reflectionProperty->setValue($newData, $this->getIdentifierValue($identifiers, $hasCompositeIdentifiers ? $identifierProperty : null));
77+
}
78+
}
79+
} else {
80+
foreach ($reflectionProperties as $propertyName => $reflectionProperty) {
81+
// Don't override the property if it's part of the subresource system
82+
if (isset($uriVariables[$propertyName])) {
83+
continue;
84+
}
85+
86+
foreach ($links as $link) {
87+
$identifierProperties = $link->getIdentifiers();
88+
if (\in_array($propertyName, $identifierProperties, true)) {
89+
continue;
90+
}
91+
92+
if (($newValue = $reflectionProperty->getValue($data)) !== $reflectionProperty->getValue($newData)) {
93+
$reflectionProperty->setValue($newData, $newValue);
94+
}
95+
}
96+
}
97+
}
98+
99+
$data = $newData;
100+
}
101+
45102
if (!$manager->contains($data) || $this->isDeferredExplicit($manager, $data)) {
46103
$manager->persist($data);
47104
}
@@ -52,14 +109,6 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
52109
return $data;
53110
}
54111

55-
/**
56-
* Gets the Doctrine object manager associated with given data.
57-
*/
58-
private function getManager($data): ?DoctrineObjectManager
59-
{
60-
return \is_object($data) ? $this->managerRegistry->getManagerForClass($this->getObjectClass($data)) : null;
61-
}
62-
63112
/**
64113
* Checks if doctrine does not manage data automatically.
65114
*/
@@ -72,4 +121,21 @@ private function isDeferredExplicit(DoctrineObjectManager $manager, $data): bool
72121

73122
return false;
74123
}
124+
125+
/**
126+
* Get reflection properties indexed by property name.
127+
*
128+
* @return array<string, \ReflectionProperty>
129+
*/
130+
private function getReflectionProperties(mixed $data): array
131+
{
132+
$ret = [];
133+
$props = (new \ReflectionObject($data))->getProperties(~\ReflectionProperty::IS_STATIC);
134+
135+
foreach ($props as $prop) {
136+
$ret[$prop->getName()] = $prop;
137+
}
138+
139+
return $ret;
140+
}
75141
}

src/Doctrine/Odm/State/CollectionProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ final class CollectionProvider implements ProviderInterface
3333
/**
3434
* @param AggregationCollectionExtensionInterface[] $collectionExtensions
3535
*/
36-
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
36+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
3737
{
38+
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
3839
}
3940

4041
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable

src/Doctrine/Odm/State/ItemProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ final class ItemProvider implements ProviderInterface
3636
/**
3737
* @param AggregationItemExtensionInterface[] $itemExtensions
3838
*/
39-
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
39+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $itemExtensions = [])
4040
{
41+
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
4142
}
4243

4344
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object

src/Doctrine/Orm/State/CollectionProvider.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ final class CollectionProvider implements ProviderInterface
3636
/**
3737
* @param QueryCollectionExtensionInterface[] $collectionExtensions
3838
*/
39-
public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
39+
public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, private readonly ManagerRegistry $managerRegistry, private readonly iterable $collectionExtensions = [])
4040
{
41+
$this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory;
4142
}
4243

4344
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable

0 commit comments

Comments
 (0)