Skip to content

Commit 7f87d6c

Browse files
committed
Implement put, post, delete subresource operations
1 parent ae2d708 commit 7f87d6c

File tree

10 files changed

+1109
-34
lines changed

10 files changed

+1109
-34
lines changed

src/Bridge/Symfony/Bundle/Resources/config/api.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@
191191
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="32" />
192192
</service>
193193

194+
<service id="api_platform.listener.request.associate" class="ApiPlatform\Core\EventListener\AssociateListener">
195+
<argument type="service" id="api_platform.property_accessor" />
196+
197+
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
198+
</service>
199+
194200
<service id="api_platform.listener.request.deserialize" class="ApiPlatform\Core\EventListener\DeserializeListener">
195201
<argument type="service" id="api_platform.serializer" />
196202
<argument type="service" id="api_platform.serializer.context_builder" />
@@ -236,6 +242,10 @@
236242
<service id="api_platform.action.put_item" alias="api_platform.action.placeholder" public="true" />
237243
<service id="api_platform.action.delete_item" alias="api_platform.action.placeholder" public="true" />
238244
<service id="api_platform.action.get_subresource" alias="api_platform.action.placeholder" public="true" />
245+
<service id="api_platform.action.post_subresource" alias="api_platform.action.placeholder" public="true" />
246+
<service id="api_platform.action.put_subresource" alias="api_platform.action.placeholder" public="true" />
247+
<service id="api_platform.action.delete_subresource" alias="api_platform.action.placeholder" public="true" />
248+
<service id="api_platform.action.patch_subresource" alias="api_platform.action.placeholder" public="true" />
239249

240250
<service id="api_platform.action.entrypoint" class="ApiPlatform\Core\Action\EntrypointAction" public="true">
241251
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
@@ -292,6 +302,7 @@
292302
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
293303
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
294304
<argument type="service" id="api_platform.path_segment_name_generator" />
305+
<argument>%api_platform.formats%</argument>
295306
</service>
296307

297308
<service id="api_platform.subresource_operation_factory.cached" class="ApiPlatform\Core\Operation\Factory\CachedSubresourceOperationFactory" decorates="api_platform.subresource_operation_factory" decoration-priority="-10" public="false">

src/Bridge/Symfony/Routing/ApiLoader.php

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,9 @@ public function load($data, $type = null): RouteCollection
109109
}
110110

111111
foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $operation) {
112-
if (null === $controller = $operation['controller'] ?? null) {
113-
$controller = self::DEFAULT_ACTION_PATTERN.'get_subresource';
112+
$this->assertOperationMethod($resourceClass, $operationId, $operation);
114113

115-
if (!$this->container->has($controller)) {
116-
throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', OperationType::SUBRESOURCE, 'GET'));
117-
}
118-
}
114+
$controller = $this->resolveOperationController($operation, OperationType::SUBRESOURCE);
119115

120116
$routeCollection->add($operation['route_name'], new Route(
121117
$operation['path'],
@@ -135,7 +131,7 @@ public function load($data, $type = null): RouteCollection
135131
$operation['options'] ?? [],
136132
$operation['host'] ?? '',
137133
$operation['schemes'] ?? [],
138-
['GET'],
134+
[$operation['method']],
139135
$operation['condition'] ?? ''
140136
));
141137
}
@@ -189,17 +185,9 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
189185
return;
190186
}
191187

192-
if (!isset($operation['method'])) {
193-
throw new RuntimeException(sprintf('Either a "route_name" or a "method" operation attribute must exist for the operation "%s" of the resource "%s".', $operationName, $resourceClass));
194-
}
195-
196-
if (null === $controller = $operation['controller'] ?? null) {
197-
$controller = sprintf('%s%s_%s', self::DEFAULT_ACTION_PATTERN, strtolower($operation['method']), $operationType);
188+
$this->assertOperationMethod($resourceClass, $operationName, $operation);
198189

199-
if (!$this->container->has($controller)) {
200-
throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', $operationType, $operation['method']));
201-
}
202-
}
190+
$controller = $this->resolveOperationController($operation, $operationType);
203191

204192
$path = trim(trim($resourceMetadata->getAttribute('route_prefix', '')), '/');
205193
$path .= $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName);
@@ -222,4 +210,24 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
222210

