From 785da59eb5ca9a58f871a1d5300f72349a74156d Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Wed, 23 Jun 2010 21:42:41 +0200 Subject: [PATCH] [HttpKernel] added the cache system --- .../Components/HttpKernel/Cache/Cache.php | 586 ++++++++++++ .../Components/HttpKernel/Cache/Esi.php | 214 +++++ .../Components/HttpKernel/Cache/Store.php | 402 ++++++++ .../Components/HttpKernel/CacheControl.php | 24 + .../HttpKernel/Listener/EsiFilter.php | 70 ++ .../Components/HttpKernel/ParameterBag.php | 10 + src/Symfony/Foundation/Cache/Cache.php | 71 ++ .../Command/InitApplicationCommand.php | 1 + .../Controller/ControllerManager.php | 50 + .../Controller/InternalController.php | 37 + .../Resources/config/internal_routing.xml | 10 + .../WebBundle/Resources/config/web.xml | 9 + .../skeleton/application/xml/Cache.php | 13 + .../skeleton/application/yaml/Cache.php | 13 + .../Components/HttpKernel/Cache/CacheTest.php | 871 ++++++++++++++++++ .../HttpKernel/Cache/CacheTestCase.php | 149 +++ .../Components/HttpKernel/Cache/StoreTest.php | 203 ++++ .../HttpKernel/Cache/TestHttpKernel.php | 69 ++ 18 files changed, 2802 insertions(+) create mode 100644 src/Symfony/Components/HttpKernel/Cache/Cache.php create mode 100644 src/Symfony/Components/HttpKernel/Cache/Esi.php create mode 100644 src/Symfony/Components/HttpKernel/Cache/Store.php create mode 100644 src/Symfony/Components/HttpKernel/Listener/EsiFilter.php create mode 100644 src/Symfony/Foundation/Cache/Cache.php create mode 100644 src/Symfony/Framework/WebBundle/Controller/InternalController.php create mode 100644 src/Symfony/Framework/WebBundle/Resources/config/internal_routing.xml create mode 100644 src/Symfony/Framework/WebBundle/Resources/skeleton/application/xml/Cache.php create mode 100644 src/Symfony/Framework/WebBundle/Resources/skeleton/application/yaml/Cache.php create mode 100644 tests/Symfony/Tests/Components/HttpKernel/Cache/CacheTest.php create mode 100644 tests/Symfony/Tests/Components/HttpKernel/Cache/CacheTestCase.php create mode 100644 tests/Symfony/Tests/Components/HttpKernel/Cache/StoreTest.php create mode 100644 tests/Symfony/Tests/Components/HttpKernel/Cache/TestHttpKernel.php diff --git a/src/Symfony/Components/HttpKernel/Cache/Cache.php b/src/Symfony/Components/HttpKernel/Cache/Cache.php new file mode 100644 index 000000000000..5f6533661037 --- /dev/null +++ b/src/Symfony/Components/HttpKernel/Cache/Cache.php @@ -0,0 +1,586 @@ + + * + * This code is partially based on the Rack-Cache library by Ryan Tomayko, + * which is released under the MIT license. + * (based on commit 02d2b48d75bcb63cf1c0c7149c077ad256542801) + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * Cache provides HTTP caching. + * + * @package Symfony + * @subpackage Components_HttpKernel + * @author Fabien Potencier + */ +class Cache implements HttpKernelInterface +{ + protected $kernel; + protected $traces; + protected $store; + protected $request; + protected $esi; + + /** + * Constructor. + * + * The available options are: + * + * * debug: If true, the traces are added as a HTTP header to ease debugging + * + * * default_ttl The number of seconds that a cache entry should be considered + * fresh when no explicit freshness information is provided in + * a response. Explicit Cache-Control or Expires headers + * override this value. (default: 0) + * + * * private_headers Set of request headers that trigger "private" cache-control behavior + * on responses that don't explicitly state whether the response is + * public or private via a Cache-Control directive. (default: Authorization and Cookie) + * + * * allow_reload Specifies whether the client can force a cache reload by including a + * Cache-Control "no-cache" directive in the request. This is enabled by + * default for compliance with RFC 2616. (default: false) + * + * * allow_revalidate Specifies whether the client can force a cache revalidate by including + * a Cache-Control "max-age=0" directive in the request. This is enabled by + * default for compliance with RFC 2616. (default: false) + * + * * stale_while_revalidate Specifies the default number of seconds (the granularity is the second as the + * Response TTL precision is a second) during which the cache can immediately return + * a stale response while it revalidates it in the background (default: 2). + * This setting is overriden by the stale-while-revalidate HTTP Cache-Control + * extension (see RFC 5861). + * + * * stale_if_error Specifies the default number of seconds (the granularit is the second) during which + * the cache can server a stale response when an error is encountered (default: 60). + * This setting is overriden by the stale-if-error HTTP Cache-Control extension + * (see RFC 5861). + * + * @param Symfony\Components\HttpKernel\HttpKernelInterface $kernel An HttpKernelInterface instance + * @param Symfony\Components\HttpKernel\Cache\Store $store A Store instance + * @param Symfony\Components\HttpKernel\Cache\Esi $esi An Esi instance + * @param array $options An array of options + */ + public function __construct(HttpKernelInterface $kernel, Store $store, Esi $esi = null, array $options = array()) + { + $this->store = $store; + $this->kernel = $kernel; + + // needed in case there is a fatal error because the backend is too slow to respond + register_shutdown_function(array($this->store, '__destruct')); + + $this->options = array_merge(array( + 'debug' => false, + 'default_ttl' => 0, + 'private_headers' => array('Authorization', 'Cookie'), + 'allow_reload' => false, + 'allow_revalidate' => false, + 'stale_while_revalidate' => 2, + 'stale_if_error' => 60, + ), $options); + $this->esi = $esi; + } + + /** + * Returns an array of events that took place during processing of the last request. + * + * @return array An array of events + */ + public function getTraces() + { + return $this->traces; + } + + /** + * Returns a log message for the events of the last request processing. + * + * @return string A log message + */ + public function getLog() + { + $log = array(); + foreach ($this->traces as $request => $traces) { + $log[] = sprintf('%s: %s', $request, implode(', ', $traces)); + } + + return implode('; ', $log); + } + + /** + * Gets the Request instance associated with the master request. + * + * @return Symfony\Components\HttpKernel\Request A Request instance + */ + public function getRequest() + { + return $this->request; + } + + /** + * Handles a Request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param integer $type The type of the request (one of HttpKernelInterface::MASTER_REQUEST, HttpKernelInterface::FORWARDED_REQUEST, or HttpKernelInterface::EMBEDDED_REQUEST) + * @param Boolean $raw Whether to catch exceptions or not (this is NOT used in this context) + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + public function handle(Request $request = null, $type = HttpKernelInterface::MASTER_REQUEST, $raw = false) + { + // FIXME: catch exceptions and implement a 500 error page here? -> in Varnish, there is a built-it error page mechanism + if (null === $request) { + $request = new Request(); + } + + if (HttpKernelInterface::MASTER_REQUEST === $type) { + $this->traces = array(); + $this->request = $request; + } + + $this->traces[$request->getMethod().' '.$request->getPathInfo()] = array(); + + if (!$request->isMethodSafe($request)) { + $response = $this->invalidate($request); + } elseif ($request->headers->has('expect')) { + $response = $this->pass($request); + } else { + $response = $this->lookup($request); + } + + $response->isNotModified($request); + + if ('head' === strtolower($request->getMethod())) { + $response->setContent(''); + } else { + $this->restoreResponseBody($response); + } + + if (HttpKernelInterface::MASTER_REQUEST === $type && $this->options['debug']) { + $response->headers->set('X-Symfony-Cache', $this->getLog()); + } + + return $response; + } + + /** + * Forwards the Request to the backend without storing the Response in the cache. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function pass(Request $request) + { + $this->record($request, 'pass'); + + return $this->forward($request); + } + + /** + * Invalidates non-safe methods (like POST, PUT, and DELETE). + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Symfony\Components\HttpKernel\Response A Response instance + * + * @see RFC2616 13.10 + */ + protected function invalidate(Request $request) + { + $response = $this->pass($request); + + // invalidate only when the response is successful + if ($response->isSuccessful() || $response->isRedirect()) { + try { + $this->store->invalidate($request); + + $this->record($request, 'invalidate'); + } catch (\Exception $e) { + $this->record($request, 'invalidate-failed'); + + if ($this->options['debug']) { + throw $e; + } + } + } + + return $response; + } + + /** + * Lookups a Response from the cache for the given Request. + * + * When a matching cache entry is found and is fresh, it uses it as the + * response without forwarding any request to the backend. When a matching + * cache entry is found but is stale, it attempts to "validate" the entry with + * the backend using conditional GET. When no matching cache entry is found, + * it triggers "miss" processing. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function lookup(Request $request) + { + if ($this->options['allow_reload'] && $request->isNoCache()) { + $this->record($request, 'reload'); + + return $this->fetch($request); + } + + try { + $entry = $this->store->lookup($request); + } catch (\Exception $e) { + $this->record($request, 'lookup-failed'); + + if ($this->options['debug']) { + throw $e; + } + + return $this->pass($request); + } + + if (null === $entry) { + $this->record($request, 'miss'); + + return $this->fetch($request); + } + + if (!$this->isFreshEnough($request, $entry)) { + $this->record($request, 'stale'); + + return $this->validate($request, $entry); + } + + $this->record($request, 'fresh'); + + $entry->headers->set('Age', $entry->getAge()); + + return $entry; + } + + /** + * Validates that a cache entry is fresh. + * + * The original request is used as a template for a conditional + * GET request with the backend. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Symfony\Components\HttpKernel\Response $entry A Response instance to validate + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function validate(Request $request, $entry) + { + $subRequest = clone $request; + + // send no head requests because we want content + $subRequest->setMethod('get'); + + // add our cached last-modified validator + $subRequest->headers->set('if_modified_since', $entry->headers->get('Last-Modified')); + + // Add our cached etag validator to the environment. + // We keep the etags from the client to handle the case when the client + // has a different private valid entry which is not cached here. + $cachedEtags = array($entry->getEtag()); + $requestEtags = $request->getEtags(); + $etags = array_unique(array_merge($cachedEtags, $requestEtags)); + $subRequest->headers->set('if_none_match', $etags ? implode(', ', $etags) : ''); + + $response = $this->forward($subRequest, false, $entry); + + if (304 == $response->getStatusCode()) { + $this->record($request, 'valid'); + + // return the response and not the cache entry if the response is valid but not cached + $etag = $response->getEtag(); + if ($etag && in_array($etag, $requestEtags) && !in_array($etag, $cachedEtags)) { + return $response; + } + + $entry = clone $entry; + $entry->headers->delete('Date'); + + foreach (array('Date', 'Expires', 'Cache-Control', 'ETag', 'Last-Modified') as $name) { + if ($response->headers->has($name)) { + $entry->headers->set($name, $response->headers->get($name)); + } + } + + $response = $entry; + } else { + $this->record($request, 'invalid'); + } + + if ($response->isCacheable()) { + $this->store($request, $response); + } + + return $response; + } + + /** + * Forwards the Request to the backend and determines whether the response should be stored. + * + * This methods is trigered when the cache missed or a reload is required. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function fetch(Request $request) + { + $subRequest = clone $request; + + // send no head requests because we want content + $subRequest->setMethod('get'); + + // avoid that the backend sends no content + $subRequest->headers->delete('if_modified_since'); + $subRequest->headers->delete('if_none_match'); + + $response = $this->forward($subRequest); + + if ($this->isPrivateRequest($request) && !$response->headers->getCacheControl()->isPublic()) { + $response->setPrivate(true); + } elseif ($this->options['default_ttl'] > 0 && null === $response->getTtl() && !$response->headers->getCacheControl()->mustRevalidate()) { + $response->setTtl($this->options['default_ttl']); + } + + if ($response->isCacheable()) { + $this->store($request, $response); + } + + return $response; + } + + /** + * Forwards the Request to the backend and returns the Response. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Boolean $raw Whether to catch exceptions or not + * @param Symfony\Components\HttpKernel\Response $response A Response instance (the stale entry if present, null otherwise) + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function forward(Request $request, $raw = false, Response $entry = null) + { + if ($this->esi) { + $this->esi->addSurrogateEsiCapability($request); + } + + // always a "master" request (as the real master request can be in cache) + $response = $this->kernel->handle($request, HttpKernelInterface::MASTER_REQUEST, $raw); + // FIXME: we probably need to also catch exceptions if raw === true + + // we don't implement the stale-if-error on Requests, which is nonetheless part of the RFC + if (null !== $entry && in_array($response->getStatusCode(), array(500, 502, 503, 504))) { + if (null === $age = $entry->headers->getCacheControl()->getStaleIfError()) { + $age = $this->options['stale_if_error']; + } + + if (abs($entry->getTtl()) < $age) { + $this->record($request, 'stale-if-error'); + + return $entry; + } + } + + $this->processResponseBody($request, $response); + + return $response; + } + + /** + * Checks whether the cache entry is "fresh enough" to satisfy the Request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Symfony\Components\HttpKernel\Response $entry A Response instance + * + * @return Boolean true if the cache entry if fresh enough, false otherwise + */ + protected function isFreshEnough(Request $request, Response $entry) + { + if (!$entry->isFresh()) { + return $this->lock($request, $entry); + } + + if ($this->options['allow_revalidate'] && null !== $maxAge = $request->headers->getCacheControl()->getMaxAge()) { + return $maxAge > 0 && $maxAge >= $entry->getAge(); + } + + return true; + } + + /** + * Locks a Request during the call to the backend. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Symfony\Components\HttpKernel\Response $entry A Response instance + * + * @return Boolean true if the cache entry can be returned even if it is staled, false otherwise + */ + protected function lock(Request $request, Response $entry) + { + // try to acquire a lock to call the backend + $lock = $this->store->lock($request, $entry); + + // there is already another process calling the backend + if (true !== $lock) { + // check if we can serve the stale entry + if (null === $age = $entry->headers->getCacheControl()->getStaleWhileRevalidate()) { + $age = $this->options['stale_while_revalidate']; + } + + if (abs($entry->getTtl()) < $age) { + $this->record($request, 'stale-while-revalidate'); + + // server the stale response while there is a revalidation + return true; + } else { + // wait for the lock to be released + $wait = 0; + while (file_exists($lock) && $wait < 5) { + sleep($wait += 0.05); + } + + if ($wait < 2) { + // replace the current entry with the fresh one + $new = $this->lookup($request); + $entry->headers = $new->headers; + $entry->setContent($new->getContent()); + $entry->setStatusCode($new->getStatusCode()); + $entry->setProtocolVersion($new->getProtocolVersion()); + $entry->setCookies($new->getCookies()); + + return true; + } else { + // backend is slow as hell, send a 503 response (to avoid the dog pile effect) + $entry->setStatusCode(503); + $entry->setContent('503 Service Unavailable'); + $entry->headers->set('Retry-After', 10); + + return true; + } + } + } + + // we have the lock, call the backend + return false; + } + + /** + * Writes the Response to the cache. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Symfony\Components\HttpKernel\Response $response A Response instance + */ + protected function store(Request $request, Response $response) + { + try { + $this->store->write($request, $response); + + $this->record($request, 'store'); + + $response->headers->set('Age', $response->getAge()); + } catch (\Exception $e) { + $this->record($request, 'store-failed'); + + if ($this->options['debug']) { + throw $e; + } + } + + // now that the response is cached, release the lock + $this->store->unlock($request); + } + + /** + * Restores the Response body. + * + * @param Symfony\Components\HttpKernel\Response $response A Response instance + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function restoreResponseBody(Response $response) + { + if ($response->headers->has('X-Body-Eval')) { + ob_start(); + + if ($response->headers->has('X-Body-File')) { + include $response->headers->get('X-Body-File'); + } else { + eval('; ?>'.$response->getContent().'setContent(ob_get_clean()); + $response->headers->delete('X-Body-Eval'); + } elseif ($response->headers->has('X-Body-File')) { + $response->setContent(file_get_contents($response->headers->get('X-Body-File'))); + } else { + return; + } + + $response->headers->delete('X-Body-File'); + + if (!$response->headers->has('Transfer-Encoding')) { + $response->headers->set('Content-Length', strlen($response->getContent())); + } + } + + protected function processResponseBody(Request $request, Response $response) + { + if (null !== $this->esi && $this->esi->needsEsiParsing($response)) { + $this->esi->process($request, $response); + } + } + + /** + * Checks if the Request includes authorization or other sensitive information + * that should cause the Response to be considered private by default. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Boolean true if the Request is private, false otherwise + */ + protected function isPrivateRequest(Request $request) + { + foreach ($this->options['private_headers'] as $key) { + $key = strtolower(str_replace('HTTP_', '', $key)); + + if ('cookie' === $key) { + if (count($request->cookies->all())) { + return true; + } + } elseif ($request->headers->has($key)) { + return true; + } + } + + return false; + } + + /** + * Records that an event took place. + * + * @param string $event The event name + */ + protected function record(Request $request, $event) + { + $this->traces[$request->getMethod().' '.$request->getPathInfo()][] = $event; + } +} diff --git a/src/Symfony/Components/HttpKernel/Cache/Esi.php b/src/Symfony/Components/HttpKernel/Cache/Esi.php new file mode 100644 index 000000000000..add45c0b877d --- /dev/null +++ b/src/Symfony/Components/HttpKernel/Cache/Esi.php @@ -0,0 +1,214 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * Esi implements the ESI capabilities to Request and Response instances. + * + * For more information, read the following W3C notes: + * + * * ESI Language Specification 1.0 (http://www.w3.org/TR/esi-lang) + * + * * Edge Architecture Specification (http://www.w3.org/TR/edge-arch) + * + * @package Symfony + * @subpackage Components_HttpKernel + * @author Fabien Potencier + */ +class Esi +{ + protected $contentTypes; + + /** + * Constructor. + * + * @param array $contentTypes An array of content-type that should be parsed for ESI information. + * (default: text/html, text/xml, and application/xml) + */ + public function __construct(array $contentTypes = array('text/html', 'text/xml', 'application/xml')) + { + $this->contentTypes = $contentTypes; + } + + /** + * Checks that at least one surrogate has ESI/1.0 capability. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Boolean true if one surrogate has ESI/1.0 capability, false otherwise + */ + public function hasSurrogateEsiCapability(Request $request) + { + if (null === $value = $request->headers->get('Surrogate-Capability')) { + return false; + } + + return preg_match('#ESI/1.0#', $value); + } + + /** + * Adds ESI/1.0 capability to the given Request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + */ + public function addSurrogateEsiCapability(Request $request) + { + $current = $request->headers->get('Surrogate-Capability'); + $new = 'symfony2="ESI/1.0"'; + + $request->headers->set('Surrogate-Capability', $current ? $current.', '.$new : $new); + } + + /** + * Adds HTTP headers to specify that the Response needs to be parsed for ESI. + * + * This method only adds an ESI HTTP header if the Response has some ESI tags. + * + * @param Symfony\Components\HttpKernel\Response $response A Response instance + */ + public function addSurrogateControl(Response $response) + { + if (false !== strpos($response->getContent(), 'headers->set('Surrogate-Control', 'content="ESI/1.0"'); + } + } + + /** + * Checks that the Response needs to be parsed for ESI tags. + * + * @param Symfony\Components\HttpKernel\Response $response A Response instance + * + * @return Boolean true if the Response needs to be parsed, false otherwise + */ + public function needsEsiParsing(Response $response) + { + if (!$control = $response->headers->get('Surrogate-Control')) { + return false; + } + + return preg_match('#content="[^"]*ESI/1.0[^"]*"#', $control); + } + + /** + * Renders an ESI tag. + * + * @param string $uri A URI + * @param string $alt An alternate URI + * @param Boolean $ignoreErrors Whether to ignore errors or not + * @param string $comment A comment to add as an esi:include tag + */ + public function renderTag($uri, $alt, $ignoreErrors = true, $comment = '') + { + $html = sprintf('', + $uri, + $ignoreErrors ? ' onerror="continue"' : '', + $alt ? sprintf(' alt="%s"', $alt) : '' + ); + + if (!empty($comment)) { + $html .= sprintf("\n%s", $comment, $output); + } + + return $html; + } + + /** + * Replaces a Response ESI tags with the included resource content. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Symfony\Components\HttpKernel\Response $response A Response instance + */ + public function process(Request $request, Response $response) + { + $this->request = $request; + $type = $response->headers->get('Content-Type'); + if (empty($type)) { + $type = 'text/html'; + } + + if (!in_array($type, $this->contentTypes)) { + return $response; + } + + // we don't use a proper XML parser here as we can have ESI tags in a plain text response + $content = $response->getContent(); + $content = preg_replace_callback('##', array($this, 'handleEsiIncludeTag'), $content); + $content = preg_replace('#]*/>#', '', $content); + $content = preg_replace('#.*?#', '', $content); + + $response->setContent($content); + $response->headers->set('X-Body-Eval', 'ESI'); + + // remove ESI/1.0 from the Surrogate-Control header + $value = $response->headers->get('Surrogate-Control'); + if (preg_match('#^content="ESI/1.0"$#', $value)) { + $response->headers->delete('Surrogate-Control'); + } else { + $response->headers->set('Surrogate-Control', preg_replace('#ESI/1.0#', '', $value)); + } + } + + /** + * Handles an ESI from the cache. + * + * @param Symfony\Components\HttpKernel\Cache\Cache $cache A Cache instance + * @param string $uri The main URI + * @param string $alt An alternative URI + * @param Boolean $ignoreErrors Whether to ignore errors or not + */ + public function handle(Cache $cache, $uri, $alt, $ignoreErrors) + { + $subRequest = Request::create($uri, 'get', array(), $cache->getRequest()->cookies->all(), array(), $cache->getRequest()->server->all()); + + try { + return $cache->handle($subRequest, HttpKernelInterface::EMBEDDED_REQUEST, true); + } catch (\Exception $e) { + if ($alt) { + return $this->handle($cache, $alt, '', $ignoreErrors); + } + + if (!$ignoreErrors) { + throw $e; + } + } + } + + /** + * Handles an ESI include tag (called internally). + * + * @param array $attributes An array containing the attributes. + * + * @param string The response content for the include. + */ + protected function handleEsiIncludeTag($attributes) + { + $options = array(); + preg_match_all('/(src|onerror|alt)="([^"]*?)"/', $attributes[1], $matches, PREG_SET_ORDER); + foreach ($matches as $set) { + $options[$set[1]] = $set[2]; + } + + if (!isset($options['src'])) { + throw new \RuntimeException('Unable to process an ESI tag without a "src" attribute.'); + } + + return sprintf('esi->handle($this, \'%s\', \'%s\', %s)->getContent() ?>'."\n", + $options['src'], + isset($options['alt']) ? $options['alt'] : null, + isset($options['onerror']) && 'continue' == $options['onerror'] ? 'true' : 'false' + ); + } +} diff --git a/src/Symfony/Components/HttpKernel/Cache/Store.php b/src/Symfony/Components/HttpKernel/Cache/Store.php new file mode 100644 index 000000000000..bacd39052db6 --- /dev/null +++ b/src/Symfony/Components/HttpKernel/Cache/Store.php @@ -0,0 +1,402 @@ + + * + * This code is partially based on the Rack-Cache library by Ryan Tomayko, + * which is released under the MIT license. + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * Store implements all the logic for storing cache metadata (Request and Response headers). + * + * @package Symfony + * @subpackage Components_HttpKernel + * @author Fabien Potencier + */ +class Store +{ + protected $root; + protected $keyCache; + protected $locks; + + /** + * Constructor. + * + * @param string $root The path to the cache directory + */ + public function __construct($root) + { + $this->root = $root; + if (!is_dir($this->root)) { + mkdir($this->root, 0755, true); + } + $this->keyCache = new \SplObjectStorage(); + $this->locks = array(); + } + + public function __destruct() + { + // unlock everything + foreach ($this->locks as $lock) { + @unlink($lock); + } + + $error = error_get_last(); + if (1 === $error['type'] && false === headers_sent()) { + // send a 503 + header('HTTP/1.0 503 Service Unavailable'); + header('Retry-After: 10'); + echo '503 Service Unavailable'; + } + } + + /** + * Locks the cache for a given Request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Boolean|string true if the lock is acquired, the path to the current lock otherwise + */ + public function lock(Request $request) + { + if (false !== $lock = @fopen($path = $this->getPath($this->getCacheKey($request).'.lck'), 'x')) { + fclose($lock); + + $this->locks[] = $path; + + return true; + } else { + return $path; + } + } + + /** + * Releases the lock for the given Request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + */ + public function unlock(Request $request) + { + return @unlink($this->getPath($this->getCacheKey($request).'.lck')); + } + + /** + * Locates a cached Response for the Request provided. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return Symfony\Components\HttpKernel\Response|null A Response instance, or null if no cache entry was found + */ + public function lookup(Request $request) + { + $key = $this->getCacheKey($request); + + if (!$entries = $this->getMetadata($key)) { + return null; + } + + // find a cached entry that matches the request. + $match = null; + foreach ($entries as $entry) { + if ($this->requestsMatch(isset($entry[1]['vary']) ? $entry[1]['vary'][0] : '', $request->headers->all(), $entry[0])) + { + $match = $entry; + + break; + } + } + + if (null === $match) { + return null; + } + + list($req, $headers) = $match; + if (file_exists($body = $this->getPath($headers['x-content-digest'][0]))) { + return $this->restoreResponse($headers, $body); + } else { + // TODO the metaStore referenced an entity that doesn't exist in + // the entityStore. We definitely want to return nil but we should + // also purge the entry from the meta-store when this is detected. + 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 Symfony\Components\HttpKernel\Request $request A Request instance + * @param Symfony\Components\HttpKernel\Response $response A Response instance + * + * @return string The key under which the response is stored + */ + public function write(Request $request, Response $response) + { + $key = $this->getCacheKey($request); + $storedEnv = $this->persistRequest($request); + + // write the response body to the entity store if this is the original response + if (!$response->headers->has('X-Content-Digest')) { + $digest = 'en'.sha1($response->getContent()); + + if (false === $this->save($digest, $response->getContent())) { + throw new \RuntimeException(sprintf('Unable to store the entity (%s).', $e->getMessage())); + } + + $response->headers->set('X-Content-Digest', $digest); + + if (!$response->headers->has('Transfer-Encoding')) { + $response->headers->set('Content-Length', strlen($response->getContent())); + } + } + + // read existing cache entries, remove non-varying, and add this one to the list + $entries = array(); + $vary = $response->headers->get('vary'); + foreach ($this->getMetadata($key) as $entry) { + if (!isset($entry[1]['vary'])) { + $entry[1]['vary'] = array(''); + } + + if ($vary != $entry[1]['vary'][0] || !$this->requestsMatch($vary, $entry[0], $storedEnv)) { + $entries[] = $entry; + } + } + + $headers = $this->persistResponse($response); + unset($headers['age']); + + array_unshift($entries, array($storedEnv, $headers)); + + if (false === $this->save($key, serialize($entries))) { + throw new \RuntimeException(sprintf('Unable to store the metadata (%s).', $e->getMessage())); + } + + return $key; + } + + /** + * Invalidates all cache entries that match the request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + */ + public function invalidate(Request $request) + { + $modified = false; + $key = $this->getCacheKey($request); + + $entries = array(); + foreach ($this->getMetadata($key) as $entry) { + $response = $this->restoreResponse($entry[1]); + if ($response->isFresh()) { + $response->expire(); + $modified = true; + $entries[] = array($entry[0], $this->persistResponse($response)); + } else { + $entries[] = $entry; + } + } + + if ($modified) { + if (false === $this->save($key, serialize($entries))) { + throw new \RuntimeException('Unable to store the metadata.'); + } + } + + // As per the RFC, invalidate Location and Content-Location URLs if present + foreach (array('Location', 'Content-Location') as $header) { + if ($uri = $request->headers->get($header)) { + $subRequest = Request::create($uri, 'get', array(), array(), array(), $request->server->all()); + + $this->invalidate($subRequest); + } + } + } + + /** + * Determines whether two Request HTTP header sets are non-varying based on + * the vary response header value provided. + * + * @param string $vary A Response vary header + * @param array $env1 A Request HTTP header array + * @param array $env2 A Request HTTP header array + * + * @return Boolean true if the the two environments match, false otherwise + */ + public function requestsMatch($vary, $env1, $env2) + { + if (empty($vary)) { + return true; + } + + foreach (preg_split('/[\s,]+/', $vary) as $header) { + $key = HeaderBag::normalizeHeaderName($header); + $v1 = isset($env1[$key]) ? $env1[$key] : null; + $v2 = isset($env2[$key]) ? $env2[$key] : null; + if ($v1 !== $v2) { + return false; + } + } + + return true; + } + + /** + * Gets all data associated with the given key. + * + * Use this method only if you know what you are doing. + * + * @param string $key The store key + * + * @return array An array of data associated with the key + */ + public function getMetadata($key) + { + if (false === $entries = $this->load($key)) { + return array(); + } + + return unserialize($entries); + } + + /** + * Purges data for the given URL. + * + * @param string $url A URL + */ + public function purge($url) + { + if (file_exists($path = $this->getPath($this->getCacheKey(Request::create($url))))) { + unlink($path); + } + } + + /** + * Loads data for the given key. + * + * Don't use this method directly, use lookup() instead + * + * @param string $key The store key + * + * @return string The data associated with the key + */ + public function load($key) + { + $path = $this->getPath($key); + + return file_exists($path) ? file_get_contents($path) : false; + } + + /** + * Save data for the given key. + * + * Don't use this method directly, use write() instead + * + * @param string $key The store key + * @param string $data The data to store + */ + public function save($key, $data) + { + $path = $this->getPath($key); + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0755, true); + } + + $tmpFile = tempnam(dirname($path), basename($path)); + if (false === $fp = @fopen($tmpFile, 'wb')) { + return false; + } + @fwrite($fp, $data); + @fclose($fp); + + if ($data != file_get_contents($tmpFile)) { + return false; + } + + if (false === @rename($tmpFile, $path)) { + return false; + } + + chmod($path, 0644); + } + + public function getPath($key) + { + return $this->root.DIRECTORY_SEPARATOR.substr($key, 0, 2).DIRECTORY_SEPARATOR.substr($key, 2, 4).DIRECTORY_SEPARATOR.substr($key, 4); + } + + /** + * Returns a cache key for the given Request. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return string A key for the given Request + */ + public function getCacheKey(Request $request) + { + if (isset($this->keyCache[$request])) { + return $this->keyCache[$request]; + } + + return $this->keyCache[$request] = 'md'.sha1($request->getUri()); + } + + /** + * Persists the Request HTTP headers. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * + * @return array An array of HTTP headers + */ + protected function persistRequest(Request $request) + { + return $request->headers->all(); + } + + /** + * Persists the Response HTTP headers. + * + * @param Symfony\Components\HttpKernel\Response $response A Response instance + * + * @return array An array of HTTP headers + */ + protected function persistResponse(Response $response) + { + $headers = $response->headers->all(); + $headers['X-Status'] = array($response->getStatusCode()); + + return $headers; + } + + /** + * Restores a Response from the HTTP headers and body. + * + * @param array $headers An array of HTTP headers for the Response + * @param string $body The Response body + */ + protected function restoreResponse($headers, $body = null) + { + $status = $headers['X-Status'][0]; + unset($headers['X-Status']); + + if (null !== $body) { + $headers['X-Body-File'] = array($body); + } + + return new Response($body, $status, $headers); + } +} diff --git a/src/Symfony/Components/HttpKernel/CacheControl.php b/src/Symfony/Components/HttpKernel/CacheControl.php index f567e6263b1b..62eaa2779a03 100644 --- a/src/Symfony/Components/HttpKernel/CacheControl.php +++ b/src/Symfony/Components/HttpKernel/CacheControl.php @@ -200,6 +200,30 @@ public function setSharedMaxAge($age) $this->setValue('s-maxage', (integer) $age); } + public function setStaleWhileRevalidate($age) + { + $this->checkAttribute('stale-while-revalidate', 'response'); + + $this->setValue('stale-while-revalidate', (integer) $age); + } + + public function getStaleWhileRevalidate() + { + $this->checkAttribute('stale-while-revalidate', 'response'); + + return array_key_exists('stale-while-revalidate', $this->attributes) ? $this->attributes['stale-while-revalidate'] : null; + } + + public function setStaleIfError($age) + { + $this->setValue('stale-if-error', (integer) $age); + } + + public function getStaleIfError() + { + return array_key_exists('stale-if-error', $this->attributes) ? $this->attributes['stale-if-error'] : null; + } + public function mustRevalidate() { $this->checkAttribute('must-revalidate', 'response'); diff --git a/src/Symfony/Components/HttpKernel/Listener/EsiFilter.php b/src/Symfony/Components/HttpKernel/Listener/EsiFilter.php new file mode 100644 index 000000000000..f7b67b87847e --- /dev/null +++ b/src/Symfony/Components/HttpKernel/Listener/EsiFilter.php @@ -0,0 +1,70 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * EsiFilter adds a Surrogate-Control HTTP header when the Response needs to be parsed for ESI. + * + * @package Symfony + * @subpackage Components_HttpKernel + * @author Fabien Potencier + */ +class EsiFilter +{ + protected $dispatcher; + protected $esi; + + /** + * Constructor. + * + * @param Symfony\Components\HttpKernel\Cache\Esi $esi An ESI instance + */ + public function __construct(Esi $esi = null) + { + $this->esi = $esi; + } + + /** + * Registers a core.response listener to add the Surrogate-Control header to a Response when needed. + * + * @param Symfony\Components\EventDispatcher\EventDispatcher $dispatcher An EventDispatcher instance + */ + public function register(EventDispatcher $dispatcher) + { + if (null !== $this->esi) + { + $dispatcher->connect('core.response', array($this, 'filter')); + } + } + + /** + * Filters the Response. + * + * @param Symfony\Components\EventDispatcher\Event $event An Event instance + * @param Symfony\Components\HttpKernel\Response $response A Response instance + */ + public function filter($event, Response $response) + { + if (HttpKernelInterface::MASTER_REQUEST !== $event->getParameter('request_type')) { + return $response; + } + + $this->esi->addSurrogateControl($response); + + return $response; + } +} diff --git a/src/Symfony/Components/HttpKernel/ParameterBag.php b/src/Symfony/Components/HttpKernel/ParameterBag.php index a49c65ae8873..408ddc22dae9 100644 --- a/src/Symfony/Components/HttpKernel/ParameterBag.php +++ b/src/Symfony/Components/HttpKernel/ParameterBag.php @@ -52,6 +52,16 @@ public function replace(array $parameters = array()) $this->parameters = $parameters; } + /** + * Adds parameters. + * + * @param array $parameters An array of parameters + */ + public function add(array $parameters = array()) + { + $this->parameters = array_replace($this->parameters, $parameters); + } + /** * Returns a parameter by name. * diff --git a/src/Symfony/Foundation/Cache/Cache.php b/src/Symfony/Foundation/Cache/Cache.php new file mode 100644 index 000000000000..4a5900c45419 --- /dev/null +++ b/src/Symfony/Foundation/Cache/Cache.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * + * @package Symfony + * @subpackage Foundation + * @author Fabien Potencier + */ +abstract class Cache extends BaseCache +{ + /** + * Constructor. + * + * @param Symfony\Components\HttpKernel\HttpKernelInterface $kernel An HttpKernelInterface instance + */ + public function __construct(HttpKernelInterface $kernel) + { + $this->store = new Store($kernel->getCacheDir().'/http_cache'); + $esi = new Esi(); + + parent::__construct($kernel, $this->store, $esi, array_merge(array('debug' => $kernel->isDebug()), $this->getOptions())); + } + + /** + * Forwards the Request to the backend and returns the Response. + * + * @param Symfony\Components\HttpKernel\Request $request A Request instance + * @param Boolean $raw Whether to catch exceptions or not + * @param Symfony\Components\HttpKernel\Response $response A Response instance (the stale entry if present, null otherwise) + * + * @return Symfony\Components\HttpKernel\Response A Response instance + */ + protected function forward(Request $request, $raw = false, Response $entry = null) + { + if (!$this->kernel->isBooted()) { + $this->kernel->boot(); + } + $this->kernel->getContainer()->setService('cache', $this); + $this->kernel->getContainer()->setService('esi', $this->esi); + + return parent::forward($request, $raw, $entry); + } + + /** + * Returns an array of options to customize the Cache configuration. + * + * @return array An array of options + */ + protected function getOptions() + { + return array(); + } +} diff --git a/src/Symfony/Framework/WebBundle/Command/InitApplicationCommand.php b/src/Symfony/Framework/WebBundle/Command/InitApplicationCommand.php index e285d5ef1c44..1cf9b463edcb 100644 --- a/src/Symfony/Framework/WebBundle/Command/InitApplicationCommand.php +++ b/src/Symfony/Framework/WebBundle/Command/InitApplicationCommand.php @@ -74,6 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $filesystem->chmod($targetDir.'/cache', 0777); $filesystem->rename($targetDir.'/Kernel.php', $targetDir.'/'.$input->getArgument('name').'Kernel.php'); + $filesystem->rename($targetDir.'/Cache.php', $targetDir.'/'.$input->getArgument('name').'Cache.php'); $filesystem->copy(__DIR__.'/../Resources/skeleton/web/front_controller.php', $file = $webDir.'/'.(file_exists($webDir.'/index.php') ? strtolower($input->getArgument('name')) : 'index').'.php'); Mustache::renderFile($file, $parameters); diff --git a/src/Symfony/Framework/WebBundle/Controller/ControllerManager.php b/src/Symfony/Framework/WebBundle/Controller/ControllerManager.php index 9eb27adc90cd..07672b8e772c 100644 --- a/src/Symfony/Framework/WebBundle/Controller/ControllerManager.php +++ b/src/Symfony/Framework/WebBundle/Controller/ControllerManager.php @@ -27,22 +27,29 @@ class ControllerManager { protected $container; protected $logger; + protected $esiSupport; public function __construct(ContainerInterface $container, LoggerInterface $logger = null) { $this->container = $container; $this->logger = $logger; + $this->esiSupport = $container->hasService('esi') && $container->getEsiService()->hasSurrogateEsiCapability($container->getRequestService()); } /** * Renders a Controller and returns the Response content. * + * Note that this method generates an esi:include tag only when both the standalone + * option is set to true and the request has ESI capability (@see Symfony\Components\HttpKernel\Cache\ESI). + * * Available options: * * * path: An array of path parameters (only when the first argument is a controller) * * query: An array of query parameters (only when the first argument is a controller) * * ignore_errors: true to return an empty string in case of an error * * alt: an alternative controller to execute in case of an error (can be a controller, a URI, or an array with the controller, the path arguments, and the query arguments) + * * standalone: whether to generate an esi:include tag or not when ESI is supported + * * comment: a comment to add when returning an esi:include tag * * @param string $controller A controller name to execute (a string like BlogBundle:Post:index), or a relative URI * @param array $options An array of options @@ -56,12 +63,25 @@ public function render($controller, array $options = array()) 'query' => array(), 'ignore_errors' => true, 'alt' => array(), + 'standalone' => false, + 'comment' => '', ), $options); if (!is_array($options['alt'])) { $options['alt'] = array($options['alt']); } + if ($this->esiSupport && $options['standalone']) { + $uri = $this->generateInternalUri($controller, $options['path'], $options['query']); + + $alt = ''; + if ($options['alt']) { + $alt = $this->generateInternalUri($options['alt'][0], isset($options['alt'][1]) ? $options['alt'][1] : array(), isset($options['alt'][2]) ? $options['alt'][2] : array()); + } + + return $this->container->getEsiService()->renderTag($uri, $alt, $options['ignore_errors'], $options['comment']); + } + $request = $this->container->getRequestService(); // controller or URI? @@ -165,4 +185,34 @@ public function getMethodArguments(array $path, $controller, $method) return $arguments; } + + /** + * Generates an internal URI for a given controller. + * + * This method uses the "_internal" route, which should be available. + * + * @param string $controller A controller name to execute (a string like BlogBundle:Post:index), or a relative URI + * @param array $path An array of path parameters + * @param array $query An array of query parameters + * + * @return string An internal URI + */ + public function generateInternalUri($controller, array $path = array(), array $query = array()) + { + if (0 === strpos($controller, '/')) { + return $controller; + } + + $uri = $this->container->getRouterService()->generate('_internal', array( + 'controller' => $controller, + 'path' => $path ? http_build_query($path) : 'none', + '_format' => $this->container->getRequestService()->getRequestFormat(), + ), true); + + if ($query) { + $uri = $uri.'?'.http_build_query($query); + } + + return $uri; + } } diff --git a/src/Symfony/Framework/WebBundle/Controller/InternalController.php b/src/Symfony/Framework/WebBundle/Controller/InternalController.php new file mode 100644 index 000000000000..c70744c432b6 --- /dev/null +++ b/src/Symfony/Framework/WebBundle/Controller/InternalController.php @@ -0,0 +1,37 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +/** + * InternalController. + * + * @package Symfony + * @subpackage Framework_WebBundle + * @author Fabien Potencier + */ +class InternalController extends Controller +{ + public function indexAction() + { + $request = $this->getRequest(); + + if ('none' !== $request->path->get('path')) + { + parse_str($request->path->get('path'), $tmp); + $request->path->add($tmp); + } + + return $this->forward($request->path->get('controller'), $request->path->all(), $request->query->all()); + } +} diff --git a/src/Symfony/Framework/WebBundle/Resources/config/internal_routing.xml b/src/Symfony/Framework/WebBundle/Resources/config/internal_routing.xml new file mode 100644 index 000000000000..c5446c05e7bb --- /dev/null +++ b/src/Symfony/Framework/WebBundle/Resources/config/internal_routing.xml @@ -0,0 +1,10 @@ + + + + + + WebBundle:Internal:index + + diff --git a/src/Symfony/Framework/WebBundle/Resources/config/web.xml b/src/Symfony/Framework/WebBundle/Resources/config/web.xml index 8bd1389594b4..59fc72b75be7 100644 --- a/src/Symfony/Framework/WebBundle/Resources/config/web.xml +++ b/src/Symfony/Framework/WebBundle/Resources/config/web.xml @@ -12,6 +12,8 @@ Symfony\Components\HttpKernel\Listener\ResponseFilter Symfony\Framework\WebBundle\Listener\ExceptionHandler WebBundle:Exception:exception + Symfony\Components\HttpKernel\Cache\Esi + Symfony\Components\HttpKernel\Listener\EsiFilter @@ -46,6 +48,13 @@ + + + + + + + diff --git a/src/Symfony/Framework/WebBundle/Resources/skeleton/application/xml/Cache.php b/src/Symfony/Framework/WebBundle/Resources/skeleton/application/xml/Cache.php new file mode 100644 index 000000000000..c05381ceffda --- /dev/null +++ b/src/Symfony/Framework/WebBundle/Resources/skeleton/application/xml/Cache.php @@ -0,0 +1,13 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Tests\Components\HttpKernel\Cache; + +require_once __DIR__.'/CacheTestCase.php'; + +class CacheTest extends CacheTestCase +{ + public function testPassesOnNonGetHeadRequests() + { + $this->setNextResponse(200); + $this->request('POST', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertResponseOk(); + $this->assertTraceContains('pass'); + $this->assertFalse($this->response->headers->has('Age')); + } + + public function testInvalidatesOnPostPutDeleteRequests() + { + foreach (array('post', 'put', 'delete') as $method) { + $this->setNextResponse(200); + $this->request($method, '/'); + + $this->assertHttpKernelIsCalled(); + $this->assertResponseOk(); + $this->assertTraceContains('invalidate'); + $this->assertTraceContains('pass'); + } + } + + public function testDoesNotCacheWithAuthorizationRequestHeaderAndNonPublicResponse() + { + $this->setNextResponse(200, array('ETag' => '"Foo"')); + $this->request('GET', '/', array('HTTP_AUTHORIZATION' => 'basic foobarbaz')); + + $this->assertHttpKernelIsCalled(); + $this->assertResponseOk(); + $this->assertEquals('private', $this->response->headers->get('Cache-Control')); + + $this->assertTraceContains('miss'); + $this->assertTraceNotContains('store'); + $this->assertFalse($this->response->headers->has('Age')); + } + + public function testDoesCacheWithAuthorizationRequestHeaderAndPublicResponse() + { + $this->setNextResponse(200, array('Cache-Control' => 'public', 'ETag' => '"Foo"')); + $this->request('GET', '/', array('HTTP_AUTHORIZATION' => 'basic foobarbaz')); + + $this->assertHttpKernelIsCalled(); + $this->assertResponseOk(); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertTrue($this->response->headers->has('Age')); + $this->assertEquals('public', $this->response->headers->get('Cache-Control')); + } + + public function testDoesNotCacheWithCookieHeaderAndNonPublicResponse() + { + $this->setNextResponse(200, array('ETag' => '"Foo"')); + $this->request('GET', '/', array(), array('foo' => 'bar')); + + $this->assertHttpKernelIsCalled(); + $this->assertResponseOk(); + $this->assertEquals('private', $this->response->headers->get('Cache-Control')); + $this->assertTraceContains('miss'); + $this->assertTraceNotContains('store'); + $this->assertFalse($this->response->headers->has('Age')); + } + + public function testDoesNotCacheRequestsWithACookieHeader() + { + $this->setNextResponse(200); + $this->request('GET', '/', array(), array('foo' => 'bar')); + + $this->assertHttpKernelIsCalled(); + $this->assertResponseOk(); + $this->assertEquals('private', $this->response->headers->get('Cache-Control')); + $this->assertTraceContains('miss'); + $this->assertTraceNotContains('store'); + $this->assertFalse($this->response->headers->has('Age')); + } + + public function testRespondsWith304WhenIfModifiedSinceMatchesLastModified() + { + $time = new \DateTime(); + + $this->setNextResponse(200, array('Last-Modified' => $time->format(DATE_RFC2822), 'Content-Type', 'text/plain'), 'Hello World'); + $this->request('GET', '/', array('HTTP_IF_MODIFIED_SINCE' => $time->format(DATE_RFC2822))); + + $this->assertHttpKernelIsCalled(); + $this->assertEquals(304, $this->response->getStatusCode()); + $this->assertFalse($this->response->headers->has('Content-Length')); + $this->assertFalse($this->response->headers->has('Content-Type')); + $this->assertEmpty($this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + } + + public function testRespondsWith304WhenIfNoneMatchMatchesETag() + { + $this->setNextResponse(200, array('ETag' => '12345', 'Content-Type', 'text/plain'), 'Hello World'); + $this->request('GET', '/', array('HTTP_IF_NONE_MATCH' => '12345')); + + $this->assertHttpKernelIsCalled(); + $this->assertEquals(304, $this->response->getStatusCode()); + $this->assertFalse($this->response->headers->has('Content-Length')); + $this->assertFalse($this->response->headers->has('Content-Type')); + $this->assertTrue($this->response->headers->has('ETag')); + $this->assertEmpty($this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + } + + public function testRespondsWith304OnlyIfIfNoneMatchAndIfModifiedSinceBothMatch() + { + $time = new \DateTime(); + + $this->setNextResponse(200, array(), '', function ($request, $response) use ($time) + { + $response->setStatusCode(200); + $response->headers->set('ETag', '12345'); + $response->headers->set('Last-Modified', $time->format(DATE_RFC2822)); + $response->headers->set('Content-Type', 'text/plain'); + $response->setContent('Hello World'); + }); + + // only ETag matches + $t = \DateTime::createFromFormat('U', time() - 3600); + $this->request('GET', '/', array('HTTP_IF_NONE_MATCH' => '12345', 'HTTP_IF_MODIFIED_SINCE' => $t->format(DATE_RFC2822))); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + + // only Last-Modified matches + $this->request('GET', '/', array('HTTP_IF_NONE_MATCH' => '1234', 'HTTP_IF_MODIFIED_SINCE' => $time->format(DATE_RFC2822))); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + + // Both matches + $this->request('GET', '/', array('HTTP_IF_NONE_MATCH' => '12345', 'HTTP_IF_MODIFIED_SINCE' => $time->format(DATE_RFC2822))); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(304, $this->response->getStatusCode()); + } + + public function testValidatesPrivateResponsesCachedOnTheClient() + { + $this->setNextResponse(200, array(), '', function ($request, $response) + { + $etags = preg_split('/\s*,\s*/', $request->headers->get('IF_NONE_MATCH')); + if ($request->cookies->has('authenticated')) { + $response->headers->set('Cache-Control', 'private, no-store'); + $response->setETag('"private tag"'); + if (in_array('"private tag"', $etags)) { + $response->setStatusCode(304); + } else { + $response->setStatusCode(200); + $response->headers->set('Content-Type', 'text/plain'); + $response->setContent('private data'); + } + } else { + $response->setETag('"public tag"'); + if (in_array('"public tag"', $etags)) { + $response->setStatusCode(304); + } else { + $response->setStatusCode(200); + $response->headers->set('Content-Type', 'text/plain'); + $response->setContent('public data'); + } + } + }); + + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('"public tag"', $this->response->headers->get('ETag')); + $this->assertEquals('public data', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + $this->request('GET', '/', array(), array('authenticated' => '')); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('"private tag"', $this->response->headers->get('ETag')); + $this->assertEquals('private data', $this->response->getContent()); + $this->assertTraceContains('stale'); + $this->assertTraceContains('invalid'); + $this->assertTraceNotContains('store'); + } + + public function testStoresResponsesWhenNoCacheRequestDirectivePresent() + { + $time = \DateTime::createFromFormat('U', time() + 5); + + $this->setNextResponse(200, array('Expires' => $time->format(DATE_RFC2822))); + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'no-cache')); + + $this->assertHttpKernelIsCalled(); + $this->assertTraceContains('store'); + $this->assertTrue($this->response->headers->has('Age')); + } + + public function testReloadsResponsesWhenCacheHitsButNoCacheRequestDirectivePresentWhenAllowReloadIsSetTrue() + { + $count = 0; + + $this->setNextResponse(200, array('Cache-Control' => 'max-age=10000'), '', function ($request, $response) use (&$count) + { + ++$count; + $response->setContent(1 == $count ? 'Hello World' : 'Goodbye World'); + }); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('store'); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('fresh'); + + $this->cacheConfig['allow_reload'] = true; + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'no-cache')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Goodbye World', $this->response->getContent()); + $this->assertTraceContains('reload'); + $this->assertTraceContains('store'); + } + + public function testDoesNotReloadResponsesWhenAllowReloadIsSetFalseDefault() + { + $count = 0; + + $this->setNextResponse(200, array('Cache-Control' => 'max-age=10000'), '', function ($request, $response) use (&$count) + { + ++$count; + $response->setContent(1 == $count ? 'Hello World' : 'Goodbye World'); + }); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('store'); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('fresh'); + + $this->cacheConfig['allow_reload'] = false; + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'no-cache')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceNotContains('reload'); + + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'no-cache')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceNotContains('reload'); + } + + public function testRevalidatesFreshCacheEntryWhenMaxAgeRequestDirectiveIsExceededWhenAllowRevalidateOptionIsSetTrue() + { + $count = 0; + + $this->setNextResponse(200, array(), '', function ($request, $response) use (&$count) + { + ++$count; + $response->headers->set('Cache-Control', 'max-age=10000'); + $response->setETag($count); + $response->setContent(1 == $count ? 'Hello World' : 'Goodbye World'); + }); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('store'); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('fresh'); + + $this->cacheConfig['allow_revalidate'] = true; + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'max-age=0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Goodbye World', $this->response->getContent()); + $this->assertTraceContains('stale'); + $this->assertTraceContains('invalid'); + $this->assertTraceContains('store'); + } + + public function testDoesNotRevalidateFreshCacheEntryWhenEnableRevalidateOptionIsSetFalseDefault() + { + $count = 0; + + $this->setNextResponse(200, array(), '', function ($request, $response) use (&$count) + { + ++$count; + $response->headers->set('Cache-Control', 'max-age=10000'); + $response->setETag($count); + $response->setContent(1 == $count ? 'Hello World' : 'Goodbye World'); + }); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('store'); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('fresh'); + + $this->cacheConfig['allow_revalidate'] = false; + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'max-age=0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceNotContains('stale'); + $this->assertTraceNotContains('invalid'); + $this->assertTraceContains('fresh'); + + $this->request('GET', '/', array('HTTP_CACHE_CONTROL' => 'max-age=0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceNotContains('stale'); + $this->assertTraceNotContains('invalid'); + $this->assertTraceContains('fresh'); + } + + public function testFetchesResponseFromBackendWhenCacheMisses() + { + $time = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse(200, array('Expires' => $time->format(DATE_RFC2822))); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertTraceContains('miss'); + $this->assertTrue($this->response->headers->has('Age')); + } + + public function testDoesNotCacheSomeStatusCodeResponses() + { + foreach (array_merge(range(201, 202), range(204, 206), range(303, 305), range(400, 403), range(405, 409), range(411, 417), range(500, 505)) as $code) { + $time = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse($code, array('Expires' => $time->format(DATE_RFC2822))); + + $this->request('GET', '/'); + $this->assertEquals($code, $this->response->getStatusCode()); + $this->assertTraceNotContains('store'); + $this->assertFalse($this->response->headers->has('Age')); + } + } + + public function testDoesNotCacheResponsesWithExplicitNoStoreDirective() + { + $time = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse(200, array('Expires' => $time->format(DATE_RFC2822), 'Cache-Control' => 'no-store')); + + $this->request('GET', '/'); + $this->assertTraceNotContains('store'); + $this->assertFalse($this->response->headers->has('Age')); + } + + public function testDoesNotCacheResponsesWithoutFreshnessInformationOrAValidator() + { + $this->setNextResponse(); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertTraceNotContains('store'); + } + + public function testCachesResponesWithExplicitNoCacheDirective() + { + $time = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse(200, array('Expires' => $time->format(DATE_RFC2822), 'Cache-Control' => 'no-cache')); + + $this->request('GET', '/'); + $this->assertTraceContains('store'); + $this->assertTrue($this->response->headers->has('Age')); + } + + public function testCachesResponsesWithAnExpirationHeader() + { + $time = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse(200, array('Expires' => $time->format(DATE_RFC2822))); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + $values = $this->getMetaStorageValues(); + $this->assertEquals(1, count($values)); + } + + public function testCachesResponsesWithAMaxAgeDirective() + { + $this->setNextResponse(200, array('Cache-Control' => 'max-age=5')); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + $values = $this->getMetaStorageValues(); + $this->assertEquals(1, count($values)); + } + + public function testCachesResponsesWithASMaxAgeDirective() + { + $this->setNextResponse(200, array('Cache-Control' => 's-maxage=5')); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + $values = $this->getMetaStorageValues(); + $this->assertEquals(1, count($values)); + } + + public function testCachesResponsesWithALastModifiedValidatorButNoFreshnessInformation() + { + $time = \DateTime::createFromFormat('U', time()); + $this->setNextResponse(200, array('Last-Modified' => $time->format(DATE_RFC2822))); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + } + + public function testCachesResponsesWithAnETagValidatorButNoFreshnessInformation() + { + $this->setNextResponse(200, array('ETag' => '"123456"')); + + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + } + + public function testHitsCachedResponsesWithExpiresHeader() + { + $time1 = \DateTime::createFromFormat('U', time() - 5); + $time2 = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse(200, array('Date' => $time1->format(DATE_RFC2822), 'Expires' => $time2->format(DATE_RFC2822))); + + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals($this->responses[0]->headers->get('Date'), $this->response->headers->get('Date')); + $this->assertTrue($this->response->headers->get('Age') > 0); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('fresh'); + $this->assertTraceNotContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + } + + public function testHitsCachedResponseWithMaxAgeDirective() + { + $time = \DateTime::createFromFormat('U', time() - 5); + $this->setNextResponse(200, array('Date' => $time->format(DATE_RFC2822), 'Cache-Control' => 'max-age=10')); + + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals($this->responses[0]->headers->get('Date'), $this->response->headers->get('Date')); + $this->assertTrue($this->response->headers->get('Age') > 0); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('fresh'); + $this->assertTraceNotContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + } + + public function testHitsCachedResponseWithSMaxAgeDirective() + { + $time = \DateTime::createFromFormat('U', time() - 5); + $this->setNextResponse(200, array('Date' => $time->format(DATE_RFC2822), 'Cache-Control' => 's-maxage=10, max-age=0')); + + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals($this->responses[0]->headers->get('Date'), $this->response->headers->get('Date')); + $this->assertTrue($this->response->headers->get('Age') > 0); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('fresh'); + $this->assertTraceNotContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + } + + public function testAssignsDefaultTtlWhenResponseHasNoFreshnessInformation() + { + $this->setNextResponse(); + + $this->cacheConfig['default_ttl'] = 10; + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertRegExp('/s-maxage=10/', $this->response->headers->get('Cache-Control')); + + $this->cacheConfig['default_ttl'] = 10; + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertTraceContains('fresh'); + $this->assertTraceNotContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + } + + public function testDoesNotAssignDefaultTtlWhenResponseHasMustRevalidateDirective() + { + $this->setNextResponse(200, array('Cache-Control' => 'must-revalidate')); + + $this->cacheConfig['default_ttl'] = 10; + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertTraceContains('miss'); + $this->assertTraceNotContains('store'); + $this->assertNotRegExp('/s-maxage/', $this->response->headers->get('Cache-Control')); + $this->assertEquals('Hello World', $this->response->getContent()); + } + + public function testFetchesFullResponseWhenCacheStaleAndNoValidatorsPresent() + { + $time = \DateTime::createFromFormat('U', time() + 5); + $this->setNextResponse(200, array('Expires' => $time->format(DATE_RFC2822))); + + // build initial request + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('Date')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertNotNull($this->response->headers->get('Age')); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + + # go in and play around with the cached metadata directly ... + $values = $this->getMetaStorageValues(); + $this->assertEquals(1, count($values)); + $tmp = unserialize($values[0]); + $time = \DateTime::createFromFormat('U', time()); + $tmp[0][1]['expires'] = $time->format(DATE_RFC2822); + $this->store->save('md'.sha1('http://localhost:80/'), serialize($tmp)); + + // build subsequent request; should be found but miss due to freshness + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals(0, $this->response->headers->get('Age')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertTraceContains('stale'); + $this->assertTraceNotContains('fresh'); + $this->assertTraceNotContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Hello World', $this->response->getContent()); + } + + public function testValidatesCachedResponsesWithLastModifiedAndNoFreshnessInformation() + { + $time = \DateTime::createFromFormat('U', time()); + $this->setNextResponse(200, array(), 'Hello World', function ($request, $response) use ($time) + { + $response->headers->set('Last-Modified', $time->format(DATE_RFC2822)); + if ($time->format(DATE_RFC2822) == $request->headers->get('IF_MODIFIED_SINCE')) { + $response->setStatusCode(304); + $response->setContent(''); + } + }); + + // build initial request + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('Last-Modified')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertTraceNotContains('stale'); + + // build subsequent request; should be found but miss due to freshness + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('Last-Modified')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertEquals(0, $this->response->headers->get('Age')); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('stale'); + $this->assertTraceContains('valid'); + $this->assertTraceContains('store'); + $this->assertTraceNotContains('miss'); + } + + public function testValidatesCachedResponsesWithETagAndNoFreshnessInformation() + { + $this->setNextResponse(200, array(), 'Hello World', function ($request, $response) + { + $response->headers->set('ETag', '"12345"'); + if ($response->getETag() == $request->headers->get('IF_NONE_MATCH')) { + $response->setStatusCode(304); + $response->setContent(''); + } + }); + + // build initial request + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('ETag')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + // build subsequent request; should be found but miss due to freshness + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertNotNull($this->response->headers->get('ETag')); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $this->assertEquals(0, $this->response->headers->get('Age')); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('stale'); + $this->assertTraceContains('valid'); + $this->assertTraceContains('store'); + $this->assertTraceNotContains('miss'); + } + + public function testReplacesCachedResponsesWhenValidationResultsInNon304Response() + { + $time = \DateTime::createFromFormat('U', time()); + $count = 0; + $this->setNextResponse(200, array(), 'Hello World', function ($request, $response) use ($time, &$count) + { + $response->headers->set('Last-Modified', $time->format(DATE_RFC2822)); + switch (++$count) { + case 1: + $response->setContent('first response'); + break; + case 2: + $response->setContent('second response'); + break; + case 3: + $response->setContent(''); + $response->setStatusCode(304); + break; + } + }); + + // first request should fetch from backend and store in cache + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('first response', $this->response->getContent()); + + // second request is validated, is invalid, and replaces cached entry + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('second response', $this->response->getContent()); + + // third response is validated, valid, and returns cached entry + $this->request('GET', '/'); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('second response', $this->response->getContent()); + + $this->assertEquals(3, $count); + } + + public function testPassesHeadRequestsThroughDirectlyOnPass() + { + $that = $this; + $this->setNextResponse(200, array(), 'Hello World', function ($request, $response) use ($that) + { + $response->setContent(''); + $response->setStatusCode(200); + $that->assertEquals('HEAD', $request->getMethod()); + }); + + $this->request('HEAD', '/', array('HTTP_EXPECT' => 'something ...')); + $this->assertHttpKernelIsCalled(); + $this->assertEquals('', $this->response->getContent()); + } + + public function testUsesCacheToRespondToHeadRequestsWhenFresh() + { + $that = $this; + $this->setNextResponse(200, array(), 'Hello World', function ($request, $response) use ($that) + { + $response->headers->set('Cache-Control', 'max-age=10'); + $response->setContent('Hello World'); + $response->setStatusCode(200); + $that->assertNotEquals('HEAD', $request->getMethod()); + }); + + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals('Hello World', $this->response->getContent()); + + $this->request('HEAD', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('', $this->response->getContent()); + $this->assertEquals(strlen('Hello World'), $this->response->headers->get('Content-Length')); + } + + public function testInvalidatesCachedResponsesOnPost() + { + $this->setNextResponse(200, array(), 'Hello World', function ($request, $response) + { + if ('GET' == $request->getMethod()) { + $response->setStatusCode(200); + $response->headers->set('Cache-Control', 'public, max-age=500'); + $response->setContent('Hello World'); + } elseif ('POST' == $request->getMethod()) { + $response->setStatusCode(303); + $response->headers->set('Location', '/'); + $response->headers->delete('Cache-Control'); + $response->setContent(''); + } + }); + + // build initial request to enter into the cache + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + // make sure it is valid + $this->request('GET', '/'); + $this->assertHttpKernelIsNotCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('fresh'); + + // now POST to same URL + $this->request('POST', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals('/', $this->response->headers->get('Location')); + $this->assertTraceContains('invalidate'); + $this->assertTraceContains('pass'); + $this->assertEquals('', $this->response->getContent()); + + // now make sure it was actually invalidated + $this->request('GET', '/'); + $this->assertHttpKernelIsCalled(); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Hello World', $this->response->getContent()); + $this->assertTraceContains('stale'); + $this->assertTraceContains('invalid'); + $this->assertTraceContains('store'); + } + + public function testServesFromCacheWhenHeadersMatch() + { + $count = 0; + $this->setNextResponse(200, array('Cache-Control' => 'max-age=10000'), '', function ($request, $response) use (&$count) + { + $response->headers->set('Vary', 'Accept User-Agent Foo'); + $response->headers->set('Cache-Control', 'max-age=10'); + $response->headers->set('X-Response-Count', ++$count); + $response->setContent($request->headers->get('USER_AGENT')); + }); + + $this->request('GET', '/', array('HTTP_ACCEPT' => 'text/html', 'HTTP_USER_AGENT' => 'Bob/1.0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Bob/1.0', $this->response->getContent()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + + $this->request('GET', '/', array('HTTP_ACCEPT' => 'text/html', 'HTTP_USER_AGENT' => 'Bob/1.0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Bob/1.0', $this->response->getContent()); + $this->assertTraceContains('fresh'); + $this->assertTraceNotContains('store'); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + } + + public function testStoresMultipleResponsesWhenHeadersDiffer() + { + $count = 0; + $this->setNextResponse(200, array('Cache-Control' => 'max-age=10000'), '', function ($request, $response) use (&$count) + { + $response->headers->set('Vary', 'Accept User-Agent Foo'); + $response->headers->set('Cache-Control', 'max-age=10'); + $response->headers->set('X-Response-Count', ++$count); + $response->setContent($request->headers->get('USER_AGENT')); + }); + + $this->request('GET', '/', array('HTTP_ACCEPT' => 'text/html', 'HTTP_USER_AGENT' => 'Bob/1.0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertEquals('Bob/1.0', $this->response->getContent()); + $this->assertEquals(1, $this->response->headers->get('X-Response-Count')); + + $this->request('GET', '/', array('HTTP_ACCEPT' => 'text/html', 'HTTP_USER_AGENT' => 'Bob/2.0')); + $this->assertEquals(200, $this->response->getStatusCode()); + $this->assertTraceContains('miss'); + $this->assertTraceContains('store'); + $this->assertEquals('Bob/2.0', $this->response->getContent()); + $this->assertEquals(2, $this->response->headers->get('X-Response-Count')); + + $this->request('GET', '/', array('HTTP_ACCEPT' => 'text/html', 'HTTP_USER_AGENT' => 'Bob/1.0')); + $this->assertTraceContains('fresh'); + $this->assertEquals('Bob/1.0', $this->response->getContent()); + $this->assertEquals(1, $this->response->headers->get('X-Response-Count')); + + $this->request('GET', '/', array('HTTP_ACCEPT' => 'text/html', 'HTTP_USER_AGENT' => 'Bob/2.0')); + $this->assertTraceContains('fresh'); + $this->assertEquals('Bob/2.0', $this->response->getContent()); + $this->assertEquals(2, $this->response->headers->get('X-Response-Count')); + + $this->request('GET', '/', array('HTTP_USER_AGENT' => 'Bob/2.0')); + $this->assertTraceContains('miss'); + $this->assertEquals('Bob/2.0', $this->response->getContent()); + $this->assertEquals(3, $this->response->headers->get('X-Response-Count')); + } +} diff --git a/tests/Symfony/Tests/Components/HttpKernel/Cache/CacheTestCase.php b/tests/Symfony/Tests/Components/HttpKernel/Cache/CacheTestCase.php new file mode 100644 index 000000000000..e4b1fa217b84 --- /dev/null +++ b/tests/Symfony/Tests/Components/HttpKernel/Cache/CacheTestCase.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Tests\Components\HttpKernel\Cache; + +require_once __DIR__.'/TestHttpKernel.php'; + +use Symfony\Components\HttpKernel\Request; +use Symfony\Components\HttpKernel\Cache\Cache; +use Symfony\Components\HttpKernel\Cache\Store; + +class CacheTestCase extends \PHPUnit_Framework_TestCase +{ + protected $kernel; + protected $cache; + protected $caches; + protected $cacheConfig; + protected $request; + protected $response; + protected $responses; + + public function setUp() + { + $this->kernel = null; + + $this->cache = null; + $this->caches = array(); + $this->cacheConfig = array(); + + $this->request = null; + $this->response = null; + $this->responses = array(); + + $this->clearDirectory(sys_get_temp_dir().'/http_cache'); + } + + public function tearDown() + { + $this->kernel = null; + $this->cache = null; + $this->caches = null; + $this->request = null; + $this->response = null; + $this->responses = null; + $this->cacheConfig = null; + + $this->clearDirectory(sys_get_temp_dir().'/http_cache'); + } + + public function assertHttpKernelIsCalled() + { + $this->assertTrue($this->kernel->hasBeenCalled()); + } + + public function assertHttpKernelIsNotCalled() + { + $this->assertFalse($this->kernel->hasBeenCalled()); + } + + public function assertResponseOk() + { + $this->assertEquals(200, $this->response->getStatusCode()); + } + + public function assertTraceContains($trace) + { + $traces = $this->cache->getTraces(); + $traces = current($traces); + + $this->assertRegExp('/'.$trace.'/', implode(', ', $traces)); + } + + public function assertTraceNotContains($trace) + { + $traces = $this->cache->getTraces(); + $traces = current($traces); + + $this->assertNotRegExp('/'.$trace.'/', implode(', ', $traces)); + } + + public function request($method, $uri = '/', $server = array(), $cookies = array()) + { + if (null === $this->kernel) { + throw new \LogicException('You must call setNextResponse() before calling request().'); + } + + $this->kernel->reset(); + + $this->store = new Store(sys_get_temp_dir().'/http_cache'); + + $this->cacheConfig['debug'] = true; + $this->cache = new Cache($this->kernel, $this->store, null, $this->cacheConfig); + $this->request = Request::create($uri, $method, array(), $cookies, array(), $server); + + $this->response = $this->cache->handle($this->request); + + $this->responses[] = $this->response; + } + + public function getMetaStorageValues() + { + $values = array(); + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(sys_get_temp_dir().'/http_cache/md'), \RecursiveIteratorIterator::LEAVES_ONLY) as $file) { + $values[] = file_get_contents($file); + } + + return $values; + } + + // A basic response with 200 status code and a tiny body. + public function setNextResponse($statusCode = 200, array $headers = array(), $body = 'Hello World', \Closure $customizer = null) + { + $called = false; + + $this->kernel = new TestHttpKernel($body, $statusCode, $headers, $customizer); + } + + static public function clearDirectory($directory) + { + if (!is_dir($directory)) { + return; + } + + $fp = opendir($directory); + while (false !== $file = readdir($fp)) { + if (!in_array($file, array('.', '..'))) + { + if (is_link($directory.'/'.$file)) { + unlink($directory.'/'.$file); + } else if (is_dir($directory.'/'.$file)) { + self::clearDirectory($directory.'/'.$file); + rmdir($directory.'/'.$file); + } else { + unlink($directory.'/'.$file); + } + } + } + + closedir($fp); + } +} diff --git a/tests/Symfony/Tests/Components/HttpKernel/Cache/StoreTest.php b/tests/Symfony/Tests/Components/HttpKernel/Cache/StoreTest.php new file mode 100644 index 000000000000..67e8f6134ac4 --- /dev/null +++ b/tests/Symfony/Tests/Components/HttpKernel/Cache/StoreTest.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Tests\Components\HttpKernel\Cache; + +require_once __DIR__.'/CacheTestCase.php'; + +use Symfony\Components\HttpKernel\Request; +use Symfony\Components\HttpKernel\Response; +use Symfony\Components\HttpKernel\Cache\Store; +use Symfony\Tests\Components\HttpKernel\Cache\CacheTestCase; + +class CacheStoreTest extends \PHPUnit_Framework_TestCase +{ + protected $request; + protected $response; + protected $store; + + public function setUp() + { + $this->request = Request::create('/'); + $this->response = new Response('hello world', 200, array()); + + CacheTestCase::clearDirectory(sys_get_temp_dir().'/http_cache'); + + $this->store = new Store(sys_get_temp_dir().'/http_cache'); + } + + public function tearDown() + { + $this->store = null; + + CacheTestCase::clearDirectory(sys_get_temp_dir().'/http_cache'); + } + + public function testReadsAnEmptyArrayWithReadWhenNothingCachedAtKey() + { + $this->assertEmpty($this->store->getMetadata('/nothing')); + } + + public function testRemovesEntriesForKeyWithPurge() + { + $request = Request::create('/foo'); + $this->store->write($request, new Response('foo')); + $this->assertNotEmpty($this->store->getMetadata($this->store->getCacheKey($request))); + + $this->assertNull($this->store->purge('/foo')); + $this->assertEmpty($this->store->getMetadata($this->store->getCacheKey($request))); + } + + public function testStoresACacheEntry() + { + $cacheKey = $this->storeSimpleEntry(); + + $this->assertNotEmpty($this->store->getMetadata($cacheKey)); + } + + public function testSetsTheXContentDigestResponseHeaderBeforeStoring() + { + $cacheKey = $this->storeSimpleEntry(); + $entries = $this->store->getMetadata($cacheKey); + list ($req, $res) = $entries[0]; + + $this->assertEquals('ena94a8fe5ccb19ba61c4c0873d391e987982fbbd3', $res['x-content-digest'][0]); + } + + public function testFindsAStoredEntryWithLookup() + { + $this->storeSimpleEntry(); + $response = $this->store->lookup($this->request); + + $this->assertNotNull($response); + $this->assertInstanceOf('Symfony\Components\HttpKernel\Response', $response); + } + + public function testDoesNotFindAnEntryWithLookupWhenNoneExists() + { + $request = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Foo', 'HTTP_BAR' => 'Bar')); + + $this->assertNull($this->store->lookup($request)); + } + + public function testCanonizesUrlsForCacheKeys() + { + $this->storeSimpleEntry($path = '/test?x=y&p=q'); + $hitsReq = Request::create($path); + $missReq = Request::create('/test?p=x'); + + $this->assertNotNull($this->store->lookup($hitsReq)); + $this->assertNull($this->store->lookup($missReq)); + } + + public function testDoesNotFindAnEntryWithLookupWhenTheBodyDoesNotExist() + { + $this->storeSimpleEntry(); + $this->assertNotNull($this->response->headers->get('X-Content-Digest')); + $path = $this->store->getPath($this->response->headers->get('X-Content-Digest')); + @unlink($path); + $this->assertNull($this->store->lookup($this->request)); + } + + public function testRestoresResponseHeadersProperlyWithLookup() + { + $this->storeSimpleEntry(); + $response = $this->store->lookup($this->request); + + $this->assertEquals($response->headers->all(), array_merge(array('content-length' => 4, 'x-body-file' => array($this->store->getPath($response->headers->get('X-Content-Digest')))), $this->response->headers->all())); + } + + public function testRestoresResponseContentFromEntityStoreWithLookup() + { + $this->storeSimpleEntry(); + $response = $this->store->lookup($this->request); + $this->assertEquals($this->store->getPath('en'.sha1('test')), $response->getContent()); + } + + public function testInvalidatesMetaAndEntityStoreEntriesWithInvalidate() + { + $this->storeSimpleEntry(); + $this->store->invalidate($this->request); + $response = $this->store->lookup($this->request); + $this->assertInstanceOf('Symfony\Components\HttpKernel\Response', $response); + $this->assertFalse($response->isFresh()); + } + + public function testSucceedsQuietlyWhenInvalidateCalledWithNoMatchingEntries() + { + $req = Request::create('/test'); + $this->store->invalidate($req); + $this->assertNull($this->store->lookup($this->request)); + } + + public function testDoesNotReturnEntriesThatVaryWithLookup() + { + $req1 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Foo', 'HTTP_BAR' => 'Bar')); + $req2 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Bling', 'HTTP_BAR' => 'Bam')); + $res = new Response('test', 200, array('Vary' => 'Foo Bar')); + $this->store->write($req1, $res); + + $this->assertNull($this->store->lookup($req2)); + } + + public function testStoresMultipleResponsesForEachVaryCombination() + { + $req1 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Foo', 'HTTP_BAR' => 'Bar')); + $res1 = new Response('test 1', 200, array('Vary' => 'Foo Bar')); + $key = $this->store->write($req1, $res1); + + $req2 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Bling', 'HTTP_BAR' => 'Bam')); + $res2 = new Response('test 2', 200, array('Vary' => 'Foo Bar')); + $this->store->write($req2, $res2); + + $req3 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Baz', 'HTTP_BAR' => 'Boom')); + $res3 = new Response('test 3', 200, array('Vary' => 'Foo Bar')); + $this->store->write($req3, $res3); + + $this->assertEquals($this->store->getPath('en'.sha1('test 3')), $this->store->lookup($req3)->getContent()); + $this->assertEquals($this->store->getPath('en'.sha1('test 2')), $this->store->lookup($req2)->getContent()); + $this->assertEquals($this->store->getPath('en'.sha1('test 1')), $this->store->lookup($req1)->getContent()); + + $this->assertEquals(3, count($this->store->getMetadata($key))); + } + + public function testOverwritesNonVaryingResponseWithStore() + { + $req1 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Foo', 'HTTP_BAR' => 'Bar')); + $res1 = new Response('test 1', 200, array('Vary' => 'Foo Bar')); + $key = $this->store->write($req1, $res1); + $this->assertEquals($this->store->getPath('en'.sha1('test 1')), $this->store->lookup($req1)->getContent()); + + $req2 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Bling', 'HTTP_BAR' => 'Bam')); + $res2 = new Response('test 2', 200, array('Vary' => 'Foo Bar')); + $this->store->write($req2, $res2); + $this->assertEquals($this->store->getPath('en'.sha1('test 2')), $this->store->lookup($req2)->getContent()); + + $req3 = Request::create('/test', 'get', array(), array(), array(), array('HTTP_FOO' => 'Foo', 'HTTP_BAR' => 'Bar')); + $res3 = new Response('test 3', 200, array('Vary' => 'Foo Bar')); + $key = $this->store->write($req3, $res3); + $this->assertEquals($this->store->getPath('en'.sha1('test 3')), $this->store->lookup($req3)->getContent()); + + $this->assertEquals(2, count($this->store->getMetadata($key))); + } + + protected function storeSimpleEntry($path = null, $headers = array()) + { + if (null === $path) { + $path = '/test'; + } + + $this->request = Request::create($path, 'get', array(), array(), array(), $headers); + $this->response = new Response('test', 200, array('Cache-Control' => 'max-age=420')); + + return $this->store->write($this->request, $this->response); + } +} diff --git a/tests/Symfony/Tests/Components/HttpKernel/Cache/TestHttpKernel.php b/tests/Symfony/Tests/Components/HttpKernel/Cache/TestHttpKernel.php new file mode 100644 index 000000000000..0609dfad70b1 --- /dev/null +++ b/tests/Symfony/Tests/Components/HttpKernel/Cache/TestHttpKernel.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Tests\Components\HttpKernel\Cache; + +use Symfony\Components\HttpKernel\HttpKernel; +use Symfony\Components\HttpKernel\Request; +use Symfony\Components\HttpKernel\Response; +use Symfony\Components\EventDispatcher\EventDispatcher; +use Symfony\Components\EventDispatcher\Event; + +class TestHttpKernel extends HttpKernel +{ + protected $body; + protected $status; + protected $headers; + protected $called; + protected $customizer; + + public function __construct($body, $status, $headers, \Closure $customizer = null) + { + $this->body = $body; + $this->status = $status; + $this->headers = $headers; + $this->customizer = $customizer; + $this->called = false; + + $this->dispatcher = new EventDispatcher(); + $this->dispatcher->connect('core.load_controller', array($this, 'loadController')); + } + + public function loadController(Event $event) + { + $event->setReturnValue(array(array($this, 'callController'), array($event['request']))); + + return true; + } + + public function callController(Request $request) + { + $this->called = true; + + $response = new Response($this->body, $this->status, $this->headers); + + if (null !== $this->customizer) { + call_user_func($this->customizer, $request, $response); + } + + return $response; + } + + public function hasBeenCalled() + { + return $this->called; + } + + public function reset() + { + $this->called = false; + } +}