From 8a4c89b27306d924688d42944aecdcb5dea11bb6 Mon Sep 17 00:00:00 2001 From: Jakub Zalas Date: Thu, 22 Sep 2016 16:23:06 +0100 Subject: [PATCH] [HttpKernel] Start an implementation of the PSR6 store for HttpCache --- .../HttpKernel/HttpCache/Psr6Store.php | 242 ++++++++++++++++++ .../Tests/HttpCache/Psr6StoreTest.php | 223 ++++++++++++++++ .../Component/HttpKernel/composer.json | 1 + 3 files changed, 466 insertions(+) create mode 100644 src/Symfony/Component/HttpKernel/HttpCache/Psr6Store.php 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..e34b31b03e3f2 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/HttpCache/Psr6Store.php @@ -0,0 +1,242 @@ +cachePool = $cachePool; + } + + /** + * 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) + { + // TODO: Implement lookup() method. + } + + /** + * 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) + { + if (!$response->headers->has('X-Content-Digest')) { + $contentDigest = $this->generateContentDigest($response); + + if (false === $this->save($contentDigest, $response->getContent())) { + throw new \RuntimeException('Unable to store the entity.'); + } + + $response->headers->set('X-Content-Digest', $contentDigest); + + if (!$response->headers->has('Transfer-Encoding')) { + $response->headers->set('Content-Length', strlen($response->getContent())); + } + } + + $key = $this->getCacheKey($request); + $headers = $response->headers->all(); + unset($headers['age']); + + $this->save($key, serialize(array(array($request->headers->all(), $headers)))); + } + + /** + * Invalidates all cache entries that match the request. + * + * @param Request $request A Request instance + */ + public function invalidate(Request $request) + { + // TODO: Implement invalidate() method. + } + + /** + * 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) + { + $lockKey = $this->getLockKey($request); + + if (isset($this->locks[$lockKey])) { + return true; + } + + $item = $this->cachePool->getItem($lockKey); + + if ($item->isHit()) { + return false; + } + + $this->cachePool->save($item); + + $this->locks[$lockKey] = true; + + return true; + } + + /** + * 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) + { + $lockKey = $this->getLockKey($request); + + if (!isset($this->locks[$lockKey])) { + return false; + } + + $this->cachePool->deleteItem($lockKey); + + unset($this->locks[$lockKey]); + + 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) + { + $lockKey = $this->getLockKey($request); + + if (isset($this->locks[$lockKey])) { + return true; + } + + return $this->cachePool->hasItem($this->getLockKey($request)); + } + + /** + * 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) + { + // TODO: Implement purge() method. + } + + /** + * Cleanups storage. + */ + public function cleanup() + { + $this->cachePool->deleteItems(array_keys($this->locks)); + $this->locks = array(); + } + + /** + * @param Request $request + * + * @return string + */ + private function getCacheKey(Request $request) + { + return 'md'.hash('sha256', $request->getUri()); + } + + /** + * @param Request $request + * + * @return string + */ + private function getLockKey(Request $request) + { + return $this->getCacheKey($request).'.lock'; + } + + /** + * @param Response $response + * + * @return string + */ + private function generateContentDigest(Response $response) + { + return 'en'.hash('sha256', $response->getContent()); + } + + /** + * @param string $key + * @param string $data + * + * @return bool + */ + private function save($key, $data) + { + return $this->cachePool->save($this->createCacheItem($key, $data)); + } + + /** + * @param string $key + * @param mixed $value + * @param bool $isHit + * + * @return CacheItem + */ + private function createCacheItem($key, $value, $isHit = false) + { + $f = \Closure::bind( + function ($key, $value, $isHit) { + $item = new CacheItem(); + $item->key = $key; + $item->value = $value; + $item->isHit = $isHit; + + return $item; + }, + null, + CacheItem::class + ); + + return $f($key, $value, $isHit); + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/HttpCache/Psr6StoreTest.php b/src/Symfony/Component/HttpKernel/Tests/HttpCache/Psr6StoreTest.php new file mode 100644 index 0000000000000..dc89aaf76b3d0 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/HttpCache/Psr6StoreTest.php @@ -0,0 +1,223 @@ +request = Request::create('/'); + $this->response = new Response('hello world', 200, array()); + + $this->cachePool = new TestArrayAdapter(); + $this->store = new Psr6Store($this->cachePool); + } + + public function testItLocksTheRequest() + { + $lockKey = $this->getRequestLockKey(); + + $result = $this->store->lock($this->request); + + $this->assertTrue($result, 'It returns true if lock is acquired.'); + $this->assertTrue($this->store->isLocked($this->request), 'Request is locked.'); + $this->assertTrue($this->cachePool->hasItem($lockKey), 'The cache pool stores the lock.'); + } + + public function testLockReturnsFalseIfTheLockWasAlreadyAcquiredByAnotherProcess() + { + $this->createLock(); + + $result = $this->store->lock($this->request); + + $this->assertFalse($result, 'It returns false if lock could not be acquired.'); + $this->assertTrue($this->store->isLocked($this->request), 'Request is locked.'); + $this->assertTrue($this->cachePool->hasItem($this->getRequestLockKey()), 'The cache pool stores the lock.'); + } + + public function testLockReturnsTrueIfTheLockWasAlreadyAcquiredByTheSameProcess() + { + $this->store->lock($this->request); + $result = $this->store->lock($this->request); + + $this->assertTrue($result, 'It returns true if lock was already acquired by the same process.'); + $this->assertTrue($this->store->isLocked($this->request), 'Request is locked.'); + $this->assertTrue($this->cachePool->hasItem($this->getRequestLockKey()), 'The cache pool stores the lock.'); + } + + public function testIsLockedReturnsFalseIfRequestIsNotLocked() + { + $this->assertFalse($this->store->isLocked($this->request), 'Request is not locked.'); + } + + public function testIsLockedReturnsTrueIfLockWasAcquiredByTheCurrentProcess() + { + $this->store->lock($this->request); + + $this->assertTrue($this->store->isLocked($this->request), 'Request is locked.'); + } + + public function testIsLockedReturnsTrueIfLockWasAcquiredByAnotherProcess() + { + $this->createLock(); + + $this->assertTrue($this->store->isLocked($this->request), 'Request is locked.'); + } + + public function testUnlockReturnsFalseIfLockWasNotAquiredByTheCurrentProcess() + { + $this->createLock(); + + $this->assertFalse($this->store->unlock($this->request), 'Request is not locked.'); + } + + public function testUnlockReturnsTrueIfLockIsReleased() + { + $this->store->lock($this->request); + + $this->assertTrue($this->store->unlock($this->request), 'Request was unlocked.'); + $this->assertFalse($this->store->isLocked($this->request), 'Request is not locked.'); + } + + public function testLocksAreReleasedOnCleanup() + { + $this->store->lock($this->request); + + $this->store->cleanup(); + + $this->assertFalse($this->store->isLocked($this->request), 'Request is no longer locked.'); + $this->assertFalse($this->cachePool->hasItem($this->getRequestLockKey()), 'Lock is no longer stored.'); + } + + public function testWriteStoresTheResponseContent() + { + $this->store->write($this->request, $this->response); + + $this->assertTrue($this->cachePool->hasItem($this->getContentDigest()), 'Response content is stored in cache.'); + $this->assertSame($this->response->getContent(), $this->cachePool->getItem($this->getContentDigest())->get(), 'Response content is stored in cache.'); + $this->assertSame($this->getContentDigest(), $this->response->headers->get('X-Content-Digest'), 'Content digest is stored in the response header.'); + $this->assertSame(strlen($this->response->getContent()), $this->response->headers->get('Content-Length'), 'Response content length is updated.'); + } + + public function testWriteDoesNotStoreTheResponseContentOfNonOriginalResponse() + { + $this->response->headers->set('X-Content-Digest', $this->getContentDigest()); + + $this->store->write($this->request, $this->response); + + $this->assertFalse($this->cachePool->hasItem($this->getContentDigest()), 'Response content is not stored in cache.'); + $this->assertFalse($this->response->headers->has('Content-Length'), 'Response content length is not updated.'); + } + + /** + * @expectedException \RuntimeException + */ + public function testWriteThrowsAnExceptionIfResponseContentCouldNotBeStoredInCache() + { + $this->cachePool->willFailOnSave($this->getContentDigest()); + + $this->store->write($this->request, $this->response); + } + + public function testWriteOnlyUpdatesContentLengthIfThereIsNoTransferEncodingHeader() + { + $this->response->headers->set('Transfer-Encoding', 'chunked'); + + $this->store->write($this->request, $this->response); + + $this->assertFalse($this->response->headers->has('Content-Length'), 'Response content length is not updated.'); + } + + public function testWriteStoresEntryMetadata() + { + $this->response->headers->set('age', 120); + + $this->store->write($this->request, $this->response); + + $cacheItem = $this->cachePool->getItem($this->getRequestCacheKey()); + + $this->assertInstanceOf('Psr\Cache\CacheItemInterface', $cacheItem, 'Metadata is stored in cache.'); + $this->assertTrue($cacheItem->isHit(), 'Metadata is stored in cache.'); + + $metadata = unserialize($cacheItem->get()); + $this->assertInternalType('array', $metadata, 'Metadata is stored in cache.'); + $this->assertCount(1, $metadata, 'One entry is stored as metadata.'); + $this->assertCount(2, $metadata[0], 'Request and response headers are stored.'); + $this->assertSame($metadata[0][0], $this->request->headers->all(), 'Request headers are stored as metadata.'); + $this->assertSame($metadata[0][1], array_diff_key($this->response->headers->all(), array('age' => array())), 'Response headers are stored as metadata with no age header.'); + } + + /** + * @return string + */ + private function getRequestCacheKey() + { + return 'md'.hash('sha256', $this->request->getUri()); + } + + /** + * @return string + */ + private function getRequestLockKey() + { + return $this->getRequestCacheKey().'.lock'; + } + + /** + * @return string + */ + private function getContentDigest() + { + return 'en'.hash('sha256', $this->response->getContent()); + } + + private function createLock() + { + $this->cachePool->save($this->cachePool->getItem($this->getRequestLockKey())); + } +} + +class TestArrayAdapter extends ArrayAdapter +{ + private $failOnSave = array(); + + public function willFailOnSave($key) + { + $this->failOnSave[] = $key; + } + + public function save(CacheItemInterface $item) + { + if (in_array($item->getKey(), $this->failOnSave)) { + return false; + } + + return parent::save($item); + } +} diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index 7c6fbc7f28a16..7436c9cc7dd12 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -25,6 +25,7 @@ "require-dev": { "symfony/browser-kit": "~2.8|~3.0", "symfony/class-loader": "~2.8|~3.0", + "symfony/cache": "~3.1", "symfony/config": "~2.8|~3.0", "symfony/console": "~2.8|~3.0", "symfony/css-selector": "~2.8|~3.0",