223211
$routeCollection->add(RouteNameGenerator::generate($operationName, $resourceShortName, $operationType), $route);
224212
}
213+
214+
private function assertOperationMethod(string $resourceClass, string $operationName, array $operation)
215+
{
216+
if (!isset($operation['method'])) {
217+
throw new RuntimeException(sprintf('Either a "route_name" or a "method" operation attribute must exist for the operation "%s" of the resource "%s".', $operationName, $resourceClass));
218+
}
219+
}
220+
221+
private function resolveOperationController(array $operation, string $operationType): string
222+
{
223+
if (null === $controller = $operation['controller'] ?? null) {
224+
$controller = sprintf('%s%s_%s', self::DEFAULT_ACTION_PATTERN, strtolower($operation['method']), $operationType);
225+
226+
if (!$this->container->has($controller)) {
227+
throw new RuntimeException(sprintf('There is no builtin action for the %s %s operation. You need to define the controller yourself.', $operationType, $operation['method']));
228+
}
229+
}
230+
231+
return $controller;
232+
}
225233
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Core\EventListener;
15+
16+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
19+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
20+
21+
/**
22+
* Associates the resource retrieved by the resource data provider with the subresource demoralized from the request body.
23+
*
24+
* @author Torrey Tsui <torreytsui@gmail.com>
25+
*/
26+
final class AssociateListener
27+
{
28+
/** @var PropertyAccessorInterface */
29+
private $propertyAccessor;
30+
31+
public function __construct(
32+
PropertyAccessorInterface $propertyAccessor
33+
) {
34+
$this->propertyAccessor = $propertyAccessor;
35+
}
36+
37+
/**
38+
* Associate the resource with the subresource.
39+
*/
40+
public function onKernelRequest(GetResponseEvent $event)
41+
{
42+
$request = $event->getRequest();
43+
$method = $request->getMethod();
44+
45+
if (
46+
'DELETE' === $method
47+
|| 'GET' === $method
48+
|| $request->isMethodSafe(false)
49+
|| !($attributes = RequestAttributesExtractor::extractAttributes($request))
50+
|| !$attributes['receive']
51+
|| null === ($resourceData = $request->attributes->get('resource_data'))
52+
) {
53+
return;
54+
}
55+
56+
// Cannot introduce a new AssociateListener as it is an edge case and EventPriority is full
57+
// Can hook up with POST_DESERIALIZE and build association it with a service - which offers extension point
58+
// Can stick with DeserializeListener for the time being and use the service - this however gives no meaning reason to couple with the DeserializeListener
59+
60+
// TODO: Extract to ease customisation? DataAssociatorInterface->support($aClass, $bClass) and associate($a, $b). What about constructor?
61+
// Maybe? DataDependencyProviderInterface->support($class, $operation) and provide($request)
62+
// TODO: associates
63+
// POST - an array, which use add method
64+
// PUT - if it has id, it already exists (The property is a collection)
65+
// PUT - without an id, don't know if it's already exist (The property is an item)
66+
$value = $request->attributes->get('data');
67+
if ($attributes['subresource_context']['collection']) {
68+
$propertyValue = $this->propertyAccessor->getValue($resourceData, $attributes['subresource_context']['property']);
69+
if ($propertyValue instanceof \Traversable) {
70+
$propertyValue = iterator_to_array($propertyValue);
71+
}
72+
$value = array_merge($propertyValue, [$value]);
73+
}
74+
$this->propertyAccessor->setValue($resourceData, $attributes['subresource_context']['property'], $value);
75+
}
76+
}

src/EventListener/ReadListener.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ public function onKernelRequest(GetResponseEvent $event): void
9797
}
9898

9999
$data = $this->getSubresourceData($identifiers, $attributes, $context);
100+
101+
if ($request->isMethod('POST')) {
102+
// Load parent resource to set relationship between it and the subresource
103+
$resourceClass = \array_slice(array_keys($context['subresource_resources']), -1, 1)[0];
104+
$resourceData = $this->itemDataProvider->getItem($resourceClass, $context['subresource_resources'][$resourceClass], $attributes['subresource_operation_name'], $context);
105+
}
100106
}
101107
} catch (InvalidIdentifierException $e) {
102108
throw new NotFoundHttpException('Not found, because of an invalid identifier configuration', $e);
@@ -107,5 +113,9 @@ public function onKernelRequest(GetResponseEvent $event): void
107113
}
108114

109115
$request->attributes->set('data', $data);
116+
117+
if (isset($resourceData)) {
118+
$request->attributes->set('resource_data', $resourceData);
119+
}
110120
}
111121
}

