From 59fca0f1d847a9bcc2bf9259ffea865c37a541e2 Mon Sep 17 00:00:00 2001 From: Yanick Witschi Date: Tue, 27 Jul 2021 17:08:08 +0200 Subject: [PATCH] Propose to merge PSR-6 HttpCache store --- .../HttpKernel/HttpCache/Psr6Store.php | 616 ++++++++++++ .../Tests/HttpCache/Fixtures/favicon.ico | Bin 0 -> 32038 bytes .../Tests/HttpCache/Psr6StoreTest.php | 920 ++++++++++++++++++ 3 files changed, 1536 insertions(+) create mode 100644 src/Symfony/Component/HttpKernel/HttpCache/Psr6Store.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/HttpCache/Fixtures/favicon.ico create mode 100644 src/Symfony/Component/HttpKernel/Tests/HttpCache/Psr6StoreTest.php diff --git a/src/Symfony/Component/HttpKernel/HttpCache/Psr6Store.php b/src/Symfony/Component/HttpKernel/HttpCache/Psr6Store.php new file mode 100644 index 0000000000000..171894142cba1 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/HttpCache/Psr6Store.php @@ -0,0 +1,616 @@ + + * + * This code is partially based on the Rack-Cache library by Ryan Tomayko, + * which is released under the MIT license. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\HttpCache; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException; +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Component\Cache\PruneableInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Lock\Exception\InvalidArgumentException as LockInvalidArgumentException; +use Symfony\Component\Lock\Exception\LockReleasingException; +use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\PersistingStoreInterface; +use Symfony\Component\Lock\Store\FlockStore; +use Symfony\Component\Lock\Store\SemaphoreStore; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Implements a storage for Symfony's HttpCache that supports PSR-6 cache + * back ends, auto-pruning of expired entries on local filesystem and cache + * invalidation by tags. + * + * @author Yanick Witschi + */ +class Psr6Store implements StoreInterface +{ + const NON_VARYING_KEY = 'non-varying'; + const COUNTER_KEY = 'write-operations-counter'; + const CLEANUP_LOCK_KEY = 'cleanup-lock'; + + /** + * @var array + */ + private $options; + + /** + * @var TagAwareAdapterInterface + */ + private $cache; + + /** + * @var LockFactory + */ + private $lockFactory; + + /** + * @var LockInterface[] + */ + private $locks = []; + + /** + * When creating a Psr6Store you can configure a number options. + * See the README for a list of all available options and their description. + */ + public function __construct(array $options = []) + { + $resolver = new OptionsResolver(); + + $resolver->setDefined('cache_directory') + ->setAllowedTypes('cache_directory', 'string'); + + $resolver->setDefault('prune_threshold', 500) + ->setAllowedTypes('prune_threshold', 'int'); + + $resolver->setDefault('cache_tags_header', 'Cache-Tags') + ->setAllowedTypes('cache_tags_header', 'string'); + + $resolver->setDefault('generate_content_digests', true) + ->setAllowedTypes('generate_content_digests', 'boolean'); + + $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'); + } + + return new FilesystemTagAwareAdapter('', 0, $options['cache_directory']); + })->setAllowedTypes('cache', AdapterInterface::class); + + $resolver->setDefault('lock_factory', function (Options $options) { + if (!isset($options['cache_directory'])) { + throw new MissingOptionsException('The cache_directory option is required unless you set the lock_factory explicitly as by default locks are also stored in the configured cache_directory.'); + } + + $defaultLockStore = $this->getDefaultLockStore($options['cache_directory']); + + return new LockFactory($defaultLockStore); + })->setAllowedTypes('lock_factory', LockFactory::class); + + $this->options = $resolver->resolve($options); + $this->cache = $this->options['cache']; + $this->lockFactory = $this->options['lock_factory']; + } + + /** + * Locates a cached Response for the Request provided. + * + * @param Request $request A Request instance + * + * @return Response|null A Response instance, or null if no cache entry was found + */ + public function lookup(Request $request): ?Response + { + $cacheKey = $this->getCacheKey($request); + + $item = $this->cache->getItem($cacheKey); + + if (!$item->isHit()) { + return null; + } + + $entries = $item->get(); + + foreach ($entries as $varyKeyResponse => $responseData) { + // This can only happen if one entry only + if (self::NON_VARYING_KEY === $varyKeyResponse) { + return $this->restoreResponse($responseData); + } + + // Otherwise we have to see if Vary headers match + $varyKeyRequest = $this->getVaryKey( + $responseData['vary'], + $request + ); + + if ($varyKeyRequest === $varyKeyResponse) { + return $this->restoreResponse($responseData); + } + } + + return null; + } + + /** + * Writes a cache entry to the store for the given Request and Response. + * + * Existing entries are read and any that match the response are removed. This + * method calls write with the new list of cache entries. + * + * @param Request $request A Request instance + * @param Response $response A Response instance + * + * @return string The key under which the response is stored + */ + public function write(Request $request, Response $response): string + { + if (null === $response->getMaxAge()) { + throw new \InvalidArgumentException('HttpCache should not forward any response without any cache expiration time to the store.'); + } + + // Save the content digest if required + $this->saveContentDigest($response); + + $cacheKey = $this->getCacheKey($request); + $headers = $response->headers->all(); + unset($headers['age']); + + $item = $this->cache->getItem($cacheKey); + + if (!$item->isHit()) { + $entries = []; + } else { + $entries = $item->get(); + } + + // Add or replace entry with current Vary header key + $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']) { + $entries[$varyKey]['content'] = $response->getContent(); + } + + // If the response has a Vary header we remove the non-varying entry + if ($response->hasVary()) { + unset($entries[self::NON_VARYING_KEY]); + } + + // Tags + $tags = []; + foreach ($response->headers->all($this->options['cache_tags_header']) as $header) { + foreach (explode(',', $header) as $tag) { + $tags[] = $tag; + } + } + + // Prune expired entries on file system if needed + $this->autoPruneExpiredEntries(); + + $this->saveDeferred($item, $entries, $response->getMaxAge(), $tags); + + // Commit all deferred cache items + $this->cache->commit(); + + return $cacheKey; + } + + /** + * Invalidates all cache entries that match the request. + * + * @param Request $request A Request instance + */ + public function invalidate(Request $request): void + { + $cacheKey = $this->getCacheKey($request); + + $this->cache->deleteItem($cacheKey); + } + + /** + * Locks the cache for a given Request. + * + * @param Request $request A Request instance + * + * @return bool|string true if the lock is acquired, the path to the current lock otherwise + */ + public function lock(Request $request) + { + $cacheKey = $this->getCacheKey($request); + + if (isset($this->locks[$cacheKey])) { + return false; + } + + $this->locks[$cacheKey] = $this->lockFactory + ->createLock($cacheKey); + + return $this->locks[$cacheKey]->acquire(); + } + + /** + * Releases the lock for the given Request. + * + * @param Request $request A Request instance + * + * @return bool False if the lock file does not exist or cannot be unlocked, true otherwise + */ + public function unlock(Request $request): bool + { + $cacheKey = $this->getCacheKey($request); + + if (!isset($this->locks[$cacheKey])) { + return false; + } + + try { + $this->locks[$cacheKey]->release(); + } catch (LockReleasingException $e) { + return false; + } finally { + unset($this->locks[$cacheKey]); + } + + return true; + } + + /** + * Returns whether or not a lock exists. + * + * @param Request $request A Request instance + * + * @return bool true if lock exists, false otherwise + */ + public function isLocked(Request $request): bool + { + $cacheKey = $this->getCacheKey($request); + + if (!isset($this->locks[$cacheKey])) { + return false; + } + + return $this->locks[$cacheKey]->isAcquired(); + } + + /** + * Purges data for the given URL. + * + * @param string $url A URL + * + * @return bool true if the URL exists and has been purged, false otherwise + */ + public function purge($url): bool + { + $cacheKey = $this->getCacheKey(Request::create($url)); + + return $this->cache->deleteItem($cacheKey); + } + + /** + * Release all locks. + * + * {@inheritdoc} + */ + public function cleanup(): void + { + try { + foreach ($this->locks as $lock) { + $lock->release(); + } + } catch (LockReleasingException $e) { + // noop + } finally { + $this->locks = []; + } + } + + /** + * The tags are set from the header configured in cache_tags_header. + * + * {@inheritdoc} + */ + public function invalidateTags(array $tags): bool + { + if (!$this->cache instanceof TagAwareAdapterInterface) { + throw new \RuntimeException('Cannot invalidate tags on a cache + implementation that does not implement the TagAwareAdapterInterface.'); + } + + try { + return $this->cache->invalidateTags($tags); + } catch (CacheInvalidArgumentException $e) { + return false; + } + } + + /** + * {@inheritdoc} + */ + public function prune(): void + { + if (!$this->cache instanceof PruneableInterface) { + return; + } + + // Make sure we do not have multiple clearing or pruning processes running + $lock = $this->lockFactory->createLock(self::CLEANUP_LOCK_KEY); + + if ($lock->acquire()) { + $this->cache->prune(); + + $lock->release(); + } + } + + /** + * {@inheritdoc} + */ + public function clear(): void + { + // Make sure we do not have multiple clearing or pruning processes running + $lock = $this->lockFactory->createLock(self::CLEANUP_LOCK_KEY); + + if ($lock->acquire()) { + $this->cache->clear(); + + $lock->release(); + } + } + + public function getCacheKey(Request $request): string + { + // Strip scheme to treat https and http the same + $uri = $request->getUri(); + $uri = substr($uri, \strlen($request->getScheme().'://')); + + return 'md'.hash('sha256', $uri); + } + + /** + * @internal Do not use in public code, this is for unit testing purposes only + */ + public function generateContentDigest(Response $response): ?string + { + if ($response instanceof BinaryFileResponse) { + return 'bf'.hash_file('sha256', $response->getFile()->getPathname()); + } + + if (!$this->options['generate_content_digests']) { + return null; + } + + return 'en'.hash('sha256', $response->getContent()); + } + + private function getVaryKey(array $vary, Request $request): string + { + if (0 === \count($vary)) { + return self::NON_VARYING_KEY; + } + + // Normalize + $vary = array_map('strtolower', $vary); + sort($vary); + + $hashData = ''; + + foreach ($vary as $headerName) { + if ('cookie' === $headerName) { + continue; + } + + $hashData .= $headerName.':'.$request->headers->get($headerName); + } + + if (\in_array('cookie', $vary, true)) { + $hashData .= 'cookies:'; + foreach ($request->cookies->all() as $k => $v) { + $hashData .= $k.'='.$v; + } + } + + return hash('sha256', $hashData); + } + + private function saveContentDigest(Response $response): void + { + if ($response->headers->has('X-Content-Digest')) { + return; + } + + $contentDigest = $this->generateContentDigest($response); + + if (null === $contentDigest) { + return; + } + + $digestCacheItem = $this->cache->getItem($contentDigest); + + if ($digestCacheItem->isHit()) { + $cacheValue = $digestCacheItem->get(); + + // BC + if (\is_string($cacheValue)) { + $cacheValue = [ + 'expires' => 0, // Forces update to the new format + 'contents' => $cacheValue, + ]; + } + } else { + $cacheValue = [ + 'expires' => 0, // Forces storing the new entry + 'contents' => $this->isBinaryFileResponseContentDigest($contentDigest) ? + $response->getFile()->getPathname() : + $response->getContent(), + ]; + } + + $responseMaxAge = (int) $response->getMaxAge(); + + // Update expires key and save the entry if required + if ($responseMaxAge > $cacheValue['expires']) { + $cacheValue['expires'] = $responseMaxAge; + + if (false === $this->saveDeferred($digestCacheItem, $cacheValue, $responseMaxAge)) { + throw new \RuntimeException('Unable to store the entity.'); + } + } + + $response->headers->set('X-Content-Digest', $contentDigest); + + // Make sure the content-length header is present + if (!$response->headers->has('Transfer-Encoding')) { + $response->headers->set('Content-Length', \strlen((string) $response->getContent())); + } + } + + /** + * Test whether a given digest identifies a BinaryFileResponse. + * + * @param string $digest + */ + private function isBinaryFileResponseContentDigest($digest): bool + { + return 'bf' === substr($digest, 0, 2); + } + + /** + * Increases a counter every time a write action is performed and then + * prunes expired cache entries if a configurable threshold is reached. + * This only happens during write operations so cache retrieval is not + * slowed down. + */ + private function autoPruneExpiredEntries(): void + { + if (0 === $this->options['prune_threshold']) { + return; + } + + $item = $this->cache->getItem(self::COUNTER_KEY); + $counter = (int) $item->get(); + + if ($counter > $this->options['prune_threshold']) { + $this->prune(); + $counter = 0; + } else { + ++$counter; + } + + $item->set($counter); + + $this->cache->saveDeferred($item); + } + + /** + * @param int $expiresAfter + * @param array $tags + */ + private function saveDeferred(CacheItemInterface $item, $data, $expiresAfter = null, $tags = []): bool + { + $item->set($data); + $item->expiresAfter($expiresAfter); + + if (0 !== \count($tags) && method_exists($item, 'tag')) { + $item->tag($tags); + } + + return $this->cache->saveDeferred($item); + } + + /** + * Restores a Response from the cached data. + * + * @param array $cacheData An array containing the cache data + */ + private function restoreResponse(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( + $cacheData['content'], + $cacheData['status'], + $cacheData['headers'] + ); + } + + // No content digest and no inline content means we cannot restore the response + return null; + } + + $item = $this->cache->getItem($cacheData['headers']['x-content-digest'][0]); + + if (!$item->isHit()) { + return null; + } + + $value = $item->get(); + + // BC + if (\is_string($value)) { + $value = ['expires' => 0, 'contents' => $value]; + } + + if ($this->isBinaryFileResponseContentDigest($cacheData['headers']['x-content-digest'][0])) { + try { + $file = new File($value['contents']); + } catch (FileNotFoundException $e) { + return null; + } + + return new BinaryFileResponse( + $file, + $cacheData['status'], + $cacheData['headers'] + ); + } + + return new Response( + $value['contents'], + $cacheData['status'], + $cacheData['headers'] + ); + } + + /** + * Build and return a default lock factory for when no explicit factory + * was specified. + * The default factory uses the best quality lock store that is available + * on this system. + */ + private function getDefaultLockStore(string $cacheDir): PersistingStoreInterface + { + try { + return new SemaphoreStore(); + } catch (LockInvalidArgumentException $exception) { + return new FlockStore($cacheDir); + } + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/Fixtures/favicon.ico b/src/Symfony/Component/HttpKernel/Tests/HttpCache/Fixtures/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e55c585990e5b2e5c6d56848ca268e1e3f027427 GIT binary patch literal 32038 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 + * + * This code is partially based on the Rack-Cache library by Ryan Tomayko, + * which is released under the MIT license. + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\HttpCache; + +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Symfony\Component\Cache\Adapter\AdapterInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Adapter\RedisAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapter; +use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpCache\Psr6Store; +use Symfony\Component\Lock\Exception\LockReleasingException; +use Symfony\Component\Lock\LockFactory; +use Symfony\Component\Lock\LockInterface; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; + +class Psr6StoreTest extends TestCase +{ + /** + * @var Psr6Store + */ + private $store; + + protected function setUp(): void + { + $this->store = new Psr6Store(['cache_directory' => sys_get_temp_dir()]); + } + + protected function tearDown(): void + { + $this->getCache()->clear(); + $this->store->cleanup(); + } + + public function testCustomCacheWithoutLockFactory(): void + { + $this->expectException(MissingOptionsException::class); + $this->expectExceptionMessage('The cache_directory option is required unless you set the lock_factory explicitly as by default locks are also stored in the configured cache_directory.'); + + $cache = $this->createMock(TagAwareAdapterInterface::class); + + new Psr6Store([ + 'cache' => $cache, + ]); + } + + public function testCustomCacheAndLockFactory(): void + { + $cache = $this->createMock(TagAwareAdapterInterface::class); + $cache->expects($this->once()) + ->method('deleteItem') + ->willReturn(true); + $lockFactory = $this->createMock(LockFactory::class); + + $store = new Psr6Store([ + 'cache' => $cache, + 'lock_factory' => $lockFactory, + ]); + + $store->purge('/'); + } + + public function testItLocksTheRequest(): void + { + $request = Request::create('/'); + $result = $this->store->lock($request); + + $this->assertTrue($result, 'It returns true if lock is acquired.'); + $this->assertTrue($this->store->isLocked($request), 'Request is locked.'); + } + + public function testLockReturnsFalseIfTheLockWasAlreadyAcquired(): void + { + $request = Request::create('/'); + $this->store->lock($request); + + $result = $this->store->lock($request); + + $this->assertFalse($result, 'It returns false if lock could not be acquired.'); + $this->assertTrue($this->store->isLocked($request), 'Request is locked.'); + } + + public function testIsLockedReturnsFalseIfRequestIsNotLocked(): void + { + $request = Request::create('/'); + $this->assertFalse($this->store->isLocked($request), 'Request is not locked.'); + } + + public function testIsLockedReturnsTrueIfLockWasAcquired(): void + { + $request = Request::create('/'); + $this->store->lock($request); + + $this->assertTrue($this->store->isLocked($request), 'Request is locked.'); + } + + public function testUnlockReturnsFalseIfLockWasNotAcquired(): void + { + $request = Request::create('/'); + $this->assertFalse($this->store->unlock($request), 'Request is not locked.'); + } + + public function testUnlockReturnsTrueIfLockIsReleased(): void + { + $request = Request::create('/'); + $this->store->lock($request); + + $this->assertTrue($this->store->unlock($request), 'Request was unlocked.'); + $this->assertFalse($this->store->isLocked($request), 'Request is not locked.'); + } + + public function testLocksAreReleasedOnCleanup(): void + { + $request = Request::create('/'); + $this->store->lock($request); + + $this->store->cleanup(); + + $this->assertFalse($this->store->isLocked($request), 'Request is no longer locked.'); + } + + public function testSameLockCanBeAcquiredAgain(): void + { + $request = Request::create('/'); + + $this->assertTrue($this->store->lock($request)); + $this->assertTrue($this->store->unlock($request)); + $this->assertTrue($this->store->lock($request)); + } + + public function testThrowsIfResponseHasNoExpirationTime(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('HttpCache should not forward any response without any cache expiration time to the store.'); + $this->store->write($request, $response); + } + + public function testWriteThrowsExceptionIfDigestCannotBeStored(): void + { + $innerCache = new ArrayAdapter(); + $cache = $this->getMockBuilder(TagAwareAdapter::class) + ->setConstructorArgs([$innerCache]) + ->onlyMethods(['saveDeferred']) + ->getMock(); + + $cache + ->expects($this->once()) + ->method('saveDeferred') + ->willReturn(false); + + $store = new Psr6Store([ + 'cache_directory' => sys_get_temp_dir(), + 'cache' => $cache, + ]); + + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unable to store the entity.'); + $store->write($request, $response); + } + + public function testWriteStoresTheResponseContent(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $contentDigest = $this->store->generateContentDigest($response); + + $this->store->write($request, $response); + + $this->assertTrue($this->getCache()->hasItem($contentDigest), 'Response content is stored in cache.'); + $this->assertSame(['expires' => 600, 'contents' => $response->getContent()], $this->getCache()->getItem($contentDigest)->get(), 'Response content is stored in cache.'); + $this->assertSame($contentDigest, $response->headers->get('X-Content-Digest'), 'Content digest is stored in the response header.'); + $this->assertSame(\strlen($response->getContent()), (int)$response->headers->get('Content-Length'), 'Response content length is updated.'); + } + + public function testWriteDoesNotStoreTheResponseContentOfNonOriginalResponse(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $contentDigest = $this->store->generateContentDigest($response); + + $response->headers->set('X-Content-Digest', $contentDigest); + + $this->store->write($request, $response); + + $this->assertFalse($this->getCache()->hasItem($contentDigest), 'Response content is not stored in cache.'); + $this->assertFalse($response->headers->has('Content-Length'), 'Response content length is not updated.'); + } + + public function testWriteOnlyUpdatesContentLengthIfThereIsNoTransferEncodingHeader(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Transfer-Encoding', 'chunked'); + + $this->store->write($request, $response); + + $this->assertFalse($response->headers->has('Content-Length'), 'Response content length is not updated.'); + } + + public function testWriteStoresEntries(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('age', 120); + + $cacheKey = $this->store->getCacheKey($request); + + $this->store->write($request, $response); + + $cacheItem = $this->getCache()->getItem($cacheKey); + + $this->assertInstanceOf(CacheItemInterface::class, $cacheItem, 'Metadata is stored in cache.'); + $this->assertTrue($cacheItem->isHit(), 'Metadata is stored in cache.'); + + $entries = $cacheItem->get(); + + $this->assertTrue(\is_array($entries), 'Entries are stored in cache.'); + $this->assertCount(1, $entries, 'One entry is stored.'); + $this->assertSame($entries[Psr6Store::NON_VARYING_KEY]['headers'], array_diff_key($response->headers->all(), ['age' => []]), 'Response headers are stored with no age header.'); + } + + public function testWriteAddsTags(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Cache-Tags', 'foobar,other tag'); + + $cacheKey = $this->store->getCacheKey($request); + + $this->store->write($request, $response); + + $this->assertTrue($this->getCache()->getItem($cacheKey)->isHit()); + $this->assertTrue($this->store->invalidateTags(['foobar'])); + $this->assertFalse($this->getCache()->getItem($cacheKey)->isHit()); + } + + public function testWriteAddsTagsWithMultipleHeaders(): void + { + $request = Request::create('/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Cache-Tags', ['foobar,other tag', 'some,more', 'tags', 'split,over', 'multiple-headers']); + + $cacheKey = $this->store->getCacheKey($request); + + $this->store->write($request, $response); + + $this->assertTrue($this->getCache()->getItem($cacheKey)->isHit()); + $this->assertTrue($this->store->invalidateTags(['multiple-headers'])); + $this->assertFalse($this->getCache()->getItem($cacheKey)->isHit()); + } + + public function testInvalidateTagsThrowsExceptionIfWrongCacheAdapterProvided(): void + { + $this->expectException(\RuntimeException::class); + $store = new Psr6Store([ + 'cache' => $this->createMock(AdapterInterface::class), + 'cache_directory' => 'foobar', + ]); + $store->invalidateTags(['foobar']); + } + + public function testInvalidateTagsReturnsFalseOnException(): void + { + $innerCache = new ArrayAdapter(); + $cache = $this->getMockBuilder(TagAwareAdapter::class) + ->setConstructorArgs([$innerCache]) + ->setMethods(['invalidateTags']) + ->getMock(); + + $cache + ->expects($this->once()) + ->method('invalidateTags') + ->willThrowException(new \Symfony\Component\Cache\Exception\InvalidArgumentException()); + + $store = new Psr6Store([ + 'cache_directory' => sys_get_temp_dir(), + 'cache' => $cache, + ]); + + $this->assertFalse($store->invalidateTags(['foobar'])); + } + + public function testVaryResponseDropsNonVaryingOne(): void + { + $request = Request::create('/'); + $nonVarying = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $varying = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Foobar', 'Foobar' => 'whatever']); + + $this->store->write($request, $nonVarying); + + $cacheKey = $this->store->getCacheKey($request); + $cacheItem = $this->getCache()->getItem($cacheKey); + $entries = $cacheItem->get(); + + $this->assertCount(1, $entries); + $this->assertSame(Psr6Store::NON_VARYING_KEY, key($entries)); + + $this->store->write($request, $varying); + + $cacheItem = $this->getCache()->getItem($cacheKey); + + $entries = $cacheItem->get(); + + $this->assertCount(1, $entries); + $this->assertNotSame(Psr6Store::NON_VARYING_KEY, key($entries)); + } + + public function testRegularCacheKey(): void + { + $request = Request::create('https://foobar.com/'); + $expected = 'md' . hash('sha256', 'foobar.com/'); + $this->assertSame($expected, $this->store->getCacheKey($request)); + } + + public function testHttpAndHttpsGenerateTheSameCacheKey(): void + { + $request = Request::create('https://foobar.com/'); + $cacheKeyHttps = $this->store->getCacheKey($request); + $request = Request::create('http://foobar.com/'); + $cacheKeyHttp = $this->store->getCacheKey($request); + + $this->assertSame($cacheKeyHttps, $cacheKeyHttp); + } + + public function testDebugInfoIsAdded(): void + { + $request = Request::create('https://foobar.com/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $this->store->write($request, $response); + + $cacheKey = $this->store->getCacheKey($request); + $cacheItem = $this->getCache()->getItem($cacheKey); + $entries = $cacheItem->get(); + $this->assertSame('https://foobar.com/', $entries[Psr6Store::NON_VARYING_KEY]['uri']); + } + + public function testRegularLookup(): void + { + $request = Request::create('https://foobar.com/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Foobar', 'whatever'); + + $this->store->write($request, $response); + + $result = $this->store->lookup($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('hello world', $result->getContent()); + $this->assertSame('whatever', $result->headers->get('Foobar')); + + $this->assertSame('enb94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9', $result->headers->get('X-Content-Digest')); + } + + public function testRegularLookupWithContentDigestsDisabled(): void + { + $request = Request::create('https://foobar.com/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Foobar', 'whatever'); + + $store = new Psr6Store([ + 'cache_directory' => sys_get_temp_dir(), + 'generate_content_digests' => false, + ]); + + $store->write($request, $response); + + $result = $store->lookup($request); + + $this->assertInstanceOf(Response::class, $result); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('hello world', $result->getContent()); + $this->assertSame('whatever', $result->headers->get('Foobar')); + $this->assertNull($result->headers->get('X-Content-Digest')); + } + + public function testRegularLookupWithBinaryResponse(): void + { + $request = Request::create('https://foobar.com/'); + $response = new BinaryFileResponse(__DIR__ . '/../Fixtures/favicon.ico', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Foobar', 'whatever'); + + $this->store->write($request, $response); + + $result = $this->store->lookup($request); + + $this->assertInstanceOf(BinaryFileResponse::class, $result); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame(__DIR__ . '/../Fixtures/favicon.ico', $result->getFile()->getPathname()); + $this->assertSame('whatever', $result->headers->get('Foobar')); + $this->assertSame('bfe8149cee23ba25e6b878864c1c8b3344ee1b3d5c6d468b2e4f7593be65bb1b68', $result->headers->get('X-Content-Digest')); + } + + public function testRegularLookupWithBinaryResponseWithContentDigestsDisabled(): void + { + $request = Request::create('https://foobar.com/'); + $response = new BinaryFileResponse(__DIR__ . '/../Fixtures/favicon.ico', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Foobar', 'whatever'); + + $store = new Psr6Store([ + 'cache_directory' => sys_get_temp_dir(), + 'generate_content_digests' => false, + ]); + + $store->write($request, $response); + + $result = $store->lookup($request); + + $this->assertInstanceOf(BinaryFileResponse::class, $result); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame(__DIR__ . '/../Fixtures/favicon.ico', $result->getFile()->getPathname()); + $this->assertSame('whatever', $result->headers->get('Foobar')); + $this->assertSame('bfe8149cee23ba25e6b878864c1c8b3344ee1b3d5c6d468b2e4f7593be65bb1b68', $result->headers->get('X-Content-Digest')); + } + + public function testRegularLookupWithRemovedBinaryResponse(): void + { + $request = Request::create('https://foobar.com/'); + $file = new File(__DIR__ . '/../Fixtures/favicon.ico'); + $response = new BinaryFileResponse($file, 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Foobar', 'whatever'); + + $this->store->write($request, $response); + + // Now move (same as remove) the file somewhere else + $movedFile = $file->move(__DIR__ . '/../Fixtures', 'favicon_bu.ico'); + + $result = $this->store->lookup($request); + $this->assertNull($result); + + // Move back for other tests + $movedFile->move(__DIR__ . '/Fixtures', 'favicon.ico'); + } + + public function testLookupWithVaryOnCookies(): void + { + // Cookies match + $request = Request::create('https://foobar.com/', 'GET', [], ['Foo' => 'Bar'], [], ['HTTP_COOKIE' => 'Foo=Bar']); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Cookie']); + $response->headers->setCookie(new Cookie('Foo', 'Bar', 0, '/')); + + $this->store->write($request, $response); + + $result = $this->store->lookup($request); + $this->assertInstanceOf(Response::class, $result); + + // Cookies do not match (manually removed on request) + $request = Request::create('https://foobar.com/', 'GET', [], ['Foo' => 'Bar'], [], ['HTTP_COOKIE' => 'Foo=Bar']); + $request->cookies->remove('Foo'); + + $result = $this->store->lookup($request); + $this->assertNull($result); + } + + public function testLookupWithEmptyCache(): void + { + $request = Request::create('https://foobar.com/'); + + $result = $this->store->lookup($request); + + $this->assertNull($result); + } + + public function testLookupWithVaryResponse(): void + { + $request = Request::create('https://foobar.com/'); + $request->headers->set('Foobar', 'whatever'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Foobar']); + + $this->store->write($request, $response); + + $request = Request::create('https://foobar.com/'); + $result = $this->store->lookup($request); + $this->assertNull($result); + + $request = Request::create('https://foobar.com/'); + $request->headers->set('Foobar', 'whatever'); + $result = $this->store->lookup($request); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('hello world', $result->getContent()); + $this->assertSame('Foobar', $result->headers->get('Vary')); + } + + public function testLookupWithMultipleVaryResponse(): void + { + $jsonRequest = Request::create('https://foobar.com/'); + $jsonRequest->headers->set('Accept', 'application/json'); + $htmlRequest = Request::create('https://foobar.com/'); + $htmlRequest->headers->set('Accept', 'text/html'); + + $jsonResponse = new Response('{}', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Accept', 'Content-Type' => 'application/json']); + $htmlResponse = new Response('', 200, ['Cache-Control' => 's-maxage=600, public', 'Vary' => 'Accept', 'Content-Type' => 'text/html']); + + // Fill cache + $this->store->write($jsonRequest, $jsonResponse); + $this->store->write($htmlRequest, $htmlResponse); + + // Should return null because no header provided + $request = Request::create('https://foobar.com/'); + $result = $this->store->lookup($request); + $this->assertNull($result); + + // Should return null because header provided but non matching content + $request = Request::create('https://foobar.com/'); + $request->headers->set('Accept', 'application/xml'); + $result = $this->store->lookup($request); + $this->assertNull($result); + + // Should return a JSON response + $request = Request::create('https://foobar.com/'); + $request->headers->set('Accept', 'application/json'); + $result = $this->store->lookup($request); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('{}', $result->getContent()); + $this->assertSame('Accept', $result->headers->get('Vary')); + $this->assertSame('application/json', $result->headers->get('Content-Type')); + + // Should return an HTML response + $request = Request::create('https://foobar.com/'); + $request->headers->set('Accept', 'text/html'); + $result = $this->store->lookup($request); + $this->assertSame(200, $result->getStatusCode()); + $this->assertSame('', $result->getContent()); + $this->assertSame('Accept', $result->headers->get('Vary')); + $this->assertSame('text/html', $result->headers->get('Content-Type')); + } + + public function testInvalidate(): void + { + $request = Request::create('https://foobar.com/'); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + $response->headers->set('Foobar', 'whatever'); + + $this->store->write($request, $response); + $cacheKey = $this->store->getCacheKey($request); + + $cacheItem = $this->getCache()->getItem($cacheKey); + $this->assertTrue($cacheItem->isHit()); + + $this->store->invalidate($request); + + $cacheItem = $this->getCache()->getItem($cacheKey); + $this->assertFalse($cacheItem->isHit()); + } + + public function testPurge(): void + { + // Request 1 + $request1 = Request::create('https://foobar.com/'); + $response1 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + // Request 2 + $request2 = Request::create('https://foobar.com/foobar'); + $response2 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $this->store->write($request1, $response1); + $this->store->write($request2, $response2); + $cacheKey1 = $this->store->getCacheKey($request1); + $cacheKey2 = $this->store->getCacheKey($request2); + + $cacheItem1 = $this->getCache()->getItem($cacheKey1); + $cacheItem2 = $this->getCache()->getItem($cacheKey2); + $this->assertTrue($cacheItem1->isHit()); + $this->assertTrue($cacheItem2->isHit()); + + $this->store->purge('https://foobar.com/'); + + $cacheItem1 = $this->getCache()->getItem($cacheKey1); + $cacheItem2 = $this->getCache()->getItem($cacheKey2); + $this->assertFalse($cacheItem1->isHit()); + $this->assertTrue($cacheItem2->isHit()); + } + + public function testClear(): void + { + // Request 1 + $request1 = Request::create('https://foobar.com/'); + $response1 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + // Request 2 + $request2 = Request::create('https://foobar.com/foobar'); + $response2 = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $this->store->write($request1, $response1); + $this->store->write($request2, $response2); + $cacheKey1 = $this->store->getCacheKey($request1); + $cacheKey2 = $this->store->getCacheKey($request2); + + $cacheItem1 = $this->getCache()->getItem($cacheKey1); + $cacheItem2 = $this->getCache()->getItem($cacheKey2); + $this->assertTrue($cacheItem1->isHit()); + $this->assertTrue($cacheItem2->isHit()); + + $this->store->clear(); + + $cacheItem1 = $this->getCache()->getItem($cacheKey1); + $cacheItem2 = $this->getCache()->getItem($cacheKey2); + $this->assertFalse($cacheItem1->isHit()); + $this->assertFalse($cacheItem2->isHit()); + } + + public function testPruneIgnoredIfCacheBackendDoesNotImplementPrunableInterface(): void + { + $cache = $this->getMockBuilder(RedisAdapter::class) + ->disableOriginalConstructor() + ->addMethods(['prune']) + ->getMock(); + $cache + ->expects($this->never()) + ->method('prune'); + + $store = new Psr6Store([ + 'cache_directory' => sys_get_temp_dir(), + 'cache' => $cache, + ]); + + $store->prune(); + } + + public function testAutoPruneExpiredEntries(): void + { + $innerCache = new ArrayAdapter(); + $cache = $this->getMockBuilder(TagAwareAdapter::class) + ->setConstructorArgs([$innerCache]) + ->setMethods(['prune']) + ->getMock(); + + $cache + ->expects($this->exactly(3)) + ->method('prune'); + + $lock = $this->createMock(LockInterface::class); + $lock + ->expects($this->exactly(3)) + ->method('acquire') + ->willReturn(true); + $lock + ->expects($this->exactly(3)) + ->method('release') + ->willReturn(true); + + $lockFactory = $this->createMock(LockFactory::class); + $lockFactory + ->expects($this->any()) + ->method('createLock') + ->with(Psr6Store::CLEANUP_LOCK_KEY) + ->willReturn($lock); + + $store = new Psr6Store([ + 'cache' => $cache, + 'prune_threshold' => 5, + 'lock_factory' => $lockFactory, + ]); + + foreach (range(1, 21) as $entry) { + $request = Request::create('https://foobar.com/' . $entry); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $store->write($request, $response); + } + + $store->cleanup(); + } + + public function testAutoPruneIsSkippedIfThresholdDisabled(): void + { + $innerCache = new ArrayAdapter(); + $cache = $this->getMockBuilder(TagAwareAdapter::class) + ->setConstructorArgs([$innerCache]) + ->setMethods(['prune']) + ->getMock(); + + $cache + ->expects($this->never()) + ->method('prune'); + + $store = new Psr6Store([ + 'cache_directory' => sys_get_temp_dir(), + 'cache' => $cache, + 'prune_threshold' => 0, + ]); + + foreach (range(1, 21) as $entry) { + $request = Request::create('https://foobar.com/' . $entry); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $store->write($request, $response); + } + + $store->cleanup(); + } + + public function testAutoPruneIsSkippedIfPruningIsAlreadyInProgress(): void + { + $innerCache = new ArrayAdapter(); + $cache = $this->getMockBuilder(TagAwareAdapter::class) + ->setConstructorArgs([$innerCache]) + ->setMethods(['prune']) + ->getMock(); + + $cache + ->expects($this->never()) + ->method('prune'); + + $lock = $this->createMock(LockInterface::class); + $lock + ->expects($this->exactly(3)) + ->method('acquire') + ->willReturn(false); + + $lockFactory = $this->createMock(LockFactory::class); + $lockFactory + ->expects($this->any()) + ->method('createLock') + ->with(Psr6Store::CLEANUP_LOCK_KEY) + ->willReturn($lock); + + $store = new Psr6Store([ + 'cache' => $cache, + 'prune_threshold' => 5, + 'lock_factory' => $lockFactory, + ]); + + foreach (range(1, 21) as $entry) { + $request = Request::create('https://foobar.com/' . $entry); + $response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']); + + $store->write($request, $response); + } + + $store->cleanup(); + } + + public function testItFailsWithoutCacheDirectoryForCache(): void + { + $this->expectException(MissingOptionsException::class); + new Psr6Store([]); + } + + public function testItFailsWithoutCacheDirectoryForLockStore(): void + { + $this->expectException(MissingOptionsException::class); + new Psr6Store(['cache' => $this->createMock(AdapterInterface::class)]); + } + + public function testUnlockReturnsFalseOnLockReleasingException(): void + { + $lock = $this->createMock(LockInterface::class); + $lock + ->expects($this->once()) + ->method('release') + ->willThrowException(new LockReleasingException()); + + $lockFactory = $this->createMock(LockFactory::class); + $lockFactory + ->expects($this->once()) + ->method('createLock') + ->willReturn($lock); + + $store = new Psr6Store([ + 'cache' => $this->createMock(AdapterInterface::class), + 'lock_factory' => $lockFactory, + ]); + + $request = Request::create('/foobar'); + $store->lock($request); + + $this->assertFalse($store->unlock($request)); + } + + public function testLockReleasingExceptionIsIgnoredOnCleanup(): void + { + $lock = $this->createMock(LockInterface::class); + $lock + ->expects($this->once()) + ->method('release') + ->willThrowException(new LockReleasingException()); + + $lockFactory = $this->createMock(LockFactory::class); + $lockFactory + ->expects($this->once()) + ->method('createLock') + ->willReturn($lock); + + $store = new Psr6Store([ + 'cache' => $this->createMock(AdapterInterface::class), + 'lock_factory' => $lockFactory, + ]); + + $request = Request::create('/foobar'); + $store->lock($request); + $store->cleanup(); + + // This test will fail if an exception is thrown, otherwise we mark it + // as passed. + $this->addToAssertionCount(1); + } + + /** + * @dataProvider contentDigestExpiryProvider + */ + public function testContentDigestExpiresCorrectly(array $responseHeaders, $expectedExpiresAfter, $previousItemExpiration = 0): void + { + // This is the mock for the meta cache item, we're not interested in this one + $cacheItem = $this->createMock(CacheItemInterface::class); + + // This is the one we're interested in this test + $contentDigestCacheItem = $this->createMock(CacheItemInterface::class); + $contentDigestCacheItem + ->expects($this->once()) + ->method('isHit') + ->willReturn(0 !== $previousItemExpiration); + + if (0 !== $previousItemExpiration) { + $contentDigestCacheItem + ->expects($this->once()) + ->method('get') + ->willReturn(['expires' => $previousItemExpiration, 'contents' => 'foobar']); + } else { + $contentDigestCacheItem + ->expects($this->once()) + ->method('set') + ->with(['expires' => $expectedExpiresAfter, 'contents' => 'foobar']); + $contentDigestCacheItem + ->expects($this->once()) + ->method('expiresAfter') + ->with($expectedExpiresAfter); + } + + $cache = $this->createMock(AdapterInterface::class); + $cache + ->expects($this->exactly(3)) + ->method('getItem') + ->withConsecutive( + ['enc3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2'], // content digest + ['md390aa862a7f27c16d72dd40967066969e7eb4b102c6215478a275766bf046665'], // meta + [Psr6Store::COUNTER_KEY], // write counter + ['md390aa862a7f27c16d72dd40967066969e7eb4b102c6215478a275766bf046665'] // meta again + ) + ->willReturnOnConsecutiveCalls($contentDigestCacheItem, $cacheItem, $cacheItem, $cacheItem); + + $cache + ->expects($this->any()) + ->method('saveDeferred') + ->willReturn(true); + + $store = new Psr6Store([ + 'cache' => $cache, + 'lock_factory' => $this->createMock(LockFactory::class), + ]); + + $response = new Response('foobar', 200, $responseHeaders); + $request = Request::create('https://foobar.com/'); + $store->write($request, $response); + } + + public function contentDigestExpiryProvider() + { + yield 'Test no previous response should take the same max age as the current response' => [ + ['Cache-Control' => 's-maxage=600, public'], + 600, + 0, + ]; + + yield 'Previous max-age was higher, digest expiration should not be touched then' => [ + ['Cache-Control' => 's-maxage=600, public'], + 900, + 900, + ]; + + yield 'Previous max-age was lower, digest expiration should be updated' => [ + ['Cache-Control' => 's-maxage=1800, public'], + 1800, + 900, + ]; + } + + /** + * @param null $store + */ + private function getCache($store = null): TagAwareAdapterInterface + { + if (null === $store) { + $store = $this->store; + } + + $reflection = new \ReflectionClass($store); + $cache = $reflection->getProperty('cache'); + $cache->setAccessible(true); + + return $cache->getValue($this->store); + } +}