Initial commit

parent 7d90f293
<?php
namespace Drupal\subrequests\Blueprint;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\SerializerInterface;
/**
* TODO: Change this comment. We'll use the serializer instead.
* Base class for blueprint parsers. There may be slightly different blueprint
* formats depending on the encoding. For instance, JSON encoded blueprints will
* reference other properties in the responses using JSON pointers, while XML
* encoded blueprints will use XPath.
*/
class Parser {
/**
* @var \Symfony\Component\Serializer\SerializerInterface
*/
protected $serializer;
/**
* The Mime-Type of the incoming requests.
*
* @var string
*/
protected $type;
/**
* Parser constructor.
*/
public function __construct(SerializerInterface $serializer) {
$this->serializer = $serializer;
}
/**
* @param \Symfony\Component\HttpFoundation\Request $request
* The master request to parse. We need from it:
* - Request body content.
* - Request mime-type.
*/
public function parseRequest(Request $request) {
$tree = $this->serializer->deserialize(
$request->getContent(),
RequestTree::class,
$request->getRequestFormat()
);
$request->attributes->add(RequestTree::SUBREQUEST_TREE, $tree);
// It assumed that all subrequests use the same Mime-Type.
$this->type = $request->getMimeType($request->getRequestFormat());
}
/**
* @param \Symfony\Component\HttpFoundation\Response[] $responses
* The responses to combine.
*
* @return \Symfony\Component\HttpFoundation\Response
* The combined response with a 207.
*/
public function combineResponses(array $responses) {
$delimiter = md5(microtime());
// Prepare the root content type header.
$content_type = sprintf(
'multipart/related; boundary="%s", type=%s',
$delimiter,
$this->type
);
$headers = ['Content-Type' => $content_type];
$context = ['delimiter' => $delimiter];
$content = $this->serializer->serialize($responses, 'multipart-related', $context);
return Response::create($content, 207, $headers);
}
/**
* Validates if a request can be constituted from this payload.
*
* @param array $data
* The user data representing a sub-request.
*
* @return bool
* TRUE if the data is valid. FALSE otherwise.
*/
public static function isValidSubrequest(array $data) {
// TODO: Implement this!
return (bool) $data;
}
}
<?php
namespace Drupal\subrequests\Blueprint;
use Symfony\Component\HttpFoundation\Request;
/**
* Contains the hierarchical information of the requests.
*/
class RequestTree {
const SUBREQUEST_TREE = '_subrequests_tree_object';
const SUBREQUEST_ID = '_subrequests_content_id';
const SUBREQUEST_DONE = '_subrequests_is_done';
/**
* @var \Symfony\Component\HttpFoundation\Request[]
*/
protected $requests;
/**
* If this tree sprouts from another requests, save the request id here.
* @var string
*/
protected $parentId;
/**
* RequestTree constructor.
*
* @param \Symfony\Component\HttpFoundation\Request[] $requests
* @param string $parent_id
*/
public function __construct(array $requests, $parent_id = NULL) {
$this->requests = $requests;
$this->parentId = $parent_id;
}
/**
* Gets a flat list of the initialized requests for the current level.
*
* All requests returned by this method can run in parallel. If a request has
* children requests depending on it (sequential) the parent request will
* contain a RequestTree itself.
*
* @return \Symfony\Component\HttpFoundation\Request[]
* The list of requests.
*/
public function getRequests() {
return $this->requests;
}
/**
* Is this tree the base one?
*
* @return bool
* TRUE if the tree is for the master request.
*/
public function isRoot() {
return !$this->getParentId();
}
/**
* Get the parent ID of the request this tree belongs to.
*
* @return string
*/
public function getParentId() {
return $this->parentId;
}
/**
* Find all the sub-trees in this tree.
*
* @return static[]
* An array of trees.
*/
public function getSubTrees() {
$trees = array_map(function (Request $request) {
return $request->attributes->get(static::SUBREQUEST_TREE);
}, $this->getRequests());
return array_filter($trees);
}
/**
* Find a request in a tree based on the request ID.
*
* @param string $request_id
* The unique ID of a request in the blueprint to find in this tree.
*
* @return \Symfony\Component\HttpFoundation\Request|NULL $request
* The request if found. NULL if not found.
*/
public function getDescendant($request_id) {
// Search this level's requests.
$found = array_filter($this->getRequests(), function (Request $request) use ($request_id) {
return $request->attributes->get(static::SUBREQUEST_ID) == $request_id;
});
if (count($found)) {
return reset($found);
}
// If the request is not in this level, then traverse the children's trees.
$found = array_filter($this->getRequests(), function (Request $request) use ($request_id) {
/** @var static $sub_tree */
if (!$sub_tree = $request->attributes->get(static::SUBREQUEST_TREE)) {
return FALSE;
}
return $sub_tree->getDescendant($request_id);
});
if (count($found)) {
return reset($found);
}
return NULL;
}
/**
* Is the request tree done?
*
* @return bool
* TRUE if all the requests in the tree and it's descendants are done.
*/
public function isDone() {
// The tree is done if all of the requests and their children are done.
return array_reduce($this->getRequests(), function ($is_done, Request $request) {
return $is_done && static::isRequestDone($request);
}, TRUE);
}
/**
* Check if a request and all its possible children are done.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*
* @return bool
* TRUE if is done. FALSE otherwise.
*/
protected static function isRequestDone(Request $request) {
// If one request is not done, the whole tree is not done.
if (!$request->attributes->get(static::SUBREQUEST_DONE)) {
return FALSE;
}
// If the request has children, then make sure those are done too.
/** @var static $sub_tree */
if ($sub_tree = $request->attributes->get(static::SUBREQUEST_TREE)) {
if (!$sub_tree->isDone()) {
return FALSE;
}
}
return TRUE;
}
}
<?php
namespace Drupal\subrequests\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\subrequests\Blueprint\Parser;
use Drupal\subrequests\Blueprint\RequestTree;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
class FrontController extends ControllerBase {
/**
* @var \Drupal\subrequests\Blueprint\Parser
*/
protected $parser;
/**
* @var \Symfony\Component\HttpKernel\HttpKernelInterface
*/
protected $httpKernel;
/**
* FrontController constructor.
*/
public function __construct(Parser $parser, HttpKernelInterface $http_kernel) {
$this->parser = $parser;
$this->httpKernel = $http_kernel;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('subrequests.blueprint_parser'),
$container->get('http_kernel')
);
}
/**
* Controller handler.
*/
public function handle(Request $request) {
$this->parser->parseRequest($request);
$responses = [];
/** @var \Drupal\subrequests\Blueprint\RequestTree $tree */
$root_tree = $request->attributes->get(RequestTree::SUBREQUEST_TREE);
$trees = [$root_tree];
// Handle all the sub-requests.
while (!$root_tree->isDone()) {
// Get all the requests in the trees for the previous pass.
$requests = array_reduce($trees, function (array $carry, RequestTree $tree) {
return array_merge($carry, $tree->getRequests());
}, []);
// Get the next batch of trees for the next level.
$trees = array_reduce($trees, function (array $carry, RequestTree $tree) {
return array_merge($carry, $tree->getSubTrees());
}, []);
// Handle the requests for the trees at this level and gather the
// responses.
$level_responses = array_map(array(
$this->httpKernel,
'handle',
), $requests);
$responses = array_merge(
$responses,
$level_responses
);
}
return $this->parser->combineResponses($responses);
}
}
<?php
namespace Drupal\subrequests\EventSubscriber;
use Drupal\subrequests\Blueprint\RequestTree;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class SubresponseSubscriber implements EventSubscriberInterface {
/**
* Marks the request as done.
*
* @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event
* The event to process.
*/
public function onResponse(FilterResponseEvent $event) {
$request = $event->getRequest();
$request->attributes->set(RequestTree::SUBREQUEST_DONE, TRUE);
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
// Run shortly before \Drupal\Core\EventSubscriber\FinishResponseSubscriber.
$events[KernelEvents::RESPONSE][] = ['onResponse', 5];
return $events;
}
}
<?php
namespace Drupal\subrequests\Normalizer;
use Drupal\subrequests\Blueprint\RequestTree;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Serializer\Serializer;
class JsonBlueprintDenormalizer implements DenormalizerInterface, SerializerAwareInterface {
/**
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* {@inheritdoc}
*/
public function setSerializer(SerializerInterface $serializer) {
if (!is_a($serializer, Serializer::class)) {
throw new \ErrorException('Serializer is unable to normalize or denormalize.');
}
$this->serializer = $serializer;
}
/**
* {@inheritdoc}
*/
public function denormalize($data, $class, $format = NULL, array $context = array()) {
// The top level is an array of normalized requests.
$requests = array_map(function ($item) use ($format) {
return $this->serializer->denormalize($item, Request::class, $format);
}, $data);
return new RequestTree($requests);
}
/**
* {@inheritdoc}
*/
public function supportsDenormalization($data, $type, $format = NULL) {
return $format === 'json'
&& $type === RequestTree::class
&& is_array($data)
&& !static::arrayIsKeyed($data);
}
/**
* Check if an array is keyed.
*
* @param array $input
* The input array to check.
*
* @return bool
* True if the array is keyed.
*/
public static function arrayIsKeyed(array $input) {
$keys = array_keys($input);
// If the array does not start at 0, it is not numeric.
if ($keys[0] !== 0) {
return TRUE;
}
// If there is a non-numeric key, the array is not numeric.
$numeric_keys = array_filter($keys, 'is_numeric');
if (count($keys) != count($numeric_keys)) {
return TRUE;
}
// If the keys are not following the natural numbers sequence, then it is
// not numeric.
for ($index = 1; $index < count($keys); $index++) {
if ($keys[$index] - $keys[$index - 1] !== 1) {
return TRUE;
}
}
return FALSE;
}
}
<?php
namespace Drupal\subrequests\Normalizer;
use Drupal\subrequests\Blueprint\Parser;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Drupal\Component\Utility\NestedArray;
class JsonSubrequestDenormalizer implements DenormalizerInterface {
/**
* Denormalizes data back into an object of the given class.
*
* @param mixed $data data to restore
* @param string $class the expected class to instantiate
* @param string $format format the given data was extracted from
* @param array $context options available to the denormalizer
*
* @return object
*/
public function denormalize($data, $class, $format = NULL, array $context = array()) {
if (!Parser::isValidSubrequest($data)) {
throw new \RuntimeException('The provided blueprint contains an invalid subrequest.');
}
$data['path'] = parse_url($data['path'], PHP_URL_PATH);
if (!is_array($data['query'])) {
$query = array();
parse_str($data['query'], $query);
$data['query'] = $query;
}
$data = NestedArray::mergeDeep($data, array(
'query' => array(),
'body' => array(),
'headers' => array(),
), parse_url($data['path']));
/** @var \Symfony\Component\HttpFoundation\Request $master_request */
$master_request = $context['master_request'];
$request = Request::create(
$data['path'],
static::getMethodFromAction($data['action']),
empty($data['body']) ? $data['query'] : $data['body'],
$master_request->cookies,
$master_request->files,
$master_request->server,
NULL
);
// Maintain the same session as in the master request.
$request->setSession($master_request->getSession());
// Replace the headers by the ones in the subrequest.
$request->headers = new HeaderBag($data['headers']);
// Add the content ID to the sub-request.
$content_id = empty($data['requestId'])
? md5(serialize($data))
: $data['requestId'];
$request->headers->add(['Content-ID', ['<' . $content_id . '>']]);
return $request;
}
/**
* Checks whether the given class is supported for denormalization by this
* normalizer.
*
* @param mixed $data Data to denormalize from
* @param string $type The class to which the data should be denormalized
* @param string $format The format being deserialized from
*
* @return bool
*/
public function supportsDenormalization($data, $type, $format = NULL) {
return $format === 'json'
&& $type === Request::class
&& is_array($data)
&& JsonBlueprintDenormalizer::arrayIsKeyed($data);
}
/**
* Gets the HTTP method from the list of allowed actions.
*
* @param string $action
* The action name.
*
* @return string
* The HTTP method.
*/
public static function getMethodFromAction($action) {
switch ($action) {
case 'create':
return Request::METHOD_POST;
case 'update':
return Request::METHOD_PATCH;
case 'replace':
return Request::METHOD_PUT;
case 'delete':
return Request::METHOD_DELETE;
case 'exists':
return Request::METHOD_HEAD;
case 'discover':
return Request::METHOD_OPTIONS;
default:
return Request::METHOD_GET;
}
}
}
<?php
namespace Drupal\subrequests\Normalizer;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\scalar;
class MultiresponseNormalizer implements NormalizerInterface {
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = array()) {
$delimiter = $context['delimiter'];
$separator = sprintf("\r\n--%s\r\n", $delimiter);
// Join the content responses with the separator.
$content_items = array_map(function (Response $part_response) {
return sprintf(
"%s\r\n\r\n%s",
$part_response->headers,
$part_response->getContent()
);
}, (array) $object);
return sprintf("--%s\r\n", $delimiter) . implode($separator, $content_items) . sprintf("\r\n--%s--", $delimiter);
}
/**
* {@inheritdoc}
*/
public function supportsNormalization($data, $format = NULL) {
if ($format !== 'multipart-response') {
return FALSE;
}
if (!is_array($data)) {
return FALSE;
}
$responses = array_filter($data, function ($response) {
return $response instanceof Response;
});
if (count($responses) !== count($data)) {
return FALSE;
}
return TRUE;
}
}
name: Subrequests
type: module
description: 'Add a front controller that you can use to make subrequests.'
core: 8.x
issue subrequests:
title: 'Issue subrequests'
description: 'Allow using the subrequests front controller to respond to multiple requests.'
subrequests.front-controller:
path: '/subrequests'
defaults:
_title: 'Returns the trace recorded by test proxy session handlers as JSON'
_controller: '\Drupal\subrequests\Controller\FrontController::handle'
methods: [POST]
options:
no_cache: TRUE
requirements:
_permission: 'issue subrequests'
services:
subrequests.blueprint_parser:
class: 'Drupal\subrequests\Blueprint\Parser'
arguments: ['@serializer']
subrequests.subresponse.subscriber:
class: Drupal\subrequests\EventSubscriber\SubresponseSubscriber
tags:
- { name: event_subscriber }
arguments: ['@current_route_match']
subrequests.denormalizer.bluprint.json:
class: Drupal\subrequests\Normalizer\JsonBlueprintDenormalizer
tags:
- { name: normalizer, priority: 0 }
subrequests.normalizer.multiresponse:
class: Drupal\subrequests\Normalizer\MultiresponseNormalizer
tags:
- { name: normalizer, priority: 0 }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment