clean install

This commit is contained in:
2024-12-10 15:08:16 +01:00
commit e14eb2d8fd
31193 changed files with 3555714 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
<?php
namespace Drupal\subrequests\Blueprint;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Cache\CacheableResponseInterface;
use Drupal\subrequests\SubrequestsTree;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Serializer;
/**
* Manages the blueprint.
*/
class BlueprintManager {
/**
* The deserializer.
*
* @var \Symfony\Component\Serializer\SerializerInterface
*/
protected $serializer;
/**
* {@inheritDoc}
*/
public function __construct(Serializer $serializer) {
$this->serializer = $serializer;
}
/**
* Takes the user input and returns a subrequest tree ready for execution.
*
* @param string $input
* The input from the user.
* @param \Symfony\Component\HttpFoundation\Request $request
* The input from the user.
*
* @return \Drupal\subrequests\SubrequestsTree
* Parsed subrequest tree.
*/
public function parse($input, Request $request) {
/** @var \Drupal\subrequests\SubrequestsTree $output */
$output = $this->serializer
->deserialize($input, SubrequestsTree::class, 'json');
$output->setMasterRequest($request);
// Forward the Host header to place nice with decoupled routers.
$this->forwardHeader('host', $request, $output);
return $output;
}
/**
* Combines the responses of blueprint.
*
* @param \Symfony\Component\HttpFoundation\Response[] $responses
* The responses to combine.
* @param string $format
* The format to combine the responses on. Default is multipart/related.
*
* @return \Symfony\Component\HttpFoundation\Response
* The combined response with a 207.
*/
public function combineResponses(array $responses, $format) {
$context = [
'sub-content-type' => $this->negotiateSubContentType($responses),
];
// Set the content.
$normalized = $this->serializer->normalize($responses, $format, $context);
$response = new CacheableResponse($normalized['content'], 207, $normalized['headers']);
// Set the cacheability metadata.
$cacheable_responses = array_filter($responses, function ($response) {
return $response instanceof CacheableResponseInterface;
});
array_walk($cacheable_responses, function (CacheableResponseInterface $partial_response) use ($response) {
$response->addCacheableDependency($partial_response->getCacheableMetadata());
});
return $response;
}
/**
* Negotiates the sub Content-Type.
*
* Checks if all responses have the same Content-Type header. If they do, then
* it returns that one. If not, it defaults to 'application/json'.
*
* @param \Symfony\Component\HttpFoundation\Response[] $responses
* The responses.
*
* @return string
* The collective content type. 'application/json' if no conciliation is
* possible.
*/
protected function negotiateSubContentType($responses) {
$output = array_reduce($responses, function ($carry, Response $response) {
$ct = $response->headers->get('Content-Type');
if (!isset($carry)) {
$carry = $ct;
}
if ($carry !== $ct) {
$carry = 'application/json';
}
return $carry;
});
return $output ?: 'application/json';
}
/**
* Forward the master request's header to the subrequest.
*
* @param string $name
* The header name to forward.
* @param \Symfony\Component\HttpFoundation\Request $from
* The request to copy headers from.
* @param \Drupal\subrequests\SubrequestsTree $tree
* The target request to copy headers to.
*/
protected function forwardHeader($name, Request $from, SubrequestsTree $tree) {
foreach ($tree as $level) {
foreach ($level as $subrequest) {
/** @var \Drupal\subrequests\Subrequest $subrequest */
if (isset($subrequest->headers[$name])) {
continue;
}
$subrequest->headers[$name] = $from->headers->get($name);
}
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Drupal\subrequests\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\subrequests\Blueprint\BlueprintManager;
use Drupal\subrequests\SubrequestsManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
/**
* Front controller to process Subrequests requests.
*/
class FrontController extends ControllerBase {
/**
* Blueprint manager.
*
* @var \Drupal\subrequests\Blueprint\BlueprintManager
*/
protected $blueprintManager;
/**
* Subrequest manager.
*
* @var \Drupal\subrequests\SubrequestsManager
*/
protected $subrequestsManager;
/**
* FrontController constructor.
*/
public function __construct(BlueprintManager $blueprint_manager, SubrequestsManager $subrequests_manager) {
$this->blueprintManager = $blueprint_manager;
$this->subrequestsManager = $subrequests_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('subrequests.blueprint_manager'),
$container->get('subrequests.subrequests_manager')
);
}
/**
* Controller handler.
*/
public function handle(Request $request) {
$data = '';
if ($request->getMethod() === Request::METHOD_POST) {
$data = $request->getContent();
}
elseif ($request->getMethod() === Request::METHOD_GET) {
$data = $request->query->get('query', '');
}
$tree = $this->blueprintManager->parse($data, $request);
$responses = $this->subrequestsManager->request($tree);
$master_request = $tree->getMasterRequest();
$output_format = $master_request->getRequestFormat();
if ($output_format === 'html') {
// Change the default format from html to multipart-related.
$output_format = 'multipart-related';
}
$master_request->getMimeType($output_format);
return $this->blueprintManager->combineResponses($responses, $output_format);
}
}

View File

@@ -0,0 +1,401 @@
<?php
namespace Drupal\subrequests;
use Drupal\Component\Serialization\Json;
use JsonPath\JsonObject;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Defines the json path replacer.
*/
class JsonPathReplacer {
/**
* Performs the JSON Path replacements in the whole batch.
*
* @param \Drupal\subrequests\Subrequest[] $batch
* The subrequests that contain replacement tokens.
* @param \Symfony\Component\HttpFoundation\Response[] $responses
* The accumulated responses from previous requests.
*
* @return \Drupal\subrequests\Subrequest[]
* An array of subrequests. Note that one input subrequest can generate N
* output subrequests. This is because JSON path expressions can return
* multiple values.
*/
public function replaceBatch(array $batch, array $responses) {
return array_reduce($batch, function (array $carry, Subrequest $subrequest) use ($responses) {
return array_merge(
$carry,
$this->replaceItem($subrequest, $responses)
);
}, []);
}
/**
* Searches for JSONPath tokens in the request and replaces them.
*
* @param \Drupal\subrequests\Subrequest $subrequest
* The list of requests that can contain tokens.
* @param \Symfony\Component\HttpFoundation\Response[] $pool
* The pool of responses that can content the values to replace.
*
* @returns \Drupal\subrequests\Subrequest[]
* The new list of requests. Note that if a JSONPath token yields many
* values then several replaced subrequests will be generated from the input
* subrequest.
*/
protected function replaceItem(Subrequest $subrequest, array $pool) {
$token_replacements = [
'uri' => $this->extractTokenReplacements($subrequest, 'uri', $pool),
'body' => $this->extractTokenReplacements($subrequest, 'body', $pool),
];
if (count($token_replacements['uri']) !== 0) {
return $this->replaceBatch(
$this->doReplaceTokensInLocation($token_replacements, $subrequest, 'uri'),
$pool
);
}
if (count($token_replacements['body']) !== 0) {
return $this->replaceBatch(
$this->doReplaceTokensInLocation($token_replacements, $subrequest, 'body'),
$pool
);
}
// If there are no replacements necessary, then just return the initial
// request.
$subrequest->_resolved = TRUE;
return [$subrequest];
}
/**
* Creates replacements for either the body or the URI.
*
* @param array $token_replacements
* Holds the info to replace text.
* @param \Drupal\subrequests\Subrequest $tokenized_subrequest
* The original copy of the subrequest.
* @param string $token_location
* Either 'body' or 'uri'.
*
* @returns \Drupal\subrequests\Subrequest[]
* The replaced subrequests.
*
* @private
*/
protected function doReplaceTokensInLocation(array $token_replacements, $tokenized_subrequest, $token_location) {
$replacements = [];
$tokens_per_content_id = $token_replacements[$token_location];
$index = 0;
// First figure out the different token resolutions and their token.
$grouped_by_token = [];
foreach ($tokens_per_content_id as $resolutions_per_token) {
foreach ($resolutions_per_token as $token => $resolutions) {
$grouped_by_token[] = array_map(function ($resolution) use ($token) {
return [
'token' => $token,
'value' => $resolution,
];
}, $resolutions);
}
}
// Then calculate the points.
$points = $this->getPoints($grouped_by_token);
foreach ($points as $point) {
// Clone the subrequest.
$cloned = clone $tokenized_subrequest;
$cloned->requestId = sprintf(
'%s#%s{%s}',
$tokenized_subrequest->requestId,
$token_location,
$index
);
$index++;
// Now replace all the tokens in the request member.
$token_subject = $this->serializeMember($token_location, $cloned->{$token_location});
foreach ($point as $replacement) {
// Do all the different replacements on the same subject.
$token_subject = $this->replaceTokenSubject(
$replacement['token'],
$replacement['value'],
$token_subject
);
}
$cloned->{$token_location} = $this->deserializeMember($token_location, $token_subject);
array_push($replacements, $cloned);
}
return $replacements;
}
/**
* Does the replacement on the token subject.
*
* @param string $token
* The thing to replace.
* @param string $value
* The thing to replace it with.
* @param string $token_subject
* The thing to replace it on.
*
* @returns string
* The replaced string.
*/
protected function replaceTokenSubject($token, $value, $token_subject) {
// Escape regular expression.
if (is_int($value) || is_float($value) || is_bool($value)) {
if (is_bool($value)) {
$value = $value ? 'true' : 'false';
}
$regexp = sprintf('/%s/', preg_quote("\"$token\"", '/'));
$token_subject = preg_replace($regexp, $value, $token_subject);
}
$regexp = sprintf('/%s/', preg_quote($token, '/'));
return preg_replace($regexp, $value, $token_subject);
}
/**
* Generates a list of sets of coordinates for the token replacements.
*
* Each point (coordinates set) end up creating a new clone of the tokenized
* subrequest.
*
* @param array $grouped_by_token
* Replacements grouped by token.
*
* @return array
* The coordinates sets.
*/
protected function getPoints($grouped_by_token) {
$current_group = array_shift($grouped_by_token);
// If this is not the last group, then call recursively.
if (empty($grouped_by_token)) {
return array_map(function ($item) {
return [$item];
}, $current_group);
}
$points = [];
foreach ($current_group as $resolution_info) {
// Get all the combinations for the next groups.
$next_points = $this->getPoints($grouped_by_token);
foreach ($next_points as $next_point) {
// Prepend the current resolution for each point.
$points[] = array_merge([$resolution_info], $next_point);
}
}
return $points;
}
/**
* Makes sure that the subject for replacement is a string.
*
* This is an abstraction to be able to treat 'uri' and 'body' replacements
* the same way.
*
* @param string $member_name
* Either 'body' or 'uri'.
* @param mixed $value
* The contents of the URI or the subrequest body.
*
* @returns string
* The serialized member.
*/
protected function serializeMember($member_name, $value) {
return $member_name === 'body'
// The body is an Object, to replace on it we serialize it first.
? Json::encode($value)
: $value;
}
/**
* Undoes the serialization that happened in _serializeMember.
*
* This is an abstraction to be able to treat 'uri' and 'body' replacements
* the same way.
*
* @param string $member_name
* Either 'body' or 'uri'.
* @param string $serialized
* The contents of the serialized URI or the serialized subrequest body.
*
* @returns mixed
* The unserialized member.
*/
protected function deserializeMember($member_name, $serialized) {
return $member_name === 'body'
// The body is an Object, to replace on it we serialize it first.
? Json::decode($serialized)
: $serialized;
}
/**
* Extracts the token replacements for a given subrequest.
*
* Given a subrequest there can be N tokens to be replaced. Each token can
* result in an list of values to be replaced. Each token may refer to many
* subjects, if the subrequest referenced in the token ended up spawning
* multiple responses. This function detects the tokens and finds the
* replacements for each token. Then returns a data structure that contains a
* list of replacements. Each item contains all the replacement needed to get
* a response for the initial request, given a particular subject for a
* particular JSONPath replacement.
*
* @param \Drupal\subrequests\Subrequest $subrequest
* The subrequest that contains the tokens.
* @param string $token_location
* Indicates if we are dealing with body or URI replacements.
* @param \Symfony\Component\HttpFoundation\Response[] $pool
* The collection of prior responses available for use with JSONPath.
*
* @returns array
* The structure containing a list of replacements for a subject response
* and a replacement candidate.
*/
protected function extractTokenReplacements(Subrequest $subrequest, $token_location, array $pool) {
// Turn the subject into a string.
$regexp_subject = $token_location === 'body'
? Json::encode($subrequest->body)
: $subrequest->uri;
// First find all the replacements to do. Use a regular expression to detect
// cases like "…{{req1.body@$.data.attributes.seasons..id}}…".
$found = $this->findTokens($regexp_subject);
// Make sure that duplicated tokens in the same location are treated as the
// same thing.
$found = array_values(array_reduce($found, function ($carry, $match) {
$carry[$match[0]] = $match;
return $carry;
}, []));
// Then calculate the replacements we will need to return.
$reducer = function ($token_replacements, $match) use ($pool) {
// Remove the .body part at the end since we only support the body
// replacement at this moment.
$provided_id = preg_replace('/\.body$/', '', $match[1]);
// Calculate what are the subjects to execute the JSONPath against.
$subjects = array_filter($pool, function (Response $response) use ($provided_id) {
// The response is considered a subject if it matches the content ID or
// it is a generated copy based of that content ID.
$pattern = sprintf('/%s(#.*)?/', preg_quote($provided_id));
$content_id = $this->getContentId($response);
return preg_match($pattern, $content_id);
});
if (count($subjects) === 0) {
$candidates = array_map(function ($response) {
$candidate = $this->getContentId($response);
return preg_replace('/#.*/', '', $candidate);
}, $pool);
throw new BadRequestHttpException(sprintf(
'Unable to find specified request for a replacement %s. Candidates are [%s].',
$provided_id,
implode(', ', $candidates)
));
}
// Find the replacements for this match given a subject. If there is more
// than one response object (a subject) for a given subrequest, then we
// generate one parallel subrequest per subject.
foreach ($subjects as $subject) {
$this->addReplacementsForSubject($match, $subject, $provided_id, $token_replacements);
}
return $token_replacements;
};
return array_reduce($found, $reducer, []);
}
/**
* Gets the clean Content ID for a response.
*
* Removes all the derived indicators and the surrounding angles.
*
* @param \Symfony\Component\HttpFoundation\Response $response
* The response to extract the Content ID from.
*
* @returns string
* The content ID.
*/
protected function getContentId(Response $response) {
$header = $response->headers->get('Content-ID', '');
return substr($header, 1, strlen($header) - 2);
}
/**
* Finds and parses all the tokens in a given string.
*
* @param string $subject
* The tokenized string. This is usually the URI or the serialized body.
*
* @returns array
* A list of all the matches. Each match contains the token, the subject to
* search replacements in and the JSONPath query to execute.
*/
protected function findTokens($subject) {
$matches = [];
$pattern = '/\{\{([^\{\}]+\.[^\{\}]+)@([^\{\}]+)\}\}/';
preg_match_all($pattern, $subject, $matches);
if (!$matches = array_filter($matches)) {
return [];
}
$output = [];
for ($index = 0; $index < count($matches[0]); $index++) {
// We only care about the first three items: full match, subject ID and
// JSONPath query.
$output[] = [
$matches[0][$index],
$matches[1][$index],
$matches[2][$index],
];
}
return $output;
}
/**
* Fill replacement values for a subrequest a subject and an structured token.
*
* @param array $match
* The structured replacement token.
* @param \Symfony\Component\HttpFoundation\Response $subject
* The response object the token refers to.
* @param string $provided_id
* The provided id.
* @param array $token_replacements
* The accumulated replacements. Adds items onto the array.
*/
protected function addReplacementsForSubject(array $match, Response $subject, $provided_id, array &$token_replacements) {
$json_object = new JsonObject($subject->getContent());
$to_replace = $json_object->get($match[2]) ?: [];
$token = $match[0];
// The replacements need to be strings. If not, then the replacement
// is not valid.
$this->validateJsonPathReplacements($to_replace);
$token_replacements[$provided_id] = empty($token_replacements[$provided_id])
? []
: $token_replacements[$provided_id];
$token_replacements[$provided_id][$token] = empty($token_replacements[$provided_id][$token])
? []
: $token_replacements[$provided_id][$token];
$token_replacements[$provided_id][$token] = array_merge($token_replacements[$provided_id][$token], $to_replace);
}
/**
* Validates tha the JSONPath query yields a string or an array of strings.
*
* @param array $to_replace
* The replacement candidates.
*
* @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException
* When the replacements are not valid.
*/
protected function validateJsonPathReplacements($to_replace) {
$is_valid = is_array($to_replace)
&& array_reduce($to_replace, function ($valid, $replacement) {
return $valid && (is_string($replacement) || is_int($replacement) || is_bool($replacement) || is_float($replacement));
}, TRUE);
if (!$is_valid) {
throw new BadRequestHttpException(sprintf(
'The replacement token did find not a list of strings. Instead it found %s.',
Json::encode($to_replace)
));
}
}
}

View File

@@ -0,0 +1,259 @@
<?php
namespace Drupal\subrequests\Normalizer;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Uuid\Php;
use Drupal\subrequests\Subrequest;
use Drupal\subrequests\SubrequestsTree;
use JsonSchema\Constraints\Constraint;
use JsonSchema\Validator;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Denormalizer that builds the blueprint based on the incoming blueprint.
*/
class JsonBlueprintDenormalizer implements DenormalizerInterface, SerializerAwareInterface {
/**
* The serializer service.
*
* @var \Symfony\Component\Serializer\Serializer
*/
protected $serializer;
/**
* The Subrequests logger channel.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The schema validator.
*
* This property will only be set if the validator library is available.
*
* @var \JsonSchema\Validator|null
*/
protected $validator;
/**
* {@inheritDoc}
*/
public function __construct(LoggerInterface $logger) {
$this->logger = $logger;
}
/**
* Sets the validator service if available.
*/
public function setValidator(Validator $validator = NULL) {
if ($validator) {
$this->validator = $validator;
}
elseif (class_exists(Validator::class)) {
$this->validator = new Validator();
}
}
/**
* {@inheritdoc}
*/
public function setSerializer(SerializerInterface $serializer): void {
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 = []): mixed {
$this->doValidateInput($data);
$data = array_map([$this, 'fillDefaults'], $data);
$subrequests = array_map(function ($item) {
return new Subrequest($item);
}, $data);
return $this->buildExecutionSequence($subrequests);
}
/**
* {@inheritdoc}
*/
public function supportsDenormalization($data, $type, $format = NULL, array $context = []): bool {
return $format === 'json'
&& $type === SubrequestsTree::class
&& is_array($data)
&& !static::arrayIsKeyed($data);
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
SubrequestsTree::class => FALSE,
];
}
/**
* Check if an array is keyed.
*
* @param array $input
* The input array to check.
*
* @return bool
* True if the array is keyed.
*/
protected static function arrayIsKeyed(array $input) {
// Empty arrays are not keyed.
if (count($input) === 0) {
return FALSE;
}
$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;
}
/**
* Fill the defaults.
*
* @param array $raw_item
* The object to turn into a Subrequest input.
*
* @return array
* The complete Subrequest.
*/
protected function fillDefaults($raw_item) {
if (empty($raw_item['requestId'])) {
$uuid = new Php();
$raw_item['requestId'] = $uuid->generate();
}
if (!isset($raw_item['body'])) {
$raw_item['body'] = NULL;
}
elseif (!empty($raw_item['body'])) {
$raw_item['body'] = Json::decode($raw_item['body']);
}
$raw_item['headers'] = !empty($raw_item['headers']) ? $raw_item['headers'] : [];
$raw_item['waitFor'] = !empty($raw_item['waitFor']) ? $raw_item['waitFor'] : ['<ROOT>'];
$raw_item['_resolved'] = FALSE;
// Detect if there is an encoded token. If so, then decode the URI.
if (
!empty($raw_item['uri']) &&
strpos($raw_item['uri'], '%7B%7B') !== FALSE &&
strpos($raw_item['uri'], '%7D%7D') !== FALSE
) {
$raw_item['uri'] = urldecode($raw_item['uri']);
}
return $raw_item;
}
/**
* Wraps validation in an assert to prevent execution in production.
*
* @see self::validateInput
*/
public function doValidateInput($input) {
if (PHP_MAJOR_VERSION >= 8) {
assert($this->validateInput($input), 'A Subrequests blueprint failed validation (see the logs for details). Please report this in the issue queue on drupal.org');
}
}
/**
* Validates the consumers's blueprint against the subrequests payload format.
*
* @param mixed $input
* The blueprint sent by the consumer.
*
* @return bool
* FALSE if the input failed validation, otherwise TRUE.
*/
protected function validateInput($input) {
// If the validator isn't set, then the validation library is not installed.
if (!$this->validator) {
return TRUE;
}
$schema_path = dirname(dirname(__DIR__)) . '/schema.json';
$this->validator->validate($input, (object) ['$ref' => 'file://' . $schema_path], Constraint::CHECK_MODE_TYPE_CAST);
if (!$this->validator->isValid()) {
// Log any potential errors.
$this->logger->debug('Consumer\'s blueprint failed validation: @data', [
'@data' => Json::encode($input),
]);
$this->logger->debug('Validation errors: @errors', [
'@errors' => Json::encode($this->validator->getErrors()),
]);
}
return $this->validator->isValid();
}
/**
* Builds the execution sequence.
*
* Builds an array where each position contains the IDs of the requests to be
* executed. All the IDs in the same position in the sequence can be executed
* in parallel.
*
* @param \Drupal\subrequests\Subrequest[] $parsed
* The parsed requests.
*
* @return \Drupal\subrequests\SubrequestsTree
* The sequence of IDs grouped by execution order.
*/
public function buildExecutionSequence(array $parsed) {
$sequence = new SubrequestsTree();
$rooted_reqs = array_filter($parsed, function (Subrequest $item) {
return $item->waitFor === ['<ROOT>'];
});
$sequence->stack($rooted_reqs);
$subreqs_with_unresolved_deps = array_values(
array_filter($parsed, function (Subrequest $item) {
return $item->waitFor !== ['<ROOT>'];
})
);
$dependency_is_resolved = function (Subrequest $item) use ($sequence) {
return empty(array_diff($item->waitFor, $sequence->allIds()));
};
while (count($subreqs_with_unresolved_deps)) {
$no_deps = array_filter($subreqs_with_unresolved_deps, $dependency_is_resolved);
if (empty($no_deps)) {
throw new BadRequestHttpException('Waiting for unresolvable request. Abort.');
}
$sequence->stack($no_deps);
$subreqs_with_unresolved_deps = array_diff($subreqs_with_unresolved_deps, $no_deps);
}
return $sequence;
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace Drupal\subrequests\Normalizer;
use Drupal\Component\Serialization\Json;
use Drupal\subrequests\Subrequest;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Creates a request object for each Subrequest.
*/
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
* Return denormalized data as object.
*/
public function denormalize($data, $class, $format = NULL, array $context = []): mixed {
/** @var \Drupal\subrequests\Subrequest $data */
$path = parse_url($data->uri, PHP_URL_PATH);
$query = parse_url($data->uri, PHP_URL_QUERY) ?: [];
if (isset($query) && !is_array($query)) {
$_query = [];
parse_str($query, $_query);
$query = $_query;
}
/** @var \Symfony\Component\HttpFoundation\Request $master_request */
$master_request = $context['master_request'];
$request = Request::create(
$path,
static::getMethodFromAction($data->action),
empty($data->body) ? $query : (array) $data->body,
$master_request->cookies ? $master_request->cookies->all() : [],
$master_request->files ? $master_request->files->all() : [],
$master_request->server ? $master_request->server->all() : [],
empty($data->body) ? '' : Json::encode($data->body)
);
// Maintain the same session as in the master request.
$session = $master_request->getSession();
$request->setSession($session);
// Replace the headers by the ones in the subrequest.
foreach ($data->headers as $name => $value) {
$request->headers->set($name, $value);
}
$this::fixBasicAuth($request);
// Add the content ID to the sub-request.
$content_id = empty($data->requestId)
? md5(serialize($data))
: $data->requestId;
$request->headers->set('Content-ID', '<' . $content_id . '>');
return $request;
}
/**
* Checks whether the given class is supported for denormalization.
*
* @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.
* @param array $context
* Options available to the denormalizer.
*
* @return bool
* Return whether the support denormalization.
*/
public function supportsDenormalization($data, $type, $format = NULL, array $context = []): bool {
return $type === Request::class
&& $data instanceof Subrequest;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
Request::class => FALSE,
Subrequest::class => FALSE,
];
}
/**
* 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;
}
}
/**
* Adds the decoded username and password headers for Basic Auth.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request to fix.
*/
protected static function fixBasicAuth(Request $request) {
// The server will not set the PHP_AUTH_USER and PHP_AUTH_PW for the
// subrequests if needed.
if ($request->headers->has('Authorization')) {
$header = $request->headers->get('Authorization');
if (strpos($header, 'Basic ') === 0) {
[$user, $pass] = explode(':', base64_decode(substr($header, 6)));
$request->headers->set('PHP_AUTH_USER', $user);
$request->headers->set('PHP_AUTH_PW', $pass);
}
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Drupal\subrequests\Normalizer;
use Drupal\Component\Serialization\Json;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Normalizes multiple response objects into a single string.
*/
class MultiresponseJsonNormalizer implements NormalizerInterface {
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []): array|bool|string|int|float|null|\ArrayObject {
// Prepare the root content type header.
$headers = ['X-Sub-Content-Type' => $context['sub-content-type'], 'Content-Type' => 'application/json'];
// Join the content responses as a JSON object with the separator.
$output = array_reduce((array) $object, function ($carry, Response $part_response) {
$part_response->headers->set('Status', $part_response->getStatusCode());
$content_id = $part_response->headers->get('Content-ID');
$content_id = substr($content_id, 1, strlen($content_id) - 2);
$carry[$content_id] = [
'headers' => $part_response->headers->all(),
'body' => $part_response->getContent(),
];
return $carry;
}, []);
$content = Json::encode($output);
return [
'content' => $content,
'headers' => $headers,
];
}
/**
* {@inheritdoc}
*/
public function supportsNormalization($data, $format = NULL, array $context = []): bool {
if ($format !== 'json') {
return FALSE;
}
if (!is_array($data)) {
return FALSE;
}
$responses = array_filter($data, function ($response) {
return $response instanceof Response;
});
if (count($responses) === 0 || count($responses) !== count($data)) {
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
'native-array' => FALSE,
Response::class => FALSE,
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Drupal\subrequests\Normalizer;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* Normalizes multiple response objects into a single string.
*/
class MultiresponseNormalizer implements NormalizerInterface {
/**
* {@inheritdoc}
*/
public function normalize($object, $format = NULL, array $context = []): array|bool|string|int|float|null|\ArrayObject {
$delimiter = md5(microtime());
// Prepare the root content type header.
$content_type = sprintf(
'multipart/related; boundary="%s"; type=%s',
$delimiter,
$context['sub-content-type']
);
$headers = ['Content-Type' => $content_type];
$separator = sprintf("\r\n--%s\r\n", $delimiter);
// Join the content responses with the separator.
$content_items = array_map(function (Response $part_response) {
$part_response->headers->set('Status', $part_response->getStatusCode());
return sprintf(
"%s\r\n%s",
$part_response->headers,
$part_response->getContent()
);
}, (array) $object);
$content = sprintf("--%s\r\n", $delimiter) . implode($separator, $content_items) . sprintf("\r\n--%s--", $delimiter);
return [
'content' => $content,
'headers' => $headers,
];
}
/**
* {@inheritdoc}
*/
public function supportsNormalization($data, $format = NULL, $context = []): bool {
if ($format !== 'multipart-related') {
return FALSE;
}
if (!is_array($data)) {
return FALSE;
}
$responses = array_filter($data, function ($response) {
return $response instanceof Response;
});
if (count($responses) === 0 || count($responses) !== count($data)) {
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function getSupportedTypes(?string $format): array {
return [
'native-array' => FALSE,
Response::class => FALSE,
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Drupal\subrequests;
use Drupal\page_cache\StackMiddleware\PageCache as CorePageCache;
use Symfony\Component\HttpFoundation\Request;
/**
* Prevents the cache ID for a request from being statically cached.
*
* @todo Remove when https://www.drupal.org/i/3050383 is fixed.
*/
final class PageCache extends CorePageCache {
/**
* Static cache of cache IDs.
*
* @var \SplObjectStorage
*/
protected $cacheIds;
/**
* {@inheritdoc}
*/
protected function getCacheId(Request $request) {
if ($this->cacheIds === NULL) {
$this->cacheIds = new \SplObjectStorage();
}
if (!isset($this->cacheIds[$request])) {
$this->cacheIds[$request] = parent::getCacheId($request);
$this->cid = NULL;
}
return $this->cacheIds[$request];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Drupal\subrequests;
/**
* Value object containing a Subrequest.
*/
class Subrequest {
/**
* The request ID.
*
* @var string
*/
public $requestId;
/**
* The parsed JSON.
*
* @var array
*/
public $body;
/**
* Array of key values.
*
* @var array
*/
public $headers;
/**
* The parent subrequests.
*
* @var string[]
*/
public $waitFor;
/**
* Is the subrequest resolved?
*
* @var bool
*/
// phpcs:ignore
public $_resolved;
/**
* The URI to request.
*
* @var string
*/
public $uri;
/**
* The action to perform.
*
* @var string
*/
public $action;
/**
* {@inheritDoc}
*/
public function __construct($values) {
$this->requestId = $values['requestId'];
$this->body = $values['body'];
$this->headers = $values['headers'];
$this->waitFor = $values['waitFor'];
$this->_resolved = $values['_resolved'];
$this->uri = $values['uri'];
$this->action = $values['action'];
}
/**
* Serialize the data.
*/
public function __toString() {
return serialize($this);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace Drupal\subrequests;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
/**
* Manages the subrequest data.
*/
class SubrequestsManager {
/**
* The kernel.
*
* @var \Symfony\Component\HttpKernel\HttpKernelInterface
*/
protected $httpKernel;
/**
* The serializer.
*
* @var \Symfony\Component\HttpKernel\HttpKernelInterface
*/
protected $serializer;
/**
* The path replacer.
*
* @var \Drupal\subrequests\JsonPathReplacer
*/
protected $replacer;
/**
* {@inheritDoc}
*/
public function __construct(HttpKernelInterface $http_kernel, DenormalizerInterface $serializer, JsonPathReplacer $replacer) {
$this->httpKernel = $http_kernel;
$this->serializer = $serializer;
$this->replacer = $replacer;
}
/**
* {@inheritDoc}
*/
public function request(SubrequestsTree $tree) {
// Loop through all sequential requests and merge them.
return $this->processBatchesSequence($tree);
}
/**
* Processes all the Subrequests until produce a collection of responses.
*
* @param \Drupal\subrequests\SubrequestsTree $tree
* The request tree that contains the requesting structure.
* @param int $_sequence
* (internal) The current index in the sequential chain.
* @param \Symfony\Component\HttpFoundation\Response[] $_responses
* (internal) The list of responses accumulated so far.
*
* @return \Symfony\Component\HttpFoundation\Response[]
* An array of responses when everything has been resolved.
*/
protected function processBatchesSequence($tree, $_sequence = 0, array $_responses = []) {
$batch = $tree[$_sequence];
// Perform all the necessary replacements for the elements in the batch.
$batch = $this->replacer->replaceBatch($batch, $_responses);
$results = array_map(function (Subrequest $subrequest) use ($tree) {
$master_request = $tree->getMasterRequest();
// Create a Symfony Request object based on the Subrequest.
/** @var \Symfony\Component\HttpFoundation\Request $request */
$request = $this->serializer->denormalize(
$subrequest,
Request::class,
NULL,
['master_request' => $master_request]
);
$response = $this->httpKernel
->handle($request, HttpKernelInterface::MAIN_REQUEST);
// Set the Content-ID header in the response.
$content_id = sprintf('<%s>', $subrequest->requestId);
$response->headers->set('Content-ID', $content_id);
return $response;
}, $batch);
// Accumulate the responses for the current batch.
$_responses = array_merge($_responses, $results);
// If we're not done, then call recursively with the updated arguments.
$_sequence++;
return $_sequence === $tree->count()
? $_responses
: $this->processBatchesSequence($tree, $_sequence, $_responses);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Drupal\subrequests;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceProviderBase;
/**
* Modifies container service definitions.
*
* @todo Remove when https://www.drupal.org/i/3050383 is fixed.
*/
final class SubrequestsServiceProvider extends ServiceProviderBase {
/**
* {@inheritdoc}
*/
public function alter(ContainerBuilder $container) {
parent::alter($container);
if (array_key_exists('page_cache', $container->getParameter('container.modules'))) {
$container->getDefinition('http_middleware.page_cache')
->setClass(PageCache::class);
}
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Drupal\subrequests;
use Symfony\Component\HttpFoundation\Request;
/**
* Value class that holds the execution tree.
*/
class SubrequestsTree extends \ArrayObject {
/**
* The master request.
*
* @var \Symfony\Component\HttpFoundation\Request
*/
protected $masterRequest;
/**
* Adds a sequence of subrequests to the stack.
*
* @param \Drupal\subrequests\Subrequest[] $subrequests
* Subrequest data.
*/
public function stack($subrequests) {
// Make sure we only push Subrequest objects.
$this->append(array_filter($subrequests, function ($subrequest) {
return $subrequest instanceof Subrequest;
}));
}
/**
* Gets the number of levels in the stack.
*
* @return int
* Stack levels.
*/
public function getNumLevels() {
return $this->count();
}
/**
* Gets the lowest level.
*
* @return \Drupal\subrequests\Subrequest[]
* The subrequests in the level.
*/
public function getLowestLevel() {
return $this->offsetGet($this->count() - 1);
}
/**
* Gets the master request.
*
* @return \Symfony\Component\HttpFoundation\Request
* The request.
*/
public function getMasterRequest() {
return $this->masterRequest;
}
/**
* Sets the master request.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
*/
public function setMasterRequest(Request $request) {
$this->masterRequest = $request;
}
/**
* Gets all the subrequest IDs.
*
* @return \Drupal\subrequests\Subrequest[]
* All the subrequests in all levels.
*/
public function allIds() {
$subrequests = [];
foreach ($this as $item) {
$subrequests = array_merge($subrequests, array_values($item));
}
$all_request_ids = array_map(function (Subrequest $subrequest) {
return $subrequest->requestId;
}, $subrequests);
array_unshift($all_request_ids, '<ROOT>');
return array_unique($all_request_ids);
}
}