diff --git a/composer.json b/composer.json index 069583d..c5fa372 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "php-http/cookie": "^0.1@dev", "symfony/stopwatch": "^2.3", "psr/log": "^1.0", + "psr/cache": "1.0.0", "php-http/encoding": "^0.1@dev" }, "autoload": { @@ -34,6 +35,7 @@ "php-http/cookie": "Allow to use CookiePlugin", "symfony/stopwatch": "Allow to use the StopwatchPlugin", "psr/log-implementation": "Allow to use the LoggerPlugin", + "psr/cache-implementation": "Allow to use the CachePlugin", "php-http/encoding": "Allow to use the Decoder and Encoder plugin" }, "scripts": { diff --git a/spec/CachePluginSpec.php b/spec/CachePluginSpec.php new file mode 100644 index 0000000..2b84c33 --- /dev/null +++ b/spec/CachePluginSpec.php @@ -0,0 +1,104 @@ +beConstructedWith($pool, ['default_ttl'=>60]); + } + + function it_is_initializable(CacheItemPoolInterface $pool) + { + $this->shouldHaveType('Http\Client\Plugin\CachePlugin'); + } + + function it_is_a_plugin() + { + $this->shouldImplement('Http\Client\Plugin\Plugin'); + } + + function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + { + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + $response->getStatusCode()->willReturn(200); + $response->getHeader('Cache-Control')->willReturn(array()); + $response->getHeader('Expires')->willReturn(array()); + + $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + $item->set($response)->willReturn($item)->shouldBeCalled(); + $item->expiresAfter(60)->willReturn($item)->shouldBeCalled(); + $pool->save($item)->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + { + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + $response->getStatusCode()->willReturn(400); + $response->getHeader('Cache-Control')->willReturn(array()); + $response->getHeader('Expires')->willReturn(array()); + + $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + { + $request->getMethod()->willReturn('POST'); + $request->getUri()->willReturn('/'); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } + + + function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response) + { + $request->getMethod()->willReturn('GET'); + $request->getUri()->willReturn('/'); + $response->getStatusCode()->willReturn(200); + $response->getHeader('Cache-Control')->willReturn(array('max-age=40')); + $response->getHeader('Age')->willReturn(array('15')); + $response->getHeader('Expires')->willReturn(array()); + + $pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item); + $item->isHit()->willReturn(false); + + // 40-15 should be 25 + $item->set($response)->willReturn($item)->shouldBeCalled(); + $item->expiresAfter(25)->willReturn($item)->shouldBeCalled(); + $pool->save($item)->shouldBeCalled(); + + $next = function (RequestInterface $request) use ($response) { + return new FulfilledPromise($response->getWrappedObject()); + }; + + $this->handleRequest($request, $next, function () {}); + } +} \ No newline at end of file diff --git a/src/CachePlugin.php b/src/CachePlugin.php new file mode 100644 index 0000000..76a1231 --- /dev/null +++ b/src/CachePlugin.php @@ -0,0 +1,168 @@ + + */ +class CachePlugin implements Plugin +{ + /** + * @var CacheItemPoolInterface + */ + private $pool; + + /** + * Default time to store object in cache. This value is used if CachePlugin::respectCacheHeaders is false or + * if cache headers are missing. + * + * @var int + */ + private $defaultTtl; + + /** + * Look at the cache headers to know how long this response is going to be cached. + * + * @var bool + */ + private $respectCacheHeaders; + + /** + * @param CacheItemPoolInterface $pool + * @param array $options + */ + public function __construct(CacheItemPoolInterface $pool, array $options = []) + { + $this->pool = $pool; + $this->defaultTtl = isset($options['default_ttl']) ? $options['default_ttl'] : null; + $this->respectCacheHeaders = isset($options['respect_cache_headers']) ? $options['respect_cache_headers'] : true; + } + + /** + * {@inheritdoc} + */ + public function handleRequest(RequestInterface $request, callable $next, callable $first) + { + $method = strtoupper($request->getMethod()); + + // if the request not is cachable, move to $next + if ($method !== 'GET' && $method !== 'HEAD') { + return $next($request); + } + + // If we can cache the request + $key = $this->createCacheKey($request); + $cacheItem = $this->pool->getItem($key); + + if ($cacheItem->isHit()) { + // return cached response + return new FulfilledPromise($cacheItem->get()); + } + + return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) { + if ($this->isCacheable($response)) { + $cacheItem->set($response) + ->expiresAfter($this->getMaxAge($response)); + $this->pool->save($cacheItem); + } + + return $response; + }); + } + + /** + * Verify that we can cache this response. + * + * @param ResponseInterface $response + * + * @return bool + */ + protected function isCacheable(ResponseInterface $response) + { + if (!in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 404, 410])) { + return false; + } + if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) { + return false; + } + + return true; + } + + /** + * Returns the value of a parameter in the cache control header. If not found we return false. If found with no + * value return true. + * + * @param ResponseInterface $response + * @param string $name + * + * @return bool|string + */ + private function getCacheControlDirective(ResponseInterface $response, $name) + { + $headers = $response->getHeader('Cache-Control'); + foreach ($headers as $header) { + if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) { + + // return the value for $name if it exists + if (isset($matches[1])) { + return $matches[1]; + } + + return true; + } + } + + return false; + } + + /** + * @param RequestInterface $request + * + * @return string + */ + private function createCacheKey(RequestInterface $request) + { + return md5($request->getMethod().' '.$request->getUri()); + } + + /** + * Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl. + * + * @param ResponseInterface $response + * + * @return int|null + */ + private function getMaxAge(ResponseInterface $response) + { + if (!$this->respectCacheHeaders) { + return $this->defaultTtl; + } + + // check for max age in the Cache-Control header + $maxAge = $this->getCacheControlDirective($response, 'max-age'); + if (!is_bool($maxAge)) { + $ageHeaders = $response->getHeader('Age'); + foreach ($ageHeaders as $age) { + return $maxAge - ((int) $age); + } + + return $maxAge; + } + + // check for ttl in the Expires header + $headers = $response->getHeader('Expires'); + foreach ($headers as $header) { + return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp(); + } + + return $this->defaultTtl; + } +}