Skip to content

Commit 6fa639d

Browse files
committed
Added a PSR6 cache plugin
1 parent 705c780 commit 6fa639d

File tree

3 files changed

+279
-0
lines changed

3 files changed

+279
-0
lines changed

composer.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
"email": "[email protected]"
1111
}
1212
],
13+
"repositories": [
14+
{
15+
"type": "git",
16+
"url": "https://github.com/php-fig/cache.git"
17+
}
18+
],
1319
"require": {
1420
"php": ">=5.4",
1521
"php-http/httplug": "1.0.0-alpha3",
@@ -22,6 +28,7 @@
2228
"php-http/cookie": "^0.1@dev",
2329
"symfony/stopwatch": "^2.3",
2430
"psr/log": "^1.0",
31+
"psr/cache": "dev-master",
2532
"php-http/encoding": "^0.1@dev"
2633
},
2734
"autoload": {
@@ -34,6 +41,7 @@
3441
"php-http/cookie": "Allow to use CookiePlugin",
3542
"symfony/stopwatch": "Allow to use the StopwatchPlugin",
3643
"psr/log-implementation": "Allow to use the LoggerPlugin",
44+
"psr/cache": "Allow to use the CachePlugin",
3745
"php-http/encoding": "Allow to use the Decoder and Encoder plugin"
3846
},
3947
"scripts": {

spec/CachePluginSpec.php

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<?php
2+
3+
namespace spec\Http\Client\Plugin;
4+
5+
use Http\Client\Utils\Promise\FulfilledPromise;
6+
use PhpSpec\ObjectBehavior;
7+
use Psr\Cache\CacheItemInterface;
8+
use Psr\Cache\CacheItemPoolInterface;
9+
use Psr\Http\Message\RequestInterface;
10+
use Psr\Http\Message\ResponseInterface;
11+
12+
class CachePluginSpec extends ObjectBehavior
13+
{
14+
function let(CacheItemPoolInterface $pool)
15+
{
16+
$this->beConstructedWith($pool, ['default_ttl'=>60]);
17+
}
18+
19+
function it_is_initializable(CacheItemPoolInterface $pool)
20+
{
21+
$this->shouldHaveType('Http\Client\Plugin\CachePlugin');
22+
}
23+
24+
function it_is_a_plugin()
25+
{
26+
$this->shouldImplement('Http\Client\Plugin\Plugin');
27+
}
28+
29+
function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
30+
{
31+
$request->getMethod()->willReturn('GET');
32+
$request->getUri()->willReturn('/');
33+
$response->getStatusCode()->willReturn(200);
34+
$response->getHeader('Cache-Control')->willReturn(array());
35+
$response->getHeader('Expires')->willReturn(array());
36+
37+
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
38+
$item->isHit()->willReturn(false);
39+
$item->set($response)->willReturn($item)->shouldBeCalled();
40+
$item->expiresAfter(60)->willReturn($item)->shouldBeCalled();
41+
$pool->save($item)->shouldBeCalled();
42+
43+
$next = function (RequestInterface $request) use ($response) {
44+
return new FulfilledPromise($response->getWrappedObject());
45+
};
46+
47+
$this->handleRequest($request, $next, function () {});
48+
}
49+
50+
function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
51+
{
52+
$request->getMethod()->willReturn('GET');
53+
$request->getUri()->willReturn('/');
54+
$response->getStatusCode()->willReturn(400);
55+
$response->getHeader('Cache-Control')->willReturn(array());
56+
$response->getHeader('Expires')->willReturn(array());
57+
58+
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
59+
$item->isHit()->willReturn(false);
60+
61+
$next = function (RequestInterface $request) use ($response) {
62+
return new FulfilledPromise($response->getWrappedObject());
63+
};
64+
65+
$this->handleRequest($request, $next, function () {});
66+
}
67+
68+
function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
69+
{
70+
$request->getMethod()->willReturn('POST');
71+
$request->getUri()->willReturn('/');
72+
73+
$next = function (RequestInterface $request) use ($response) {
74+
return new FulfilledPromise($response->getWrappedObject());
75+
};
76+
77+
$this->handleRequest($request, $next, function () {});
78+
}
79+
80+
81+
function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
82+
{
83+
$request->getMethod()->willReturn('GET');
84+
$request->getUri()->willReturn('/');
85+
$response->getStatusCode()->willReturn(200);
86+
$response->getHeader('Cache-Control')->willReturn(array('max-age=40'));
87+
$response->getHeader('Age')->willReturn(array('15'));
88+
$response->getHeader('Expires')->willReturn(array());
89+
90+
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
91+
$item->isHit()->willReturn(false);
92+
93+
// 40-15 should be 25
94+
$item->set($response)->willReturn($item)->shouldBeCalled();
95+
$item->expiresAfter(25)->willReturn($item)->shouldBeCalled();
96+
$pool->save($item)->shouldBeCalled();
97+
98+
$next = function (RequestInterface $request) use ($response) {
99+
return new FulfilledPromise($response->getWrappedObject());
100+
};
101+
102+
$this->handleRequest($request, $next, function () {});
103+
}
104+
}

src/CachePlugin.php

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
namespace Http\Client\Plugin;
4+
5+
use Psr\Cache\CacheItemPoolInterface;
6+
use Psr\Http\Message\RequestInterface;
7+
use Psr\Http\Message\ResponseInterface;
8+
9+
/**
10+
* Allow for caching a response.
11+
*
12+
* @author Tobias Nyholm <[email protected]>
13+
*/
14+
class CachePlugin implements Plugin
15+
{
16+
/**
17+
* @var CacheItemPoolInterface
18+
*/
19+
private $pool;
20+
21+
/**
22+
* Default time to store object in cache. This value is used if CachePlugin::respectCacheHeaders is false or
23+
* if cache headers are missing.
24+
*
25+
* @var int
26+
*/
27+
private $defaultTtl;
28+
29+
/**
30+
* Look at the cache headers to know how long this response is going to be cached.
31+
*
32+
* @var bool
33+
*/
34+
private $respectCacheHeaders;
35+
36+
/**
37+
* @param CacheItemPoolInterface $pool
38+
* @param array $options
39+
*/
40+
public function __construct(CacheItemPoolInterface $pool, array $options = array())
41+
{
42+
$this->pool = $pool;
43+
$this->defaultTtl = isset($options['default_ttl']) ? $options['default_ttl'] : null;
44+
$this->respectCacheHeaders = isset($options['respect_cache_headers']) ? $options['respect_cache_headers'] : true;
45+
}
46+
47+
/**
48+
* {@inheritdoc}
49+
*/
50+
public function handleRequest(RequestInterface $request, callable $next, callable $first)
51+
{
52+
$method = strtoupper($request->getMethod());
53+
54+
// if the request not is cachable, move to $next
55+
if ($method !== 'GET' && $method !== 'HEAD') {
56+
return $next($request);
57+
}
58+
59+
// If we can cache the request
60+
$key = $this->createCacheKey($request);
61+
$cacheItem = $this->pool->getItem($key);
62+
63+
if ($cacheItem->isHit()) {
64+
// return cached response
65+
return $cacheItem->get();
66+
}
67+
68+
return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) {
69+
if ($this->isCacheable($response)) {
70+
$cacheItem->set($response)
71+
->expiresAfter($this->getMaxAge($response));
72+
$this->pool->save($cacheItem);
73+
}
74+
75+
return $response;
76+
});
77+
}
78+
79+
/**
80+
* Verify that we can cache this response.
81+
*
82+
* @param ResponseInterface $response
83+
*
84+
* @return bool
85+
*/
86+
private function isCacheable(ResponseInterface $response)
87+
{
88+
if (!in_array($response->getStatusCode(), array(200, 203, 300, 301, 302, 404, 410))) {
89+
return false;
90+
}
91+
if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) {
92+
return false;
93+
}
94+
95+
return true;
96+
}
97+
98+
/**
99+
* Returns the value of a parameter in the cache control header. If not found we return false. If found with no
100+
* value return true.
101+
*
102+
* @param ResponseInterface $response
103+
* @param string $name
104+
*
105+
* @return bool|string
106+
*/
107+
private function getCacheControlDirective(ResponseInterface $response, $name)
108+
{
109+
$headers = $response->getHeader('Cache-Control');
110+
foreach ($headers as $header) {
111+
if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
112+
113+
// return the value for $name if it exists
114+
if (isset($matches[1])) {
115+
return $matches[1];
116+
}
117+
118+
return true;
119+
}
120+
}
121+
122+
return false;
123+
}
124+
125+
/**
126+
* @param RequestInterface $request
127+
*
128+
* @return string
129+
*/
130+
private function createCacheKey(RequestInterface $request)
131+
{
132+
return md5($request->getMethod().' '.$request->getUri());
133+
}
134+
135+
/**
136+
* Get a ttl in seconds. It could return null if we do not respect cache headers and got no defaultTtl.
137+
*
138+
* @param ResponseInterface $response
139+
*
140+
* @return int|null
141+
*/
142+
private function getMaxAge(ResponseInterface $response)
143+
{
144+
if (!$this->respectCacheHeaders) {
145+
return $this->defaultTtl;
146+
}
147+
148+
// check for max age in the Cache-Control header
149+
$maxAge = $this->getCacheControlDirective($response, 'max-age');
150+
if (!is_bool($maxAge)) {
151+
$ageHeaders = $response->getHeader('Age');
152+
foreach ($ageHeaders as $age) {
153+
return $maxAge-((int) $age);
154+
}
155+
156+
return $maxAge;
157+
}
158+
159+
// check for ttl in the Expires header
160+
$headers = $response->getHeader('Expires');
161+
foreach ($headers as $header) {
162+
return (new \DateTime($header))->getTimestamp() - (new \DateTime())->getTimestamp();
163+
}
164+
165+
return $this->defaultTtl;
166+
}
167+
}

0 commit comments

Comments
 (0)