Skip to content

Commit

Permalink
Implement put, post, delete subresource operations
Browse files Browse the repository at this point in the history
  • Loading branch information
torreytsui committed Apr 6, 2019
1 parent 2e94741 commit ebe2d51
Show file tree
Hide file tree
Showing 10 changed files with 1,114 additions and 34 deletions.
11 changes: 11 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="32" />
</service>

<service id="api_platform.listener.request.associate" class="ApiPlatform\Core\EventListener\AssociateListener">
<argument type="service" id="api_platform.property_accessor" />

<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="1" />
</service>

<service id="api_platform.listener.request.deserialize" class="ApiPlatform\Core\EventListener\DeserializeListener">
<argument type="service" id="api_platform.serializer" />
<argument type="service" id="api_platform.serializer.context_builder" />
Expand Down Expand Up @@ -236,6 +242,10 @@
<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.put_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.patch_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 Expand Up @@ -292,6 +302,7 @@
<argument type="service" id="api_platform.metadata.property.name_collection_factory" />
<argument type="service" id="api_platform.metadata.property.metadata_factory" />
<argument type="service" id="api_platform.path_segment_name_generator" />
<argument>%api_platform.formats%</argument>
</service>

<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">
Expand Down
42 changes: 25 additions & 17 deletions src/Bridge/Symfony/Routing/ApiLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,9 @@ public function load($data, $type = null): RouteCollection
}

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

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

$routeCollection->add($operation['route_name'], new Route(
$operation['path'],
Expand All @@ -135,7 +131,7 @@ public function load($data, $type = null): RouteCollection
$operation['options'] ?? [],
$operation['host'] ?? '',
$operation['schemes'] ?? [],
['GET'],
[$operation['method']],
$operation['condition'] ?? ''
));
}
Expand Down Expand Up @@ -189,17 +185,9 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas
return;
}

if (!isset($operation['method'])) {
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));
}

if (null === $controller = $operation['controller'] ?? null) {
$controller = sprintf('%s%s_%s', self::DEFAULT_ACTION_PATTERN, strtolower($operation['method']), $operationType);
$this->assertOperationMethod($resourceClass, $operationName, $operation);

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

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

$routeCollection->add(RouteNameGenerator::generate($operationName, $resourceShortName, $operationType), $route);
}

private function assertOperationMethod(string $resourceClass, string $operationName, array $operation)
{
if (!isset($operation['method'])) {
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));
}
}

private function resolveOperationController(array $operation, string $operationType): string
{
if (null === $controller = $operation['controller'] ?? null) {
$controller = sprintf('%s%s_%s', self::DEFAULT_ACTION_PATTERN, strtolower($operation['method']), $operationType);

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

return $controller;
}
}
76 changes: 76 additions & 0 deletions src/EventListener/AssociateListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\Core\EventListener;

use ApiPlatform\Core\Util\RequestAttributesExtractor;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;

/**
* Associates the resource retrieved by the resource data provider with the subresource demoralized from the request body.
*
* @author Torrey Tsui <torreytsui@gmail.com>
*/
final class AssociateListener
{
/** @var PropertyAccessorInterface */
private $propertyAccessor;

public function __construct(
PropertyAccessorInterface $propertyAccessor
) {
$this->propertyAccessor = $propertyAccessor;
}

/**
* Associate the resource with the subresource.
*/
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
$method = $request->getMethod();

if (
'DELETE' === $method
|| 'GET' === $method
|| $request->isMethodSafe(false)
|| !($attributes = RequestAttributesExtractor::extractAttributes($request))
|| !$attributes['receive']
|| null === ($resourceData = $request->attributes->get('resource_data'))
) {
return;
}

// Cannot introduce a new AssociateListener as it is an edge case and EventPriority is full
// Can hook up with POST_DESERIALIZE and build association it with a service - which offers extension point
// Can stick with DeserializeListener for the time being and use the service - this however gives no meaning reason to couple with the DeserializeListener

// TODO: Extract to ease customisation? DataAssociatorInterface->support($aClass, $bClass) and associate($a, $b). What about constructor?
// Maybe? DataDependencyProviderInterface->support($class, $operation) and provide($request)
// TODO: associates
// POST - an array, which use add method
// PUT - if it has id, it already exists (The property is a collection)
// PUT - without an id, don't know if it's already exist (The property is an item)
$value = $request->attributes->get('data');
if ($attributes['subresource_context']['collection']) {
$propertyValue = $this->propertyAccessor->getValue($resourceData, $attributes['subresource_context']['property']);
if ($propertyValue instanceof \Traversable) {
$propertyValue = iterator_to_array($propertyValue);
}
$value = array_merge($propertyValue, [$value]);
}
$this->propertyAccessor->setValue($resourceData, $attributes['subresource_context']['property'], $value);
}
}
14 changes: 14 additions & 0 deletions src/EventListener/ReadListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ public function onKernelRequest(GetResponseEvent $event): void
}

