Skip to content

Commit

Permalink
Allow POST/DELETE on an ApiSubresource
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentchalamon committed Apr 5, 2019
1 parent be932e9 commit 18bcf90
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 10 deletions.
164 changes: 164 additions & 0 deletions features/main/subresource.feature
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,170 @@ Feature: Subresource support
}
"""

@createSchema
Scenario: Create an entry on a subresource
Given there is a dummy object with a fourth level relation
When I add "Content-Type" header equal to "application/ld+json"
And I send a "POST" request to "/dummies/1/related_dummies" with body:
"""
{
"name": "New related dummy"
}
"""
Then the response status code should be 201
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
Then I send a "GET" request to "/dummies/1/related_dummies"
And the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be equal to:
"""
{
"@context": "/contexts/RelatedDummy",
"@id": "/dummies/1/related_dummies",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/related_dummies/1",
"@type": "https://schema.org/Product",
"id": 1,
"name": "Hello",
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
},
{
"@id": "/related_dummies/2",
"@type": "https://schema.org/Product",
"id": 2,
"name": null,
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
},
{
"@id": "/related_dummies/3",
"@type": "https://schema.org/Product",
"id": 3,
"name": "New related dummy",
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": null,
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
}
],
"hydra:totalItems": 3,
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "relatedToDummyFriend.dummyFriend",
"property": "relatedToDummyFriend.dummyFriend",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "relatedToDummyFriend.dummyFriend[]",
"property": "relatedToDummyFriend.dummyFriend",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "name",
"property": "name",
"required": false
}
]
}
}
"""

@createSchema
Scenario: Delete an entry on a subresource
Given there is a dummy object with a fourth level relation
When I send a "DELETE" request to "/dummies/1/related_dummies/1"
Then the response status code should be 204
And I send a "GET" request to "/dummies/1/related_dummies"
And the response status code should be 200
And the response should be in JSON
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
And the JSON should be equal to:
"""
{
"@context": "/contexts/RelatedDummy",
"@id": "/dummies/1/related_dummies",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/related_dummies/2",
"@type": "https://schema.org/Product",
"id": 2,
"name": null,
"symfony": "symfony",
"dummyDate": null,
"thirdLevel": {
"@id": "/third_levels/1",
"@type": "ThirdLevel",
"fourthLevel": "/fourth_levels/1"
},
"relatedToDummyFriend": [],
"dummyBoolean": null,
"embeddedDummy": [],
"age": null
}
],
"hydra:totalItems": 1,
"hydra:search": {
"@type": "hydra:IriTemplate",
"hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name}",
"hydra:variableRepresentation": "BasicRepresentation",
"hydra:mapping": [
{
"@type": "IriTemplateMapping",
"variable": "relatedToDummyFriend.dummyFriend",
"property": "relatedToDummyFriend.dummyFriend",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "relatedToDummyFriend.dummyFriend[]",
"property": "relatedToDummyFriend.dummyFriend",
"required": false
},
{
"@type": "IriTemplateMapping",
"variable": "name",
"property": "name",
"required": false
}
]
}
}
"""

Scenario: Get offers subresource from aggregate offers subresource
Given I have a product with offers
When I send a "GET" request to "/dummy_products/2/offers/1/offers"
Expand Down
2 changes: 2 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@
<service id="api_platform.action.put_item" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.delete_item" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.get_subresource" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.post_subresource" alias="api_platform.action.placeholder" public="true" />
<service id="api_platform.action.delete_subresource" alias="api_platform.action.placeholder" public="true" />

<service id="api_platform.action.entrypoint" class="ApiPlatform\Core\Action\EntrypointAction" public="true">
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
Expand Down
3 changes: 2 additions & 1 deletion src/Bridge/Symfony/Routing/ApiLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,14 @@ public function load($data, $type = null): RouteCollection
'identifiers' => $operation['identifiers'],
'collection' => $operation['collection'],
'operationId' => $operationId,
'resourceClass' => $resourceClass,
],
] + ($operation['defaults'] ?? []),
$operation['requirements'] ?? [],
$operation['options'] ?? [],
$operation['host'] ?? '',
$operation['schemes'] ?? [],
['GET'],
[$operation['method'] ?? 'GET'],
$operation['condition'] ?? ''
));
}
Expand Down
16 changes: 15 additions & 1 deletion src/EventListener/ReadListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,21 @@ public function onKernelRequest(GetResponseEvent $event): void
throw new RuntimeException('No subresource data provider.');
}

$data = $this->getSubresourceData($identifiers, $attributes, $context);
if ($request->isMethodSafe(false)) {
$data = $this->getSubresourceData($identifiers, $attributes, $context);
} else {
if (!isset($attributes['subresource_context']['resourceClass'])) {
throw new RuntimeException('No root resource class found.');
}
$data = $this->itemDataProvider->getItem($attributes['subresource_context']['resourceClass'], $identifiers, null, $context);
// if (null === $rootData) {
// throw new NotFoundHttpException('Not Found');
// }
// $request->attributes->set('rootData', $rootData);
// $request->attributes->set('data', null);
//
// return;
}
}
} catch (InvalidIdentifierException $e) {
throw new NotFoundHttpException('Not found, because of an invalid identifier configuration', $e);
Expand Down
16 changes: 14 additions & 2 deletions src/EventListener/WriteListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,21 @@ public function onKernelView(GetResponseForControllerResultEvent $event): void
if ($hasOutput) {
$request->attributes->set('_api_write_item_iri', $this->iriConverter->getIriFromItem($controllerResult));
}
break;

// In case of POST request on a subresource, add data to root object collection association
if ($request->attributes->has('rootData')) {
$request->attributes->get('rootData')->addRelatedDummy($request->attributes->get('data'));
$this->dataPersister->persist($request->attributes->get('rootData'));
}
break;
case 'DELETE':
$this->dataPersister->remove($controllerResult);
// In case of POST request on a subresource, add data to root object collection association
if ($request->attributes->has('rootData')) {
$request->attributes->get('rootData')->removeRelatedDummy($request->attributes->get('data'));
$this->dataPersister->persist($request->attributes->get('rootData'));
} else {
$this->dataPersister->remove($controllerResult);
}
$event->setControllerResult(null);
break;
}
Expand Down
34 changes: 28 additions & 6 deletions src/Operation/Factory/SubresourceOperationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace ApiPlatform\Core\Operation\Factory;

use ApiPlatform\Core\Bridge\Symfony\Routing\ApiLoader;
use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
Expand Down Expand Up @@ -56,9 +57,9 @@ public function create(string $resourceClass): array
* Handles subresource operations recursively and declare their corresponding routes.
*
* @param string $rootResourceClass null on the first iteration, it then keeps track of the origin resource class
* @param array $parentOperation the previous call operation
* @param int $depth the number of visited
* @param int $maxDepth
* @param array $parentOperation the previous call operation
* @param int $depth the number of visited
* @param int $maxDepth
*/
private function computeSubresourceOperations(string $resourceClass, array &$tree, string $rootResourceClass = null, array $parentOperation = null, array $visited = [], int $depth = 0, int $maxDepth = null): void
{
Expand Down Expand Up @@ -99,10 +100,10 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
}

$rootResourceMetadata = $this->resourceMetadataFactory->create($rootResourceClass);
$operationName = 'get';
$operation = [
'property' => $property,
'collection' => $subresource->isCollection(),
'method' => 'GET',
'resource_class' => $subresourceClass,
'shortNames' => [$subresourceMetadata->getShortName()],
];
Expand All @@ -113,7 +114,7 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
$operation['operation_name'] = sprintf(
'%s_%s%s',
RouteNameGenerator::inflector($operation['property'], $operation['collection'] ?? false),
$operationName,
'get',
self::SUBRESOURCE_SUFFIX
);

Expand Down Expand Up @@ -162,7 +163,7 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre
if (isset($subresourceOperation['path'])) {
$operation['path'] = $subresourceOperation['path'];
} else {
$operation['path'] = str_replace(self::FORMAT_SUFFIX, '', (string) $parentOperation['path']);
$operation['path'] = str_replace(self::FORMAT_SUFFIX, '', (string)$parentOperation['path']);

if ($parentOperation['collection']) {
[$key] = end($operation['identifiers']);
Expand All @@ -183,6 +184,27 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre

$tree[$operation['route_name']] = $operation;

// todo Add ApiSubresource annotation option to enable/disable these operations
$method = $subresource->isCollection() ? 'post' : 'delete';
$operationName = sprintf(
'%s_%s%s',
RouteNameGenerator::inflector($operation['property'], $operation['collection'] ?? false),
$method,
self::SUBRESOURCE_SUFFIX
);
$routeName = sprintf(
'%s%s_%s',
RouteNameGenerator::ROUTE_NAME_PREFIX,
RouteNameGenerator::inflector($rootResourceMetadata->getShortName()),
$operationName
);
$tree[$routeName] = [
'operation_name' => $operationName,
'route_name' => $routeName,
'method' => strtoupper($method),
'controller' => ApiLoader::DEFAULT_ACTION_PATTERN.$method.self::SUBRESOURCE_SUFFIX,
] + $operation;

$this->computeSubresourceOperations($subresourceClass, $tree, $rootResourceClass, $operation, $visited + [$visiting => true], ++$depth, $maxDepth);
}
}
Expand Down

0 comments on commit 18bcf90

Please sign in to comment.