实现请求缓存1天。

This commit is contained in:
2025-04-18 15:38:58 +08:00
parent 2d3f7d8511
commit 96da83a1ab
209 changed files with 19400 additions and 1657 deletions

View File

@@ -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.

View File

@@ -0,0 +1,309 @@
# 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)
# Development
## Docker quick start
### Initialization
```bash
make init
```
### Running test
```bash
make test
```
### Entering container shell
```bash
make shell
```

View File

@@ -0,0 +1,58 @@
{
"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/promises": "^1.4 || ^2.0",
"guzzlehttp/psr7": "^1.7.0 || ^2.0.0"
},
"require-dev": {
"phpunit/phpunit": "^8.5.15 || ^9.5",
"doctrine/cache": "^1.10",
"league/flysystem": "^2.5",
"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
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Kevinrob\GuzzleCache;
/**
*
* This object is only meant to provide a callable to `GuzzleHttp\Psr7\PumpStream`.
*
* @internal don't use it in your project.
*/
class BodyStore
{
private $body;
private $read = 0;
private $toRead;
public function __construct(string $body)
{
$this->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;
}
}

View File

@@ -0,0 +1,333 @@
<?php
namespace Kevinrob\GuzzleCache;
use GuzzleHttp\Psr7\PumpStream;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class CacheEntry implements \Serializable
{
/**
* @var RequestInterface
*/
protected $request;
/**
* @var ResponseInterface
*/
protected $response;
/**
* @var \DateTime
*/
protected $staleAt;
/**
* @var \DateTime
*/
protected $staleIfErrorTo;
/**
* @var \DateTime
*/
protected $staleWhileRevalidateTo;
/**
* @var \DateTime
*/
protected $dateCreated;
/**
* Cached timestamp of staleAt variable.
*
* @var int
*/
protected $timestampStale;
/**
* @param RequestInterface $request
* @param ResponseInterface $response
* @param \DateTime $staleAt
* @param \DateTime|null $staleIfErrorTo if null, detected with the headers (RFC 5861)
* @param \DateTime|null $staleWhileRevalidateTo
*/
public function __construct(
RequestInterface $request,
ResponseInterface $response,
\DateTime $staleAt,
\DateTime $staleIfErrorTo = null,
\DateTime $staleWhileRevalidateTo = null
) {
$this->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 __serialize(): array
{
return [
'request' => self::toSerializeableMessage($this->request),
'response' => $this->response !== null ? self::toSerializeableMessage($this->response) : null,
'staleAt' => $this->staleAt,
'staleIfErrorTo' => $this->staleIfErrorTo,
'staleWhileRevalidateTo' => $this->staleWhileRevalidateTo,
'dateCreated' => $this->dateCreated,
'timestampStale' => $this->timestampStale,
];
}
public function __unserialize(array $data): void
{
$prefix = '';
if (isset($data["\0*\0request"])) {
// We are unserializing a cache entry which was serialized with a version < 4.1.1
$prefix = "\0*\0";
}
$this->request = self::restoreStreamBody($data[$prefix.'request']);
$this->response = $data[$prefix.'response'] !== null ? self::restoreStreamBody($data[$prefix.'response']) : null;
$this->staleAt = $data[$prefix.'staleAt'];
$this->staleIfErrorTo = $data[$prefix.'staleIfErrorTo'];
$this->staleWhileRevalidateTo = $data[$prefix.'staleWhileRevalidateTo'];
$this->dateCreated = $data[$prefix.'dateCreated'];
$this->timestampStale = $data[$prefix.'timestampStale'];
}
/**
* Stream/Resource can't be serialized... So we copy the content into an implementation of `Psr\Http\Message\StreamInterface`
*
* @template T of MessageInterface
*
* @param T $message
* @return T
*/
private static function toSerializeableMessage(MessageInterface $message): MessageInterface
{
$bodyString = (string)$message->getBody();
return $message->withBody(
new PumpStream(
new BodyStore($bodyString),
[
'size' => mb_strlen($bodyString),
]
)
);
}
/**
* @template T of MessageInterface
*
* @param T $message
* @return T
*/
private static function restoreStreamBody(MessageInterface $message): MessageInterface
{
return $message->withBody(
\GuzzleHttp\Psr7\Utils::streamFor((string) $message->getBody())
);
}
public function serialize()
{
return serialize($this->__serialize());
}
public function unserialize($data)
{
$this->__unserialize(unserialize($data));
}
}

View File

@@ -0,0 +1,400 @@
<?php
namespace Kevinrob\GuzzleCache;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Promise\FulfilledPromise;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Psr7\Response;
use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface;
use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Class CacheMiddleware.
*/
class CacheMiddleware
{
const HEADER_RE_VALIDATION = 'X-Kevinrob-GuzzleCache-ReValidation';
const HEADER_INVALIDATION = 'X-Kevinrob-GuzzleCache-Invalidation';
const HEADER_CACHE_INFO = 'X-Kevinrob-Cache';
const HEADER_CACHE_HIT = 'HIT';
const HEADER_CACHE_MISS = 'MISS';
const HEADER_CACHE_STALE = 'STALE';
/**
* @var array of Promise
*/
protected $waitingRevalidate = [];
/**
* @var Client
*/
protected $client;
/**
* @var CacheStrategyInterface
*/
protected $cacheStorage;
/**
* List of allowed HTTP methods to cache
* Key = method name (upscaling)
* Value = true.
*
* @var array
*/
protected $httpMethods = ['GET' => 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\Utils::inspectAll($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) {
$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);
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Kevinrob\GuzzleCache;
class KeyValueHttpHeader implements \Iterator
{
/**
* Take from https://github.com/hapijs/wreck.
*/
const REGEX_SPLIT = '/(?:^|(?:\s*\,\s*))([^\x00-\x20\(\)<>@\,;\:\\\\"\/\[\]\?\=\{\}\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);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Kevinrob\GuzzleCache\CacheEntry;
interface CacheStorageInterface
{
/**
* @param string $key
*
* @return CacheEntry|null the data or false
*/
public function fetch($key);
/**
* @param string $key
* @param CacheEntry $data
*
* @return bool
*/
public function save($key, CacheEntry $data);
/**
* @param string $key
*
* @return bool
*/
public function delete($key);
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Doctrine\Common\Cache\Cache;
use Kevinrob\GuzzleCache\CacheEntry;
class CompressedDoctrineCacheStorage implements CacheStorageInterface
{
/**
* @var Cache
*/
protected $cache;
/**
* @param Cache $cache
*/
public function __construct(Cache $cache)
{
$this->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;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Doctrine\Common\Cache\Cache;
use Kevinrob\GuzzleCache\CacheEntry;
class DoctrineCacheStorage implements CacheStorageInterface
{
/**
* @var Cache
*/
protected $cache;
/**
* @param Cache $cache
*/
public function __construct(Cache $cache)
{
$this->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;
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Kevinrob\GuzzleCache\CacheEntry;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\FilesystemException;
class FlysystemStorage implements CacheStorageInterface
{
/**
* @var Filesystem
*/
protected $filesystem;
public function __construct(FilesystemAdapter $adapter)
{
$this->filesystem = new Filesystem($adapter);
}
/**
* @inheritdoc
*/
public function fetch($key)
{
if ($this->filesystem->fileExists($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)
{
$this->filesystem->write($key, serialize($data));
}
/**
* {@inheritdoc}
*/
public function delete($key)
{
try {
$this->filesystem->delete($key);
} catch (FilesystemException $ex) {
return true;
}
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Illuminate\Contracts\Cache\Repository as Cache;
use Kevinrob\GuzzleCache\CacheEntry;
class LaravelCacheStorage implements CacheStorageInterface
{
/**
* @var Cache
*/
protected $cache;
/**
* @param Cache $cache
*/
public function __construct(Cache $cache)
{
$this->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();
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Kevinrob\GuzzleCache\CacheEntry;
use Psr\SimpleCache\CacheInterface;
class Psr16CacheStorage implements CacheStorageInterface
{
/**
* @var CacheInterface
*/
private $cache;
public function __construct(CacheInterface $cache)
{
$this->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);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Kevinrob\GuzzleCache\CacheEntry;
class Psr6CacheStorage implements CacheStorageInterface
{
/**
* The cache pool.
*
* @var CacheItemPoolInterface
*/
protected $cachePool;
/**
* The last item retrieved from the cache.
*
* This item is transiently stored so that save() can reuse the cache item
* usually retrieved by fetch() beforehand, instead of requesting it a second time.
*
* @var CacheItemInterface|null
*/
protected $lastItem;
/**
* @param CacheItemPoolInterface $cachePool
*/
public function __construct(CacheItemPoolInterface $cachePool)
{
$this->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);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Kevinrob\GuzzleCache\CacheEntry;
/**
* This cache class is backed by a PHP Array.
*/
class VolatileRuntimeStorage implements CacheStorageInterface
{
/**
* @var CacheEntry[]
*/
protected $cache = [];
/**
* @param string $key
*
* @return CacheEntry|null the data or false
*/
public function fetch($key)
{
if (isset($this->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;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace Kevinrob\GuzzleCache\Storage;
use Kevinrob\GuzzleCache\CacheEntry;
class WordPressObjectCacheStorage implements CacheStorageInterface
{
/**
* @var string
*/
private $group;
/**
* @param string $group
*/
public function __construct($group = 'guzzle')
{
$this->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;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy;
use Kevinrob\GuzzleCache\CacheEntry;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
interface CacheStrategyInterface
{
/**
* Return a CacheEntry or null if no cache.
*
* @param RequestInterface $request
*
* @return CacheEntry|null
*/
public function fetch(RequestInterface $request);
/**
* @param RequestInterface $request
* @param ResponseInterface $response
*
* @return bool true if success
*/
public function cache(RequestInterface $request, ResponseInterface $response);
/**
* @param RequestInterface $request
* @param ResponseInterface $response
*
* @return bool true if success
*/
public function update(RequestInterface $request, ResponseInterface $response);
/**
* @param RequestInterface $request
*
* @return bool
*/
public function delete(RequestInterface $request);
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy\Delegate;
use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface;
use Kevinrob\GuzzleCache\Strategy\NullCacheStrategy;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class DelegatingCacheStrategy implements CacheStrategyInterface
{
/**
* @var array
*/
private $requestMatchers = [];
/**
* @var CacheStrategyInterface
*/
private $defaultCacheStrategy;
/**
* DelegatingCacheStrategy constructor.
*/
public function __construct(CacheStrategyInterface $defaultCacheStrategy = null)
{
$this->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);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy\Delegate;
use Psr\Http\Message\RequestInterface;
interface RequestMatcherInterface
{
/**
* @param RequestInterface $request
* @return bool
*/
public function matches(RequestInterface $request);
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy;
use Kevinrob\GuzzleCache\CacheEntry;
use Kevinrob\GuzzleCache\KeyValueHttpHeader;
use Kevinrob\GuzzleCache\Storage\CacheStorageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* This strategy represents a "greedy" HTTP client.
*
* It can be used to cache responses in spite of any cache related response headers,
* but it SHOULDN'T be used unless absolutely necessary, e.g. when accessing
* badly designed APIs without Cache control.
*
* Obviously, this follows no RFC :(.
*/
class GreedyCacheStrategy extends PrivateCacheStrategy
{
const HEADER_TTL = 'X-Kevinrob-GuzzleCache-TTL';
/**
* @var int
*/
protected $defaultTtl;
/**
* @var KeyValueHttpHeader
*/
private $varyHeaders;
public function __construct(CacheStorageInterface $cache = null, $defaultTtl, KeyValueHttpHeader $varyHeaders = null)
{
$this->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));
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy;
use Kevinrob\GuzzleCache\CacheEntry;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class NullCacheStrategy implements CacheStrategyInterface
{
/**
* @inheritDoc
*/
public function fetch(RequestInterface $request)
{
return null;
}
/**
* @inheritDoc
*/
public function cache(RequestInterface $request, ResponseInterface $response)
{
return true;
}
/**
* @inheritDoc
*/
public function update(RequestInterface $request, ResponseInterface $response)
{
return true;
}
/**
* {@inheritdoc}
*/
public function delete(RequestInterface $request)
{
return true;
}
}

View File

@@ -0,0 +1,256 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy;
use Kevinrob\GuzzleCache\CacheEntry;
use Kevinrob\GuzzleCache\KeyValueHttpHeader;
use Kevinrob\GuzzleCache\Storage\CacheStorageInterface;
use Kevinrob\GuzzleCache\Storage\VolatileRuntimeStorage;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* This strategy represents a "private" HTTP client.
* Pay attention to share storage between application with caution!
*
* For example, a response with cache-control header "private, max-age=60"
* will be cached by this strategy.
*
* The rules applied are from RFC 7234.
*
* @see https://tools.ietf.org/html/rfc7234
*/
class PrivateCacheStrategy implements CacheStrategyInterface
{
/**
* @var CacheStorageInterface
*/
protected $storage;
/**
* @var int[]
*/
protected $statusAccepted = [
200 => 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));
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Kevinrob\GuzzleCache\Strategy;
use Kevinrob\GuzzleCache\KeyValueHttpHeader;
use Kevinrob\GuzzleCache\Storage\CacheStorageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* This strategy represents a "public" or "shared" HTTP client.
* You can share the storage between applications.
*
* For example, a response with cache-control header "private, max-age=60"
* will be NOT cached by this strategy.
*
* The rules applied are from RFC 7234.
*
* @see https://tools.ietf.org/html/rfc7234
*/
class PublicCacheStrategy extends PrivateCacheStrategy
{
public function __construct(CacheStorageInterface $cache = null)
{
parent::__construct($cache);
array_unshift($this->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);
}
}