<?php
namespace Can\RestBundle\EventListener;
use Can\RestBundle\Caching\CacheConfiguration;
use Can\RestBundle\Caching\CacheHeader;
use Can\RestBundle\Caching\CacheUtil;
use Can\RestBundle\Caching\ETagGeneratorInterface;
use Can\RestBundle\CanRestBundle;
use Can\RestBundle\Cors\CorsUtil;
use Can\RestBundle\DataTransfer\Collection\CollectionDTO;
use Can\RestBundle\DataTransfer\DTOInterface;
use Can\RestBundle\Http\Status;
use Can\RestBundle\Mapping\MetadataStore;
use Can\RestBundle\URI\ServiceNode;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
use Symfony\Component\HttpKernel\EventListener\SessionListener;
/**
* Adds the response cache headers.
*
* @group caching
*
* @package can/rest-bundle
* @author lechecacharro <lechecacharro@gmail.com>
*/
class CacheHeadersListener
{
/**
* @var CacheConfiguration
*/
private $configuration;
/**
* @var MetadataStore
*/
private $store;
/**
* @var ETagGeneratorInterface
*/
private $etagGenerator;
/**
* CacheHeadersListener constructor.
*
* @param CacheConfiguration $configuration
* @param MetadataStore $store
* @param ETagGeneratorInterface $etagGenerator
*/
public function __construct(CacheConfiguration $configuration, MetadataStore $store, ETagGeneratorInterface $etagGenerator)
{
$this->configuration = $configuration;
$this->store = $store;
$this->etagGenerator = $etagGenerator;
}
/**
* @param ResponseEvent $event
*/
public function onResponse(ResponseEvent $event): void
{
if (! $this->configuration->isEnabled()) {
return;
}
$request = $event->getRequest();
if (! $request->attributes->get(CanRestBundle::ATTR_SERVICE_ZONE)) {
return;
}
$response = $event->getResponse();
// Turn off caching if the request method is not cacheable
// or if the response status is not cacheable
if (! $this->isRequestMethodCacheable($request) ||
! $this->isResponseStatusCacheable($response)) {
$this->preventSymfonyAutoCache($response);
$this->disableCaching($response);
return;
}
// Handle errors and redirections apart (note that only
// 300, 301, 308, 404, 405, 410, 414, 421, 451, 501 codes
// should be handled)
if (! $response->isSuccessful() && $this->isPermanentResponse($response)) {
$this->preventSymfonyAutoCache($response);
$this->immutable($response);
return;
}
// Fallthrough; at this point, response status is either
// 200, 203, 204 or 206
// Skip if the request is not directed to a service node
if (! $request->attributes->get(CanRestBundle::ATTR_SERVICE_NODE)) {
return;
}
$cacheConfig = $this->getEffectiveCacheConfiguration($request->attributes->get(CanRestBundle::ATTR_SERVICE_NODE));
if (! $cacheConfig->isEnabled()) {
// Turn off caching if disabled by configuration
$this->preventSymfonyAutoCache($response);
$this->disableCaching($response);
return;
}
$entities = $request->attributes->get(CanRestBundle::ATTR_RESPONSE_ENTITY);
if ($this->isEmpty($entities)) {
// Skip caching empty responses is empty
return;
}
$etags = $this->generateETags($entities);
if (count($etags) <= 30) {
// Sets the list of resource URIs included in this response
// in the "Cache-Tags" HTTP header. Currently, CloudFlare
// supports the "Cache-Tags" header (up to 30).
$response->headers->set(CacheHeader::CACHE_TAGS, implode(',', $etags));
}
if ($cacheConfig->isValidationCachePolicy()) {
$response->headers->set(CacheHeader::ETAG, $this->generateETag($etags));
}
// Enable caching
$cachingDirectives = [];
if ($cacheConfig->isPrivateCache() ||
$cacheConfig->isPublicCache()) {
// Enable public/private caching
$cachingDirectives[] = $cacheConfig->getCacheType();
}
if (-1 < $cacheConfig->getTimeToLive()) {
$cachingDirectives[] = sprintf('max-age=%d', $cacheConfig->getTimeToLive());
}
// Symfony will modify the caching directives (e.g., they won't let
// you specify "max-age: 60", as they manipulate the actual
// directives for the Cache-Control header -- this will result in
// a "max-age: 60, private" header) -- They shouldn't be doing this
// (we know what we're doing)
$this->preventSymfonyAutoCache($response);
$response->headers->set('Cache-Control', implode(', ', $cachingDirectives));
if ($cacheConfig->getVary()) {
$response->headers->set('Vary', implode(',', $cacheConfig->getVary()));
}
// An origin server SHOULD send Last-Modified for any selected
// representation for which a last modification date can be reasonably
// and consistently determined, since its use in conditional requests
// and evaluating cache freshness ([RFC7234]) results in a substantial
// reduction of HTTP traffic on the Internet and can be a significant
// factor in improving service scalability and reliability.
//
// RFC 7232 (https://tools.ietf.org/html/rfc7232#section-2.2.1)
// The Last-Modified date cannot be greater than the response message
// origination time (the Date header value). If Last-Modified evaluates
// to some time in the future, according to the origin server's clock,
// then the origin server MUST replace that value with the message
// origination date. This prevents a future modification date from
// having an adverse impact on cache validation.
//
// See RFC 7232 (https://tools.ietf.org/html/rfc7232#section-2.2.1)
$date = (string) $response->headers->get('Date');
/*if ($lastModified > $date) {
$lastModified = $date;
}*/
}
/**
* @param DTOInterface | null $entities
*
* @return bool
*/
protected function isEmpty(?DTOInterface $entities): bool
{
if ($entities instanceof CollectionDTO) {
return $entities->isEmpty();
}
return empty($entities);
}
/**
* @param string[] $etags
*
* @return string
*/
protected function generateETag(array $etags): string
{
return $this->etagGenerator->generateETag($etags);
}
/**
* @param DTOInterface $dto
*
* @return string[]
*/
protected function generateETags($dto): array
{
$etags = [];
if ($dto instanceof CollectionDTO) {
foreach ($dto->items as $item) {
$etags[] = $this->etagGenerator->generateETag($item);
}
} else if (null !== $dto) {
$etags[] = $this->etagGenerator->generateETag($dto);
}
return $etags;
}
/**
* @param ServiceNode $serviceNode
*
* @return CacheConfiguration
*/
protected function getEffectiveCacheConfiguration(ServiceNode $serviceNode)
{
// Clone default configuration
$cacheConfiguration = clone $this->configuration;
// The cache configuration for the resource overrides the default
// cache configuration
if ($this->store->hasMetadataFor($serviceNode)) {
$metadata = $this->store->getMetadataFor($serviceNode);
$cacheConfiguration->merge(CacheConfiguration::createFrom($metadata));
// The cache configuration for the operation overrides the
// generic resource configuration
}
return $cacheConfiguration;
}
/**
* @param Request $request
*
* @return bool
*/
protected function isRequestMethodCacheable(Request $request): bool
{
if ($request->isMethodCacheable()) {
// Symfony's Request returns true just for GET and HEAD methods
return true;
}
if ('OPTIONS' === $request->getMethod()) {
if (CorsUtil::isPreFlight($request)) {
// DO NOT cache pre-flight requests -- note that public caches
// can still cache the response if it contains an "Access-
// Control-Max-Age" header. See the CORS configuration for
// details
return false;
}
// Allow caching of resource options if the user stated so
return $this->configuration->isAllowResourceOptionsCache();
}
return false;
}
/**
* @param Response $response
*
* @return bool
*/
protected function isResponseStatusCacheable(Response $response): bool
{
return CacheUtil::isCacheableStatus($response->getStatusCode());
}
/**
* @param Response $response
*
* @return bool
*/
protected function isPermanentResponse(Response $response): bool
{
return Status::isPermanent($response->getStatusCode());
}
/**
* Disables response caching.
*
* @param Response $response
*/
protected function disableCaching(Response $response): void
{
$cacheDirectives = ['no-store'];
if ($this->configuration->isCompatibilityMode()) {
$cacheDirectives = [
'no-cache',
'no-store',
'must-revalidate',
];
$response->headers->set(CacheHeader::PRAGMA, 'no-cache');
}
// Set Cache-Control directives disabling caching; also
// set an Expires header for proxies
$response->headers->set(CacheHeader::CACHE_CONTROL, implode(', ', $cacheDirectives));
$response->headers->set(CacheHeader::EXPIRES, '0');
}
/**
* @param Response $response
*/
protected function mustRevalidate(Response $response): void
{
// Should revalidate each time
$response->headers->set(CacheHeader::CACHE_CONTROL, 'no-store');
}
/**
* @param Response $response
*/
protected function immutable(Response $response): void
{
// Response is not going to change over time
$response->headers->set(CacheHeader::CACHE_CONTROL, 'public, max-age=31536000, immutable');
}
/**
* Prevents Symfony's SessionListener from messing around with the Cache-Headers.
*
* @see SessionListener
*
* @param Response $response
*/
protected function preventSymfonyAutoCache(Response $response): void
{
$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, true);
}
}