$data = $this->getSubresourceData($identifiers, $attributes, $context);

if ($request->isMethod('POST') || $request->isMethod('PUT')) {
$resourceClass = \array_slice(array_keys($context['subresource_resources']), -1, 1)[0] ?? null;
if (null === $resourceClass) {
throw new RuntimeException('No subresource resource class.');
}

// Load parent resource to set relationship between it and the subresource
$resourceData = $this->itemDataProvider->getItem((string) $resourceClass, $context['subresource_resources'][$resourceClass], $attributes['subresource_operation_name'], $context);
}
}
} catch (InvalidIdentifierException $e) {
throw new NotFoundHttpException('Not found, because of an invalid identifier configuration', $e);
Expand All @@ -107,5 +117,9 @@ public function onKernelRequest(GetResponseEvent $event): void
}

$request->attributes->set('data', $data);

if (isset($resourceData)) {
$request->attributes->set('resource_data', $resourceData);
}
}
}
33 changes: 22 additions & 11 deletions src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,19 @@ public function __construct(ResourceMetadataFactoryInterface $decorated, array $
}

/**
* {@inheritdoc}
* @internal
*/
public function create(string $resourceClass): ResourceMetadata
public static function populateOperations(string $resourceClass, ResourceMetadata $resourceMetadata, array $formats): ResourceMetadata
{
$resourceMetadata = $this->decorated->create($resourceClass);
$isAbstract = (new \ReflectionClass($resourceClass))->isAbstract();

$collectionOperations = $resourceMetadata->getCollectionOperations();
if (null === $collectionOperations) {
$resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations(
$resourceMetadata = $resourceMetadata->withCollectionOperations(static::createOperations(
$isAbstract ? ['GET'] : ['GET', 'POST']
));
} else {
$resourceMetadata = $this->normalize(true, $resourceMetadata, $collectionOperations);
$resourceMetadata = static::normalize(true, $resourceMetadata, $collectionOperations, $formats);
}

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

if (isset($this->formats['jsonapi'])) {
if (isset($formats['jsonapi'])) {
$methods[] = 'PATCH';
}
}

$resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods));
$resourceMetadata = $resourceMetadata->withItemOperations(static::createOperations($methods));
} else {
$resourceMetadata = $this->normalize(false, $resourceMetadata, $itemOperations);
$resourceMetadata = static::normalize(false, $resourceMetadata, $itemOperations, $formats);
}

return $resourceMetadata;
}

