summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMateu Aguiló Bosch2017-03-19 16:00:59 (GMT)
committerMateu Aguiló Bosch2017-03-19 16:00:59 (GMT)
commit09ef18a48a4439670b1de740cb77bbc71c479832 (patch)
treeb9b110d1911d5f329baa7c81eb4a3e39e8a2df23
parent7d90f29353bcd306205524de4a7bac2b35ded4b6 (diff)
Initial commit
-rw-r--r--src/Blueprint/Parser.php92
-rw-r--r--src/Blueprint/RequestTree.php152
-rw-r--r--src/Controller/FrontController.php78
-rw-r--r--src/EventSubscriber/SubresponseSubscriber.php33
-rw-r--r--src/Normalizer/JsonBlueprintDenormalizer.php81
-rw-r--r--src/Normalizer/JsonSubrequestDenormalizer.php110
-rw-r--r--src/Normalizer/MultiresponseNormalizer.php48
-rw-r--r--subrequests.info.yml4
-rw-r--r--subrequests.permissions.yml3
-rw-r--r--subrequests.routing.yml10
-rw-r--r--subrequests.services.yml17
11 files changed, 628 insertions, 0 deletions
diff --git a/src/Blueprint/Parser.php b/src/Blueprint/Parser.php
new file mode 100644
index 0000000..239bfe0
--- /dev/null
+++ b/src/Blueprint/Parser.php
@@ -0,0 +1,92 @@
+<?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;
+ }
+
+}
diff --git a/src/Blueprint/RequestTree.php b/src/Blueprint/RequestTree.php
new file mode 100644
index 0000000..475d152
--- /dev/null
+++ b/src/Blueprint/RequestTree.php
@@ -0,0 +1,152 @@
+<?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;
+ }
+
+}
diff --git a/src/Controller/FrontController.php b/src/Controller/FrontController.php
new file mode 100644
index 0000000..21e3016
--- /dev/null
+++ b/src/Controller/FrontController.php
@@ -0,0 +1,78 @@
+<?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);
+ }
+
+}
diff --git a/src/EventSubscriber/SubresponseSubscriber.php b/src/EventSubscriber/SubresponseSubscriber.php
new file mode 100644
index 0000000..4e650a2
--- /dev/null
+++ b/src/EventSubscriber/SubresponseSubscriber.php
@@ -0,0 +1,33 @@
+<?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;
+ }
+
+}
diff --git a/src/Normalizer/JsonBlueprintDenormalizer.php b/src/Normalizer/JsonBlueprintDenormalizer.php
new file mode 100644
index 0000000..6169473
--- /dev/null
+++ b/src/Normalizer/JsonBlueprintDenormalizer.php
@@ -0,0 +1,81 @@
+<?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;
+ }
+
+}
diff --git a/src/Normalizer/JsonSubrequestDenormalizer.php b/src/Normalizer/JsonSubrequestDenormalizer.php
new file mode 100644
index 0000000..115c5f2
--- /dev/null
+++ b/src/Normalizer/JsonSubrequestDenormalizer.php
@@ -0,0 +1,110 @@
+<?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;
+ }
+ }
+
+}
diff --git a/src/Normalizer/MultiresponseNormalizer.php b/src/Normalizer/MultiresponseNormalizer.php
new file mode 100644
index 0000000..1d748fe
--- /dev/null
+++ b/src/Normalizer/MultiresponseNormalizer.php
@@ -0,0 +1,48 @@
+<?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;
+ }
+
+}
diff --git a/subrequests.info.yml b/subrequests.info.yml
new file mode 100644
index 0000000..1616349
--- /dev/null
+++ b/subrequests.info.yml
@@ -0,0 +1,4 @@
+name: Subrequests
+type: module
+description: 'Add a front controller that you can use to make subrequests.'
+core: 8.x
diff --git a/subrequests.permissions.yml b/subrequests.permissions.yml
new file mode 100644
index 0000000..c5526c6
--- /dev/null
+++ b/subrequests.permissions.yml
@@ -0,0 +1,3 @@
+issue subrequests:
+ title: 'Issue subrequests'
+ description: 'Allow using the subrequests front controller to respond to multiple requests.'
diff --git a/subrequests.routing.yml b/subrequests.routing.yml
new file mode 100644
index 0000000..5856944
--- /dev/null
+++ b/subrequests.routing.yml
@@ -0,0 +1,10 @@
+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'
diff --git a/subrequests.services.yml b/subrequests.services.yml
new file mode 100644
index 0000000..edd9dcf
--- /dev/null
+++ b/subrequests.services.yml
@@ -0,0 +1,17 @@
+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 }