Initial commit

This commit is contained in:
2020-10-07 10:37:15 +02:00
commit ce5f440392
28157 changed files with 4429172 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
# CHANGELOG
## 0.2.0 - 2019-09-16
* Improvement: Tests for expired items without must-revalidate #20
* Improvement: Support for including Vary headers in cache keys #21
* Improvement: Test for the can_cache option #23
* Bugfix: Not adding timezone to dates #24
* Improvement: Add $defaultTtl to CacheStorage constructor #25
* Bugfix: Error caches responses for without vary headers #26
* Bugfix: stale-if-header not being added to max-age #27
* Bugfix: max-age and freshness confusing zero and null #28
* Bugfix: Use date() method to fix missing GMT #29
* Improvement: Tests for stale-if-error behaviour #30
* Improvement: Delete cache entries on both 404 and 410 responses #33
* Refactoring: Minor ValidationSubscriber.php cleanup #35
* Refactoring: Minor ValidationSubscriber.php cleanup #35
* Improvement: Extend caching ttl considerations #56
* Improvement: Add purge method #57
* Improvement: Integration test for the calculation of the "resident_time" #60
* Improvement: Support for PHP 7.0 & 7.1 #73
## 0.1.0 - 2014-10-29
* Initial release.

View File

@@ -0,0 +1,19 @@
Copyright (c) 2014 Michael Dowling, https://github.com/mtdowling <mtdowling@gmail.com>
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,14 @@
all: clean coverage
test:
vendor/bin/phpunit
coverage:
vendor/bin/phpunit --coverage-html=artifacts/coverage
open artifacts/coverage/index.html
view-coverage:
open artifacts/coverage/index.html
clean:
rm -rf artifacts/*

View File

@@ -0,0 +1,138 @@
=======================
Guzzle Cache Subscriber
=======================
.. important::
**This repo has not been updated for Guzzle 6 and only supports Guzzle 5.**
See https://github.com/Kevinrob/guzzle-cache-middleware for a nice Guzzle 6
compatible Cache middleware.
Provides a private transparent proxy cache for caching HTTP responses.
Here's a simple example of how it's used:
.. code-block:: php
use GuzzleHttp\Client;
use GuzzleHttp\Subscriber\Cache\CacheSubscriber;
$client = new Client(['defaults' => ['debug' => true]]);
// Use the helper method to attach a cache to the client.
CacheSubscriber::attach($client);
// Send the first request
$a = $client->get('http://en.wikipedia.org/wiki/Main_Page');
// Send the second request. This will find a cache hit which must be
// validated. The validation request returns a 304, which yields the original
// cached response.
$b = $client->get('http://en.wikipedia.org/wiki/Main_Page');
Running the above code sample should output verbose cURL information that looks
something like this:
::
> GET /wiki/Main_Page HTTP/1.1
Host: en.wikipedia.org
User-Agent: Guzzle/4.2.1 curl/7.37.0 PHP/5.5.13
Via: 1.1 GuzzleCache/4.2.1
< HTTP/1.1 200 OK
< Server: Apache
< X-Content-Type-Options: nosniff
< Content-language: en
< X-UA-Compatible: IE=Edge
< Vary: Accept-Encoding,Cookie
< Last-Modified: Thu, 21 Aug 2014 01:51:49 GMT
< Content-Type: text/html; charset=UTF-8
< X-Varnish: 2345493325, 1998949714 1994269567
< Via: 1.1 varnish, 1.1 varnish
< Transfer-Encoding: chunked
< Date: Thu, 21 Aug 2014 02:34:12 GMT
< Age: 2541
< Connection: keep-alive
< X-Cache: cp1055 hit (1), cp1068 frontend hit (25353)
< Cache-Control: private, s-maxage=0, max-age=0, must-revalidate
< Set-Cookie: GeoIP=US:Seattle:47.6062:-122.3321:v4; Path=/; Domain=.wikipedia.org
<
* Connection #0 to host en.wikipedia.org left intact
* Re-using existing connection! (#0) with host en.wikipedia.org
> GET /wiki/Main_Page HTTP/1.1
Host: en.wikipedia.org
User-Agent: Guzzle/4.2.1 curl/7.37.0 PHP/5.5.13
Via: 1.1 GuzzleCache/4.2.1, 1.1 GuzzleCache/4.2.1
If-Modified-Since: Thu, 21 Aug 2014 01:51:49 GMT
< HTTP/1.1 304 Not Modified
< Server: Apache
< X-Content-Type-Options: nosniff
< Content-language: en
< X-UA-Compatible: IE=Edge
< Vary: Accept-Encoding,Cookie
< Last-Modified: Thu, 21 Aug 2014 01:51:49 GMT
< Content-Type: text/html; charset=UTF-8
< X-Varnish: 2345493325, 1998950450 1994269567
< Via: 1.1 varnish, 1.1 varnish
< Date: Thu, 21 Aug 2014 02:34:12 GMT
< Age: 2541
< Connection: keep-alive
< X-Cache: cp1055 hit (1), cp1068 frontend hit (25360)
< Cache-Control: private, s-maxage=0, max-age=0, must-revalidate
< Set-Cookie: GeoIP=US:Seattle:47.6062:-122.3321:v4; Path=/; Domain=.wikipedia.org
<
* Connection #0 to host en.wikipedia.org left intact
Installing
----------
Add the following to your composer.json:
.. code-block:: javascript
{
"require": {
"guzzlehttp/cache-subscriber": "0.2.*@dev"
}
}
or
.. code-block:: console
$ composer require guzzlehttp/cache-subscriber
Creating a CacheSubscriber
--------------------------
The easiest way to create a CacheSubscriber is using the ``attach()`` helper
method of ``GuzzleHttp\Subscriber\Cache\CacheSubscriber``. This method accepts
a request or client object and attaches the necessary subscribers used to
perform cache lookups, validation requests, and automatic purging of resources.
The ``attach()`` method accepts the following options:
storage
A ``GuzzleHttp\Subscriber\Cache\CacheStorageInterface`` object used to
store cached responses. If no value is not provided, an in-memory array
cache will be used.
validate
A Boolean value that determines if cached response are ever validated
against the origin server. This setting defaults to ``true`` but can be
disabled by passing ``false``.
purge
A Boolean value that determines if cached responses are purged when
non-idempotent requests are sent to their URI. This setting defaults to
``true`` but can be disabled by passing ``false``.
can_cache
An optional callable used to determine if a request can be cached. The
callable accepts a ``GuzzleHttp\Message\RequestInterface`` and returns a
Boolean value. If no value is provided, the default behavior is utilized.
.. warning::
This is a WIP update for Guzzle 5+. It hasn't been tested and is in
active development. Expect bugs and breaks.

View File

@@ -0,0 +1,30 @@
{
"name": "guzzlehttp/cache-subscriber",
"description": "Guzzle HTTP cache subscriber",
"homepage": "http://guzzlephp.org/",
"keywords": ["cache", "guzzle"],
"license": "MIT",
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"require": {
"php": ">=5.4.0",
"guzzlehttp/guzzle": "~5.0",
"doctrine/cache": "~1.3"
},
"require-dev": {
"phpunit/phpunit": "~4.0"
},
"autoload": {
"psr-4": { "GuzzleHttp\\Subscriber\\Cache\\": "src" }
},
"extra": {
"branch-alias": {
"dev-master": "0.2-dev"
}
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite>
<directory>tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -0,0 +1,403 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use Doctrine\Common\Cache\Cache;
use GuzzleHttp\Message\AbstractMessage;
use GuzzleHttp\Message\MessageInterface;
use GuzzleHttp\Message\Request;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\Response;
use GuzzleHttp\Message\ResponseInterface;
use GuzzleHttp\Stream;
use GuzzleHttp\Stream\StreamInterface;
/**
* Default cache storage implementation.
*/
class CacheStorage implements CacheStorageInterface
{
/** @var string */
private $keyPrefix;
/** @var int Default cache TTL */
private $defaultTtl;
/** @var Cache */
private $cache;
/** @var array Headers are excluded from the caching (see RFC 2616:13.5.1) */
private static $noCache = [
'age' => true,
'connection' => true,
'keep-alive' => true,
'proxy-authenticate' => true,
'proxy-authorization' => true,
'te' => true,
'trailers' => true,
'transfer-encoding' => true,
'upgrade' => true,
'set-cookie' => true,
'set-cookie2' => true,
];
/**
* @param Cache $cache Cache backend.
* @param string $keyPrefix (optional) Key prefix to add to each key.
* @param int $defaultTtl (optional) The default TTL to set, in seconds.
*/
public function __construct(Cache $cache, $keyPrefix = null, $defaultTtl = 0)
{
$this->cache = $cache;
$this->keyPrefix = $keyPrefix;
$this->defaultTtl = $defaultTtl;
}
public function cache(
RequestInterface $request,
ResponseInterface $response
) {
$ctime = time();
$ttl = $this->getTtl($response);
$key = $this->getCacheKey($request, $this->normalizeVary($response));
$headers = $this->persistHeaders($request);
$entries = $this->getManifestEntries($key, $ctime, $response, $headers);
$bodyDigest = null;
// Persist the Vary response header.
if ($response->hasHeader('vary')) {
$this->cacheVary($request, $response);
}
// Persist the response body if needed
if ($response->getBody() && $response->getBody()->getSize() > 0) {
$body = $response->getBody();
$bodyDigest = $this->getBodyKey($request->getUrl(), $body);
$this->cache->save($bodyDigest, (string) $body, $ttl);
}
array_unshift($entries, [
$headers,
$this->persistHeaders($response),
$response->getStatusCode(),
$bodyDigest,
$ctime + $ttl
]);
$this->cache->save($key, serialize($entries));
}
public function delete(RequestInterface $request)
{
$vary = $this->fetchVary($request);
$key = $this->getCacheKey($request, $vary);
$entries = $this->cache->fetch($key);
if (!$entries) {
return;
}
// Delete each cached body
foreach (unserialize($entries) as $entry) {
if ($entry[3]) {
$this->cache->delete($entry[3]);
}
}
// Delete any cached Vary header responses.
$this->deleteVary($request);
$this->cache->delete($key);
}
public function purge($url)
{
foreach (['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PURGE'] as $m) {
$this->delete(new Request($m, $url));
}
}
public function fetch(RequestInterface $request)
{
$vary = $this->fetchVary($request);
if ($vary) {
$key = $this->getCacheKey($request, $vary);
} else {
$key = $this->getCacheKey($request);
}
$entries = $this->cache->fetch($key);
if (!$entries) {
return null;
}
$match = $matchIndex = null;
$headers = $this->persistHeaders($request);
$entries = unserialize($entries);
foreach ($entries as $index => $entry) {
$vary = isset($entry[1]['vary']) ? $entry[1]['vary'] : '';
if ($this->requestsMatch($vary, $headers, $entry[0])) {
$match = $entry;
$matchIndex = $index;
break;
}
}
if (!$match) {
return null;
}
// Ensure that the response is not expired
$response = null;
if ($match[4] < time()) {
$response = -1;
} else {
$response = new Response($match[2], $match[1]);
if ($match[3]) {
if ($body = $this->cache->fetch($match[3])) {
$response->setBody(Stream\Utils::create($body));
} else {
// The response is not valid because the body was somehow
// deleted
$response = -1;
}
}
}
if ($response === -1) {
// Remove the entry from the metadata and update the cache
unset($entries[$matchIndex]);
if ($entries) {
$this->cache->save($key, serialize($entries));
} else {
$this->cache->delete($key);
}
return null;
}
return $response;
}
/**
* Hash a request URL into a string that returns cache metadata.
*
* @param RequestInterface $request The Request to generate the cache key
* for.
* @param array $vary (optional) An array of headers to vary
* the cache key by.
*
* @return string
*/
private function getCacheKey(RequestInterface $request, array $vary = [])
{
$key = $request->getMethod() . ' ' . $request->getUrl();
// If Vary headers have been passed in, fetch each header and add it to
// the cache key.
foreach ($vary as $header) {
$key .= " $header: " . $request->getHeader($header);
}
return $this->keyPrefix . md5($key);
}
/**
* Create a cache key for a response's body.
*
* @param string $url URL of the entry
* @param StreamInterface $body Response body
*
* @return string
*/
private function getBodyKey($url, StreamInterface $body)
{
return $this->keyPrefix . md5($url) . Stream\Utils::hash($body, 'md5');
}
/**
* Determines whether two Request HTTP header sets are non-varying.
*
* @param string $vary Response vary header
* @param array $r1 HTTP header array
* @param array $r2 HTTP header array
*
* @return bool
*/
private function requestsMatch($vary, $r1, $r2)
{
if ($vary) {
foreach (explode(',', $vary) as $header) {
$key = trim(strtolower($header));
$v1 = isset($r1[$key]) ? $r1[$key] : null;
$v2 = isset($r2[$key]) ? $r2[$key] : null;
if ($v1 !== $v2) {
return false;
}
}
}
return true;
}
/**
* Creates an array of cacheable and normalized message headers.
*
* @param MessageInterface $message
*
* @return array
*/
private function persistHeaders(MessageInterface $message)
{
// Clone the response to not destroy any necessary headers when caching
$headers = array_diff_key($message->getHeaders(), self::$noCache);
// Cast the headers to a string
foreach ($headers as &$value) {
$value = implode(', ', $value);
}
return $headers;
}
/**
* Return the TTL to use when caching a Response.
*
* @param ResponseInterface $response The response being cached.
*
* @return int The TTL in seconds.
*/
private function getTtl(ResponseInterface $response)
{
$ttl = 0;
if ($cacheControl = $response->getHeader('Cache-Control')) {
$maxAge = Utils::getDirective($response, 'max-age');
if (is_numeric($maxAge)) {
$ttl += $maxAge;
}
// According to RFC5861 stale headers are *in addition* to any
// max-age values.
$stale = Utils::getDirective($response, 'stale-if-error');
if (is_numeric($stale)) {
$ttl += $stale;
}
} elseif ($expires = $response->getHeader('Expires')) {
$ttl += strtotime($expires) - time();
}
return $ttl ?: $this->defaultTtl;
}
private function getManifestEntries(
$key,
$currentTime,
ResponseInterface $response,
$persistedRequest
) {
$entries = [];
$manifest = $this->cache->fetch($key);
if (!$manifest) {
return $entries;
}
// Determine which cache entries should still be in the cache
$vary = $response->getHeader('Vary');
foreach (unserialize($manifest) as $entry) {
// Check if the entry is expired
if ($entry[4] < $currentTime) {
continue;
}
$varyCmp = isset($entry[1]['vary']) ? $entries[1]['vary'] : '';
if ($vary != $varyCmp ||
!$this->requestsMatch($vary, $entry[0], $persistedRequest)
) {
$entries[] = $entry;
}
}
return $entries;
}
/**
* Return a sorted list of Vary headers.
*
* While headers are case-insensitive, header values are not. We can only
* normalize the order of headers to combine cache entries.
*
* @param ResponseInterface $response The Response with Vary headers.
*
* @return array An array of sorted headers.
*/
private function normalizeVary(ResponseInterface $response)
{
$parts = AbstractMessage::normalizeHeader($response, 'vary');
sort($parts);
return $parts;
}
/**
* Cache the Vary headers from a response.
*
* @param RequestInterface $request The Request that generated the Vary
* headers.
* @param ResponseInterface $response The Response with Vary headers.
*/
private function cacheVary(
RequestInterface $request,
ResponseInterface $response
) {
$key = $this->getVaryKey($request);
$this->cache->save($key, $this->normalizeVary($response), $this->getTtl($response));
}
/**
* Fetch the Vary headers associated with a request, if they exist.
*
* Only responses, and not requests, contain Vary headers. However, we need
* to be able to determine what Vary headers were set for a given URL and
* request method on a future request.
*
* @param RequestInterface $request The Request to fetch headers for.
*
* @return array An array of headers.
*/
private function fetchVary(RequestInterface $request)
{
$key = $this->getVaryKey($request);
$varyHeaders = $this->cache->fetch($key);
return is_array($varyHeaders) ? $varyHeaders : [];
}
/**
* Delete the headers associated with a Vary request.
*
* @param RequestInterface $request The Request to delete headers for.
*/
private function deleteVary(RequestInterface $request)
{
$key = $this->getVaryKey($request);
$this->cache->delete($key);
}
/**
* Get the cache key for Vary headers.
*
* @param RequestInterface $request The Request to fetch the key for.
*
* @return string The generated key.
*/
private function getVaryKey(RequestInterface $request)
{
$key = $this->keyPrefix . md5('vary ' . $this->getCacheKey($request));
return $key;
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\ResponseInterface;
/**
* Interface used to cache HTTP responses.
*/
interface CacheStorageInterface
{
/**
* Get a Response from the cache for a request.
*
* @param RequestInterface $request
*
* @return null|ResponseInterface
*/
public function fetch(RequestInterface $request);
/**
* Cache an HTTP request.
*
* @param RequestInterface $request Request being cached
* @param ResponseInterface $response Response to cache
*/
public function cache(
RequestInterface $request,
ResponseInterface $response
);
/**
* Deletes cache entries that match a request.
*
* @param RequestInterface $request Request to delete from cache
*/
public function delete(RequestInterface $request);
/**
* Purge all cache entries for a given URL.
*
* @param string $url
*/
public function purge($url);
}

View File

@@ -0,0 +1,275 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use Doctrine\Common\Cache\ArrayCache;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Event\BeforeEvent;
use GuzzleHttp\Event\CompleteEvent;
use GuzzleHttp\Event\ErrorEvent;
use GuzzleHttp\Event\HasEmitterInterface;
use GuzzleHttp\Event\RequestEvents;
use GuzzleHttp\Event\SubscriberInterface;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\ResponseInterface;
/**
* Plugin to enable the caching of GET and HEAD requests.
*
* Caching can be done on all requests passing through this plugin or only
* after retrieving resources with cacheable response headers.
*
* This is a simple implementation of RFC 2616 and should be considered a
* private transparent proxy cache, meaning authorization and private data can
* be cached.
*
* It also implements RFC 5861's `stale-if-error` Cache-Control extension,
* allowing stale cache responses to be used when an error is encountered
* (such as a `500 Internal Server Error` or DNS failure).
*/
class CacheSubscriber implements SubscriberInterface
{
/** @var CacheStorageInterface $cache Object used to cache responses */
private $storage;
/** @var callable Determines if a request is cacheable */
private $canCache;
/**
* @param CacheStorageInterface $cache Cache storage
* @param callable $canCache Callable used to determine if a
* request can be cached. Accepts a
* RequestInterface and returns a
* boolean value.
*/
public function __construct(
CacheStorageInterface $cache,
callable $canCache
) {
$this->storage = $cache;
$this->canCache = $canCache;
}
/**
* Helper method used to easily attach a cache to a request or client.
*
* This method accepts an array of options that are used to control the
* caching behavior:
*
* - storage: An optional GuzzleHttp\Subscriber\Cache\CacheStorageInterface.
* If no value is not provided, an in-memory array cache will be used.
* - validate: Boolean value that determines if cached response are ever
* validated against the origin server. Defaults to true but can be
* disabled by passing false.
* - purge: Boolean value that determines if cached responses are purged
* when non-idempotent requests are sent to their URI. Defaults to true
* but can be disabled by passing false.
* - can_cache: An optional callable used to determine if a request can be
* cached. The callable accepts a RequestInterface and returns a boolean
* value. If no value is provided, the default behavior is utilized.
*
* @param HasEmitterInterface $subject Client or request to attach to,
* @param array $options Options used to control the cache.
*
* @return array Returns an associative array containing a 'subscriber' key
* that holds the created CacheSubscriber, and a 'storage'
* key that contains the cache storage used by the subscriber.
*/
public static function attach(
HasEmitterInterface $subject,
array $options = []
) {
if (!isset($options['storage'])) {
$options['storage'] = new CacheStorage(new ArrayCache());
}
if (!isset($options['can_cache'])) {
$options['can_cache'] = [
'GuzzleHttp\Subscriber\Cache\Utils',
'canCacheRequest',
];
}
$emitter = $subject->getEmitter();
$cache = new self($options['storage'], $options['can_cache']);
$emitter->attach($cache);
if (!isset($options['validate']) || $options['validate'] === true) {
$emitter->attach(new ValidationSubscriber(
$options['storage'],
$options['can_cache'])
);
}
if (!isset($options['purge']) || $options['purge'] === true) {
$emitter->attach(new PurgeSubscriber($options['storage']));
}
return ['subscriber' => $cache, 'storage' => $options['storage']];
}
public function getEvents()
{
return [
'before' => ['onBefore', RequestEvents::LATE],
'complete' => ['onComplete', RequestEvents::EARLY],
'error' => ['onError', RequestEvents::EARLY]
];
}
/**
* Checks if a request can be cached, and if so, intercepts with a cached
* response is available.
*
* @param BeforeEvent $event
*/
public function onBefore(BeforeEvent $event)
{
$request = $event->getRequest();
if (!$this->canCacheRequest($request)) {
$this->cacheMiss($request);
return;
}
if (!($response = $this->storage->fetch($request))) {
$this->cacheMiss($request);
return;
}
$response->setHeader('Age', Utils::getResponseAge($response));
$valid = $this->validate($request, $response);
// Validate that the response satisfies the request
if ($valid) {
$request->getConfig()->set('cache_lookup', 'HIT');
$request->getConfig()->set('cache_hit', true);
$event->intercept($response);
} else {
$this->cacheMiss($request);
}
}
/**
* Checks if the request and response can be cached, and if so, store it.
*
* @param CompleteEvent $event
*/
public function onComplete(CompleteEvent $event)
{
$request = $event->getRequest();
$response = $event->getResponse();
// Cache the response if it can be cached and isn't already
if ($request->getConfig()->get('cache_lookup') === 'MISS'
&& call_user_func($this->canCache, $request)
&& Utils::canCacheResponse($response)
) {
// Store the date when the response was cached
$response->setHeader('X-Guzzle-Cache-Date', gmdate('D, d M Y H:i:s T', time()));
$this->storage->cache($request, $response);
}
$this->addResponseHeaders($request, $response);
}
/**
* If the request failed, then check if a cached response would suffice.
*
* @param ErrorEvent $event
*/
public function onError(ErrorEvent $event)
{
$request = $event->getRequest();
if (!call_user_func($this->canCache, $request)) {
return;
}
$response = $this->storage->fetch($request);
// Intercept the failed response if possible
if ($response && $this->validateFailed($request, $response)) {
$request->getConfig()->set('cache_hit', 'error');
$response->setHeader('Age', Utils::getResponseAge($response));
$event->intercept($response);
}
}
private function cacheMiss(RequestInterface $request)
{
$request->getConfig()->set('cache_lookup', 'MISS');
}
private function validate(
RequestInterface $request,
ResponseInterface $response
) {
// Validation is handled in another subscriber and can be optionally
// enabled/disabled.
if (Utils::getDirective($response, 'must-revalidate')) {
return true;
}
return Utils::isResponseValid($request, $response);
}
private function validateFailed(
RequestInterface $request,
ResponseInterface $response
) {
$req = Utils::getDirective($request, 'stale-if-error');
$res = Utils::getDirective($response, 'stale-if-error');
if (!$req && !$res) {
return false;
}
$responseAge = Utils::getResponseAge($response);
$maxAge = Utils::getMaxAge($response);
if (($req && $responseAge - $maxAge > $req) ||
($responseAge - $maxAge > $res)
) {
return false;
}
return true;
}
private function canCacheRequest(RequestInterface $request)
{
return !$request->getConfig()->get('cache.disable')
&& call_user_func($this->canCache, $request);
}
private function addResponseHeaders(
RequestInterface $request,
ResponseInterface $response
) {
$params = $request->getConfig();
$lookup = $params['cache_lookup'] . ' from GuzzleCache';
$response->addHeader('X-Cache-Lookup', $lookup);
if ($params['cache_hit'] === true) {
$response->addHeader('X-Cache', 'HIT from GuzzleCache');
} elseif ($params['cache_hit'] == 'error') {
$response->addHeader('X-Cache', 'HIT_ERROR from GuzzleCache');
} else {
$response->addHeader('X-Cache', 'MISS from GuzzleCache');
}
$freshness = Utils::getFreshness($response);
// Only add a Warning header if we are returning a stale response.
if ($params['cache_hit'] && $freshness !== null && $freshness <= 0) {
$response->addHeader(
'Warning',
sprintf(
'%d GuzzleCache/' . ClientInterface::VERSION . ' "%s"',
110,
'Response is stale'
)
);
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use GuzzleHttp\Event\BeforeEvent;
use GuzzleHttp\Event\RequestEvents;
use GuzzleHttp\Event\SubscriberInterface;
use GuzzleHttp\Message\Response;
/**
* Automatically purges a URL when a non-idempotent request is made to it.
*/
class PurgeSubscriber implements SubscriberInterface
{
/** @var CacheStorageInterface */
private $storage;
/** @var array */
private static $purgeMethods = [
'PUT' => true,
'POST' => true,
'DELETE' => true,
'PATCH' => true,
'PURGE' => true,
];
/**
* @param CacheStorageInterface $storage Storage to modify if purging
*/
public function __construct($storage)
{
$this->storage = $storage;
}
public function getEvents()
{
return ['before' => ['onBefore', RequestEvents::LATE]];
}
public function onBefore(BeforeEvent $event)
{
$request = $event->getRequest();
if (isset(self::$purgeMethods[$request->getMethod()])) {
$this->storage->purge($request->getUrl());
if ('PURGE' === $request->getMethod()) {
$event->intercept(new Response(204));
}
}
}
}

View File

@@ -0,0 +1,202 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use GuzzleHttp\Message\AbstractMessage;
use GuzzleHttp\Message\MessageInterface;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\ResponseInterface;
/**
* Cache utility functions.
*/
class Utils
{
/**
* Get a cache control directive from a message.
*
* @param MessageInterface $message Message to retrieve
* @param string $part Cache directive to retrieve
*
* @return mixed|bool|null
*/
public static function getDirective(MessageInterface $message, $part)
{
$parts = AbstractMessage::parseHeader($message, 'Cache-Control');
foreach ($parts as $line) {
if (isset($line[$part])) {
return $line[$part];
} elseif (in_array($part, $line)) {
return true;
}
}
return null;
}
/**
* Gets the age of a response in seconds.
*
* @param ResponseInterface $response
*
* @return int
*/
public static function getResponseAge(ResponseInterface $response)
{
if ($response->hasHeader('Age')) {
return (int) $response->getHeader('Age');
}
$date = strtotime($response->getHeader('Date') ?: 'now');
return time() - $date;
}
/**
* Gets the number of seconds from the current time in which a response
* is still considered fresh.
*
* @param ResponseInterface $response
*
* @return int|null Returns the number of seconds
*/
public static function getMaxAge(ResponseInterface $response)
{
$smaxage = Utils::getDirective($response, 's-maxage');
if (is_numeric($smaxage)) {
return (int) $smaxage;
}
$maxage = Utils::getDirective($response, 'max-age');
if (is_numeric($maxage)) {
return (int) $maxage;
}
if ($response->hasHeader('Expires')) {
return strtotime($response->getHeader('Expires')) - time();
}
return null;
}
/**
* Get the freshness of a response by returning the difference of the
* maximum lifetime of the response and the age of the response.
*
* Freshness values less than 0 mean that the response is no longer fresh
* and is ABS(freshness) seconds expired. Freshness values of greater than
* zero is the number of seconds until the response is no longer fresh.
* A NULL result means that no freshness information is available.
*
* @param ResponseInterface $response Response to get freshness of
*
* @return int|null
*/
public static function getFreshness(ResponseInterface $response)
{
$maxAge = self::getMaxAge($response);
$age = self::getResponseAge($response);
return is_int($maxAge) && is_int($age) ? ($maxAge - $age) : null;
}
/**
* Default function used to determine if a request can be cached.
*
* @param RequestInterface $request Request to check
*
* @return bool
*/
public static function canCacheRequest(RequestInterface $request)
{
$method = $request->getMethod();
// Only GET and HEAD requests can be cached
if ($method !== 'GET' && $method !== 'HEAD') {
return false;
}
// Don't fool with Range requests for now
if ($request->hasHeader('Range')) {
return false;
}
return self::getDirective($request, 'no-store') === null;
}
/**
* Determines if a response can be cached.
*
* @param ResponseInterface $response Response to check
*
* @return bool
*/
public static function canCacheResponse(ResponseInterface $response)
{
static $cacheCodes = [200, 203, 300, 301, 410];
// Check if the response is cacheable based on the code
if (!in_array((int) $response->getStatusCode(), $cacheCodes)) {
return false;
}
// Make sure a valid body was returned and can be cached
$body = $response->getBody();
if ($body && (!$body->isReadable() || !$body->isSeekable())) {
return false;
}
// Never cache no-store resources (this is a private cache, so private
// can be cached)
if (self::getDirective($response, 'no-store')) {
return false;
}
// Don't fool with Content-Range requests for now
if ($response->hasHeader('Content-Range')) {
return false;
}
$freshness = self::getFreshness($response);
return $freshness === null // No freshness info.
|| $freshness >= 0 // It's fresh
|| $response->hasHeader('ETag') // Can validate
|| $response->hasHeader('Last-Modified'); // Can validate
}
public static function isResponseValid(
RequestInterface $request,
ResponseInterface $response
) {
$responseAge = Utils::getResponseAge($response);
$maxAge = Utils::getDirective($response, 'max-age');
// Increment the age based on the X-Guzzle-Cache-Date
if ($cacheDate = $response->getHeader('X-Guzzle-Cache-Date')) {
$responseAge += (time() - strtotime($cacheDate));
$response->setHeader('Age', $responseAge);
}
// Check the request's max-age header against the age of the response
if ($maxAge !== null && $responseAge > $maxAge) {
return false;
}
// Check the response's max-age header against the freshness level
$freshness = Utils::getFreshness($response);
if ($freshness !== null) {
$maxStale = Utils::getDirective($request, 'max-stale');
if ($maxStale !== null) {
if ($freshness < (-1 * $maxStale)) {
return false;
}
} elseif ($maxAge !== null && $responseAge > $maxAge) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,207 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use GuzzleHttp\Event\CompleteEvent;
use GuzzleHttp\Event\RequestEvents;
use GuzzleHttp\Event\SubscriberInterface;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\ResponseInterface;
/**
* Validates cached responses as needed.
*
* @Link http://tools.ietf.org/html/rfc7234#section-4.3
*/
class ValidationSubscriber implements SubscriberInterface
{
/** @var CacheStorageInterface Cache object storing cache data */
private $storage;
/** @var callable */
private $canCache;
/** @var array */
private static $gone = [404 => true, 410 => true];
/** @var array */
private static $replaceHeaders = [
'Date',
'Expires',
'Cache-Control',
'ETag',
'Last-Modified',
];
/**
* @param CacheStorageInterface $cache Cache storage
* @param callable $canCache Callable used to determine if a
* request can be cached. Accepts a
* RequestInterface and returns a
* boolean value.
*/
public function __construct(
CacheStorageInterface $cache,
callable $canCache
) {
$this->storage = $cache;
$this->canCache = $canCache;
}
public function getEvents()
{
return ['complete' => ['onComplete', RequestEvents::EARLY]];
}
public function onComplete(CompleteEvent $e)
{
$lookup = $e->getRequest()->getConfig()->get('cache_lookup');
if ($lookup == 'HIT' &&
$this->shouldvalidate($e->getRequest(), $e->getResponse())
) {
$this->validate($e->getRequest(), $e->getResponse(), $e);
}
}
private function validate(
RequestInterface $request,
ResponseInterface $response,
CompleteEvent $event
) {
try {
$validate = $this->createRevalidationRequest($request, $response);
$validated = $event->getClient()->send($validate);
} catch (BadResponseException $e) {
$this->handleBadResponse($e);
}
if ($validated->getStatusCode() == 200) {
$this->handle200Response($request, $validated, $event);
} elseif ($validated->getStatusCode() == 304) {
$this->handle304Response($request, $response, $validated, $event);
}
}
private function shouldValidate(
RequestInterface $request,
ResponseInterface $response
) {
if ($request->getMethod() != 'GET'
|| $request->getConfig()->get('cache.disable')
) {
return false;
}
$validate = Utils::getDirective($request, 'Pragma') === 'no-cache'
|| Utils::getDirective($response, 'Pragma') === 'no-cache'
|| Utils::getDirective($request, 'must-revalidate')
|| Utils::getDirective($response, 'must-revalidate')
|| Utils::getDirective($request, 'no-cache')
|| Utils::getDirective($response, 'no-cache')
|| Utils::getDirective($response, 'max-age') === '0'
|| Utils::getDirective($response, 's-maxage') === '0';
// Use the strong ETag validator if available and the response contains
// no Cache-Control directive
if (!$validate
&& !$response->hasHeader('Cache-Control')
&& $response->hasHeader('ETag')
) {
$validate = true;
}
return $validate;
}
/**
* Handles a bad response when attempting to validate.
*
* If the resource no longer exists, then remove from the cache.
*
* @param BadResponseException $e Exception encountered
*
* @throws BadResponseException
*/
private function handleBadResponse(BadResponseException $e)
{
if (isset(self::$gone[$e->getResponse()->getStatusCode()])) {
$this->storage->delete($e->getRequest());
}
throw $e;
}
/**
* Creates a request to use for revalidation.
*
* @param RequestInterface $request Request
* @param ResponseInterface $response Response to validate
*
* @return RequestInterface returns a revalidation request
*/
private function createRevalidationRequest(
RequestInterface $request,
ResponseInterface $response
) {
$validate = clone $request;
$validate->getConfig()->set('cache.disable', true);
$validate->removeHeader('Pragma');
$validate->removeHeader('Cache-Control');
$responseDate = $response->getHeader('Last-Modified')
?: $response->getHeader('Date');
$validate->setHeader('If-Modified-Since', $responseDate);
if ($etag = $response->getHeader('ETag')) {
$validate->setHeader('If-None-Match', $etag);
}
return $validate;
}
private function handle200Response(
RequestInterface $request,
ResponseInterface $validateResponse,
CompleteEvent $event
) {
// Store the 200 response in the cache if possible
if (Utils::canCacheResponse($validateResponse)) {
$this->storage->cache($request, $validateResponse);
}
$event->intercept($validateResponse);
}
private function handle304Response(
RequestInterface $request,
ResponseInterface $response,
ResponseInterface $validated,
CompleteEvent $event
) {
// Make sure that this response has the same ETag
if ($validated->getHeader('ETag') !== $response->getHeader('ETag')) {
// Revalidation failed, so remove from cache and retry.
$this->storage->delete($request);
$event->intercept($event->getClient()->send($request));
return;
}
// Replace cached headers with any of these headers from the
// origin server that might be more up to date
$modified = false;
foreach (self::$replaceHeaders as $name) {
if ($validated->hasHeader($name)
&& $validated->getHeader($name) != $response->getHeader($name)
) {
$modified = true;
$response->setHeader($name, $validated->getHeader($name));
}
}
// Store the updated response in cache
if ($modified) {
$this->storage->cache($request, $response);
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace GuzzleHttp\Tests\Subscriber\Cache;
use Doctrine\Common\Cache\ArrayCache;
use GuzzleHttp\Message\Response;
use GuzzleHttp\Subscriber\Cache\CacheStorage;
/**
* Test the CacheStorage class.
*
* @class CacheStorageTest
*/
class CacheStorageTest extends \PHPUnit_Framework_TestCase
{
/**
* Test that a Response's max-age returns the correct TTL.
*/
public function testGetTtlMaxAge()
{
$response = new Response(200, [
'Cache-control' => 'max-age=10',
]);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache());
$ttl = $getTtl->invokeArgs($cache, [$response]);
$this->assertEquals(10, $ttl);
}
/**
* Test that the default TTL for cachable responses with no max-age headers
* is zero.
*/
public function testGetTtlDefault()
{
$response = new Response(200);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache());
$ttl = $getTtl->invokeArgs($cache, [$response]);
// assertSame() here to be specific about null / false returns.
$this->assertSame(0, $ttl);
}
/**
* Test setting the default TTL.
*/
public function testSetTtlDefault()
{
$response = new Response(200);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache(), null, 10);
$ttl = $getTtl->invokeArgs($cache, [$response]);
$this->assertEquals(10, $ttl);
}
/**
* Test that stale-if-error is added to the max-age header.
*/
public function testGetTtlMaxAgeStaleIfError()
{
$response = new Response(200, [
'Cache-control' => 'max-age=10, stale-if-error=10',
]);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache());
$ttl = $getTtl->invokeArgs($cache, [$response]);
$this->assertEquals(20, $ttl);
}
/**
* Test that stale-if-error works without a max-age header.
*/
public function testGetTtlStaleIfErrorAlone()
{
$response = new Response(200, [
'Cache-control' => 'stale-if-error=10',
]);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache());
$ttl = $getTtl->invokeArgs($cache, [$response]);
$this->assertEquals(10, $ttl);
}
/**
* Test that expires is considered when cache-control is not available.
*/
public function testGetTtlExpires()
{
$expires = new \DateTime('+100 seconds');
$response = new Response(200, [
'Expires' => $expires->format(DATE_RFC1123),
]);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache());
$ttl = $getTtl->invokeArgs($cache, [$response]);
$this->assertEquals(100, $ttl);
}
/**
* Test that cache-control is considered before expires.
*/
public function testGetTtlCacheControlExpires()
{
$expires = new \DateTime('+100 seconds');
$response = new Response(200, [
'Expires' => $expires->format(DATE_RFC1123),
'Cache-control' => 'max-age=10',
]);
$getTtl = $this->getMethod('getTtl');
$cache = new CacheStorage(new ArrayCache());
$ttl = $getTtl->invokeArgs($cache, [$response]);
$this->assertEquals(10, $ttl);
}
/**
* Return a protected or private method.
*
* @param string $name The name of the method.
*
* @return \ReflectionMethod A method object.
*/
protected static function getMethod($name)
{
$class = new \ReflectionClass('GuzzleHttp\Subscriber\Cache\CacheStorage');
$method = $class->getMethod($name);
$method->setAccessible(true);
return $method;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace GuzzleHttp\Tests\Subscriber\Cache;
use GuzzleHttp\Client;
use GuzzleHttp\Subscriber\Cache\CacheSubscriber;
class CacheSubscriberTest extends \PHPUnit_Framework_TestCase
{
public function testCreatesAndAttachedDefaultSubscriber()
{
$client = new Client();
$cache = CacheSubscriber::attach($client);
$this->assertArrayHasKey('subscriber', $cache);
$this->assertArrayHasKey('storage', $cache);
$this->assertInstanceOf(
'GuzzleHttp\Subscriber\Cache\CacheStorage',
$cache['storage']
);
$this->assertInstanceOf(
'GuzzleHttp\Subscriber\Cache\CacheSubscriber',
$cache['subscriber']
);
$this->assertTrue($client->getEmitter()->hasListeners('error'));
}
}

View File

@@ -0,0 +1,768 @@
<?php
namespace GuzzleHttp\Tests\Subscriber\Cache;
require_once __DIR__ . '/../vendor/guzzlehttp/ringphp/tests/Client/Server.php';
require_once __DIR__ . '/../vendor/guzzlehttp/guzzle/tests/Server.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\Response;
use GuzzleHttp\Stream\Stream;
use GuzzleHttp\Subscriber\Cache\CacheSubscriber;
use GuzzleHttp\Subscriber\History;
use GuzzleHttp\Tests\Server;
class IntegrationTest extends \PHPUnit_Framework_TestCase
{
protected function setUp()
{
Server::start();
}
protected function tearDown()
{
Server::stop();
}
public function testCachesResponses()
{
Server::enqueue([
new Response(200, [
'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM',
'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT',
'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
'Age' => '1277'
]),
new Response(304, [
'Content-Type' => 'text/html; charset=UTF-8',
'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM',
'Date' => 'Wed, 29 Oct 2014 20:52:16 GMT',
'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
'Age' => '1278'
]),
new Response(200, [
'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM',
'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT',
'Cache-Control' => 'private, s-maxage=0, max-age=0',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
'Age' => '1277'
]),
new Response(200, [
'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM',
'Date' => 'Wed, 29 Oct 2014 20:53:15 GMT',
'Cache-Control' => 'private, s-maxage=0, max-age=0',
'Last-Modified' => 'Wed, 29 Oct 2014 20:53:00 GMT',
'Age' => '1277'
]),
]);
$history = new History();
$client = $this->setupClient($history);
$response1 = $client->get('/foo');
$this->assertEquals(200, $response1->getStatusCode());
$response2 = $client->get('/foo');
$this->assertEquals(200, $response2->getStatusCode());
$last = $history->getLastResponse();
$this->assertEquals('HIT from GuzzleCache', $last->getHeader('X-Cache-Lookup'));
$this->assertEquals('HIT from GuzzleCache', $last->getHeader('X-Cache'));
// Validate that expired requests without must-revalidate expire.
$response3 = $client->get('/foo');
$this->assertEquals(200, $response3->getStatusCode());
$response4 = $client->get('/foo');
$this->assertEquals(200, $response4->getStatusCode());
$last = $history->getLastResponse();
$this->assertEquals('MISS from GuzzleCache', $last->getHeader('X-Cache-Lookup'));
$this->assertEquals('MISS from GuzzleCache', $last->getHeader('X-Cache'));
// Validate that all of our requests were received.
$this->assertCount(4, Server::received());
}
/**
* Test that Warning headers aren't added to cache misses.
*/
public function testCacheMissNoWarning()
{
Server::enqueue([
new Response(200, [
'Vary' => 'Accept-Encoding,Cookie,X-Use-HHVM',
'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT',
'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
'Age' => '1277',
]),
]);
$client = $this->setupClient();
$response = $client->get('/foo');
$this->assertFalse($response->hasHeader('warning'));
}
/**
* Test that the Vary header creates unique cache entries.
*
* @throws \Exception
*/
public function testVaryUniqueResponses()
{
$now = $this->date();
Server::enqueue(
[
new Response(
200, [
'Vary' => 'Accept',
'Content-type' => 'text/html',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000',
'Last-Modified' => $now,
], Stream::factory('It works!')
),
new Response(
200, [
'Vary' => 'Accept',
'Content-type' => 'application/json',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000',
'Last-Modified' => $now,
], Stream::factory(json_encode(['body' => 'It works!']))
),
]
);
$client = $this->setupClient();
$response1 = $client->get(
'/foo',
['headers' => ['Accept' => 'text/html']]
);
$this->assertEquals('It works!', $this->getResponseBody($response1));
$response2 = $client->get(
'/foo',
['headers' => ['Accept' => 'application/json']]
);
$this->assertEquals(
'MISS from GuzzleCache',
$response2->getHeader('x-cache')
);
$decoded = json_decode($this->getResponseBody($response2));
if (!isset($decoded) || !isset($decoded->body)) {
$this->fail('JSON response could not be decoded.');
} else {
$this->assertEquals('It works!', $decoded->body);
}
}
public function testCachesResponsesForWithoutVaryHeader()
{
$now = $this->date();
Server::enqueue(
[
new Response(
200, [
'Content-type' => 'text/html',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000, must-revalidate',
'Last-Modified' => $now,
], Stream::factory()
),
new Response(
200, [
'Content-type' => 'text/html',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000, must-revalidate',
'Last-Modified' => $now,
], Stream::factory()
),
]
);
$client = $this->setupClient();
$response1 = $client->get('/foo');
$this->assertEquals(200, $response1->getStatusCode());
$response2 = $client->get('/foo');
$this->assertEquals(200, $response2->getStatusCode());
$this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache'));
}
/**
* Test that requests varying on both Accept and User-Agent properly split
* different User-Agents into different cache items.
*/
public function testVaryUserAgent()
{
$this->setupMultipleVaryResponses();
$client = $this->setupClient();
$response1 = $client->get(
'/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/1.0',
]
]
);
$this->assertEquals(
'Test/1.0 request.',
$this->getResponseBody($response1)
);
$response2 = $client->get(
'/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/2.0',
]
]
);
$this->assertEquals(
'MISS from GuzzleCache',
$response2->getHeader('x-cache')
);
$this->assertEquals(
'Test/2.0 request.',
$this->getResponseBody($response2)
);
// Test that we get cache hits where both Vary headers match.
$response5 = $client->get(
'/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/2.0',
]
]
);
$this->assertEquals(
'HIT from GuzzleCache',
$response5->getHeader('x-cache')
);
$this->assertEquals(
'Test/2.0 request.',
$this->getResponseBody($response5)
);
}
/**
* Test that requests varying on Accept but not User-Agent return different responses.
*/
public function testVaryAccept()
{
$this->setupMultipleVaryResponses();
$client = $this->setupClient();
// Prime the cache.
$client->get(
'/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/1.0',
]
]
);
$client->get(
'/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/2.0',
]
]
);
$response1 = $client->get(
'/foo',
[
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'Testing/1.0',
]
]
);
$this->assertEquals(
'MISS from GuzzleCache',
$response1->getHeader('x-cache')
);
$this->assertEquals(
'Test/1.0 request.',
json_decode($this->getResponseBody($response1))->body
);
$response2 = $client->get(
'/foo',
[
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'Testing/2.0',
]
]
);
$this->assertEquals(
'MISS from GuzzleCache',
$response2->getHeader('x-cache')
);
$this->assertEquals(
'Test/2.0 request.',
json_decode($this->getResponseBody($response2))->body
);
}
/**
* Test that we return cached responses when multiple Vary headers match.
*/
public function testMultipleVaryMatch()
{
$this->setupMultipleVaryResponses();
$client = $this->setupClient();
// Prime the cache.
$client->get('/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/1.0',
]
]
);
$client->get('/foo',
[
'headers' => [
'Accept' => 'text/html',
'User-Agent' => 'Testing/2.0',
]
]
);
$client->get('/foo',
[
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'Testing/1.0',
]
]
);
$client->get('/foo',
[
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'Testing/2.0',
]
]
);
$response = $client->get(
'/foo',
[
'headers' => [
'Accept' => 'application/json',
'User-Agent' => 'Testing/2.0',
]
]
);
$this->assertEquals(
'HIT from GuzzleCache',
$response->getHeader('x-cache')
);
$this->assertEquals(
'Test/2.0 request.',
json_decode($this->getResponseBody($response))->body
);
}
/**
* Test that stale responses are used on errors if allowed.
*/
public function testOnErrorStaleResponse()
{
$now = $this->date();
Server::enqueue([
new Response(200, [
'Date' => $now,
'Cache-Control' => 'private, max-age=0, must-revalidate, stale-if-error=666',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
], Stream::factory('It works!')),
new Response(503, [
'Date' => $now,
'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
'Age' => '1277'
]),
]);
$client = $this->setupClient();
// Prime the cache.
$response1 = $client->get('/foo');
$this->assertEquals(200, $response1->getStatusCode());
// This should return the first request.
$response2 = $client->get('/foo');
$this->assertEquals(200, $response2->getStatusCode());
$this->assertEquals('It works!', $this->getResponseBody($response2));
$this->assertEquals('HIT_ERROR from GuzzleCache', $response2->getHeader('x-cache'));
$this->assertCount(2, Server::received());
}
/**
* Test that expired stale responses aren't returned.
*/
public function testOnErrorStaleResponseExpired()
{
// These dates are in the past, so the responses will be expired.
Server::enqueue([
new Response(200, [
'Date' => 'Wed, 29 Oct 2014 20:52:15 GMT',
'Cache-Control' => 'private, max-age=0, must-revalidate, stale-if-error=10',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
]),
new Response(503, [
'Date' => 'Wed, 29 Oct 2014 20:55:15 GMT',
'Cache-Control' => 'private, s-maxage=0, max-age=0, must-revalidate',
'Last-Modified' => 'Wed, 29 Oct 2014 20:30:57 GMT',
]),
]);
$client = $this->setupClient();
// Prime the cache.
$response1 = $client->get('/foo');
$this->assertEquals(200, $response1->getStatusCode());
$this->assertEquals('Wed, 29 Oct 2014 20:52:15 GMT', $response1->getHeader('Date'));
try {
$client->get('/foo');
$this->fail('503 was not thrown with an expired cache entry.');
} catch (ServerException $e) {
$this->assertEquals(503, $e->getCode());
$this->assertEquals('Wed, 29 Oct 2014 20:55:15 GMT', $e->getResponse()->getHeader('Date'));
$this->assertCount(2, Server::received());
}
}
/**
* Test that the can_cache option can modify cache behaviour.
*/
public function testCanCache()
{
$now = $this->date();
// Return an uncacheable response, that is then cached by can_cache
// returning TRUE.
Server::enqueue(
[
new Response(
200, [
'Date' => $now,
'Cache-Control' => 'private, max-age=0, no-cache',
'Last-Modified' => $now,
], Stream::factory('It works!')),
new Response(
304, [
'Date' => $now,
'Cache-Control' => 'private, max-age=0, no-cache',
'Last-Modified' => $now,
'Age' => 0,
]),
]
);
$client = new Client(['base_url' => Server::$url]);
CacheSubscriber::attach(
$client,
[
'can_cache' => function (RequestInterface $request) {
return true;
}
]
);
$response1 = $client->get('/foo');
$this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup'));
$response2 = $client->get('/foo');
$this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup'));
$this->assertEquals('It works!', $this->getResponseBody($response2));
}
/**
* Test that PURGE can delete cached responses.
*/
public function testCanPurge()
{
$now = $this->date();
// Return a cached response that is then purged, and requested again
Server::enqueue(
[
new Response(
200, [
'Date' => $now,
'Cache-Control' => 'public, max-age=60',
'Last-Modified' => $now,
], Stream::factory('It is foo!')),
new Response(
200, [
'Date' => $now,
'Cache-Control' => 'public, max-age=60',
'Last-Modified' => $now,
], Stream::factory('It is bar!')),
]
);
$client = $this->setupClient();
$response1 = $client->get('/foo');
$this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup'));
$this->assertEquals('It is foo!', $this->getResponseBody($response1));
$response2 = $client->get('/foo');
$this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup'));
$this->assertEquals('It is foo!', $this->getResponseBody($response2));
$response3 = $client->send($client->createRequest('PURGE', '/foo'));
$this->assertEquals(204, $response3->getStatusCode());
$response4 = $client->get('/foo');
$this->assertEquals('MISS from GuzzleCache', $response4->getHeader('X-Cache-Lookup'));
$this->assertEquals('It is bar!', $this->getResponseBody($response4));
}
/**
* Test that cache entries are deleted when a response 404s.
*/
public function test404CacheDelete()
{
$this->fourXXCacheDelete(404);
}
/**
* Test that cache entries are deleted when a response 410s.
*/
public function test410CacheDelete()
{
$this->fourXXCacheDelete(410);
}
/**
* Test the resident_time calculation (RFC7234 4.2.3)
*/
public function testAgeIsIncremented()
{
Server::enqueue([
new Response(200, [
'Date' => $this->date(),
'Cache-Control' => 'public, max-age=60',
'Age' => '59'
], Stream::factory('Age is 59!')),
new Response(200, [
'Date' => $this->date(),
'Cache-Control' => 'public, max-age=60',
'Age' => '0'
], Stream::factory('It works!')),
]);
$client = $this->setupClient();
// First request : the response is cached
$response1 = $client->get('/foo');
$this->assertEquals(200, $response1->getStatusCode());
$this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup'));
$this->assertEquals('Age is 59!', $this->getResponseBody($response1));
// Second request : cache hit, age is now 60
sleep(1);
$response2 = $client->get('/foo');
$this->assertEquals(200, $response1->getStatusCode());
$this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup'));
// This request should not be valid anymore : age is 59 + 2 = 61 which is strictly greater than 60
sleep(1);
$response3 = $client->get('/foo');
$this->assertEquals(200, $response3->getStatusCode());
$this->assertEquals('MISS from GuzzleCache', $response3->getHeader('X-Cache-Lookup'));
$this->assertEquals('It works!', $this->getResponseBody($response3));
$this->assertCount(2, Server::received());
}
/**
* Decode a response body from TestServer.
*
* TestServer encodes all responses with base64, so we need to decode them
* before we can do any assert's on them.
*
* @param Response $response The response with a body to decode.
*
* @return string
*/
private function getResponseBody($response)
{
return base64_decode($response->getBody());
}
/**
* Set up responses used by our Vary tests.
*
* @throws \Exception
*/
private function setupMultipleVaryResponses()
{
$now = $this->date();
Server::enqueue(
[
new Response(
200, [
'Vary' => 'Accept, User-Agent',
'Content-type' => 'text/html',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000',
'Last-Modified' => $now,
], Stream::factory('Test/1.0 request.')
),
new Response(
200,
[
'Vary' => 'Accept, User-Agent',
'Content-type' => 'text/html',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000',
'Last-Modified' => $now,
],
Stream::factory('Test/2.0 request.')
),
new Response(
200, [
'Vary' => 'Accept, User-Agent',
'Content-type' => 'application/json',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000',
'Last-Modified' => $now,
], Stream::factory(json_encode(['body' => 'Test/1.0 request.']))
),
new Response(
200, [
'Vary' => 'Accept, User-Agent',
'Content-type' => 'application/json',
'Date' => $now,
'Cache-Control' => 'public, s-maxage=1000, max-age=1000',
'Last-Modified' => $now,
], Stream::factory(json_encode(['body' => 'Test/2.0 request.']))
),
]
);
}
/**
* Setup a Guzzle client for testing.
*
* @param History $history (optional) parameter of a History to track
* requests in.
*
* @return Client A client ready to run test requests against.
*/
private function setupClient(History $history = null)
{
$client = new Client(['base_url' => Server::$url]);
CacheSubscriber::attach($client);
if ($history) {
$client->getEmitter()->attach($history);
}
return $client;
}
/**
* Return a date string suitable for using in an HTTP header.
*
* @param int $timestamp (optional) A Unix timestamp to generate the date.
*
* @return string The generated date string.
*/
private function date($timestamp = null)
{
if (!$timestamp) {
$timestamp = time();
}
return gmdate("D, d M Y H:i:s", $timestamp) . ' GMT';
}
/**
* Helper to test that a 400 response deletes cache entries.
*
* @param int $errorCode The error code to test, such as 404 or 410.
*
* @throws \Exception
*/
private function fourXXCacheDelete($errorCode)
{
$now = $this->date();
Server::enqueue(
[
new Response(
200, [
'Date' => $now,
'Cache-Control' => 'public, max-age=1000, must-revalidate',
'Last-Modified' => $now,
]
),
new Response(
304, [
'Date' => $now,
'Cache-Control' => 'public, max-age=1000, must-revalidate',
'Last-Modified' => $now,
'Age' => 0,
]
),
new Response(
$errorCode, [
'Date' => $now,
'Cache-Control' => 'public, max-age=1000, must-revalidate',
'Last-Modified' => $now,
]
),
new Response(
200, [
'Date' => $now,
'Cache-Control' => 'public, max-age=1000, must-revalidate',
'Last-Modified' => $now,
]
),
]
);
$client = $this->setupClient();
$response1 = $client->get('/foo');
$this->assertEquals('MISS from GuzzleCache', $response1->getHeader('X-Cache-Lookup'));
$response2 = $client->get('/foo');
$this->assertEquals('HIT from GuzzleCache', $response2->getHeader('X-Cache-Lookup'));
try {
$client->get('/foo');
$this->fail($errorCode . ' was not thrown.');
} catch (RequestException $e) {
$response3 = $e->getResponse();
$this->assertEquals($errorCode, $response3->getStatusCode());
$this->assertEquals('MISS from GuzzleCache', $response3->getHeader('X-Cache-Lookup'));
}
$response4 = $client->get('/foo');
$this->assertEquals('MISS from GuzzleCache', $response4->getHeader('X-Cache-Lookup'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace GuzzleHttp\Subscriber\Cache;
use GuzzleHttp\Message\MessageFactory;
use PHPUnit_Framework_TestCase;
/**
* Test the Utils class.
*
* @class UtilsTest
*/
class UtilsTest extends PHPUnit_Framework_TestCase
{
/**
* Test that a max-age of zero isn't returned as null.
*/
public function testGetMaxAgeZero()
{
$messageFactory = new MessageFactory();
$response = $messageFactory->createResponse(200, ['Cache-Control' => 's-maxage=0']);
$this->assertSame(0, Utils::getMaxAge($response));
$response = $messageFactory->createResponse(200, ['Cache-Control' => 'max-age=0']);
$this->assertSame(0, Utils::getMaxAge($response));
$response = $messageFactory->createResponse(200, ['Expires' => gmdate('D, d M Y H:i:s') . ' GMT']);
$this->assertLessThanOrEqual(0, Utils::getMaxAge($response));
}
/**
* Test that a response with no max-age information returns null.
*/
public function testGetMaxAgeNull()
{
$messageFactory = new MessageFactory();
$response = $messageFactory->createResponse(200);
$this->assertSame(null, Utils::getMaxAge($response));
}
/**
* Test that a response that is zero fresh returns zero and not null.
*/
public function testGetFreshnessZero()
{
$messageFactory = new MessageFactory();
$response = $messageFactory->createResponse(200,
[
'Cache-Control' => 'max-age=0',
'Age' => 0,
]);
$this->assertSame(0, Utils::getFreshness($response));
}
/**
* Test that responses that can't have freshness determined return null.
*/
public function testGetFreshnessNull()
{
$messageFactory = new MessageFactory();
$response = $messageFactory->createResponse(200);
$this->assertSame(null, Utils::getFreshness($response));
}
}