src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,19 @@ public function __construct(ResourceMetadataFactoryInterface $decorated, array $
4949
}
5050

5151
/**
52-
* {@inheritdoc}
52+
* @internal
5353
*/
54-
public function create(string $resourceClass): ResourceMetadata
54+
public static function populateOperations(string $resourceClass, ResourceMetadata $resourceMetadata, array $formats): ResourceMetadata
5555
{
56-
$resourceMetadata = $this->decorated->create($resourceClass);
5756
$isAbstract = (new \ReflectionClass($resourceClass))->isAbstract();
5857

5958
$collectionOperations = $resourceMetadata->getCollectionOperations();
6059
if (null === $collectionOperations) {
61-
$resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations(
60+
$resourceMetadata = $resourceMetadata->withCollectionOperations(static::createOperations(
6261
$isAbstract ? ['GET'] : ['GET', 'POST']
6362
));
6463
} else {
65-
$resourceMetadata = $this->normalize(true, $resourceMetadata, $collectionOperations);
64+
$resourceMetadata = static::normalize(true, $resourceMetadata, $collectionOperations, $formats);
6665
}
6766

6867
$itemOperations = $resourceMetadata->getItemOperations();
@@ -72,16 +71,28 @@ public function create(string $resourceClass): ResourceMetadata
7271
if (!$isAbstract) {
7372
$methods[] = 'PUT';
7473

75-
if (isset($this->formats['jsonapi'])) {
74+
if (isset($formats['jsonapi'])) {
7675
$methods[] = 'PATCH';
7776
}
7877
}
7978

80-
$resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods));
79+
$resourceMetadata = $resourceMetadata->withItemOperations(static::createOperations($methods));
8180
} else {
82-
$resourceMetadata = $this->normalize(false, $resourceMetadata, $itemOperations);
81+
$resourceMetadata = static::normalize(false, $resourceMetadata, $itemOperations, $formats);
8382
}
8483

