Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
   1  <?php
   2  
   3  namespace Kevinrob\GuzzleCache;
   4  
   5  use GuzzleHttp\Client;
   6  use GuzzleHttp\Exception\TransferException;
   7  use GuzzleHttp\Promise\FulfilledPromise;
   8  use GuzzleHttp\Promise\Promise;
   9  use GuzzleHttp\Promise\RejectedPromise;
  10  use GuzzleHttp\Promise\Utils;
  11  use GuzzleHttp\Psr7\Response;
  12  use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface;
  13  use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy;
  14  use Psr\Http\Message\RequestInterface;
  15  use Psr\Http\Message\ResponseInterface;
  16  
  17  /**
  18   * Class CacheMiddleware.
  19   */
  20  class CacheMiddleware
  21  {
  22      const HEADER_RE_VALIDATION = 'X-Kevinrob-GuzzleCache-ReValidation';
  23      const HEADER_INVALIDATION = 'X-Kevinrob-GuzzleCache-Invalidation';
  24      const HEADER_CACHE_INFO = 'X-Kevinrob-Cache';
  25      const HEADER_CACHE_HIT = 'HIT';
  26      const HEADER_CACHE_MISS = 'MISS';
  27      const HEADER_CACHE_STALE = 'STALE';
  28  
  29      /**
  30       * @var array of Promise
  31       */
  32      protected $waitingRevalidate = [];
  33  
  34      /**
  35       * @var Client
  36       */
  37      protected $client;
  38  
  39      /**
  40       * @var CacheStrategyInterface
  41       */
  42      protected $cacheStorage;
  43  
  44      /**
  45       * List of allowed HTTP methods to cache
  46       * Key = method name (upscaling)
  47       * Value = true.
  48       *
  49       * @var array
  50       */
  51      protected $httpMethods = ['GET' => true];
  52  
  53      /**
  54       * List of safe methods
  55       *
  56       * https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
  57       *
  58       * @var array
  59       */
  60      protected $safeMethods = ['GET' => true, 'HEAD' => true, 'OPTIONS' => true, 'TRACE' => true];
  61  
  62      /**
  63       * @param CacheStrategyInterface|null $cacheStrategy
  64       */
  65      public function __construct(CacheStrategyInterface $cacheStrategy = null)
  66      {
  67          $this->cacheStorage = $cacheStrategy !== null ? $cacheStrategy : new PrivateCacheStrategy();
  68  
  69          register_shutdown_function([$this, 'purgeReValidation']);
  70      }
  71  
  72      /**
  73       * @param Client $client
  74       */
  75      public function setClient(Client $client)
  76      {
  77          $this->client = $client;
  78      }
  79  
  80      /**
  81       * @param CacheStrategyInterface $cacheStorage
  82       */
  83      public function setCacheStorage(CacheStrategyInterface $cacheStorage)
  84      {
  85          $this->cacheStorage = $cacheStorage;
  86      }
  87  
  88      /**
  89       * @return CacheStrategyInterface
  90       */
  91      public function getCacheStorage()
  92      {
  93          return $this->cacheStorage;
  94      }
  95  
  96      /**
  97       * @param array $methods
  98       */
  99      public function setHttpMethods(array $methods)
 100      {
 101          $this->httpMethods = $methods;
 102      }
 103  
 104      public function getHttpMethods()
 105      {
 106          return $this->httpMethods;
 107      }
 108  
 109      /**
 110       * Will be called at the end of the script.
 111       */
 112      public function purgeReValidation()
 113      {
 114          // Call to \GuzzleHttp\Promise\inspect_all throws error, replacing with the latest one.
 115          Utils::inspectAll($this->waitingRevalidate);
 116      }
 117  
 118      /**
 119       * @param callable $handler
 120       *
 121       * @return callable
 122       */
 123      public function __invoke(callable $handler)
 124      {
 125          return function (RequestInterface $request, array $options) use (&$handler) {
 126              if (!isset($this->httpMethods[strtoupper($request->getMethod())])) {
 127                  // No caching for this method allowed
 128  
 129                  return $handler($request, $options)->then(
 130                      function (ResponseInterface $response) use ($request) {
 131                          if (!isset($this->safeMethods[$request->getMethod()])) {
 132                              // Invalidate cache after a call of non-safe method on the same URI
 133                              $response = $this->invalidateCache($request, $response);
 134                          }
 135  
 136                          return $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
 137                      }
 138                  );
 139              }
 140  
 141              if ($request->hasHeader(self::HEADER_RE_VALIDATION)) {
 142                  // It's a re-validation request, so bypass the cache!
 143                  return $handler($request->withoutHeader(self::HEADER_RE_VALIDATION), $options);
 144              }
 145  
 146              // Retrieve information from request (Cache-Control)
 147              $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control'));
 148              $onlyFromCache = $reqCacheControl->has('only-if-cached');
 149              $staleResponse = $reqCacheControl->has('max-stale')
 150                  && $reqCacheControl->get('max-stale') === '';
 151              $maxStaleCache = $reqCacheControl->get('max-stale', null);
 152              $minFreshCache = $reqCacheControl->get('min-fresh', null);
 153  
 154              // If cache => return new FulfilledPromise(...) with response
 155              $cacheEntry = $this->cacheStorage->fetch($request);
 156              if ($cacheEntry instanceof CacheEntry) {
 157                  $body = $cacheEntry->getResponse()->getBody();
 158                  if ($body->tell() > 0) {
 159                      $body->rewind();
 160                  }
 161  
 162                  if ($cacheEntry->isFresh()
 163                      && ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0)
 164                  ) {
 165                      // Cache HIT!
 166                      return new FulfilledPromise(
 167                          $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
 168                      );
 169                  } elseif ($staleResponse
 170                      || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache)
 171                  ) {
 172                      // Staled cache!
 173                      return new FulfilledPromise(
 174                          $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT)
 175                      );
 176                  } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) {
 177                      // Re-validation header
 178                      $request = static::getRequestWithReValidationHeader($request, $cacheEntry);
 179  
 180                      if ($cacheEntry->staleWhileValidate()) {
 181                          static::addReValidationRequest($request, $this->cacheStorage, $cacheEntry);
 182  
 183                          return new FulfilledPromise(
 184                              $cacheEntry->getResponse()
 185                                  ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE)
 186                          );
 187                      }
 188                  }
 189              } else {
 190                  $cacheEntry = null;
 191              }
 192  
 193              if ($cacheEntry === null && $onlyFromCache) {
 194                  // Explicit asking of a cached response => 504
 195                  return new FulfilledPromise(
 196                      new Response(504)
 197                  );
 198              }
 199  
 200              /** @var Promise $promise */
 201              $promise = $handler($request, $options);
 202  
 203              return $promise->then(
 204                  function (ResponseInterface $response) use ($request, $cacheEntry) {
 205                      // Check if error and looking for a staled content
 206                      if ($response->getStatusCode() >= 500) {
 207                          $responseStale = static::getStaleResponse($cacheEntry);
 208                          if ($responseStale instanceof ResponseInterface) {
 209                              return $responseStale;
 210                          }
 211                      }
 212  
 213                      $update = false;
 214  
 215                      if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) {
 216                          // Not modified => cache entry is re-validate
 217                          /** @var ResponseInterface $response */
 218                          $response = $response
 219                              ->withStatus($cacheEntry->getResponse()->getStatusCode())
 220                              ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT);
 221                          $response = $response->withBody($cacheEntry->getResponse()->getBody());
 222  
 223                          // Merge headers of the "304 Not Modified" and the cache entry
 224                          /**
 225                           * @var string $headerName
 226                           * @var string[] $headerValue
 227                           */
 228                          foreach ($cacheEntry->getOriginalResponse()->getHeaders() as $headerName => $headerValue) {
 229                              if (!$response->hasHeader($headerName) && $headerName !== self::HEADER_CACHE_INFO) {
 230                                  $response = $response->withHeader($headerName, $headerValue);
 231                              }
 232                          }
 233  
 234                          $update = true;
 235                      } else {
 236                          $response = $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS);
 237                      }
 238  
 239                      return static::addToCache($this->cacheStorage, $request, $response, $update);
 240                  },
 241                  function ($reason) use ($cacheEntry) {
 242                      if ($reason instanceof TransferException) {
 243                          $response = static::getStaleResponse($cacheEntry);
 244                          if ($response instanceof ResponseInterface) {
 245                              return $response;
 246                          }
 247                      }
 248  
 249                      return new RejectedPromise($reason);
 250                  }
 251              );
 252          };
 253      }
 254  
 255      /**
 256       * @param CacheStrategyInterface $cache
 257       * @param RequestInterface $request
 258       * @param ResponseInterface $response
 259       * @param bool $update cache
 260       * @return ResponseInterface
 261       */
 262      protected static function addToCache(
 263          CacheStrategyInterface $cache,
 264          RequestInterface $request,
 265          ResponseInterface $response,
 266          $update = false
 267      ) {
 268          $body = $response->getBody();
 269  
 270          // If the body is not seekable, we have to replace it by a seekable one
 271          if (!$body->isSeekable()) {
 272              $response = $response->withBody(
 273                  \GuzzleHttp\Psr7\Utils::streamFor($body->getContents())
 274              );
 275          }
 276  
 277          if ($update) {
 278              $cache->update($request, $response);
 279          } else {
 280              $cache->cache($request, $response);
 281          }
 282  
 283          // always rewind back to the start otherwise other middlewares may get empty "content"
 284          if ($body->isSeekable()) {
 285              $response->getBody()->rewind();
 286          }
 287  
 288          return $response;
 289      }
 290  
 291      /**
 292       * @param RequestInterface       $request
 293       * @param CacheStrategyInterface $cacheStorage
 294       * @param CacheEntry             $cacheEntry
 295       *
 296       * @return bool if added
 297       */
 298      protected function addReValidationRequest(
 299          RequestInterface $request,
 300          CacheStrategyInterface &$cacheStorage,
 301          CacheEntry $cacheEntry
 302      ) {
 303          // Add the promise for revalidate
 304          if ($this->client !== null) {
 305              /** @var RequestInterface $request */
 306              $request = $request->withHeader(self::HEADER_RE_VALIDATION, '1');
 307              $this->waitingRevalidate[] = $this->client
 308                  ->sendAsync($request)
 309                  ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) {
 310                      $update = false;
 311  
 312                      if ($response->getStatusCode() == 304) {
 313                          // Not modified => cache entry is re-validate
 314                          /** @var ResponseInterface $response */
 315                          $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode());
 316                          $response = $response->withBody($cacheEntry->getResponse()->getBody());
 317  
 318                          // Merge headers of the "304 Not Modified" and the cache entry
 319                          foreach ($cacheEntry->getResponse()->getHeaders() as $headerName => $headerValue) {
 320                              if (!$response->hasHeader($headerName)) {
 321                                  $response = $response->withHeader($headerName, $headerValue);
 322                              }
 323                          }
 324  
 325                          $update = true;
 326                      }
 327  
 328                      static::addToCache($cacheStorage, $request, $response, $update);
 329                  });
 330  
 331              return true;
 332          }
 333  
 334          return false;
 335      }
 336  
 337      /**
 338       * @param CacheEntry|null $cacheEntry
 339       *
 340       * @return null|ResponseInterface
 341       */
 342      protected static function getStaleResponse(CacheEntry $cacheEntry = null)
 343      {
 344          // Return staled cache entry if we can
 345          if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) {
 346              return $cacheEntry->getResponse()
 347                  ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE);
 348          }
 349  
 350          return;
 351      }
 352  
 353      /**
 354       * @param RequestInterface $request
 355       * @param CacheEntry       $cacheEntry
 356       *
 357       * @return RequestInterface
 358       */
 359      protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry)
 360      {
 361          if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) {
 362              $request = $request->withHeader(
 363                  'If-Modified-Since',
 364                  $cacheEntry->getResponse()->getHeader('Last-Modified')
 365              );
 366          }
 367          if ($cacheEntry->getResponse()->hasHeader('Etag')) {
 368              $request = $request->withHeader(
 369                  'If-None-Match',
 370                  $cacheEntry->getResponse()->getHeader('Etag')
 371              );
 372          }
 373  
 374          return $request;
 375      }
 376  
 377      /**
 378       * @param CacheStrategyInterface|null $cacheStorage
 379       *
 380       * @return CacheMiddleware the Middleware for Guzzle HandlerStack
 381       *
 382       * @deprecated Use constructor => `new CacheMiddleware()`
 383       */
 384      public static function getMiddleware(CacheStrategyInterface $cacheStorage = null)
 385      {
 386          return new self($cacheStorage);
 387      }
 388  
 389      /**
 390       * @param RequestInterface $request
 391       *
 392       * @param ResponseInterface $response
 393       *
 394       * @return ResponseInterface
 395       */
 396      private function invalidateCache(RequestInterface $request, ResponseInterface $response)
 397      {
 398          foreach (array_keys($this->httpMethods) as $method) {
 399              $this->cacheStorage->delete($request->withMethod($method));
 400          }
 401  
 402          return $response->withHeader(self::HEADER_INVALIDATION, true);
 403      }
 404  }