mustache_templates-8.x-1.0-beta4/src/MustacheHttp.php
src/MustacheHttp.php
<?php
namespace Drupal\mustache;
use Drupal\Core\Cache\BackendChain;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Http\ClientFactory;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Url;
use Drupal\mustache\Exception\MustacheException;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Http\Message\ResponseInterface;
/**
* Handling and caching of HTTP traffic to retrieve data for Mustache templates.
*/
class MustacheHttp {
/**
* The minimum threshold of time in seconds retrieved data should be cached.
*
* @var int
*/
protected $ttlMin;
/**
* The maximum threshold of time in seconds retrieved data should be cached.
*
* @var int
*/
protected $ttlMax;
/**
* A volatility value that is a range around the given TTL minimum value.
*
* @var int
*/
protected $volatility;
/**
* The HTTP client.
*
* @var \GuzzleHttp\Client
*/
protected $client;
/**
* The cache backend that is an in-memory cache for Mustache data.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $memoryCache;
/**
* The consistent cache backend for Mustache data.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $consistentCache;
/**
* The chained cache backends for Mustache data.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $chainedCache;
/**
* The logger channel.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected $logger;
/**
* A list of lock wait counts.
*
* @var array
*/
protected $lockWait = [];
/**
* The MustacheHttp constructor.
*
* @param int $cache_ttl_min
* The minimum threshold of time in seconds retrieved data should be cached.
* Setting the value to CacheBackendInterface::CACHE_PERMANENT means caching
* forever, regardless of the TTL submitted by the response. Setting to 0
* means caching in accordance to the submitted TTL of the response.
* @param int $cache_ttl_max
* The maximum threshold of time in seconds retrieved data should be cached.
* Setting the value to CacheBackendInterface::CACHE_PERMANENT means caching
* in accordance to the submitted TTL of the response, also possibly caching
* forever if the response says it's cacheable and does not (correctly)
* specify when it expires. Setting to 0 means no caching at all.
* @param int $cache_volatility
* A volatility value that is used as a range around the calculated expiry.
* Without using a volatility, cache items might expire all at once and
* would possibly result in a request flood. This value is meant for trying
* to reduce that risk. Set to 0 to not use any volatility.
* @param \Drupal\Core\Http\ClientFactory $client_factory
* The HTTP client factory.
* @param \Drupal\Core\Cache\CacheBackendInterface $memory_cache
* The cache backend that is an in-memory cache for Mustache data.
* @param \Drupal\Core\Cache\CacheBackendInterface $consistent_cache
* The consistent cache backend for Mustache data.
* @param \Drupal\Core\Logger\LoggerChannelInterface $logger_channel
* The logger channel.
*/
public function __construct($cache_ttl_min, $cache_ttl_max, $cache_volatility, ClientFactory $client_factory, CacheBackendInterface $memory_cache, CacheBackendInterface $consistent_cache, LoggerChannelInterface $logger_channel) {
$this->ttlMin = $cache_ttl_min < 0 ? CacheBackendInterface::CACHE_PERMANENT : (int) $cache_ttl_min;
$this->ttlMax = $cache_ttl_max < 0 ? CacheBackendInterface::CACHE_PERMANENT : (int) $cache_ttl_max;
$this->volatility = (int) $cache_volatility;
$client_config = [
'headers' => [
'User-Agent' => 'Drupal/' . \Drupal::VERSION . ' (+https://www.drupal.org/) ' . \GuzzleHttp\default_user_agent() . ' Mustache/2',
],
];
$this->client = $client_factory->fromOptions($client_config);
$this->memoryCache = $memory_cache;
$this->consistentCache = $consistent_cache;
$this->chainedCache = (new BackendChain())
->appendBackend($memory_cache)
->appendBackend($consistent_cache);
$this->logger = $logger_channel;
}
/**
* Get data from the given URL.
*
* @param \Drupal\Core\Url|string $url
* The URL to get the data from.
* @param \Drupal\Core\Render\BubbleableMetadata|null $bubbleable_metadata
* (optional) The bubbleable metadata for attaching cacheability metadata.
* @param bool $throw_exception
* Set to TRUE when an exception should be thrown on errors.
* @param bool $use_cache
* Set to FALSE to bypass caching and always fire up a request instead.
*
* @return array|false
* Either the decoded data as associative array, or FALSE on failure.
*/
public function getData($url, BubbleableMetadata $bubbleable_metadata = NULL, $throw_exception = FALSE, $use_cache = TRUE) {
if (!($url instanceof Url) && !is_string($url)) {
throw new MustacheException(t('The $url param must be a Url object or string.'));
}
$uri = $url instanceof Url ? (clone $url)->setAbsolute(TRUE)->toString() : $url;
if (!isset($bubbleable_metadata)) {
$bubbleable_metadata = new BubbleableMetadata();
}
if ($data = $this->readCache($use_cache, $uri, $bubbleable_metadata)) {
if (['__m_error' => TRUE] == $data) {
$this->logger->error(t('The endpoint @url seems to be faulty. Please see previous logged error messages for more information. This error message would disappear on its own, in case the endpoint has recovered.',
['@url' => $uri]));
return FALSE;
}
return $data;
}
$body = NULL;
$data = NULL;
$lock = \Drupal::lock();
$lid = $this->buildLockId($uri);
$lock_acquired = $lock->acquire($lid);
if (!$lock_acquired) {
if (!isset($this->lockWait[$lid])) {
$this->lockWait[$lid] = 0;
}
sleep(1);
$this->lockWait[$lid]++;
if ($this->lockWait[$lid] > 5) {
$this->logger->warning(t('Lock wait exceeded for endpoint @uri. Now trying to fetch data from it. This may cause more traffic than needed.', ['@url' => $uri]));
}
else {
return $this->getData($url, $bubbleable_metadata, $throw_exception, $use_cache);
}
}
unset($this->lockWait[$lid]);
try {
$response = $this->client->request('GET', $uri);
}
catch (GuzzleException $e) {
$this->logger->error(t('Failed to fetch data from @url. Exception message: @message',
['@url' => $uri, '@message' => $e->getMessage()]));
if ($throw_exception) {
throw $e;
}
return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata);
}
if (!($response->getStatusCode() == 200)) {
$message = t('Failed to fetch data from @url. Returned status code was: @status',
['@url' => $uri, '@status' => $response->getStatusCode()]);
$this->logger->error($message);
if ($throw_exception) {
throw new MustacheException($message);
}
return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
}
try {
$body = trim($response->getBody()->getContents());
}
catch (\RuntimeException $e) {
$this->logger->error(t('Failed to read response body contents from @url. Exception message: @message',
['@url' => $uri, '@message' => $e->getMessage()]));
if ($throw_exception) {
throw $e;
}
return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
}
$data = $this->decode($body);
if ($data === FALSE) {
$message = t('Invalid Json data retrieved from url @url.', ['@url' => $uri]);
$this->logger->error($message);
if ($throw_exception) {
throw new MustacheException($message);
}
return $this->fallbackOnFailure($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
}
if (is_array($data) && !isset($data['previous']) && !empty($data) && !static::arrayIsSequential($data)) {
if ($previous = $this->readCache($use_cache, $uri, new BubbleableMetadata(), TRUE)) {
if (isset($previous['previous'])) {
$prev_prev = $previous['previous'];
// Unset the "previous" key from the previously fetched data, in order
// to avoid unlimited growth.
unset($previous['previous']);
if ($previous == $data) {
// Previously fetched data appears to be the same, thus jump once
// more back to previous data of the previously fetched one.
unset($prev_prev['previous']);
$previous = $prev_prev;
}
}
$data['previous'] = $previous;
// We trick a little bit here, telling ::writeCache that we originally
// received JSON-encoded data, but now having the "previous" data key.
$body = json_encode($data);
}
}
$this->writeCache($use_cache, $uri, $body, $data, $bubbleable_metadata, $response);
if ($lock_acquired) {
$lock->release($lid);
}
return $data;
}
/**
* Get the memory cache for Mustache data.
*
* @return \Drupal\Core\Cache\CacheBackendInterface
* The memory cache.
*/
public function getDataMemoryCache() {
return $this->memoryCache;
}
/**
* Get the consistent cache for Mustache data.
*
* @return \Drupal\Core\Cache\CacheBackendInterface
* The consistent cache.
*/
public function getDataConsistentCache() {
return $this->consistentCache;
}
/**
* Get the chained cache for Mustache data.
*
* @return \Drupal\Core\Cache\CacheBackendInterface
* The chained cache.
*/
public function getDataChainedCache() {
return $this->chainedCache;
}
/**
* Implementation method of reading data from cache.
*
* @param bool $use_cache
* Whether caching should be used or not.
* @param string &$uri
* The URI that is being used to perform the request.
* @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
* The bubbleable metadata for attaching cacheability metadata.
* @param bool $allow_invalid
* (optional) If TRUE, a cache item may be returned even if it is expired or
* has been invalidated.
*
* @return array|false
* The data as array on successful cache hit, FALSE otherwise.
*/
protected function readCache($use_cache, &$uri, BubbleableMetadata $bubbleable_metadata, $allow_invalid = FALSE) {
if (!$use_cache) {
return FALSE;
}
$cid = $this->buildCacheId($uri);
// When TTL min is set to a permanent cache lifetime, it would mean that
// one does not care about any TTLs from received responses. For that reason
// we allow invalid items to be returned, as this may also save some lives.
$allow_invalid = $allow_invalid || ($this->ttlMin == CacheBackendInterface::CACHE_PERMANENT);
if ($cached = $this->chainedCache->get($cid, $allow_invalid)) {
if ($cached->expire != CacheBackendInterface::CACHE_PERMANENT) {
$request_time = \Drupal::time()->getRequestTime();
$max_age = $cached->expire > $request_time ? $cached->expire - $request_time : 0;
$max_age = $this->ttlMin > $max_age ? $this->ttlMin : $max_age;
$bubbleable_metadata->setCacheMaxAge($max_age);
}
elseif (!$cached->valid) {
$max_age = $this->ttlMin > 0 ? $this->ttlMin : 30;
$bubbleable_metadata->setCacheMaxAge($max_age);
}
if (is_string($cached->data)) {
// A string indicates that the contents of the response body was saved.
// Thus we may try to convert it to a decoded array here, so that
// subsequent calls within the same process don't need to decode again.
$data = $this->decode($cached->data);
$this->memoryCache->set($cid, $data, $cached->expire, $cached->tags);
return $data;
}
return $cached->data;
}
return FALSE;
}
/**
* Implementation method of writing data into the cache.
*
* @param bool $use_cache
* Whether caching should be used or not.
* @param string $uri
* The URI that was used for performing the request.
* @param string $body
* The extracted contents of the response body.
* @param array &$data
* The data to cache.
* @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
* The bubbleable metadata for attaching cacheability metadata.
* @param \Psr\Http\Message\ResponseInterface|null $response
* The received response object (if any).
*/
protected function writeCache($use_cache, $uri, $body, array &$data, BubbleableMetadata $bubbleable_metadata, ResponseInterface $response = NULL) {
if (!$use_cache || $this->ttlMax === 0) {
return;
}
$expire = $this->ttlMin;
$exclude = ['private', 'no-cache', 'no-store', 'must-revalidate'];
$current_time = \Drupal::time()->getCurrentTime();
// Read out TTL from response header (if available),
// compare it with TTL max and set expiring time accordingly.
if ($response && $this->ttlMin != CacheBackendInterface::CACHE_PERMANENT) {
$values = [];
foreach ($response->getHeader('Cache-Control') as $header) {
foreach (explode(',', $header) as $value) {
$value = mb_strtolower(trim($value));
if (in_array($value, $exclude)) {
$expire = $this->ttlMin;
$values = [];
break 2;
}
if ($value == 'public') {
$expire = $this->ttlMax;
continue;
}
$values[] = $value;
}
}
foreach ($values as $value) {
if (strpos($value, 'age=')) {
$value = explode('=', $value);
$value = intval(end($value));
// Fetch the highest possible value.
$expire = $expire < $value ? $value : $expire;
}
}
if ($this->ttlMax != CacheBackendInterface::CACHE_PERMANENT && $expire > $this->ttlMax) {
$expire = $this->ttlMax;
}
elseif ($expire >= PHP_INT_MAX - $current_time) {
$expire = CacheBackendInterface::CACHE_PERMANENT;
}
elseif ($expire < 0 && $expire != CacheBackendInterface::CACHE_PERMANENT) {
$expire = $this->ttlMin;
}
}
if ($expire > 0 && $this->volatility > 0) {
$vola = $this->volatility < $expire ? random_int(-$this->volatility, $this->volatility) : random_int(-$expire + 1, $expire - 1);
$expire += $vola;
}
$cid = $this->buildCacheId($uri);
$tags = ['mustache:http'];
if ($expire === 0 && $this->ttlMax !== 0) {
// At least store in memory cache, so that the current PHP process is not
// getting busy on redundant request calls.
$expire = $this->ttlMin > 0 ? $this->ttlMin : 30;
$bubbleable_metadata->setCacheMaxAge($expire);
// Convert from the relative time period to an absolute Unix timestamp.
$expire += $current_time;
$this->memoryCache->set($cid, $data, $expire, $tags);
return;
}
elseif ($expire > 0) {
$bubbleable_metadata->setCacheMaxAge($expire);
// Convert from the relative time period to an absolute Unix timestamp.
$expire += $current_time;
}
$this->memoryCache->set($cid, $data, $expire, $tags);
// Write the response body as string into the consistent backend, in order
// to avoid serialization and possibly dangerous unserialization of the
// retrieved data. We cannot verdict the data's trustworthiness here.
$this->consistentCache->set($cid, $body, $expire, $tags);
}
/**
* Builds the cache ID.
*
* @param string $uri
* The URI that was or is being used for performing the request.
*
* @return string
* The cache ID.
*/
protected function buildCacheId($uri) {
return 'mustache:http:' . $uri;
}
/**
* Builds the lock ID.
*
* @param string $uri
* The URI that was or is being used for performing the request.
*
* @return string
* The lock ID.
*/
protected function buildLockId($uri) {
return 'mustache:http:' . $uri;
}
/**
* Decodes the contents of a response body.
*
* @param string $body
* The extracted contents of the response body.
*
* @return array|false
* The decoded data as associative array, or FALSE on failure.
*/
protected function decode($body) {
$data = json_decode($body, TRUE);
if (!is_array($data)) {
if ($data === NULL && ($body === 'null' || $body === 'NULL')) {
$data = [];
}
elseif ($data !== NULL) {
$data = [$data];
}
else {
$data = FALSE;
}
}
return $data;
}
/**
* Implementation of a fallback behavior when something went wrong.
*
* @param bool $use_cache
* Whether caching should be used or not.
* @param string $uri
* The URI that was used for performing the request.
* @param mixed &$body
* The extracted contents of the response body (if any).
* @param mixed &$data
* The decoded data (if any).
* @param \Drupal\Core\Render\BubbleableMetadata $bubbleable_metadata
* The bubbleable metadata for attaching cacheability metadata.
* @param \Psr\Http\Message\ResponseInterface|null $response
* The received response object (if any).
*
* @return array|false
* Either decoded data to be used as fallback value, or FALSE if nothing
* else could have helped as a fallback solution.
*/
protected function fallbackOnFailure($use_cache, $uri, &$body, &$data, BubbleableMetadata $bubbleable_metadata, ResponseInterface $response = NULL) {
if ($ttl_min = $this->ttlMin) {
$this->ttlMin = 30;
}
// Allow to self-heal by setting max-age to 0 by default. Writing into the
// cache would most probably raise the value, but it shouldn't be possible
// to cache faulty and stale data indefinitely.
$bubbleable_metadata->setCacheMaxAge(0);
// As long as we still have something in our cache, use it and let's hope
// that the endpoint will get well soon.
if ($data = $this->readCache($use_cache, $uri, $bubbleable_metadata, TRUE)) {
// We trick a little bit here, telling ::writeCache that we received
// valid Json (but we didn't).
$body = json_encode($data);
$this->writeCache($use_cache, $uri, $body, $data, $bubbleable_metadata);
$this->ttlMin = $ttl_min;
return $data;
}
// Flag this operation for our own cache, so that we don't retry too often
// within the next few seconds. Give the endpoint a chance to breathe.
$error_data = ['__m_error' => TRUE];
$this->writeCache($use_cache, $uri, json_encode($error_data), $error_data, $bubbleable_metadata);
$this->ttlMin = $ttl_min;
return FALSE;
}
/**
* Checks whether the given array is sequential.
*
* @param array $array
* The array to check for.
*
* @return bool
* Returns TRUE when it is sequential, FALSE otherwise.
*/
protected static function arrayIsSequential(array $array) {
$i = 0;
foreach (array_keys($array) as $key) {
if ($key !== $i) {
return FALSE;
}
$i++;
}
return TRUE;
}
}
