Skip to content

Commit d3ea36e

Browse files
committed
Added a PSR6 cache plugin
1 parent 821e8a3 commit d3ea36e

File tree

3 files changed

+269
-0
lines changed

3 files changed

+269
-0
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"php-http/cookie": "Allow to use CookiePlugin",
3535
"symfony/stopwatch": "Allow to use the StopwatchPlugin",
3636
"psr/log-implementation": "Allow to use the LoggerPlugin",
37+
"psr/cache": "Allow to use the CachePlugin",
3738
"php-http/encoding": "Allow to use the Decoder and Encoder plugin"
3839
},
3940
"scripts": {

spec/CachePluginSpec.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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);
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->exists()->willReturn(false);
39+
$item->set($response, 60)->shouldBeCalled();
40+
$pool->save($item)->shouldBeCalled();
41+
42+
$next = function (RequestInterface $request) use ($response) {
43+
return new FulfilledPromise($response->getWrappedObject());
44+
};
45+
46+
$this->handleRequest($request, $next, function () {});
47+
}
48+
49+
function it_doesnt_store_failed_responses(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
50+
{
51+
$request->getMethod()->willReturn('GET');
52+
$request->getUri()->willReturn('/');
53+
$response->getStatusCode()->willReturn(400);
54+
$response->getHeader('Cache-Control')->willReturn(array());
55+
$response->getHeader('Expires')->willReturn(array());
56+
57+
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
58+
$item->exists()->willReturn(false);
59+
60+
$next = function (RequestInterface $request) use ($response) {
61+
return new FulfilledPromise($response->getWrappedObject());
62+
};
63+
64+
$this->handleRequest($request, $next, function () {});
65+
}
66+
67+
function it_doesnt_store_post_requests(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
68+
{
69+
$request->getMethod()->willReturn('POST');
70+
$request->getUri()->willReturn('/');
71+
72+
$next = function (RequestInterface $request) use ($response) {
73+
return new FulfilledPromise($response->getWrappedObject());
74+
};
75+
76+
$this->handleRequest($request, $next, function () {});
77+
}
78+
79+
80+
function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemInterface $item, RequestInterface $request, ResponseInterface $response)
81+
{
82+
$request->getMethod()->willReturn('GET');
83+
$request->getUri()->willReturn('/');
84+
$response->getStatusCode()->willReturn(200);
85+
$response->getHeader('Cache-Control')->willReturn(array('max-age=40'));
86+
$response->getHeader('Age')->willReturn(array('15'));
87+
$response->getHeader('Expires')->willReturn(array());
88+
89+
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
90+
$item->exists()->willReturn(false);
91+
92+
// 40-15 should be 25
93+
$item->set($response, 25)->shouldBeCalled();
94+
$pool->save($item)->shouldBeCalled();
95+
96+
$next = function (RequestInterface $request) use ($response) {
97+
return new FulfilledPromise($response->getWrappedObject());
98+
};
99+
100+
$this->handleRequest($request, $next, function () {});
101+
}
102+
}

src/CachePlugin.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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'] : 60;
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->exists()) {
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, $this->getMaxAge($response));
71+
$this->pool->save($cacheItem);
72+
}
73+
74+
return $response;
75+
});
76+
}
77+
78+
/**
79+
* Verify that we can cache this response.
80+
*
81+
* @param ResponseInterface $response
82+
*
83+
* @return bool
84+
*/
85+
private function isCacheable(ResponseInterface $response)
86+
{
87+
if (!in_array($response->getStatusCode(), array(200, 203, 300, 301, 302, 404, 410))) {
88+
return false;
89+
}
90+
if ($this->getCacheControlDirective($response, 'no-store') || $this->getCacheControlDirective($response, 'private')) {
91+
return false;
92+
}
93+
94+
return true;
95+
}
96+
97+
/**
98+
* Returns the value of a parameter in the cache control header. If not found we return false. If found with no
99+
* value return true.
100+
*
101+
* @param ResponseInterface $response
102+
* @param string $name
103+
*
104+
* @return bool|string
105+
*/
106+
private function getCacheControlDirective(ResponseInterface $response, $name)
107+
{
108+
$headers = $response->getHeader('Cache-Control');
109+
foreach ($headers as $header) {
110+
if (preg_match(sprintf('|%s=?([0-9]+)?|i', $name), $header, $matches)) {
111+
112+
// return the value for $name if it exists
113+
if (isset($matches[1])) {
114+
return $matches[1];
115+
}
116+
117+
return true;
118+
}
119+
}
120+
121+
return false;
122+
}
123+
124+
/**
125+
* @param RequestInterface $request
126+
*
127+
* @return string
128+
*/
129+
private function createCacheKey(RequestInterface $request)
130+
{
131+
return md5($request->getMethod().' '.$request->getUri());
132+
}
133+
134+
/**
135+
* Get a ttl in seconds or as a \DateTime object.
136+
*
137+
* @param ResponseInterface $response
138+
*
139+
* @return int|\DateTime
140+
*/
141+
private function getMaxAge(ResponseInterface $response)
142+
{
143+
if (!$this->respectCacheHeaders) {
144+
return $this->defaultTtl;
145+
}
146+
147+
// check for max age in the Cache-Control header
148+
$maxAge = $this->getCacheControlDirective($response, 'max-age');
149+
if (!is_bool($maxAge)) {
150+
$ageHeaders = $response->getHeader('Age');
151+
foreach ($ageHeaders as $age) {
152+
return $maxAge-((int) $age);
153+
}
154+
155+
return $maxAge;
156+
}
157+
158+
// check for ttl in the Expires header
159+
$headers = $response->getHeader('Expires');
160+
foreach ($headers as $header) {
161+
return new \DateTime($header);
162+
}
163+
164+
return $this->defaultTtl;
165+
}
166+
}

0 commit comments

Comments
 (0)