/**
* {@inheritdoc}
*/
public function create(string $resourceClass): ResourceMetadata
{
$resourceMetadata = $this->decorated->create($resourceClass);
$formats = $this->formats;
$resourceMetadata = self::populateOperations($resourceClass, $resourceMetadata, $formats);

$graphql = $resourceMetadata->getGraphql();
if (null === $graphql) {
$resourceMetadata = $resourceMetadata->withGraphql(['query' => [], 'delete' => [], 'update' => [], 'create' => []]);
Expand All @@ -92,7 +103,7 @@ public function create(string $resourceClass): ResourceMetadata
return $resourceMetadata;
}

private function createOperations(array $methods): array
private static function createOperations(array $methods): array
{
$operations = [];
foreach ($methods as $method) {
Expand All @@ -102,7 +113,7 @@ private function createOperations(array $methods): array
return $operations;
}

private function normalize(bool $collection, ResourceMetadata $resourceMetadata, array $operations): ResourceMetadata
private static function normalize(bool $collection, ResourceMetadata $resourceMetadata, array $operations, array $formats): ResourceMetadata
{
$newOperations = [];
foreach ($operations as $operationName => $operation) {
Expand All @@ -116,7 +127,7 @@ private function normalize(bool $collection, ResourceMetadata $resourceMetadata,
if ($collection) {
$supported = isset(self::SUPPORTED_COLLECTION_OPERATION_METHODS[$upperOperationName]);
} else {
$supported = isset(self::SUPPORTED_ITEM_OPERATION_METHODS[$upperOperationName]) || (isset($this->formats['jsonapi']) && 'PATCH' === $upperOperationName);
$supported = isset(self::SUPPORTED_ITEM_OPERATION_METHODS[$upperOperationName]) || (isset($formats['jsonapi']) && 'PATCH' === $upperOperationName);
}

if (!isset($operation['method']) && !isset($operation['route_name'])) {
Expand Down
17 changes: 14 additions & 3 deletions src/Operation/Factory/SubresourceOperationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use ApiPlatform\Core\Metadata\Resource\Factory\OperationResourceMetadataFactory;
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
use ApiPlatform\Core\Operation\PathSegmentNameGeneratorInterface;

Expand All @@ -32,13 +33,15 @@ final class SubresourceOperationFactory implements SubresourceOperationFactoryIn
private $propertyNameCollectionFactory;
private $propertyMetadataFactory;
private $pathSegmentNameGenerator;
private $formats;

public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator)
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator, array $formats = [])
{
$this->resourceMetadataFactory = $resourceMetadataFactory;
$this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
$this->propertyMetadataFactory = $propertyMetadataFactory;
$this->pathSegmentNameGenerator = $pathSegmentNameGenerator;
$this->formats = $formats;
}

/**
Expand Down Expand Up @@ -140,8 +143,7 @@ public function create(string $rootResourceClass): array
// TODO design a dev friendly way to define subresource operations so they can be partially disabled
// 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
$subresourceMetadata = $this->resourceMetadataFactory->create($subresourceClass);
$subresourceMetadata = $subresourceMetadata->withCollectionOperations(['get' => ['method' => 'GET']]);
$subresourceMetadata = $subresourceMetadata->withItemOperations(['get' => ['method' => 'GET']]);
$subresourceMetadata = OperationResourceMetadataFactory::populateOperations($subresourceClass, $subresourceMetadata, $this->formats);

if (($isLastItem = $propertyMetadata->isIdentifier()) && $allowLastItemWorkaround) {
// TODO: next majory: throw an exception and remove $isLastItem and $allowLastItemWorkaround and their impacts
Expand Down Expand Up @@ -295,13 +297,19 @@ public function create(string $rootResourceClass): array
*
* related_dummy
* - GET /dummies/{id}/related_dummy Item
* - PUT /dummies/{id}/related_dummy Item
* - DELETE /dummies/{id}/related_dummy Item
* - PATCH /dummies/{id}/related_dummy Item
*
* Convention 2: /$resource/{id}/$subresources.{_format}
* Example 2: /dummy/{id}/related_dummies/{id}.{_format}
* Template 2: /dummy/{id}/%s/{id}.{_format}
*
* related_dummies
* - GET /dummies/{id}/related_dummies/{id} Item
* - PUT /dummies/{id}/related_dummies/{id} Item
* - DELETE /dummies/{id}/related_dummies/{id} Item
* - PATCH /dummies/{id}/related_dummies/{id} Item
*
*/
'path' => $overriddenPath ?? sprintf(
Expand Down Expand Up @@ -361,10 +369,13 @@ public function create(string $rootResourceClass): array
if (!$isLastItem && (!empty($subresourceCollectionOperations) || !empty($subresourceItemOperations))) {
$collectionOperationOverriddenPath =
$subresourceCollectionPathByMethod['GET'] ??
$subresourceCollectionPathByMethod['POST'] ??
null;

$itemOperationOverriddenPath =
$subresourceItemPathByMethod['GET'] ??
$subresourceItemPathByMethod['PUT'] ??
$subresourceItemPathByMethod['DELETE'] ??
null;

$subresourcePathSegment = $subresourceSegment.($subresource->isCollection() ? '/{'.$identifierName.'}' : '');
Expand Down
Loading

0 comments on commit ebe2d51

Please sign in to comment.