Skip to content

Commit bf0305e

Browse files
committed
Added support for gzipping the cached files
1 parent 9b3eb5b commit bf0305e

File tree

3 files changed

+192
-24
lines changed

3 files changed

+192
-24
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ passing an array of `$options` in the constructor:
9797

9898
**Type**: `boolean`
9999
**Default**: `true`
100+
101+
* **gzip_level**: Whether or not content digests should be generated.
102+
See "Generating Content Digests" for more information.
103+
104+
**Type**: `int`
105+
**Default**: `9`
100106

101107
### Generating Content Digests
102108

src/Psr6Store.php

+126-24
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace Toflar\Psr6HttpCacheStore;
1515

16+
use Psr\Cache\CacheItemInterface;
1617
use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException;
1718
use Symfony\Component\Cache\Adapter\AdapterInterface;
1819
use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter;
@@ -79,6 +80,16 @@ public function __construct(array $options = [])
7980
$resolver->setDefault('generate_content_digests', true)
8081
->setAllowedTypes('generate_content_digests', 'boolean');
8182

83+
$resolver->setDefault('gzip_level', 9)
84+
->setAllowedTypes('gzip_level', 'int')
85+
->setNormalizer('gzip_level', function (Options $options, int $value): int {
86+
if ($value < 0 || $value > 9) {
87+
throw new \InvalidArgumentException('The gzip_level has to be between 0 (disabled) and 9.');
88+
}
89+
90+
return $value;
91+
});
92+
8293
$resolver->setDefault('cache', function (Options $options) {
8394
if (!isset($options['cache_directory'])) {
8495
throw new MissingOptionsException('The cache_directory option is required unless you set the cache explicitly');
@@ -119,7 +130,7 @@ public function lookup(Request $request): ?Response
119130
foreach ($entries as $varyKeyResponse => $responseData) {
120131
// This can only happen if one entry only
121132
if (self::NON_VARYING_KEY === $varyKeyResponse) {
122-
return $this->restoreResponse($responseData);
133+
return $this->restoreResponse($request, $responseData);
123134
}
124135

125136
// Otherwise we have to see if Vary headers match
@@ -129,7 +140,7 @@ public function lookup(Request $request): ?Response
129140
);
130141

131142
if ($varyKeyRequest === $varyKeyResponse) {
132-
return $this->restoreResponse($responseData);
143+
return $this->restoreResponse($request, $responseData);
133144
}
134145
}
135146

@@ -146,8 +157,6 @@ public function write(Request $request, Response $response): string
146157
$this->saveContentDigest($response);
147158

148159
$cacheKey = $this->getCacheKey($request);
149-
$headers = $response->headers->all();
150-
unset($headers['age']);
151160

152161
/** @var CacheItem $item */
153162
$item = $this->cache->getItem($cacheKey);
@@ -162,16 +171,19 @@ public function write(Request $request, Response $response): string
162171
$varyKey = $this->getVaryKey($response->getVary(), $request);
163172
$entries[$varyKey] = [
164173
'vary' => $response->getVary(),
165-
'headers' => $headers,
166174
'status' => $response->getStatusCode(),
167175
'uri' => $request->getUri(), // For debugging purposes
168176
];
169177

170178
// Add content if content digests are disabled
171179
if (!$this->options['generate_content_digests']) {
180+
$this->gzipResponse($response);
172181
$entries[$varyKey]['content'] = $response->getContent();
173182
}
174183

184+
// Set headers (after potentially gzipping the response)
185+
$entries[$varyKey]['headers'] = $this->getHeadersForCache($response);
186+
175187
// If the response has a Vary header we remove the non-varying entry
176188
if ($response->hasVary()) {
177189
unset($entries[self::NON_VARYING_KEY]);
@@ -196,6 +208,19 @@ public function write(Request $request, Response $response): string
196208
return $cacheKey;
197209
}
198210

211+
private function getHeadersForCache(Response $response): array
212+
{
213+
$headers = $response->headers->all();
214+
unset($headers['age']);
215+
216+
return $headers;
217+
}
218+
219+
/**
220+
* Invalidates all cache entries that match the request.
221+
*
222+
* @param Request $request A Request instance
223+
*/
199224
public function invalidate(Request $request): void
200225
{
201226
$cacheKey = $this->getCacheKey($request);
@@ -375,6 +400,26 @@ private function getVaryKey(array $vary, Request $request): string
375400
return hash('sha256', $hashData);
376401
}
377402

403+
private function isResponseGzipped(Response $response): bool
404+
{
405+
return $response->headers->get('Content-Encoding') === 'gzip';
406+
}
407+
408+
private function doesRequestSupportGzip(Request $request): bool
409+
{
410+
return \in_array('gzip', $request->getEncodings());
411+
}
412+
413+
private function isGzipSupported(): bool
414+
{
415+
return $this->options['gzip_level'] !== 0 && function_exists('gzencode') && function_exists('gzdecode');
416+
}
417+
418+
private function isCacheGzipped(array $headers): bool
419+
{
420+
return isset($headers['content-encoding'][0]) && $headers['content-encoding'][0] === 'gzip';
421+
}
422+
378423
private function saveContentDigest(Response $response): void
379424
{
380425
if ($response->headers->has('X-Content-Digest')) {
@@ -391,20 +436,17 @@ private function saveContentDigest(Response $response): void
391436

392437
if ($digestCacheItem->isHit()) {
393438
$cacheValue = $digestCacheItem->get();
394-
395-
// BC
396-
if (\is_string($cacheValue)) {
397-
$cacheValue = [
398-
'expires' => 0, // Forces update to the new format
399-
'contents' => $cacheValue,
400-
];
401-
}
402439
} else {
440+
if ($this->isBinaryFileResponseContentDigest($contentDigest)) {
441+
$contents = $response->getFile()->getPathname();
442+
} else {
443+
$this->gzipResponse($response);
444+
$contents = $response->getContent();
445+
}
446+
403447
$cacheValue = [
404448
'expires' => 0, // Forces storing the new entry
405-
'contents' => $this->isBinaryFileResponseContentDigest($contentDigest) ?
406-
$response->getFile()->getPathname() :
407-
$response->getContent(),
449+
'contents' => $contents
408450
];
409451
}
410452

@@ -427,6 +469,25 @@ private function saveContentDigest(Response $response): void
427469
}
428470
}
429471

472+
private function gzipResponse(Response $response): void
473+
{
474+
// Not supported or already gzipped
475+
if ($response instanceof BinaryFileResponse || !$this->isGzipSupported() || $this->isResponseGzipped($response)) {
476+
return;
477+
}
478+
479+
$encoded = gzencode((string) $response->getContent(), $this->options['gzip_level']);
480+
481+
// Could not gzip
482+
if (false === $encoded) {
483+
return;
484+
}
485+
486+
// Update the content and set the encoding header
487+
$response->setContent($encoded);
488+
$response->headers->set('Content-Encoding', 'gzip');
489+
}
490+
430491
/**
431492
* Test whether a given digest identifies a BinaryFileResponse.
432493
*
@@ -481,13 +542,14 @@ private function saveDeferred(CacheItem $item, $data, ?int $expiresAfter = null,
481542
*
482543
* @param array $cacheData An array containing the cache data
483544
*/
484-
private function restoreResponse(array $cacheData): ?Response
545+
private function restoreResponse(Request $request, array $cacheData): ?Response
485546
{
486547
// Check for content digest header
487548
if (!isset($cacheData['headers']['x-content-digest'][0])) {
488549
// No digest was generated but the content was stored inline
489550
if (isset($cacheData['content'])) {
490-
return new Response(
551+
return $this->buildResponseFromCache(
552+
$request,
491553
$cacheData['content'],
492554
$cacheData['status'],
493555
$cacheData['headers']
@@ -506,11 +568,6 @@ private function restoreResponse(array $cacheData): ?Response
506568

507569
$value = $item->get();
508570

509-
// BC
510-
if (\is_string($value)) {
511-
$value = ['contents' => $value];
512-
}
513-
514571
if ($this->isBinaryFileResponseContentDigest($cacheData['headers']['x-content-digest'][0])) {
515572
try {
516573
$file = new File($value['contents']);
@@ -525,13 +582,58 @@ private function restoreResponse(array $cacheData): ?Response
525582
);
526583
}
527584

528-
return new Response(
585+
return $this->buildResponseFromCache(
586+
$request,
529587
$value['contents'],
530588
$cacheData['status'],
531589
$cacheData['headers']
532590
);
533591
}
534592

593+
private function buildResponseFromCache(Request $request, string $contents, int $status, array $headers): ?Response
594+
{
595+
// If the cache entry is not gzipped we return the file as is.
596+
if (!$this->isCacheGzipped($headers)) {
597+
return new Response(
598+
$contents,
599+
$status,
600+
$headers
601+
);
602+
}
603+
604+
// Otherwise it was gzipped. Let's check if the client supports gzip, in which case we'll also return as is for
605+
// the client to decode
606+
if ($this->doesRequestSupportGzip($request)) {
607+
return new Response(
608+
$contents,
609+
$status,
610+
$headers
611+
);
612+
}
613+
614+
// Otherwise we now have to decode which we can only do if our setup supports it
615+
if ($this->isGzipSupported()) {
616+
$decoded = gzdecode($contents);
617+
618+
if (false === $decoded) {
619+
return null;
620+
}
621+
622+
// Unset the encoding header because it is now not encoded anymore
623+
unset($headers['content-encoding']);
624+
625+
return new Response(
626+
$decoded,
627+
$status,
628+
$headers
629+
);
630+
}
631+
632+
// Cache file was encoded (previously gzipping was supported and now the setup has changed but the cached entries
633+
// are still here) but could not be decoded anymore here - we're unable to serve a response now.
634+
return null;
635+
}
636+
535637
/**
536638
* Build and return a default lock factory for when no explicit factory
537639
* was specified.

tests/Psr6StoreTest.php

+60
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use Symfony\Component\Lock\Exception\LockReleasingException;
3030
use Symfony\Component\Lock\LockFactory;
3131
use Symfony\Component\Lock\SharedLockInterface;
32+
use Symfony\Component\Lock\Store\InMemoryStore;
3233
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;
3334

3435
class Psr6StoreTest extends TestCase
@@ -58,6 +59,16 @@ public function testCustomCacheWithoutLockFactory(): void
5859
]);
5960
}
6061

62+
public function testWrongGzipLevel(): void
63+
{
64+
$this->expectException(\InvalidArgumentException::class);
65+
$this->expectExceptionMessage('The gzip_level has to be between 0 (disabled) and 9.');
66+
67+
new Psr6Store([
68+
'gzip_level' => 20
69+
]);
70+
}
71+
6172
public function testCustomCacheAndLockFactory(): void
6273
{
6374
$cache = $this->createMock(TagAwareAdapterInterface::class);
@@ -631,6 +642,54 @@ public function testClear(): void
631642
$this->assertFalse($cacheItem2->isHit());
632643
}
633644

645+
public function testGzipHandling(): void
646+
{
647+
$cache = new ArrayAdapter();
648+
$lockFactory = new LockFactory(new InMemoryStore());
649+
$store = new Psr6Store([
650+
'cache' => $cache,
651+
'lock_factory' => $lockFactory,
652+
'generate_content_digests' => false,
653+
'gzip_level' => 9
654+
]);
655+
656+
$regularRequest = Request::create('https://foobar.com/');
657+
$gzipSupportingRequest = Request::create('https://foobar.com/');
658+
$gzipSupportingRequest->headers->set('Accept-Encoding', 'gzip, deflate, br');
659+
660+
$response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']);
661+
$store->write($regularRequest, $response);
662+
663+
$cacheKey = $store->getCacheKey($regularRequest);
664+
$cacheItem = $cache->getItem($cacheKey);
665+
$this->assertTrue($cacheItem->isHit());
666+
667+
// Should be gzip encoded on level 9
668+
$this->assertSame(gzencode('hello world', 9), $cacheItem->get()['non-varying']['content']);
669+
670+
// Content should be decoded if we don't support gzip
671+
$response = $store->lookup(Request::create('https://foobar.com/'));
672+
$this->assertSame('hello world', $response->getContent());
673+
$this->assertFalse($response->headers->has('Content-Encoding'));
674+
675+
// Content should be gzip encoded if we support gzip
676+
$response = $store->lookup($gzipSupportingRequest);
677+
$this->assertSame(gzencode('hello world', 9), $response->getContent());
678+
$this->assertSame('gzip', $response->headers->get('Content-Encoding'));
679+
680+
// Gzipped cache file still exists but for some reason, gzip features are not available on the system anymore
681+
// so we cannot decode it anymore - in this case, lookup should work for gzip supporting request but fail for
682+
// the regular request as decoding doesn't work.
683+
$store = new Psr6Store([
684+
'cache' => $cache,
685+
'lock_factory' => $lockFactory,
686+
'generate_content_digests' => false,
687+
'gzip_level' => 0 // Same as not having gzip features available
688+
]);
689+
$this->assertInstanceOf(Response::class, $store->lookup($gzipSupportingRequest));
690+
$this->assertNull($store->lookup($regularRequest));
691+
}
692+
634693
public function testPruneIgnoredIfCacheBackendDoesNotImplementPrunableInterface(): void
635694
{
636695
$cache = $this->getMockBuilder(RedisAdapter::class)
@@ -886,6 +945,7 @@ public function testContentDigestExpiresCorrectly(array $responseHeaders, $expec
886945
$store = new Psr6Store([
887946
'cache' => $cache,
888947
'lock_factory' => $this->createMock(LockFactory::class),
948+
'gzip_level' => 0,
889949
]);
890950

891951
$response = new Response('foobar', 200, $responseHeaders);

0 commit comments

Comments
 (0)