Skip to content

Commit ebf5fe6

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

File tree

3 files changed

+187
-24
lines changed

3 files changed

+187
-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

+121-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,14 @@ 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+
199219
public function invalidate(Request $request): void
200220
{
201221
$cacheKey = $this->getCacheKey($request);
@@ -375,6 +395,26 @@ private function getVaryKey(array $vary, Request $request): string
375395
return hash('sha256', $hashData);
376396
}
377397

398+
private function isResponseGzipped(Response $response): bool
399+
{
400+
return $response->headers->get('Content-Encoding') === 'gzip';
401+
}
402+
403+
private function doesRequestSupportGzip(Request $request): bool
404+
{
405+
return \in_array('gzip', $request->getEncodings());
406+
}
407+
408+
private function isGzipSupported(): bool
409+
{
410+
return $this->options['gzip_level'] !== 0 && function_exists('gzencode') && function_exists('gzdecode');
411+
}
412+
413+
private function isCacheGzipped(array $headers): bool
414+
{
415+
return isset($headers['content-encoding'][0]) && $headers['content-encoding'][0] === 'gzip';
416+
}
417+
378418
private function saveContentDigest(Response $response): void
379419
{
380420
if ($response->headers->has('X-Content-Digest')) {
@@ -391,20 +431,17 @@ private function saveContentDigest(Response $response): void
391431

392432
if ($digestCacheItem->isHit()) {
393433
$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-
}
402434
} else {
435+
if ($this->isBinaryFileResponseContentDigest($contentDigest)) {
436+
$contents = $response->getFile()->getPathname();
437+
} else {
438+
$this->gzipResponse($response);
439+
$contents = $response->getContent();
440+
}
441+
403442
$cacheValue = [
404443
'expires' => 0, // Forces storing the new entry
405-
'contents' => $this->isBinaryFileResponseContentDigest($contentDigest) ?
406-
$response->getFile()->getPathname() :
407-
$response->getContent(),
444+
'contents' => $contents
408445
];
409446
}
410447

@@ -427,6 +464,25 @@ private function saveContentDigest(Response $response): void
427464
}
428465
}
429466

467+
private function gzipResponse(Response $response): void
468+
{
469+
// Not supported or already gzipped
470+
if ($response instanceof BinaryFileResponse || !$this->isGzipSupported() || $this->isResponseGzipped($response)) {
471+
return;
472+
}
473+
474+
$encoded = gzencode((string) $response->getContent(), $this->options['gzip_level']);
475+
476+
// Could not gzip
477+
if (false === $encoded) {
478+
return;
479+
}
480+
481+
// Update the content and set the encoding header
482+
$response->setContent($encoded);
483+
$response->headers->set('Content-Encoding', 'gzip');
484+
}
485+
430486
/**
431487
* Test whether a given digest identifies a BinaryFileResponse.
432488
*
@@ -481,13 +537,14 @@ private function saveDeferred(CacheItem $item, $data, ?int $expiresAfter = null,
481537
*
482538
* @param array $cacheData An array containing the cache data
483539
*/
484-
private function restoreResponse(array $cacheData): ?Response
540+
private function restoreResponse(Request $request, array $cacheData): ?Response
485541
{
486542
// Check for content digest header
487543
if (!isset($cacheData['headers']['x-content-digest'][0])) {
488544
// No digest was generated but the content was stored inline
489545
if (isset($cacheData['content'])) {
490-
return new Response(
546+
return $this->buildResponseFromCache(
547+
$request,
491548
$cacheData['content'],
492549
$cacheData['status'],
493550
$cacheData['headers']
@@ -506,11 +563,6 @@ private function restoreResponse(array $cacheData): ?Response
506563

507564
$value = $item->get();
508565

509-
// BC
510-
if (\is_string($value)) {
511-
$value = ['contents' => $value];
512-
}
513-
514566
if ($this->isBinaryFileResponseContentDigest($cacheData['headers']['x-content-digest'][0])) {
515567
try {
516568
$file = new File($value['contents']);
@@ -525,13 +577,58 @@ private function restoreResponse(array $cacheData): ?Response
525577
);
526578
}
527579

528-
return new Response(
580+
return $this->buildResponseFromCache(
581+
$request,
529582
$value['contents'],
530583
$cacheData['status'],
531584
$cacheData['headers']
532585
);
533586
}
534587

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