Skip to content

Commit 3d5870e

Browse files
committed
Added a PSR6 cache plugin
1 parent 3557c6a commit 3d5870e

File tree

3 files changed

+274
-0
lines changed

3 files changed

+274
-0
lines changed

composer.json

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

0 commit comments

Comments
 (0)