Skip to content

[WIP] General batch endpoint #1645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions src/Action/BatchEndpointAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?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\Action;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
* Provides an endpoint for batch processing by splitting up
*
* @author Yanick Witschi <yanick.witschi@terminal42.ch>
*/
class BatchEndpointAction
{
/**
* @var HttpKernelInterface
*/
private $kernel;

/**
* @var array
*/
private $validKeys = [
'path',
'method',
'headers',
'body',
];

/**
* @param HttpKernelInterface $kernel
*/
public function __construct(HttpKernelInterface $kernel)
{
$this->kernel = $kernel;
}

/**
* Execute multiple subrequests from batch request.
*
* @param Request $request
* @param array $data
*
* @return array
*/
public function __invoke(Request $request, array $data = null): array
{
if (!$this->validateBatchData((array) $data)) {
throw new BadRequestHttpException('Batch request data not accepted.');
}

$result = [];

foreach ($data as $k => $item) {

// Copy current headers if no specific provided for simplicity
// otherwise one would have to provide Content-Type with every
// single entry.
if (!isset($item['headers'])) {
$item['headers'] = $request->headers->all();
}

// Make sure Content-Length is always correct
$item['headers']['content-length'] = strlen($item['body']);

$result[] = $this->convertResponse(
$this->executeSubRequest(
$k,
$item['path'],
$item['method'],
$item['headers'],
$item['body']
)
);
}

return $result;
}

/**
* Converts a response into an array.
*
* @param Response $response
*
* @return array
*/
private function convertResponse(Response $response): array
{
return [
'status' => $response->getStatusCode(),
'body' => $response->getContent(),
'headers' => $response->headers->all(),
];
}

/**
* Validates that the keys are all correctly present.
*
* @param array $data
*
* @return bool
*/
private function validateBatchData(array $data)
{
if (0 === count($data)) {
return false;
}

foreach ($data as $item) {
if (0 !== count(array_diff(array_keys($item), $this->validKeys))) {
return false;
}
}

return true;
}

/**
* Executes a subrequest.
*
* @param int $index
* @param string $path
* @param string $method
* @param array $headers
* @param string $body
*
* @return Response
*/
private function executeSubRequest(int $index, string $path, string $method, array $headers, string $body): Response
{
$subRequest = Request::create($path, $method, [], [], [], [], $body);
$subRequest->headers->replace($headers);

try {
return $this->kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
} catch (\Exception $e) {
return Response::create(sprintf('Batch element #%d failed, check the log files.', $index), 400);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ private function handleConfig(ContainerBuilder $container, array $config, array
{
$container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']);
$container->setParameter('api_platform.enable_docs', $config['enable_docs']);
$container->setParameter('api_platform.batch_endpoint', $config['batch_endpoint']);
$container->setParameter('api_platform.title', $config['title']);
$container->setParameter('api_platform.description', $config['description']);
$container->setParameter('api_platform.version', $config['version']);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public function getConfigTreeBuilder()
->booleanNode('enable_swagger_ui')->defaultValue(class_exists(TwigBundle::class))->info('Enable Swagger ui.')->end()
->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end()
->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end()
->scalarNode('batch_endpoint')->defaultValue('')->info('Set the batch endpoint path. If an empty string is provided, the endpoint is disabled.')->end()

->arrayNode('oauth')
->canBeEnabled()
Expand Down
5 changes: 5 additions & 0 deletions src/Bridge/Symfony/Bundle/Resources/config/api.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<argument>%api_platform.graphql.enabled%</argument>
<argument>%api_platform.enable_entrypoint%</argument>
<argument>%api_platform.enable_docs%</argument>
<argument>%api_platform.batch_endpoint%</argument>

<tag name="routing.loader" />
</service>
Expand Down Expand Up @@ -195,6 +196,10 @@
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
</service>

<service id="api_platform.action.batch_endpoint" class="ApiPlatform\Core\Action\BatchEndpointAction" public="true">
<argument type="service" id="http_kernel" />
</service>

<service id="api_platform.action.documentation" class="ApiPlatform\Core\Documentation\Action\DocumentationAction" public="true">
<argument type="service" id="api_platform.metadata.resource.name_collection_factory" />
<argument>%api_platform.title%</argument>
Expand Down
21 changes: 20 additions & 1 deletion src/Bridge/Symfony/Routing/ApiLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ final class ApiLoader extends Loader
private $graphqlEnabled;
private $entrypointEnabled;
private $docsEnabled;
private $batchEndpoint;

public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $graphqlEnabled = false, bool $entrypointEnabled = true, bool $docsEnabled = true)
public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $graphqlEnabled = false, bool $entrypointEnabled = true, bool $docsEnabled = true, string $batchEndpoint = '')
{
$this->fileLoader = new XmlFileLoader(new FileLocator($kernel->locateResource('@ApiPlatformBundle/Resources/config/routing')));
$this->resourceNameCollectionFactory = $resourceNameCollectionFactory;
Expand All @@ -67,6 +68,7 @@ public function __construct(KernelInterface $kernel, ResourceNameCollectionFacto
$this->graphqlEnabled = $graphqlEnabled;
$this->entrypointEnabled = $entrypointEnabled;
$this->docsEnabled = $docsEnabled;
$this->batchEndpointEnabled = $batchEndpointEnabled;
}

/**
Expand Down Expand Up @@ -130,6 +132,23 @@ public function load($data, $type = null): RouteCollection
}
}

if ('' !== $this->batchEndpoint) {
$routeCollection->add(RouteNameGenerator::ROUTE_NAME_PREFIX . 'batch_endpoint', new Route(
$this->batchEndpoint,
[
'_controller' => self::DEFAULT_ACTION_PATTERN.'batch_endpoint',
'_format' => null,
'_api_batch_request' => true,
'_api_respond' => true,
],
[],
[],
'',
[],
['POST']
));
}

return $routeCollection;
}

Expand Down
30 changes: 24 additions & 6 deletions src/EventListener/DeserializeListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\Exception\NotAcceptableHttpException;
use Symfony\Component\Serializer\Encoder\DecoderInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;

Expand Down Expand Up @@ -47,17 +48,34 @@ public function __construct(SerializerInterface $serializer, SerializerContextBu
public function onKernelRequest(GetResponseEvent $event)
{
$request = $event->getRequest();
if (
$request->isMethodSafe(false)

if ($request->isMethodSafe(false)
|| $request->isMethod(Request::METHOD_DELETE)
|| !($attributes = RequestAttributesExtractor::extractAttributes($request))
|| !$attributes['receive']
|| ('' === ($requestContent = $request->getContent()) && $request->isMethod(Request::METHOD_PUT))
) {
return;
}

$format = $this->getFormat($request);

if ($request->attributes->get('_api_batch_request') && '' !== $request->getContent()) {
if (!$this->serializer instanceof DecoderInterface) {
return;
}

$request->attributes->set(
'data',
$this->serializer->decode($request->getContent(), $format)
);
return;
}

if (!($attributes = RequestAttributesExtractor::extractAttributes($request))
|| !$attributes['receive']
|| ('' === $request->getContent() && $request->isMethod(Request::METHOD_PUT))
) {
return;
}

$context = $this->serializerContextBuilder->createFromRequest($request, false, $attributes);

$data = $request->attributes->get('data');
Expand All @@ -68,7 +86,7 @@ public function onKernelRequest(GetResponseEvent $event)
$request->attributes->set(
'data',
$this->serializer->deserialize(
$requestContent, $attributes['resource_class'], $format, $context
$request->getContent(), $attributes['resource_class'], $this->getFormat($request), $context
)
);
}
Expand Down
4 changes: 4 additions & 0 deletions src/Util/RequestAttributesExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public static function extractAttributes(Request $request)
$result['subresource_context'] = $subresourceContext;
}

if ($isBatchRequest = $request->attributes->get('_api_batch_request')) {
$result['batch_request'] = true;
}

if (null === $result['resource_class']) {
return [];
}
Expand Down