84+
return $resourceMetadata;
85+
}
86+
87+
/**
88+
* {@inheritdoc}
89+
*/
90+
public function create(string $resourceClass): ResourceMetadata
91+
{
92+
$resourceMetadata = $this->decorated->create($resourceClass);
93+
$formats = $this->formats;
94+
$resourceMetadata = self::populateOperations($resourceClass, $resourceMetadata, $formats);
95+
8596
$graphql = $resourceMetadata->getGraphql();
8697
if (null === $graphql) {
8798
$resourceMetadata = $resourceMetadata->withGraphql(['query' => [], 'delete' => [], 'update' => [], 'create' => []]);
@@ -92,7 +103,7 @@ public function create(string $resourceClass): ResourceMetadata
92103
return $resourceMetadata;
93104
}
94105

95-
private function createOperations(array $methods): array
106+
private static function createOperations(array $methods): array
96107
{
97108
$operations = [];
98109
foreach ($methods as $method) {
@@ -102,7 +113,7 @@ private function createOperations(array $methods): array
102113
return $operations;
103114
}
104115

105-
private function normalize(bool $collection, ResourceMetadata $resourceMetadata, array $operations): ResourceMetadata
116+
private static function normalize(bool $collection, ResourceMetadata $resourceMetadata, array $operations, array $formats): ResourceMetadata
106117
{
107118
$newOperations = [];
108119
foreach ($operations as $operationName => $operation) {
@@ -116,7 +127,7 @@ private function normalize(bool $collection, ResourceMetadata $resourceMetadata,
116127
if ($collection) {
117128
$supported = isset(self::SUPPORTED_COLLECTION_OPERATION_METHODS[$upperOperationName]);
118129
} else {
119-
$supported = isset(self::SUPPORTED_ITEM_OPERATION_METHODS[$upperOperationName]) || (isset($this->formats['jsonapi']) && 'PATCH' === $upperOperationName);
130+
$supported = isset(self::SUPPORTED_ITEM_OPERATION_METHODS[$upperOperationName]) || (isset($formats['jsonapi']) && 'PATCH' === $upperOperationName);
120131
}
121132

122133
if (!isset($operation['method']) && !isset($operation['route_name'])) {

src/Operation/Factory/SubresourceOperationFactory.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator;
1717
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
1818
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
19+
use ApiPlatform\Core\Metadata\Resource\Factory\OperationResourceMetadataFactory;
1920
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
2021
use ApiPlatform\Core\Operation\PathSegmentNameGeneratorInterface;
2122

@@ -32,13 +33,15 @@ final class SubresourceOperationFactory implements SubresourceOperationFactoryIn
3233
private $propertyNameCollectionFactory;
3334
private $propertyMetadataFactory;
3435
private $pathSegmentNameGenerator;
36+
private $formats;
3537

36-
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator)
38+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator, array $formats = [])
3739
{
3840
$this->resourceMetadataFactory = $resourceMetadataFactory;
3941
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
4042
$this->propertyMetadataFactory = $propertyMetadataFactory;
4143
$this->pathSegmentNameGenerator = $pathSegmentNameGenerator;
44+
$this->formats = $formats;
4245
}
4346

4447
/**
@@ -141,8 +144,7 @@ public function create(string $rootResourceClass): array
141144
// TODO design a dev friendly way to define subresource operations so they can be partially disabled
142145
// TODO Maybe merge in them with the default so we don't need to worry in later on population or we will probably need to resolve on the fly
143146
$subresourceMetadata = $this->resourceMetadataFactory->create($subresourceClass);
144-
$subresourceMetadata = $subresourceMetadata->withCollectionOperations(['get' => ['method' => 'GET']]);
145-
$subresourceMetadata = $subresourceMetadata->withItemOperations(['get' => ['method' => 'GET']]);
147+
$subresourceMetadata = OperationResourceMetadataFactory::populateOperations($subresourceClass, $subresourceMetadata, $this->formats);
146148

147149
if (($isLastItem = $propertyMetadata->isIdentifier()) && $allowLastItemWorkaround) {
148150
// TODO: next majory: throw an exception and remove $isLastItem and $allowLastItemWorkaround and their impacts
@@ -294,13 +296,19 @@ public function create(string $rootResourceClass): array
294296
*
295297
* related_dummy
296298
* - GET /dummies/{id}/related_dummy Item
299+
* - PUT /dummies/{id}/related_dummy Item
300+
* - DELETE /dummies/{id}/related_dummy Item
301+
* - PATCH /dummies/{id}/related_dummy Item
297302
*
298303
* Convention 2: /$resource/{id}/$subresources.{_format}
299304
* Example 2: /dummy/{id}/related_dummies/{id}.{_format}
300305
* Template 2: /dummy/{id}/%s/{id}.{_format}
301306
*
302307
* related_dummies
303308
* - GET /dummies/{id}/related_dummies/{id} Item
309+
* - PUT /dummies/{id}/related_dummies/{id} Item
310+
* - DELETE /dummies/{id}/related_dummies/{id} Item
311+
* - PATCH /dummies/{id}/related_dummies/{id} Item
304312
*
305313
*/
306314
'path' => $overriddenPath ?? sprintf(
@@ -360,11 +368,14 @@ public function create(string $rootResourceClass): array
360368
if (!$isLastItem && (!empty($subresourceCollectionOperations) || !empty($subresourceItemOperations))) {
361369
$collectionOperationOverriddenPath =
362370
$subresourceCollectionPathByMethod['GET'] ??
371+
$subresourceCollectionPathByMethod['POST'] ??
363372
null
364373
;
365374

366375
$itemOperationOverriddenPath =
367376
$subresourceItemPathByMethod['GET'] ??
377+
$subresourceItemPathByMethod['PUT'] ??
378+
$subresourceItemPathByMethod['DELETE'] ??
368379
null
369380
;
370381

tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,10 @@ private function getPartialContainerBuilderProphecy()
889889
'api_platform.action.get_collection' => 'api_platform.action.placeholder',
890890
'api_platform.action.get_item' => 'api_platform.action.placeholder',
891891
'api_platform.action.get_subresource' => 'api_platform.action.placeholder',
892+
'api_platform.action.post_subresource' => 'api_platform.action.placeholder',
893+
'api_platform.action.delete_subresource' => 'api_platform.action.placeholder',
894+
'api_platform.action.put_subresource' => 'api_platform.action.placeholder',
895+
'api_platform.action.patch_subresource' => 'api_platform.action.placeholder',
892896
'api_platform.action.post_collection' => 'api_platform.action.placeholder',
893897
'api_platform.action.put_item' => 'api_platform.action.placeholder',
894898
'api_platform.action.patch_item' => 'api_platform.action.placeholder',

0 commit comments

Comments
 (0)