From 59a99877198fa900fbb36d4d33563e3436d9fc5c Mon Sep 17 00:00:00 2001 From: Safat Date: Tue, 29 Nov 2022 10:31:09 +1100 Subject: [PATCH] MDL-76135 core: Add guzzle cache middleware --- lib/classes/component.php | 1 + lib/guzzlehttp/kevinrob/guzzlecache/LICENSE | 22 + lib/guzzlehttp/kevinrob/guzzlecache/README.md | 292 +++++++++++++ .../kevinrob/guzzlecache/composer.json | 57 +++ .../kevinrob/guzzlecache/src/BodyStore.php | 46 ++ .../kevinrob/guzzlecache/src/CacheEntry.php | 302 +++++++++++++ .../guzzlecache/src/CacheMiddleware.php | 402 ++++++++++++++++++ .../guzzlecache/src/KeyValueHttpHeader.php | 131 ++++++ .../src/Storage/CacheStorageInterface.php | 30 ++ .../CompressedDoctrineCacheStorage.php | 74 ++++ .../src/Storage/DoctrineCacheStorage.php | 74 ++++ .../src/Storage/FlysystemStorage.php | 61 +++ .../src/Storage/LaravelCacheStorage.php | 84 ++++ .../src/Storage/Psr16CacheStorage.php | 52 +++ .../src/Storage/Psr6CacheStorage.php | 92 ++++ .../src/Storage/VolatileRuntimeStorage.php | 60 +++ .../Storage/WordPressObjectCacheStorage.php | 73 ++++ .../src/Strategy/CacheStrategyInterface.php | 42 ++ .../Delegate/DelegatingCacheStrategy.php | 101 +++++ .../Delegate/RequestMatcherInterface.php | 15 + .../src/Strategy/GreedyCacheStrategy.php | 119 ++++++ .../src/Strategy/NullCacheStrategy.php | 43 ++ .../src/Strategy/PrivateCacheStrategy.php | 256 +++++++++++ .../src/Strategy/PublicCacheStrategy.php | 42 ++ lib/guzzlehttp/readme_moodle.txt | 5 + lib/thirdpartylibs.xml | 8 + 26 files changed, 2484 insertions(+) create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/LICENSE create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/README.md create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/composer.json create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/BodyStore.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/CacheEntry.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/CacheMiddleware.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/KeyValueHttpHeader.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/CacheStorageInterface.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/CompressedDoctrineCacheStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/DoctrineCacheStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/FlysystemStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/LaravelCacheStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr16CacheStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr6CacheStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/VolatileRuntimeStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/WordPressObjectCacheStorage.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/CacheStrategyInterface.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/Delegate/DelegatingCacheStrategy.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/Delegate/RequestMatcherInterface.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/GreedyCacheStrategy.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/NullCacheStrategy.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/PrivateCacheStrategy.php create mode 100644 lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/PublicCacheStrategy.php diff --git a/lib/classes/component.php b/lib/classes/component.php index da1ec54b051..d297a131c32 100644 --- a/lib/classes/component.php +++ b/lib/classes/component.php @@ -116,6 +116,7 @@ class core_component { 'GuzzleHttp\\Psr7' => 'lib/guzzlehttp/psr7/src', 'GuzzleHttp\\Promise' => 'lib/guzzlehttp/promises/src', 'GuzzleHttp' => 'lib/guzzlehttp/guzzle/src', + 'Kevinrob\\GuzzleCache' => 'lib/guzzlehttp/kevinrob/guzzlecache/src', ); /** diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/LICENSE b/lib/guzzlehttp/kevinrob/guzzlecache/LICENSE new file mode 100644 index 00000000000..f56457772c0 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Kevin Robatel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/README.md b/lib/guzzlehttp/kevinrob/guzzlecache/README.md new file mode 100644 index 00000000000..25d60240345 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/README.md @@ -0,0 +1,292 @@ +# guzzle-cache-middleware + +[![Latest Stable Version](https://poser.pugx.org/kevinrob/guzzle-cache-middleware/v/stable)](https://packagist.org/packages/kevinrob/guzzle-cache-middleware) [![Total Downloads](https://poser.pugx.org/kevinrob/guzzle-cache-middleware/downloads)](https://packagist.org/packages/kevinrob/guzzle-cache-middleware) [![License](https://poser.pugx.org/kevinrob/guzzle-cache-middleware/license)](https://packagist.org/packages/kevinrob/guzzle-cache-middleware) +![Tests](https://github.com/Kevinrob/guzzle-cache-middleware/workflows/Tests/badge.svg) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/?branch=master) [![Code Coverage](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/Kevinrob/guzzle-cache-middleware/?branch=master) + + +A HTTP Cache for [Guzzle](https://github.com/guzzle/guzzle) 6+. It's a simple Middleware to be added in the HandlerStack. + +## Goals +- RFC 7234 compliance +- Performance and transparency +- Assured compatibility with PSR-7 + +## Built-in storage interfaces +- [Doctrine cache](https://github.com/doctrine/cache) +- [Laravel cache](https://laravel.com/docs/5.2/cache) +- [Flysystem](https://github.com/thephpleague/flysystem) +- [PSR6](https://github.com/php-fig/cache) +- [WordPress Object Cache](https://codex.wordpress.org/Class_Reference/WP_Object_Cache) + +## Installation + +`composer require kevinrob/guzzle-cache-middleware` + +or add it the your `composer.json` and run `composer update kevinrob/guzzle-cache-middleware`. + +# Why? +Performance. It's very common to do some HTTP calls to an API for rendering a page and it takes times to do it. + +# How? +With a simple Middleware added at the top of the `HandlerStack` of Guzzle. + +```php +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; +use Kevinrob\GuzzleCache\CacheMiddleware; + +// Create default HandlerStack +$stack = HandlerStack::create(); + +// Add this middleware to the top with `push` +$stack->push(new CacheMiddleware(), 'cache'); + +// Initialize the client with the handler option +$client = new Client(['handler' => $stack]); +``` + +# Examples + +## Doctrine/Cache +You can use a cache from `Doctrine/Cache`: +```php +[...] +use Doctrine\Common\Cache\FilesystemCache; +use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; +use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; + +[...] +$stack->push( + new CacheMiddleware( + new PrivateCacheStrategy( + new DoctrineCacheStorage( + new FilesystemCache('/tmp/') + ) + ) + ), + 'cache' +); +``` + +You can use `ChainCache` for using multiple `CacheProvider` instances. With that provider, you have to sort the different caches from the faster to the slower. Like that, you can have a very fast cache. +```php +[...] +use Doctrine\Common\Cache\ChainCache; +use Doctrine\Common\Cache\ArrayCache; +use Doctrine\Common\Cache\FilesystemCache; +use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; +use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; + +[...] +$stack->push(new CacheMiddleware( + new PrivateCacheStrategy( + new DoctrineCacheStorage( + new ChainCache([ + new ArrayCache(), + new FilesystemCache('/tmp/'), + ]) + ) + ) +), 'cache'); +``` + +## Laravel cache +You can use a cache with Laravel, e.g. Redis, Memcache etc.: +```php +[...] +use Illuminate\Support\Facades\Cache; +use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; +use Kevinrob\GuzzleCache\Storage\LaravelCacheStorage; + +[...] + +$stack->push( + new CacheMiddleware( + new PrivateCacheStrategy( + new LaravelCacheStorage( + Cache::store('redis') + ) + ) + ), + 'cache' +); +``` + +## Flysystem +```php +[...] +use League\Flysystem\Adapter\Local; +use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; +use Kevinrob\GuzzleCache\Storage\FlysystemStorage; + +[...] + +$stack->push( + new CacheMiddleware( + new PrivateCacheStrategy( + new FlysystemStorage( + new Local('/path/to/cache') + ) + ) + ), + 'cache' +); +``` + +## WordPress Object Cache +```php +[...] +use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; +use Kevinrob\GuzzleCache\Storage\WordPressObjectCacheStorage; + +[...] + +$stack->push( + new CacheMiddleware( + new PrivateCacheStrategy( + new WordPressObjectCacheStorage() + ) + ), + 'cache' +); +``` + +## Public and shared +It's possible to add a public shared cache to the stack: +```php +[...] +use Doctrine\Common\Cache\FilesystemCache; +use Doctrine\Common\Cache\PredisCache; +use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy; +use Kevinrob\GuzzleCache\Strategy\PublicCacheStrategy; +use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; + +[...] +// Private caching +$stack->push( + new CacheMiddleware( + new PrivateCacheStrategy( + new DoctrineCacheStorage( + new FilesystemCache('/tmp/') + ) + ) + ), + 'private-cache' +); + +// Public caching +$stack->push( + new CacheMiddleware( + new PublicCacheStrategy( + new DoctrineCacheStorage( + new PredisCache( + new Predis\Client('tcp://10.0.0.1:6379') + ) + ) + ) + ), + 'shared-cache' +); +``` + +## Greedy caching +In some cases servers might send insufficient or no caching headers at all. +Using the greedy caching strategy allows defining an expiry TTL on your own while +disregarding any possibly present caching headers: +```php +[...] +use Kevinrob\GuzzleCache\KeyValueHttpHeader; +use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy; +use Kevinrob\GuzzleCache\Storage\DoctrineCacheStorage; +use Doctrine\Common\Cache\FilesystemCache; + +[...] +// Greedy caching +$stack->push( + new CacheMiddleware( + new GreedyCacheStrategy( + new DoctrineCacheStorage( + new FilesystemCache('/tmp/') + ), + 1800, // the TTL in seconds + new KeyValueHttpHeader(['Authorization']) // Optional - specify the headers that can change the cache key + ) + ), + 'greedy-cache' +); +``` + +## Delegate caching +Because your client may call different apps, on different domains, you may need to define which strategy is suitable to your requests. + +To solve this, all you have to do is to define a default cache strategy, and override it by implementing your own Request Matchers. + +Here's an example: +```php +namespace App\RequestMatcher; + +use Kevinrob\GuzzleCache\Strategy\Delegate\RequestMatcherInterface; +use Psr\Http\Message\RequestInterface; + +class ExampleOrgRequestMatcher implements RequestMatcherInterface +{ + + /** + * @inheritDoc + */ + public function matches(RequestInterface $request) + { + return false !== strpos($request->getUri()->getHost(), 'example.org'); + } +} +``` + +```php +namespace App\RequestMatcher; + +use Kevinrob\GuzzleCache\Strategy\Delegate\RequestMatcherInterface; +use Psr\Http\Message\RequestInterface; + +class TwitterRequestMatcher implements RequestMatcherInterface +{ + + /** + * @inheritDoc + */ + public function matches(RequestInterface $request) + { + return false !== strpos($request->getUri()->getHost(), 'twitter.com'); + } +} +``` + +```php +require_once __DIR__ . '/vendor/autoload.php'; + +use App\RequestMatcher\ExampleOrgRequestMatcher; +use App\RequestMatcher\TwitterRequestMatcher; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; +use Kevinrob\GuzzleCache\CacheMiddleware; +use Kevinrob\GuzzleCache\Strategy; + +$strategy = new Strategy\Delegate\DelegatingCacheStrategy($defaultStrategy = new Strategy\NullCacheStrategy()); +$strategy->registerRequestMatcher(new ExampleOrgRequestMatcher(), new Strategy\PublicCacheStrategy()); +$strategy->registerRequestMatcher(new TwitterRequestMatcher(), new Strategy\PrivateCacheStrategy()); + +$stack = HandlerStack::create(); +$stack->push(new CacheMiddleware($strategy)); +$guzzle = new Client(['handler' => $stack]); +``` + +With this example: +* All requests to `example.org` will be handled by `PublicCacheStrategy` +* All requests to `twitter.com` will be handled by `PrivateCacheStrategy` +* All other requests won't be cached. + +## Drupal +See [Guzzle Cache](https://www.drupal.org/project/guzzle_cache) module. + +# Links that talk about the project +- [Speeding Up APIs/Apps/Smart Toasters with HTTP Response Caching](https://apisyouwonthate.com/blog/speeding-up-apis-apps-smart-toasters-with-http-response-caching) +- [Caching HTTP-Requests with Guzzle 6 and PSR-6](http://a.kabachnik.info/caching-http-requests-with-guzzle-6-and-psr-6.html) diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/composer.json b/lib/guzzlehttp/kevinrob/guzzlecache/composer.json new file mode 100644 index 00000000000..0508384a169 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/composer.json @@ -0,0 +1,57 @@ +{ + "name": "kevinrob/guzzle-cache-middleware", + "type": "library", + "description": "A HTTP/1.1 Cache for Guzzle 6. It's a simple Middleware to be added in the HandlerStack. (RFC 7234)", + "keywords": ["guzzle", "guzzle6", "cache", "http", "http 1.1", "psr6", "psr7", "handler", "middleware", "cache-control", "rfc7234", "performance", "php", "promise", "expiration", "validation", "Etag", "flysystem", "doctrine"], + "homepage": "https://github.com/Kevinrob/guzzle-cache-middleware", + "license": "MIT", + "authors": [ + { + "name": "Kevin Robatel", + "email": "kevinrob2@gmail.com", + "homepage": "https://github.com/Kevinrob" + } + ], + "require": { + "php": ">=7.2.0", + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "guzzlehttp/psr7": "^1.7.0 || ^2.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.15 || ^9.5", + "doctrine/cache": "^1.10", + "league/flysystem": "^1.0", + "psr/cache": "^1.0", + "cache/array-adapter": "^0.4 || ^0.5 || ^1.0", + "illuminate/cache": "^5.0", + "cache/simple-cache-bridge": "^0.1 || ^1.0", + "symfony/phpunit-bridge": "^4.4 || ^5.0", + "symfony/cache": "^4.4 || ^5.0" + }, + "autoload": { + "psr-4": { + "Kevinrob\\GuzzleCache\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Kevinrob\\GuzzleCache\\Tests\\": "tests/" + } + }, + "suggest": { + "guzzlehttp/guzzle": "For using this library. It was created for Guzzle6 (but you can use it with any PSR-7 HTTP client).", + "doctrine/cache": "This library has a lot of ready-to-use cache storage (to be used with Kevinrob\\GuzzleCache\\Storage\\DoctrineCacheStorage). Use only versions >=1.4.0 < 2.0.0", + "league/flysystem": "To be used with Kevinrob\\GuzzleCache\\Storage\\FlysystemStorage", + "psr/cache": "To be used with Kevinrob\\GuzzleCache\\Storage\\Psr6CacheStorage", + "psr/simple-cache": "To be used with Kevinrob\\GuzzleCache\\Storage\\Psr16CacheStorage", + "laravel/framework": "To be used with Kevinrob\\GuzzleCache\\Storage\\LaravelCacheStorage" + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "config": { + "allow-plugins": { + "kylekatarnls/update-helper": true + } + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/BodyStore.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/BodyStore.php new file mode 100644 index 00000000000..0ff38a1a233 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/BodyStore.php @@ -0,0 +1,46 @@ +body = $body; + $this->toRead = mb_strlen($this->body); + } + + /** + * @param int $length + * @return false|string + */ + public function __invoke(int $length) + { + if ($this->toRead <= 0) { + return false; + } + + $length = min($length, $this->toRead); + + $body = mb_substr( + $this->body, + $this->read, + $length + ); + $this->toRead -= $length; + $this->read += $length; + return $body; + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/CacheEntry.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/CacheEntry.php new file mode 100644 index 00000000000..7e3c8bc37ab --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/CacheEntry.php @@ -0,0 +1,302 @@ +dateCreated = new \DateTime(); + + $this->request = $request; + $this->response = $response; + $this->staleAt = $staleAt; + + $values = new KeyValueHttpHeader($response->getHeader('Cache-Control')); + + if ($staleIfErrorTo === null && $values->has('stale-if-error')) { + $this->staleIfErrorTo = (new \DateTime( + '@'.($this->staleAt->getTimestamp() + (int) $values->get('stale-if-error')) + )); + } else { + $this->staleIfErrorTo = $staleIfErrorTo; + } + + if ($staleWhileRevalidateTo === null && $values->has('stale-while-revalidate')) { + $this->staleWhileRevalidateTo = new \DateTime( + '@'.($this->staleAt->getTimestamp() + (int) $values->get('stale-while-revalidate')) + ); + } else { + $this->staleWhileRevalidateTo = $staleWhileRevalidateTo; + } + } + + /** + * @return ResponseInterface + */ + public function getResponse() + { + return $this->response + ->withHeader('Age', $this->getAge()); + } + + /** + * @return ResponseInterface + */ + public function getOriginalResponse() + { + return $this->response; + } + + /** + * @return RequestInterface + */ + public function getOriginalRequest() + { + return $this->request; + } + + /** + * @param RequestInterface $request + * @return bool + */ + public function isVaryEquals(RequestInterface $request) + { + if ($this->response->hasHeader('Vary')) { + if ($this->request === null) { + return false; + } + + foreach ($this->getVaryHeaders() as $key => $value) { + if (!$this->request->hasHeader($key) + && !$request->hasHeader($key) + ) { + // Absent from both + continue; + } elseif ($this->request->getHeaderLine($key) + == $request->getHeaderLine($key) + ) { + // Same content + continue; + } + + return false; + } + } + + return true; + } + + /** + * Get the vary headers that should be honoured by the cache. + * + * @return KeyValueHttpHeader + */ + public function getVaryHeaders() + { + return new KeyValueHttpHeader($this->response->getHeader('Vary')); + } + + /** + * @return \DateTime + */ + public function getStaleAt() + { + return $this->staleAt; + } + + /** + * @return bool + */ + public function isFresh() + { + return !$this->isStale(); + } + + /** + * @return bool + */ + public function isStale() + { + return $this->getStaleAge() > 0; + } + + /** + * @return int positive value equal staled + */ + public function getStaleAge() + { + // This object is immutable + if ($this->timestampStale === null) { + $this->timestampStale = $this->staleAt->getTimestamp(); + } + + return time() - $this->timestampStale; + } + + /** + * @return bool + */ + public function serveStaleIfError() + { + return $this->staleIfErrorTo !== null + && $this->staleIfErrorTo->getTimestamp() >= (new \DateTime())->getTimestamp(); + } + + /** + * @return bool + */ + public function staleWhileValidate() + { + return $this->staleWhileRevalidateTo !== null + && $this->staleWhileRevalidateTo->getTimestamp() >= (new \DateTime())->getTimestamp(); + } + + /** + * @return bool + */ + public function hasValidationInformation() + { + return $this->response->hasHeader('Etag') || $this->response->hasHeader('Last-Modified'); + } + + /** + * Time in seconds how long the entry should be kept in the cache + * + * This will not give the time (in seconds) that the response will still be fresh for + * from the HTTP point of view, but an upper bound on how long it is necessary and + * reasonable to keep the response in a cache (to re-use it or re-validate it later on). + * + * @return int TTL in seconds (0 = infinite) + */ + public function getTTL() + { + if ($this->hasValidationInformation()) { + // No TTL if we have a way to re-validate the cache + return 0; + } + + $ttl = 0; + + // Keep it when stale if error + if ($this->staleIfErrorTo !== null) { + $ttl = max($ttl, $this->staleIfErrorTo->getTimestamp() - time()); + } + + // Keep it when stale-while-revalidate + if ($this->staleWhileRevalidateTo !== null) { + $ttl = max($ttl, $this->staleWhileRevalidateTo->getTimestamp() - time()); + } + + // Keep it until it become stale + $ttl = max($ttl, $this->staleAt->getTimestamp() - time()); + + // Don't return 0, it's reserved for infinite TTL + return $ttl !== 0 ? (int) $ttl : -1; + } + + /** + * @return int Age in seconds + */ + public function getAge() + { + return time() - $this->dateCreated->getTimestamp(); + } + + public function __sleep() + { + // Stream/Resource can't be serialized... So we copy the content into an implementation of `Psr\Http\Message\StreamInterface` + if ($this->response !== null) { + $responseBody = (string)$this->response->getBody(); + $this->response = $this->response->withBody( + new PumpStream( + new BodyStore($responseBody), + [ + 'size' => mb_strlen($responseBody), + ] + ) + ); + } + + $requestBody = (string)$this->request->getBody(); + $this->request = $this->request->withBody( + new PumpStream( + new BodyStore($requestBody), + [ + 'size' => mb_strlen($requestBody) + ] + ) + ); + + return array_keys(get_object_vars($this)); + } + + public function __wakeup() + { + // We re-create the stream of the response + if ($this->response !== null) { + $this->response = $this->response + ->withBody( + \GuzzleHttp\Psr7\Utils::streamFor((string) $this->response->getBody()) + ); + } + $this->request = $this->request + ->withBody( + \GuzzleHttp\Psr7\Utils::streamFor((string) $this->request->getBody()) + ); + } + +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/CacheMiddleware.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/CacheMiddleware.php new file mode 100644 index 00000000000..bb55f0cd5be --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/CacheMiddleware.php @@ -0,0 +1,402 @@ + true]; + + /** + * List of safe methods + * + * https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1 + * + * @var array + */ + protected $safeMethods = ['GET' => true, 'HEAD' => true, 'OPTIONS' => true, 'TRACE' => true]; + + /** + * @param CacheStrategyInterface|null $cacheStrategy + */ + public function __construct(CacheStrategyInterface $cacheStrategy = null) + { + $this->cacheStorage = $cacheStrategy !== null ? $cacheStrategy : new PrivateCacheStrategy(); + + register_shutdown_function([$this, 'purgeReValidation']); + } + + /** + * @param Client $client + */ + public function setClient(Client $client) + { + $this->client = $client; + } + + /** + * @param CacheStrategyInterface $cacheStorage + */ + public function setCacheStorage(CacheStrategyInterface $cacheStorage) + { + $this->cacheStorage = $cacheStorage; + } + + /** + * @return CacheStrategyInterface + */ + public function getCacheStorage() + { + return $this->cacheStorage; + } + + /** + * @param array $methods + */ + public function setHttpMethods(array $methods) + { + $this->httpMethods = $methods; + } + + public function getHttpMethods() + { + return $this->httpMethods; + } + + /** + * Will be called at the end of the script. + */ + public function purgeReValidation() + { + \GuzzleHttp\Promise\inspect_all($this->waitingRevalidate); + } + + /** + * @param callable $handler + * + * @return callable + */ + public function __invoke(callable $handler) + { + return function (RequestInterface $request, array $options) use (&$handler) { + if (!isset($this->httpMethods[strtoupper($request->getMethod())])) { + // No caching for this method allowed + + return $handler($request, $options)->then( + function (ResponseInterface $response) use ($request) { + if (!isset($this->safeMethods[$request->getMethod()])) { + // Invalidate cache after a call of non-safe method on the same URI + $response = $this->invalidateCache($request, $response); + } + + return $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS); + } + ); + } + + if ($request->hasHeader(self::HEADER_RE_VALIDATION)) { + // It's a re-validation request, so bypass the cache! + return $handler($request->withoutHeader(self::HEADER_RE_VALIDATION), $options); + } + + // Retrieve information from request (Cache-Control) + $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); + $onlyFromCache = $reqCacheControl->has('only-if-cached'); + $staleResponse = $reqCacheControl->has('max-stale') + && $reqCacheControl->get('max-stale') === ''; + $maxStaleCache = $reqCacheControl->get('max-stale', null); + $minFreshCache = $reqCacheControl->get('min-fresh', null); + + // If cache => return new FulfilledPromise(...) with response + $cacheEntry = $this->cacheStorage->fetch($request); + if ($cacheEntry instanceof CacheEntry) { + $body = $cacheEntry->getResponse()->getBody(); + if ($body->tell() > 0) { + $body->rewind(); + } + + if ($cacheEntry->isFresh() + && ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0) + ) { + // Cache HIT! + return new FulfilledPromise( + $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT) + ); + } elseif ($staleResponse + || ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache) + ) { + // Staled cache! + return new FulfilledPromise( + $cacheEntry->getResponse()->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT) + ); + } elseif ($cacheEntry->hasValidationInformation() && !$onlyFromCache) { + // Re-validation header + $request = static::getRequestWithReValidationHeader($request, $cacheEntry); + + if ($cacheEntry->staleWhileValidate()) { + static::addReValidationRequest($request, $this->cacheStorage, $cacheEntry); + + return new FulfilledPromise( + $cacheEntry->getResponse() + ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE) + ); + } + } + } else { + $cacheEntry = null; + } + + if ($cacheEntry === null && $onlyFromCache) { + // Explicit asking of a cached response => 504 + return new FulfilledPromise( + new Response(504) + ); + } + + /** @var Promise $promise */ + $promise = $handler($request, $options); + + return $promise->then( + function (ResponseInterface $response) use ($request, $cacheEntry) { + // Check if error and looking for a staled content + if ($response->getStatusCode() >= 500) { + $responseStale = static::getStaleResponse($cacheEntry); + if ($responseStale instanceof ResponseInterface) { + return $responseStale; + } + } + + $update = false; + + if ($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry) { + // Not modified => cache entry is re-validate + /** @var ResponseInterface $response */ + $response = $response + ->withStatus($cacheEntry->getResponse()->getStatusCode()) + ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_HIT); + $response = $response->withBody($cacheEntry->getResponse()->getBody()); + + // Merge headers of the "304 Not Modified" and the cache entry + /** + * @var string $headerName + * @var string[] $headerValue + */ + foreach ($cacheEntry->getOriginalResponse()->getHeaders() as $headerName => $headerValue) { + if (!$response->hasHeader($headerName) && $headerName !== self::HEADER_CACHE_INFO) { + $response = $response->withHeader($headerName, $headerValue); + } + } + + $update = true; + } else { + $response = $response->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_MISS); + } + + return static::addToCache($this->cacheStorage, $request, $response, $update); + }, + function ($reason) use ($cacheEntry) { + if ($reason instanceof TransferException) { + $response = static::getStaleResponse($cacheEntry); + if ($response instanceof ResponseInterface) { + return $response; + } + } + + return new RejectedPromise($reason); + } + ); + }; + } + + /** + * @param CacheStrategyInterface $cache + * @param RequestInterface $request + * @param ResponseInterface $response + * @param bool $update cache + * @return ResponseInterface + */ + protected static function addToCache( + CacheStrategyInterface $cache, + RequestInterface $request, + ResponseInterface $response, + $update = false + ) { + $body = $response->getBody(); + + // If the body is not seekable, we have to replace it by a seekable one + if (!$body->isSeekable()) { + $response = $response->withBody( + \GuzzleHttp\Psr7\Utils::streamFor($body->getContents()) + ); + } + + if ($update) { + $cache->update($request, $response); + } else { + $cache->cache($request, $response); + } + + // always rewind back to the start otherwise other middlewares may get empty "content" + if ($body->isSeekable()) { + $response->getBody()->rewind(); + } + + return $response; + } + + /** + * @param RequestInterface $request + * @param CacheStrategyInterface $cacheStorage + * @param CacheEntry $cacheEntry + * + * @return bool if added + */ + protected function addReValidationRequest( + RequestInterface $request, + CacheStrategyInterface &$cacheStorage, + CacheEntry $cacheEntry + ) { + // Add the promise for revalidate + if ($this->client !== null) { + /** @var RequestInterface $request */ + $request = $request->withHeader(self::HEADER_RE_VALIDATION, '1'); + $this->waitingRevalidate[] = $this->client + ->sendAsync($request) + ->then(function (ResponseInterface $response) use ($request, &$cacheStorage, $cacheEntry) { + $update = false; + + if ($response->getStatusCode() == 304) { + // Not modified => cache entry is re-validate + /** @var ResponseInterface $response */ + $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode()); + $response = $response->withBody($cacheEntry->getResponse()->getBody()); + + // Merge headers of the "304 Not Modified" and the cache entry + foreach ($cacheEntry->getResponse()->getHeaders() as $headerName => $headerValue) { + if (!$response->hasHeader($headerName)) { + $response = $response->withHeader($headerName, $headerValue); + } + } + + $update = true; + } + + static::addToCache($cacheStorage, $request, $response, $update); + }); + + return true; + } + + return false; + } + + /** + * @param CacheEntry|null $cacheEntry + * + * @return null|ResponseInterface + */ + protected static function getStaleResponse(CacheEntry $cacheEntry = null) + { + // Return staled cache entry if we can + if ($cacheEntry instanceof CacheEntry && $cacheEntry->serveStaleIfError()) { + return $cacheEntry->getResponse() + ->withHeader(self::HEADER_CACHE_INFO, self::HEADER_CACHE_STALE); + } + + return; + } + + /** + * @param RequestInterface $request + * @param CacheEntry $cacheEntry + * + * @return RequestInterface + */ + protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry) + { + if ($cacheEntry->getResponse()->hasHeader('Last-Modified')) { + $request = $request->withHeader( + 'If-Modified-Since', + $cacheEntry->getResponse()->getHeader('Last-Modified') + ); + } + if ($cacheEntry->getResponse()->hasHeader('Etag')) { + $request = $request->withHeader( + 'If-None-Match', + $cacheEntry->getResponse()->getHeader('Etag') + ); + } + + return $request; + } + + /** + * @param CacheStrategyInterface|null $cacheStorage + * + * @return CacheMiddleware the Middleware for Guzzle HandlerStack + * + * @deprecated Use constructor => `new CacheMiddleware()` + */ + public static function getMiddleware(CacheStrategyInterface $cacheStorage = null) + { + return new self($cacheStorage); + } + + /** + * @param RequestInterface $request + * + * @param ResponseInterface $response + * + * @return ResponseInterface + */ + private function invalidateCache(RequestInterface $request, ResponseInterface $response) + { + foreach (array_keys($this->httpMethods) as $method) { + $this->cacheStorage->delete($request->withMethod($method)); + } + + return $response->withHeader(self::HEADER_INVALIDATION, true); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/KeyValueHttpHeader.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/KeyValueHttpHeader.php new file mode 100644 index 00000000000..d931d1ac302 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/KeyValueHttpHeader.php @@ -0,0 +1,131 @@ +@\,;\:\\\\"\/\[\]\?\=\{\}\x7F]+)(?:\=(?:([^\x00-\x20\(\)<>@\,;\:\\\\"\/\[\]\?\=\{\}\x7F]+)|(?:\"((?:[^"\\\\]|\\\\.)*)\")))?/'; + + /** + * @var string[] + */ + protected $values = []; + + /** + * @param array $values + */ + public function __construct(array $values) + { + foreach ($values as $value) { + $matches = []; + if (preg_match_all(self::REGEX_SPLIT, $value, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $val = ''; + if (count($match) == 3) { + $val = $match[2]; + } elseif (count($match) > 3) { + $val = $match[3]; + } + + $this->values[$match[1]] = $val; + } + } + } + } + + /** + * @param string $key + * + * @return bool + */ + public function has($key) + { + // For performance, we can use isset, + // but it will not match if value == 0 + return isset($this->values[$key]) || array_key_exists($key, $this->values); + } + + /** + * @param string $key + * @param string $default the value to return if don't exist + * @return string + */ + public function get($key, $default = '') + { + if ($this->has($key)) { + return $this->values[$key]; + } + + return $default; + } + + /** + * @return bool + */ + public function isEmpty() + { + return count($this->values) === 0; + } + + /** + * Return the current element + * @link http://php.net/manual/en/iterator.current.php + * @return mixed Can return any type. + * @since 5.0.0 + */ + #[\ReturnTypeWillChange] + public function current() + { + return current($this->values); + } + + /** + * Move forward to next element + * @link http://php.net/manual/en/iterator.next.php + * @return void Any returned value is ignored. + * @since 5.0.0 + */ + public function next(): void + { + next($this->values); + } + + /** + * Return the key of the current element + * @link http://php.net/manual/en/iterator.key.php + * @return mixed scalar on success, or null on failure. + * @since 5.0.0 + * + */ + #[\ReturnTypeWillChange] + public function key() + { + return key($this->values); + } + + /** + * Checks if current position is valid + * @link http://php.net/manual/en/iterator.valid.php + * @return boolean The return value will be casted to boolean and then evaluated. + * Returns true on success or false on failure. + * @since 5.0.0 + */ + public function valid(): bool + { + return key($this->values) !== null; + } + + /** + * Rewind the Iterator to the first element + * @link http://php.net/manual/en/iterator.rewind.php + * @return void Any returned value is ignored. + * @since 5.0.0 + */ + public function rewind(): void + { + reset($this->values); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/CacheStorageInterface.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/CacheStorageInterface.php new file mode 100644 index 00000000000..0ec9e1af08a --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/CacheStorageInterface.php @@ -0,0 +1,30 @@ +cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function fetch($key) + { + try { + $cache = unserialize(gzuncompress($this->cache->fetch($key))); + if ($cache instanceof CacheEntry) { + return $cache; + } + } catch (\Exception $ignored) { + return; + } + + return; + } + + /** + * {@inheritdoc} + */ + public function save($key, CacheEntry $data) + { + try { + $lifeTime = $data->getTTL(); + if ($lifeTime >= 0) { + return $this->cache->save( + $key, + gzcompress(serialize($data)), + $lifeTime + ); + } + } catch (\Exception $ignored) { + // No fail if we can't save it the storage + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + try { + return $this->cache->delete($key); + } catch (\Exception $ignored) { + // Don't fail if we can't delete it + } + + return false; + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/DoctrineCacheStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/DoctrineCacheStorage.php new file mode 100644 index 00000000000..9f5f1609511 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/DoctrineCacheStorage.php @@ -0,0 +1,74 @@ +cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function fetch($key) + { + try { + $cache = unserialize($this->cache->fetch($key)); + if ($cache instanceof CacheEntry) { + return $cache; + } + } catch (\Exception $ignored) { + return; + } + + return; + } + + /** + * {@inheritdoc} + */ + public function save($key, CacheEntry $data) + { + try { + $lifeTime = $data->getTTL(); + if ($lifeTime >= 0) { + return $this->cache->save( + $key, + serialize($data), + $lifeTime + ); + } + } catch (\Exception $ignored) { + // No fail if we can't save it the storage + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + try { + return $this->cache->delete($key); + } catch (\Exception $ignored) { + // Don't fail if we can't delete it + } + + return false; + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/FlysystemStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/FlysystemStorage.php new file mode 100644 index 00000000000..e2db2e922d7 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/FlysystemStorage.php @@ -0,0 +1,61 @@ +filesystem = new Filesystem($adapter); + } + + /** + * @inheritdoc + */ + public function fetch($key) + { + if ($this->filesystem->has($key)) { + // The file exist, read it! + $data = @unserialize( + $this->filesystem->read($key) + ); + + if ($data instanceof CacheEntry) { + return $data; + } + } + + return; + } + + /** + * @inheritdoc + */ + public function save($key, CacheEntry $data) + { + return $this->filesystem->put($key, serialize($data)); + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + try { + return $this->filesystem->delete($key); + } catch (FileNotFoundException $ex) { + return true; + } + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/LaravelCacheStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/LaravelCacheStorage.php new file mode 100644 index 00000000000..3e3416d95f2 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/LaravelCacheStorage.php @@ -0,0 +1,84 @@ +cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function fetch($key) + { + try { + $cache = unserialize($this->cache->get($key, '')); + if ($cache instanceof CacheEntry) { + return $cache; + } + } catch (\Exception $ignored) { + return; + } + + return; + } + + /** + * {@inheritdoc} + */ + public function save($key, CacheEntry $data) + { + try { + $lifeTime = $this->getLifeTime($data); + if ($lifeTime === 0) { + return $this->cache->forever( + $key, + serialize($data) + ); + } else if ($lifeTime > 0) { + return $this->cache->add( + $key, + serialize($data), + $lifeTime + ); + } + } catch (\Exception $ignored) { + // No fail if we can't save it the storage + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + return $this->cache->forget($key); + } + + protected function getLifeTime(CacheEntry $data) + { + $version = app()->version(); + if (preg_match('/^\d+(\.\d+)?(\.\d+)?/', $version) && version_compare($version, '5.8.0') < 0) { + // getTTL returns seconds, Laravel needs minutes before v5.8 + return $data->getTTL() / 60; + } + + return $data->getTTL(); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr16CacheStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr16CacheStorage.php new file mode 100644 index 00000000000..fe97a257087 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr16CacheStorage.php @@ -0,0 +1,52 @@ +cache = $cache; + } + + /** + * {@inheritdoc} + */ + public function fetch($key) + { + $data = $this->cache->get($key); + if ($data instanceof CacheEntry) { + return $data; + } + + return null; + } + + /** + * {@inheritdoc} + */ + public function save($key, CacheEntry $data) + { + $ttl = $data->getTTL(); + if ($ttl === 0) { + return $this->cache->set($key, $data); + } + return $this->cache->set($key, $data, $data->getTTL()); + } + + /** + * {@inheritdoc} + */ + public function delete($key) + { + return $this->cache->delete($key); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr6CacheStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr6CacheStorage.php new file mode 100644 index 00000000000..b1344ef4620 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/Psr6CacheStorage.php @@ -0,0 +1,92 @@ +cachePool = $cachePool; + } + + /** + * {@inheritdoc} + */ + public function fetch($key) + { + $item = $this->cachePool->getItem($key); + $this->lastItem = $item; + + $cache = $item->get(); + + if ($cache instanceof CacheEntry) { + return $cache; + } + + return null; + } + + /** + * {@inheritdoc} + */ + public function save($key, CacheEntry $data) + { + if ($this->lastItem && $this->lastItem->getKey() == $key) { + $item = $this->lastItem; + } else { + $item = $this->cachePool->getItem($key); + } + + $this->lastItem = null; + + $item->set($data); + + $ttl = $data->getTTL(); + if ($ttl === 0) { + // No expiration + $item->expiresAfter(null); + } else { + $item->expiresAfter($ttl); + } + + return $this->cachePool->save($item); + } + + /** + * @param string $key + * + * @return bool + */ + public function delete($key) + { + if (null !== $this->lastItem && $this->lastItem->getKey() === $key) { + $this->lastItem = null; + } + + return $this->cachePool->deleteItem($key); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/VolatileRuntimeStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/VolatileRuntimeStorage.php new file mode 100644 index 00000000000..ec77d30f82e --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/VolatileRuntimeStorage.php @@ -0,0 +1,60 @@ +cache[$key])) { + return $this->cache[$key]; + } + + return; + } + + /** + * @param string $key + * @param CacheEntry $data + * + * @return bool + */ + public function save($key, CacheEntry $data) + { + $this->cache[$key] = $data; + + return true; + } + + /** + * @param string $key + * + * @return bool + */ + public function delete($key) + { + if (true === array_key_exists($key, $this->cache)) { + unset($this->cache[$key]); + + return true; + } + + return false; + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/WordPressObjectCacheStorage.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/WordPressObjectCacheStorage.php new file mode 100644 index 00000000000..c81e739d91f --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Storage/WordPressObjectCacheStorage.php @@ -0,0 +1,73 @@ +group = $group; + } + + /** + * @param string $key + * + * @return CacheEntry|null the data or false + */ + public function fetch($key) + { + try { + $cache = unserialize(wp_cache_get($key, $this->group)); + if ($cache instanceof CacheEntry) { + return $cache; + } + } catch (\Exception $ignored) { + // Don't fail if we can't load it + } + + return null; + } + + /** + * @param string $key + * @param CacheEntry $data + * + * @return bool + */ + public function save($key, CacheEntry $data) + { + try { + return wp_cache_set($key, serialize($data), $this->group, $data->getTTL()); + } catch (\Exception $ignored) { + // Don't fail if we can't save it + } + + return false; + } + + /** + * @param string $key + * + * @return bool + */ + public function delete($key) + { + try { + return wp_cache_delete($key, $this->group); + } catch (\Exception $ignored) { + // Don't fail if we can't delete it + } + + return false; + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/CacheStrategyInterface.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/CacheStrategyInterface.php new file mode 100644 index 00000000000..a88df2d1c67 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/CacheStrategyInterface.php @@ -0,0 +1,42 @@ +defaultCacheStrategy = $defaultCacheStrategy ?: new NullCacheStrategy(); + } + + /** + * @param CacheStrategyInterface $defaultCacheStrategy + */ + public function setDefaultCacheStrategy(CacheStrategyInterface $defaultCacheStrategy) + { + $this->defaultCacheStrategy = $defaultCacheStrategy; + } + + /** + * @param RequestMatcherInterface $requestMatcher + * @param CacheStrategyInterface $cacheStrategy + */ + final public function registerRequestMatcher(RequestMatcherInterface $requestMatcher, CacheStrategyInterface $cacheStrategy) + { + $this->requestMatchers[] = [ + $requestMatcher, + $cacheStrategy, + ]; + } + + /** + * @param RequestInterface $request + * @return CacheStrategyInterface + */ + private function getStrategyFor(RequestInterface $request) + { + /** + * @var RequestMatcherInterface $requestMatcher + * @var CacheStrategyInterface $cacheStrategy + */ + foreach ($this->requestMatchers as $requestMatcher) { + list($requestMatcher, $cacheStrategy) = $requestMatcher; + if ($requestMatcher->matches($request)) { + return $cacheStrategy; + } + } + + return $this->defaultCacheStrategy; + } + + /** + * @inheritDoc + */ + public function fetch(RequestInterface $request) + { + return $this->getStrategyFor($request)->fetch($request); + } + + /** + * @inheritDoc + */ + public function cache(RequestInterface $request, ResponseInterface $response) + { + return $this->getStrategyFor($request)->cache($request, $response); + } + + /** + * @inheritDoc + */ + public function update(RequestInterface $request, ResponseInterface $response) + { + return $this->getStrategyFor($request)->update($request, $response); + } + + /** + * {@inheritdoc} + */ + public function delete(RequestInterface $request) + { + return $this->getStrategyFor($request)->delete($request); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/Delegate/RequestMatcherInterface.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/Delegate/RequestMatcherInterface.php new file mode 100644 index 00000000000..5890bc22344 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/Delegate/RequestMatcherInterface.php @@ -0,0 +1,15 @@ +defaultTtl = $defaultTtl; + $this->varyHeaders = $varyHeaders; + parent::__construct($cache); + } + + protected function getCacheKey(RequestInterface $request, KeyValueHttpHeader $varyHeaders = null) + { + if (null === $varyHeaders || $varyHeaders->isEmpty()) { + return hash( + 'sha256', + 'greedy'.$request->getMethod().$request->getUri() + ); + } + + $cacheHeaders = []; + foreach ($varyHeaders as $key => $value) { + if ($request->hasHeader($key)) { + $cacheHeaders[$key] = $request->getHeader($key); + } + } + + return hash( + 'sha256', + 'greedy'.$request->getMethod().$request->getUri().json_encode($cacheHeaders) + ); + } + + public function cache(RequestInterface $request, ResponseInterface $response) + { + $warningMessage = sprintf('%d - "%s" "%s"', + 299, + 'Cached although the response headers indicate not to do it!', + (new \DateTime())->format(\DateTime::RFC1123) + ); + + $response = $response->withAddedHeader('Warning', $warningMessage); + + if ($cacheObject = $this->getCacheObject($request, $response)) { + return $this->storage->save( + $this->getCacheKey($request, $this->varyHeaders), + $cacheObject + ); + } + + return false; + } + + protected function getCacheObject(RequestInterface $request, ResponseInterface $response) + { + if (!array_key_exists($response->getStatusCode(), $this->statusAccepted)) { + // Don't cache it + return null; + } + + if (null !== $this->varyHeaders && $this->varyHeaders->has('*')) { + // This will never match with a request + return; + } + + $response = $response->withoutHeader('Etag')->withoutHeader('Last-Modified'); + + $ttl = $this->defaultTtl; + if ($request->hasHeader(self::HEADER_TTL)) { + $ttlHeaderValues = $request->getHeader(self::HEADER_TTL); + $ttl = (int)reset($ttlHeaderValues); + } + + return new CacheEntry($request->withoutHeader(self::HEADER_TTL), $response, new \DateTime(sprintf('+%d seconds', $ttl))); + } + + public function fetch(RequestInterface $request) + { + $cache = $this->storage->fetch($this->getCacheKey($request, $this->varyHeaders)); + return $cache; + } + + /** + * {@inheritdoc} + */ + public function delete(RequestInterface $request) + { + return $this->storage->delete($this->getCacheKey($request)); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/NullCacheStrategy.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/NullCacheStrategy.php new file mode 100644 index 00000000000..22e3929a885 --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/NullCacheStrategy.php @@ -0,0 +1,43 @@ + 200, + 203 => 203, + 204 => 204, + 300 => 300, + 301 => 301, + 404 => 404, + 405 => 405, + 410 => 410, + 414 => 414, + 418 => 418, + 501 => 501, + ]; + + /** + * @var string[] + */ + protected $ageKey = [ + 'max-age', + ]; + + public function __construct(CacheStorageInterface $cache = null) + { + $this->storage = $cache !== null ? $cache : new VolatileRuntimeStorage(); + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @return CacheEntry|null entry to save, null if can't cache it + */ + protected function getCacheObject(RequestInterface $request, ResponseInterface $response) + { + if (!isset($this->statusAccepted[$response->getStatusCode()])) { + // Don't cache it + return; + } + + $cacheControl = new KeyValueHttpHeader($response->getHeader('Cache-Control')); + $varyHeader = new KeyValueHttpHeader($response->getHeader('Vary')); + + if ($varyHeader->has('*')) { + // This will never match with a request + return; + } + + if ($cacheControl->has('no-store')) { + // No store allowed (maybe some sensitives data...) + return; + } + + if ($cacheControl->has('no-cache')) { + // Stale response see RFC7234 section 5.2.1.4 + $entry = new CacheEntry($request, $response, new \DateTime('-1 seconds')); + + return $entry->hasValidationInformation() ? $entry : null; + } + + foreach ($this->ageKey as $key) { + if ($cacheControl->has($key)) { + return new CacheEntry( + $request, + $response, + new \DateTime('+'.(int) $cacheControl->get($key).'seconds') + ); + } + } + + if ($response->hasHeader('Expires')) { + $expireAt = \DateTime::createFromFormat(\DateTime::RFC1123, $response->getHeaderLine('Expires')); + if ($expireAt !== false) { + return new CacheEntry( + $request, + $response, + $expireAt + ); + } + } + + return new CacheEntry($request, $response, new \DateTime('-1 seconds')); + } + + /** + * Generate a key for the response cache. + * + * @param RequestInterface $request + * @param null|KeyValueHttpHeader $varyHeaders The vary headers which should be honoured by the cache (optional) + * + * @return string + */ + protected function getCacheKey(RequestInterface $request, KeyValueHttpHeader $varyHeaders = null) + { + if (!$varyHeaders) { + return hash('sha256', $request->getMethod().$request->getUri()); + } + + $cacheHeaders = []; + + foreach ($varyHeaders as $key => $value) { + if ($request->hasHeader($key)) { + $cacheHeaders[$key] = $request->getHeader($key); + } + } + + return hash('sha256', $request->getMethod().$request->getUri().json_encode($cacheHeaders)); + } + + /** + * Return a CacheEntry or null if no cache. + * + * @param RequestInterface $request + * + * @return CacheEntry|null + */ + public function fetch(RequestInterface $request) + { + /** @var int|null $maxAge */ + $maxAge = null; + + if ($request->hasHeader('Cache-Control')) { + $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); + if ($reqCacheControl->has('no-cache')) { + // Can't return cache + return null; + } + + $maxAge = $reqCacheControl->get('max-age', null); + } elseif ($request->hasHeader('Pragma')) { + $pragma = new KeyValueHttpHeader($request->getHeader('Pragma')); + if ($pragma->has('no-cache')) { + // Can't return cache + return null; + } + } + + $cache = $this->storage->fetch($this->getCacheKey($request)); + if ($cache !== null) { + $varyHeaders = $cache->getVaryHeaders(); + + // vary headers exist from a previous response, check if we have a cache that matches those headers + if (!$varyHeaders->isEmpty()) { + $cache = $this->storage->fetch($this->getCacheKey($request, $varyHeaders)); + + if (!$cache) { + return null; + } + } + + if ((string)$cache->getOriginalRequest()->getUri() !== (string)$request->getUri()) { + return null; + } + + if ($maxAge !== null) { + if ($cache->getAge() > $maxAge) { + // Cache entry is too old for the request requirements! + return null; + } + } + + if (!$cache->isVaryEquals($request)) { + return null; + } + } + + return $cache; + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * + * @return bool true if success + */ + public function cache(RequestInterface $request, ResponseInterface $response) + { + $reqCacheControl = new KeyValueHttpHeader($request->getHeader('Cache-Control')); + if ($reqCacheControl->has('no-store')) { + // No caching allowed + return false; + } + + $cacheObject = $this->getCacheObject($request, $response); + if ($cacheObject !== null) { + // store the cache against the URI-only key + $success = $this->storage->save( + $this->getCacheKey($request), + $cacheObject + ); + + $varyHeaders = $cacheObject->getVaryHeaders(); + + if (!$varyHeaders->isEmpty()) { + // also store the cache against the vary headers based key + $success = $this->storage->save( + $this->getCacheKey($request, $varyHeaders), + $cacheObject + ); + } + + return $success; + } + + return false; + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * + * @return bool true if success + */ + public function update(RequestInterface $request, ResponseInterface $response) + { + return $this->cache($request, $response); + } + + /** + * {@inheritdoc} + */ + public function delete(RequestInterface $request) + { + return $this->storage->delete($this->getCacheKey($request)); + } +} diff --git a/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/PublicCacheStrategy.php b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/PublicCacheStrategy.php new file mode 100644 index 00000000000..873ac2c123a --- /dev/null +++ b/lib/guzzlehttp/kevinrob/guzzlecache/src/Strategy/PublicCacheStrategy.php @@ -0,0 +1,42 @@ +ageKey, 's-maxage'); + } + + /** + * {@inheritdoc} + */ + protected function getCacheObject(RequestInterface $request, ResponseInterface $response) + { + $cacheControl = new KeyValueHttpHeader($response->getHeader('Cache-Control')); + if ($cacheControl->has('private')) { + return; + } + + return parent::getCacheObject($request, $response); + } +} diff --git a/lib/guzzlehttp/readme_moodle.txt b/lib/guzzlehttp/readme_moodle.txt index 38de60367c4..ae933f96c91 100644 --- a/lib/guzzlehttp/readme_moodle.txt +++ b/lib/guzzlehttp/readme_moodle.txt @@ -14,3 +14,8 @@ Update guzzle psr-7 message implementation library 1. Download the latest psr-7 library from https://github.com/guzzle/psr7 2. Copy the src directory to lib/guzzlehttp/psr-7 folder 3. Copy the associated files LICENCE, README.md etc. to psr-7 directory + +Update guzzle cache implementation library +1. Download the latest Guzzle cache middleware library from https://github.com/Kevinrob/guzzle-cache-middleware +2. Copy the src directory to guzzlehttp/kevinrob/guzzlecache folder +3. Copy the associated files LICENCE, README.md etc. to guzzlecache directory diff --git a/lib/thirdpartylibs.xml b/lib/thirdpartylibs.xml index 4f1c617ce3f..04fe52563ec 100644 --- a/lib/thirdpartylibs.xml +++ b/lib/thirdpartylibs.xml @@ -682,4 +682,12 @@ All rights reserved. MIT https://github.com/symfony/deprecation-contracts + + guzzlehttp/kevinrob/guzzlecache + Guzzle cache middleware + A HTTP Cache for Guzzle as a simple Middleware to be added in the HandlerStack. + 4.0.2 + MIT + https://github.com/Kevinrob/guzzle-cache-middleware +