Skip to content

Commit 79f8b99

Browse files
authored
Merge 31837ab into ff042d2
2 parents ff042d2 + 31837ab commit 79f8b99

File tree

3 files changed

+155
-16
lines changed

3 files changed

+155
-16
lines changed

composer.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
"Http\\Client\\Common\\Plugin\\": "src/"
2727
}
2828
},
29+
"autoload-dev": {
30+
"psr-4": {
31+
"spec\\Http\\Client\\Common\\Plugin\\": "spec/"
32+
}
33+
},
2934
"scripts": {
3035
"test": "vendor/bin/phpspec run",
3136
"test-ci": "vendor/bin/phpspec run -c phpspec.yml.ci"

spec/CachePluginSpec.php

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace spec\Http\Client\Common\Plugin;
44

5+
use Prophecy\Argument;
6+
use Prophecy\Comparator\Factory as ComparatorFactory;
57
use Http\Message\StreamFactory;
68
use Http\Promise\FulfilledPromise;
79
use PhpSpec\ObjectBehavior;
@@ -15,7 +17,7 @@ class CachePluginSpec extends ObjectBehavior
1517
{
1618
function let(CacheItemPoolInterface $pool, StreamFactory $streamFactory)
1719
{
18-
$this->beConstructedWith($pool, $streamFactory, ['default_ttl'=>60]);
20+
$this->beConstructedWith($pool, $streamFactory, ['default_ttl'=>60, 'cache_lifetime'=>1000]);
1921
}
2022

2123
function it_is_initializable(CacheItemPoolInterface $pool)
@@ -39,14 +41,22 @@ function it_caches_responses(CacheItemPoolInterface $pool, CacheItemInterface $i
3941
$request->getUri()->willReturn('/');
4042
$response->getStatusCode()->willReturn(200);
4143
$response->getBody()->willReturn($stream);
42-
$response->getHeader('Cache-Control')->willReturn(array());
43-
$response->getHeader('Expires')->willReturn(array());
44+
$response->getHeader('Cache-Control')->willReturn(array())->shouldBeCalled();
45+
$response->getHeader('Expires')->willReturn(array())->shouldBeCalled();
46+
$response->getHeader('ETag')->willReturn(array())->shouldBeCalled();
4447

4548
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
4649
$item->isHit()->willReturn(false);
47-
$item->set(['response' => $response, 'body' => $httpBody])->willReturn($item)->shouldBeCalled();
48-
$item->expiresAfter(60)->willReturn($item)->shouldBeCalled();
49-
$pool->save($item)->shouldBeCalled();
50+
$item->expiresAfter(1060)->willReturn($item)->shouldBeCalled();
51+
52+
$item->set(Argument::that($this->getCacheItemMatcher([
53+
'response' => $response->getWrappedObject(),
54+
'body' => $httpBody,
55+
'expiresAt' => 0,
56+
'createdAt' => 0,
57+
'etag' => []
58+
])))->willReturn($item)->shouldBeCalled();
59+
$pool->save(Argument::any())->shouldBeCalled();
5060

5161
$next = function (RequestInterface $request) use ($response) {
5262
return new FulfilledPromise($response->getWrappedObject());
@@ -100,13 +110,20 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI
100110
$response->getHeader('Cache-Control')->willReturn(array('max-age=40'));
101111
$response->getHeader('Age')->willReturn(array('15'));
102112
$response->getHeader('Expires')->willReturn(array());
113+
$response->getHeader('ETag')->willReturn(array());
103114

104115
$pool->getItem('e3b717d5883a45ef9493d009741f7c64')->shouldBeCalled()->willReturn($item);
105116
$item->isHit()->willReturn(false);
106117

107-
// 40-15 should be 25
108-
$item->set(['response' => $response, 'body' => $httpBody])->willReturn($item)->shouldBeCalled();
109-
$item->expiresAfter(25)->willReturn($item)->shouldBeCalled();
118+
$item->set(Argument::that($this->getCacheItemMatcher([
119+
'response' => $response->getWrappedObject(),
120+
'body' => $httpBody,
121+
'expiresAt' => 0,
122+
'createdAt' => 0,
123+
'etag' => []
124+
])))->willReturn($item)->shouldBeCalled();
125+
// 40-15 should be 25 + the default 1000
126+
$item->expiresAfter(1025)->willReturn($item)->shouldBeCalled();
110127
$pool->save($item)->shouldBeCalled();
111128

112129
$next = function (RequestInterface $request) use ($response) {
@@ -115,4 +132,25 @@ function it_calculate_age_from_response(CacheItemPoolInterface $pool, CacheItemI
115132

116133
$this->handleRequest($request, $next, function () {});
117134
}
135+
136+
private function getCacheItemMatcher(array $expectedData)
137+
{
138+
return function(array $actualData) use ($expectedData) {
139+
foreach ($expectedData as $key => $value) {
140+
if (!isset($actualData[$key])) {
141+
return false;
142+
}
143+
144+
if ($key === 'expiresAt' || $key === 'createdAt') {
145+
// We do not need to validate the value of these fields.
146+
continue;
147+
}
148+
149+
if ($actualData[$key] !== $value) {
150+
return false;
151+
}
152+
}
153+
return true;
154+
};
155+
}
118156
}

src/CachePlugin.php

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Http\Client\Common\Plugin;
66
use Http\Message\StreamFactory;
77
use Http\Promise\FulfilledPromise;
8+
use Psr\Cache\CacheItemInterface;
89
use Psr\Cache\CacheItemPoolInterface;
910
use Psr\Http\Message\RequestInterface;
1011
use Psr\Http\Message\ResponseInterface;
@@ -57,7 +58,6 @@ public function __construct(CacheItemPoolInterface $pool, StreamFactory $streamF
5758
public function handleRequest(RequestInterface $request, callable $next, callable $first)
5859
{
5960
$method = strtoupper($request->getMethod());
60-
6161
// if the request not is cachable, move to $next
6262
if ($method !== 'GET' && $method !== 'HEAD') {
6363
return $next($request);
@@ -68,15 +68,41 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
6868
$cacheItem = $this->pool->getItem($key);
6969

7070
if ($cacheItem->isHit()) {
71-
// return cached response
7271
$data = $cacheItem->get();
73-
$response = $data['response'];
74-
$response = $response->withBody($this->streamFactory->createStream($data['body']));
72+
if (isset($data['expiresAt']) && time() > $data['expiresAt']) {
73+
// This item is still valid according to previous cache headers
74+
return new FulfilledPromise($this->createResponseFromCacheItem($cacheItem));
75+
}
76+
77+
// Add headers to ask the server if this cache is still valid
78+
if ($mod = $this->getModifiedAt($cacheItem)) {
79+
$mod = new \DateTime('@'.$mod);
80+
$mod->setTimezone(new \DateTimeZone('GMT'));
81+
$request = $request->withHeader('If-Modified-Since', sprintf('%s GMT', $mod->format('l, d-M-y H:i:s')));
82+
}
7583

76-
return new FulfilledPromise($response);
84+
if ($etag = $this->getETag($cacheItem)) {
85+
$request = $request->withHeader('If-None-Match', $etag);
86+
}
7787
}
7888

7989
return $next($request)->then(function (ResponseInterface $response) use ($cacheItem) {
90+
if (304 === $response->getStatusCode()) {
91+
if (!$cacheItem->isHit()) {
92+
// We do not have the item in cache. We can return the cached response.
93+
return $response;
94+
}
95+
96+
// The cached response we have is still valid
97+
$data = $cacheItem->get();
98+
$maxAge = $this->getMaxAge($response);
99+
$data['expiresAt'] = time() + $maxAge;
100+
$cacheItem->set($data)->expiresAfter($this->config['cache_lifetime'] + $maxAge);
101+
$this->pool->save($cacheItem);
102+
103+
return $this->createResponseFromCacheItem($cacheItem);
104+
}
105+
80106
if ($this->isCacheable($response)) {
81107
$bodyStream = $response->getBody();
82108
$body = $bodyStream->__toString();
@@ -86,8 +112,16 @@ public function handleRequest(RequestInterface $request, callable $next, callabl
86112
$response = $response->withBody($this->streamFactory->createStream($body));
87113
}
88114

89-
$cacheItem->set(['response' => $response, 'body' => $body])
90-
->expiresAfter($this->getMaxAge($response));
115+
$maxAge = $this->getMaxAge($response);
116+
$cacheItem
117+
->expiresAfter($this->config['cache_lifetime'] + $maxAge)
118+
->set([
119+
'response' => $response,
120+
'body' => $body,
121+
'expiresAt' => time() + $maxAge,
122+
'createdAt' => time(),
123+
'etag' => $response->getHeader('ETag'),
124+
]);
91125
$this->pool->save($cacheItem);
92126
}
93127

@@ -194,11 +228,73 @@ private function getMaxAge(ResponseInterface $response)
194228
private function configureOptions(OptionsResolver $resolver)
195229
{
196230
$resolver->setDefaults([
231+
'cache_lifetime' => 2592000, // 30 days
197232
'default_ttl' => null,
198233
'respect_cache_headers' => true,
199234
]);
200235

236+
$resolver->setAllowedTypes('cache_lifetime', 'int');
201237
$resolver->setAllowedTypes('default_ttl', ['int', 'null']);
202238
$resolver->setAllowedTypes('respect_cache_headers', 'bool');
203239
}
240+
241+
/**
242+
* @param CacheItemInterface $cacheItem
243+
*
244+
* @return ResponseInterface
245+
*/
246+
private function createResponseFromCacheItem(CacheItemInterface $cacheItem)
247+
{
248+
$data = $cacheItem->get();
249+
250+
/** @var ResponseInterface $response */
251+
$response = $data['response'];
252+
$response = $response->withBody($this->streamFactory->createStream($data['body']));
253+
254+
return $response;
255+
}
256+
257+
/**
258+
* Get the timestamp when the cached response was stored.
259+
*
260+
* @param CacheItemInterface $cacheItem
261+
*
262+
* @return int|null
263+
*/
264+
private function getModifiedAt(CacheItemInterface $cacheItem)
265+
{
266+
$data = $cacheItem->get();
267+
if (!isset($data['createdAt'])) {
268+
return;
269+
}
270+
271+
return $data['createdAt'];
272+
}
273+
274+
/**
275+
* Get the ETag from the cached response.
276+
*
277+
* @param CacheItemInterface $cacheItem
278+
*
279+
* @return string|null
280+
*/
281+
private function getETag(CacheItemInterface $cacheItem)
282+
{
283+
$data = $cacheItem->get();
284+
if (!isset($data['etag'])) {
285+
return;
286+
}
287+
288+
if (is_array($data['etag'])) {
289+
foreach ($data['etag'] as $etag) {
290+
if (!empty($etag)) {
291+
return $etag;
292+
}
293+
}
294+
295+
return;
296+
}
297+
298+
return $data['etag'];
299+
}
204300
}

0 commit comments

Comments
 (0)