diff --git a/README.md b/README.md index 7abc4bd..4156e8a 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ passing an array of `$options` in the constructor: **Type**: `boolean` **Default**: `true` + +* **gzip_level**: The gzip level to reduce the required cache storage. Use `0` to + deactivate gzipping. + + **Type**: `int` + **Default**: `6` ### Generating Content Digests diff --git a/src/Psr6Store.php b/src/Psr6Store.php index 8cb36cb..4b46e1a 100644 --- a/src/Psr6Store.php +++ b/src/Psr6Store.php @@ -13,6 +13,7 @@ namespace Toflar\Psr6HttpCacheStore; +use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; use Symfony\Component\Cache\Adapter\AdapterInterface; use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; @@ -79,6 +80,16 @@ public function __construct(array $options = []) $resolver->setDefault('generate_content_digests', true) ->setAllowedTypes('generate_content_digests', 'boolean'); + $resolver->setDefault('gzip_level', 6) + ->setAllowedTypes('gzip_level', 'int') + ->setNormalizer('gzip_level', function (Options $options, int $value): int { + if ($value < 0 || $value > 9) { + throw new \InvalidArgumentException('The gzip_level has to be between 0 (disabled) and 9.'); + } + + return $value; + }); + $resolver->setDefault('cache', function (Options $options) { if (!isset($options['cache_directory'])) { 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 foreach ($entries as $varyKeyResponse => $responseData) { // This can only happen if one entry only if (self::NON_VARYING_KEY === $varyKeyResponse) { - return $this->restoreResponse($responseData); + return $this->restoreResponse($request, $responseData); } // Otherwise we have to see if Vary headers match @@ -129,7 +140,7 @@ public function lookup(Request $request): ?Response ); if ($varyKeyRequest === $varyKeyResponse) { - return $this->restoreResponse($responseData); + return $this->restoreResponse($request, $responseData); } } @@ -146,8 +157,6 @@ public function write(Request $request, Response $response): string $this->saveContentDigest($response); $cacheKey = $this->getCacheKey($request); - $headers = $response->headers->all(); - unset($headers['age']); /** @var CacheItem $item */ $item = $this->cache->getItem($cacheKey); @@ -162,16 +171,19 @@ public function write(Request $request, Response $response): string $varyKey = $this->getVaryKey($response->getVary(), $request); $entries[$varyKey] = [ 'vary' => $response->getVary(), - 'headers' => $headers, 'status' => $response->getStatusCode(), 'uri' => $request->getUri(), // For debugging purposes ]; // Add content if content digests are disabled if (!$this->options['generate_content_digests']) { + $this->gzipResponse($response); $entries[$varyKey]['content'] = $response->getContent(); } + // Set headers (after potentially gzipping the response) + $entries[$varyKey]['headers'] = $this->getHeadersForCache($response); + // If the response has a Vary header we remove the non-varying entry if ($response->hasVary()) { unset($entries[self::NON_VARYING_KEY]); @@ -196,6 +208,14 @@ public function write(Request $request, Response $response): string return $cacheKey; } + private function getHeadersForCache(Response $response): array + { + $headers = $response->headers->all(); + unset($headers['age']); + + return $headers; + } + public function invalidate(Request $request): void { $cacheKey = $this->getCacheKey($request); @@ -375,6 +395,26 @@ private function getVaryKey(array $vary, Request $request): string return hash('sha256', $hashData); } + private function isResponseGzipped(Response $response): bool + { + return $response->headers->get('Content-Encoding') === 'gzip'; + } + + private function doesRequestSupportGzip(Request $request): bool + { + return \in_array('gzip', $request->getEncodings()); + } + + private function isGzipSupported(): bool + { + return $this->options['gzip_level'] !== 0 && function_exists('gzencode') && function_exists('gzdecode'); + } + + private function isCacheGzipped(array $headers): bool + { + return isset($headers['content-encoding'][0]) && $headers['content-encoding'][0] === 'gzip'; + } + private function saveContentDigest(Response $response): void { if ($response->headers->has('X-Content-Digest')) { @@ -391,20 +431,17 @@ private function saveContentDigest(Response $response): void if ($digestCacheItem->isHit()) { $cacheValue = $digestCacheItem->get(); - - // BC - if (\is_string($cacheValue)) { - $cacheValue = [ - 'expires' => 0, // Forces update to the new format - 'contents' => $cacheValue, - ]; - } } else { + if ($this->isBinaryFileResponseContentDigest($contentDigest)) { + $contents = $response->getFile()->getPathname(); + } else { + $this->gzipResponse($response); + $contents = $response->getContent(); + } + $cacheValue = [ 'expires' => 0, // Forces storing the new entry - 'contents' => $this->isBinaryFileResponseContentDigest($contentDigest) ? - $response->getFile()->getPathname() : - $response->getContent(), + 'contents' => $contents ]; } @@ -427,6 +464,29 @@ private function saveContentDigest(Response $response): void } } + private function gzipResponse(Response $response): void + { + // Do not gzip if already encoded or not supported + if ($response->headers->has('Content-Encoding') || + $response instanceof BinaryFileResponse || + !$this->isGzipSupported() || + $this->isResponseGzipped($response) + ) { + return; + } + + $encoded = gzencode((string) $response->getContent(), $this->options['gzip_level']); + + // Could not gzip + if (false === $encoded) { + return; + } + + // Update the content and set the encoding header + $response->setContent($encoded); + $response->headers->set('Content-Encoding', 'gzip'); + } + /** * Test whether a given digest identifies a BinaryFileResponse. * @@ -481,13 +541,14 @@ private function saveDeferred(CacheItem $item, $data, ?int $expiresAfter = null, * * @param array $cacheData An array containing the cache data */ - private function restoreResponse(array $cacheData): ?Response + private function restoreResponse(Request $request, array $cacheData): ?Response { // Check for content digest header if (!isset($cacheData['headers']['x-content-digest'][0])) { // No digest was generated but the content was stored inline if (isset($cacheData['content'])) { - return new Response( + return $this->buildResponseFromCache( + $request, $cacheData['content'], $cacheData['status'], $cacheData['headers'] @@ -506,11 +567,6 @@ private function restoreResponse(array $cacheData): ?Response $value = $item->get(); - // BC - if (\is_string($value)) { - $value = ['contents' => $value]; - } - if ($this->isBinaryFileResponseContentDigest($cacheData['headers']['x-content-digest'][0])) { try { $file = new File($value['contents']); @@ -525,13 +581,58 @@ private function restoreResponse(array $cacheData): ?Response ); } - return new Response( + return $this->buildResponseFromCache( + $request, $value['contents'], $cacheData['status'], $cacheData['headers'] ); } + private function buildResponseFromCache(Request $request, string $contents, int $status, array $headers): ?Response + { + // If the cache entry is not gzipped we return the file as is. + if (!$this->isCacheGzipped($headers)) { + return new Response( + $contents, + $status, + $headers + ); + } + + // Otherwise it was gzipped. Let's check if the client supports gzip, in which case we'll also return as is for + // the client to decode + if ($this->doesRequestSupportGzip($request)) { + return new Response( + $contents, + $status, + $headers + ); + } + + // Otherwise we now have to decode which we can only do if our setup supports it + if ($this->isGzipSupported()) { + $decoded = gzdecode($contents); + + if (false === $decoded) { + return null; + } + + // Unset the encoding header because it is now not encoded anymore + unset($headers['content-encoding']); + + return new Response( + $decoded, + $status, + $headers + ); + } + + // Cache file was encoded (previously gzipping was supported and now the setup has changed but the cached entries + // are still here) but could not be decoded anymore here - we're unable to serve a response now. + return null; + } + /** * Build and return a default lock factory for when no explicit factory * was specified. diff --git a/tests/Psr6StoreTest.php b/tests/Psr6StoreTest.php index 2ddb658..ded44c7 100644 --- a/tests/Psr6StoreTest.php +++ b/tests/Psr6StoreTest.php @@ -29,6 +29,7 @@ use Symfony\Component\Lock\Exception\LockReleasingException; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\SharedLockInterface; +use Symfony\Component\Lock\Store\InMemoryStore; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; class Psr6StoreTest extends TestCase @@ -58,6 +59,16 @@ public function testCustomCacheWithoutLockFactory(): void ]); } + public function testWrongGzipLevel(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The gzip_level has to be between 0 (disabled) and 9.'); + + new Psr6Store([ + 'gzip_level' => 20 + ]); + } + public function testCustomCacheAndLockFactory(): void { $cache = $this->createMock(TagAwareAdapterInterface::class); @@ -631,6 +642,77 @@ public function testClear(): void $this->assertFalse($cacheItem2->isHit()); } + public function testGzipHandling(): void + { + $cache = new ArrayAdapter(); + $lockFactory = new LockFactory(new InMemoryStore()); + $store = new Psr6Store([ + 'cache' => $cache, + 'lock_factory' => $lockFactory, + 'generate_content_digests' => false, + 'gzip_level' => 4 + ]); + + $regularRequest = Request::create('https://foobar.com/'); + $gzipSupportingRequest = Request::create('https://foobar.com/'); + $gzipSupportingRequest->headers->set('Accept-Encoding', 'gzip, deflate, br'); + + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $store->write($regularRequest, $response); + + $cacheKey = $store->getCacheKey($regularRequest); + $cacheItem = $cache->getItem($cacheKey); + $this->assertTrue($cacheItem->isHit()); + + // Should be gzip encoded on level 4 + $this->assertSame(gzencode('hello world', 4), $cacheItem->get()['non-varying']['content']); + + // Content should be decoded if we don't support gzip + $response = $store->lookup(Request::create('https://foobar.com/')); + $this->assertSame('hello world', $response->getContent()); + $this->assertFalse($response->headers->has('Content-Encoding')); + + // Content should be gzip encoded if we support gzip + $response = $store->lookup($gzipSupportingRequest); + $this->assertSame(gzencode('hello world', 4), $response->getContent()); + $this->assertSame('gzip', $response->headers->get('Content-Encoding')); + + // Gzipped cache file still exists but for some reason, gzip features are not available on the system anymore + // so we cannot decode it anymore - in this case, lookup should work for gzip supporting request but fail for + // the regular request as decoding doesn't work. + $store = new Psr6Store([ + 'cache' => $cache, + 'lock_factory' => $lockFactory, + 'generate_content_digests' => false, + 'gzip_level' => 0 // Same as not having gzip features available + ]); + $this->assertInstanceOf(Response::class, $store->lookup($gzipSupportingRequest)); + $this->assertNull($store->lookup($regularRequest)); + } + + public function testIgnoresGzipIfResponseIsAlreadyEncoded(): void + { + $cache = new ArrayAdapter(); + $lockFactory = new LockFactory(new InMemoryStore()); + $store = new Psr6Store([ + 'cache' => $cache, + 'lock_factory' => $lockFactory, + 'generate_content_digests' => false, + 'gzip_level' => 6 + ]); + + $request = Request::create('https://foobar.com/'); + $response = new Response('CwWAaGVsbG8gd29ybGQD', 200, ['Cache-Control' => 's-maxage=600, public', 'Content-Encoding' => 'br']); + $store->write($request, $response); + + $cacheKey = $store->getCacheKey($request); + $cacheItem = $cache->getItem($cacheKey); + $this->assertTrue($cacheItem->isHit()); + + // Should be untouched as it is already brotli encoded + $this->assertSame('CwWAaGVsbG8gd29ybGQD', $cacheItem->get()['non-varying']['content']); + } + public function testPruneIgnoredIfCacheBackendDoesNotImplementPrunableInterface(): void { $cache = $this->getMockBuilder(RedisAdapter::class) @@ -886,6 +968,7 @@ public function testContentDigestExpiresCorrectly(array $responseHeaders, $expec $store = new Psr6Store([ 'cache' => $cache, 'lock_factory' => $this->createMock(LockFactory::class), + 'gzip_level' => 0, ]); $response = new Response('foobar', 200, $responseHeaders);