<?php
namespace AppBundle\Controller;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Controller for page fragments rendered as Edge Side Includes (ESI)
*
* ESI would normally be rendered via `{{ render_esi(controller(...)) }}` function in twig, however that method
* generates a unique signature for each fragment, hence making URI un-guessable. Even though this makes sense from
* security viewpoint (in some scenarios), it would prevent us to selectively invalidate varnish cache via PURGE method.
*
* Registering this controller as a separate route and rendering ESI tags manually allows us to use ESI fragments at
* predictable URIs.
*
* Adding new ESI fragments for existing twig `render(controller(...))` calls:
*
* 1. register a route - see e.g. `esiModerators` in `routing.yml`
* 2. add method that wraps the original controller method (see e.g.
* {@link EsiController::showModeratorsAction()})
* 3. verify that cache/vary headers added by the original controller method are in line with what you set in your new
* EsiController method
* 4. replace original `render()` calls with `esi_wrapper('nameOfNewEsiRoute')` (see example below)
*
* **Note:**
* It is _immensely_ important to differentiate between ESI fragments that are user-agnostic (`esi/anonymous`) and those
* that are not. The former can call `::setResponseCacheHeaders($response, false, ...)` while the latter _must_ add
* `Vary: cookie` header by using `true` for the second argument! Varnish should then merge the `Vary` header internally
* and differentiate cache entries per-user (while still disregarding cookies for ESI fragments under `esi/anonymous`
* route).
*
* Also see ESI-specific comment in `routing.yml` regarding `anonymous/shared/user` link format for ESI fragments.
*
* Example - route without route params:
*
* {{ esi_wrapper('esiModerators') }}
*
* Example - route with route params:
*
* {{ esi_wrapper('esiImportedRss', {'context': 'homepage', 'rssSlug': 'fmg'}) }}
*/
class EsiController extends BaseController
{
/**
* Render site header menu as ESI fragment
*
* @return Response
*/
public function showHeaderMenuAction()
{
// render via original controller
$response = $this->forward('AppBundle:Snippets:getHeaderMenu');
// ... but override cache headers
$isEsiAnonymous = true;
$response = $this->setResponseCacheHeadersFromConfig($response, !$isEsiAnonymous, 'cache_ttl_seconds_menu');
return $response;
}
/**
* Render site footer as ESI fragment
*
* @return Response
*/
public function showFooterAction()
{
// render via original controller
$response = $this->forward('AppBundle:Snippets:getFooter');
// ... but override cache headers
$isEsiAnonymous = true;
$response = $this->setResponseCacheHeadersFromConfig($response, !$isEsiAnonymous, 'cache_ttl_seconds_menu');
return $response;
}
/**
* Generic ESI renderer for any content loaded from locally cached JSON files
*
* Cache expiration is determined based on different between cron interval vs last modification time of JSON file.
*
* @param string $context Context where ESI fragment is displayed, i.e. `homepage`, (page) `section`or `sidebar`
* @param string $rssSlug Slug identifying which cached JSON file to use
*
* @return Response
*/
public function showImportedRssAction($context, $rssSlug)
{
// identify active configuration
list('forward' => $forward, 'cacheKey' => $cacheKey) = $this->getImportedRssConfiguration($context, $rssSlug);
// render via original controller
$response = $this->forward($forward);
// determine cache interval
$cronInterval = $this->getOptionValue($cacheKey);
$cacheInterval = $this->getCacheIntervalFromFileModificationTime("data/feeds/{$rssSlug}.json", $cronInterval);
// override cache headers
$isEsiAnonymous = true;
$response = $this->setResponseCacheHeaders($response, !$isEsiAnonymous, $cacheInterval);
return $response;
}
/**
* Configuration helper for {@link EsiController::showImportedRssAction}
*
* @param string $context See {@link EsiController::showImportedRssAction}
* @param string $rssSlug See {@link EsiController::showImportedRssAction}
*
* @return array Returns array with configuration keys `forward` and `cacheKey`
*/
private function getImportedRssConfiguration($context, $rssSlug)
{
$defaults = ['cacheKey' => 'cron_interval_rss'];
$config = [
// 'context/rssSlug' => [ 'forward' => 'bundle:controller:method', 'cacheKey' => 'cron_interval_foobar' ]
'homepage/fmg' => ['forward' => 'AppBundle:Homepage:showFmgArticles'],
// examples:
// 'homepage/fmg' => ['forward' => 'AppBundle:Homepage:showFmgArticles', 'cacheKey' => 'croninterval_rss_fmg'],
// 'section/hokej' => ['forward' => 'AppBundle:Page:showHokejArticles']
// 'aside/*' => ['forward' => 'AppBundle:Snippets:showSidebarRssArticles']
// '*/hokej' => ['forward' => 'AppBundle:Snippets:showHokejArticles']
];
// check if configuration is available for requested route parameters
switch (true) {
// direct match
case array_key_exists("{$context}/{$rssSlug}", $config):
$configKey = "{$context}/{$rssSlug}";
break;
// any context for given rssSlug
case array_key_exists("*/{$rssSlug}", $config):
$configKey = "*/{$rssSlug}";
break;
// any rssSlug for given context
case array_key_exists("{$context}/*", $config):
$configKey = "{$context}/*";
break;
// giving up...
default:
throw new NotFoundHttpException();
}
// apply defaults to selected config
$activeConfig = array_merge($defaults, $config[$configKey]);
return $activeConfig;
}
/**
* Render moderators section as ESI fragment
*
* @return Response
*/
public function showModeratorsAction()
{
// render via original controller
$response = $this->forward('AppBundle:Snippets:getModerators');
// ... but override cache headers
$isEsiAnonymous = true;
$response = $this->setResponseCacheHeadersFromConfig($response, !$isEsiAnonymous, 'cache_ttl_seconds_other');
return $response;
}
/**
* Get cache interval based on file modification date
*
* Used as alternative to {@link BaseController::getCacheInterval} for ESI fragments that render content from JSON
* files that are managed by local or remote cron scripts. This allows to synchronize expiration of cached ESI
* fragments in varnish with cron execution intervals.
*
* @param string $filePath Path to file relative to project root directory
* @param int $cronInterval How often (in seconds) is cron executed?
* @param int $cronLag How many seconds to lag behind file modification date, taking cron accuracy and
* processing time into account?
* @param int $recheckInterval How often (in seconds) to re-check if file did not exist yet or was outdated?
*
* @return int
*/
private function getCacheIntervalFromFileModificationTime($filePath, $cronInterval, $cronLag = 15,
$recheckInterval = 15)
{
/** @var LoggerInterface $logger */
$logger = $this->get('logger');
$cronInterval = intval($cronInterval, 10);
if ($cronInterval <= 0) {
// cron interval not configured or invalid
$logger->warning('Given invalid cron interval while being asked to determine ESI cache headers for file'
. ' modification of data/' . $filePath);
return $recheckInterval;
}
$jsonPath = $this->getCachedJsonPath($filePath);
if (false === $jsonPath) {
$logger->warning('Failed to determine ESI cache headers from file modification date of data/' . $filePath);
return $recheckInterval;
}
$jsonAge = time() - filemtime($jsonPath);
$isOutdated = ($jsonAge >= $cronInterval + $cronLag);
if ($isOutdated) {
return $recheckInterval;
}
return $cronInterval + $cronLag - $jsonAge;
}
}