vendor/can/rest/src/Can/RestBundle/EventListener/CacheHeadersListener.php line 68

Open in your IDE?
  1. <?php
  2. namespace Can\RestBundle\EventListener;
  3. use Can\RestBundle\Caching\CacheConfiguration;
  4. use Can\RestBundle\Caching\CacheHeader;
  5. use Can\RestBundle\Caching\CacheUtil;
  6. use Can\RestBundle\Caching\ETagGeneratorInterface;
  7. use Can\RestBundle\CanRestBundle;
  8. use Can\RestBundle\Cors\CorsUtil;
  9. use Can\RestBundle\DataTransfer\Collection\CollectionDTO;
  10. use Can\RestBundle\DataTransfer\DTOInterface;
  11. use Can\RestBundle\Http\Status;
  12. use Can\RestBundle\Mapping\MetadataStore;
  13. use Can\RestBundle\URI\ServiceNode;
  14. use Symfony\Component\HttpFoundation\Request;
  15. use Symfony\Component\HttpFoundation\Response;
  16. use Symfony\Component\HttpKernel\Event\ResponseEvent;
  17. use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
  18. use Symfony\Component\HttpKernel\EventListener\SessionListener;
  19. /**
  20.  * Adds the response cache headers.
  21.  *
  22.  * @group caching
  23.  *
  24.  * @package can/rest-bundle
  25.  * @author lechecacharro <lechecacharro@gmail.com>
  26.  */
  27. class CacheHeadersListener
  28. {
  29.     /**
  30.      * @var CacheConfiguration
  31.      */
  32.     private $configuration;
  33.     /**
  34.      * @var MetadataStore
  35.      */
  36.     private $store;
  37.     /**
  38.      * @var ETagGeneratorInterface
  39.      */
  40.     private $etagGenerator;
  41.     /**
  42.      * CacheHeadersListener constructor.
  43.      *
  44.      * @param CacheConfiguration     $configuration
  45.      * @param MetadataStore          $store
  46.      * @param ETagGeneratorInterface $etagGenerator
  47.      */
  48.     public function __construct(CacheConfiguration $configurationMetadataStore $storeETagGeneratorInterface $etagGenerator)
  49.     {
  50.         $this->configuration $configuration;
  51.         $this->store $store;
  52.         $this->etagGenerator $etagGenerator;
  53.     }
  54.     /**
  55.      * @param ResponseEvent $event
  56.      */
  57.     public function onResponse(ResponseEvent $event): void
  58.     {
  59.         if (! $this->configuration->isEnabled()) {
  60.             return;
  61.         }
  62.         $request $event->getRequest();
  63.         if (! $request->attributes->get(CanRestBundle::ATTR_SERVICE_ZONE)) {
  64.             return;
  65.         }
  66.         $response $event->getResponse();
  67.         // Turn off caching if the request method is not cacheable
  68.         // or if the response status is not cacheable
  69.         if (! $this->isRequestMethodCacheable($request) ||
  70.             ! $this->isResponseStatusCacheable($response)) {
  71.             $this->preventSymfonyAutoCache($response);
  72.             $this->disableCaching($response);
  73.             return;
  74.         }
  75.         // Handle errors and redirections apart (note that only
  76.         // 300, 301, 308, 404, 405, 410, 414, 421, 451, 501 codes
  77.         // should be handled)
  78.         if (! $response->isSuccessful() && $this->isPermanentResponse($response)) {
  79.             $this->preventSymfonyAutoCache($response);
  80.             $this->immutable($response);
  81.             return;
  82.         }
  83.         // Fallthrough; at this point, response status is either
  84.         // 200, 203, 204 or 206
  85.         // Skip if the request is not directed to a service node
  86.         if (! $request->attributes->get(CanRestBundle::ATTR_SERVICE_NODE)) {
  87.             return;
  88.         }
  89.         $cacheConfig $this->getEffectiveCacheConfiguration($request->attributes->get(CanRestBundle::ATTR_SERVICE_NODE));
  90.         if (! $cacheConfig->isEnabled()) {
  91.             // Turn off caching if disabled by configuration
  92.             $this->preventSymfonyAutoCache($response);
  93.             $this->disableCaching($response);
  94.             return;
  95.         }
  96.         $entities $request->attributes->get(CanRestBundle::ATTR_RESPONSE_ENTITY);
  97.         if ($this->isEmpty($entities)) {
  98.             // Skip caching empty responses is empty
  99.             return;
  100.         }
  101.         $etags $this->generateETags($entities);
  102.         if (count($etags) <= 30) {
  103.             // Sets the list of resource URIs included in this response
  104.             // in the "Cache-Tags" HTTP header. Currently, CloudFlare
  105.             // supports the "Cache-Tags" header (up to 30).
  106.             $response->headers->set(CacheHeader::CACHE_TAGSimplode(','$etags));
  107.         }
  108.         if ($cacheConfig->isValidationCachePolicy()) {
  109.             $response->headers->set(CacheHeader::ETAG$this->generateETag($etags));
  110.         }
  111.         // Enable caching
  112.         $cachingDirectives = [];
  113.         if ($cacheConfig->isPrivateCache() ||
  114.             $cacheConfig->isPublicCache()) {
  115.             // Enable public/private caching
  116.             $cachingDirectives[] = $cacheConfig->getCacheType();
  117.         }
  118.         if (-$cacheConfig->getTimeToLive()) {
  119.             $cachingDirectives[] = sprintf('max-age=%d'$cacheConfig->getTimeToLive());
  120.         }
  121.         // Symfony will modify the caching directives (e.g., they won't let
  122.         // you specify "max-age: 60", as they manipulate the actual
  123.         // directives for the Cache-Control header -- this will result in
  124.         // a "max-age: 60, private" header) -- They shouldn't be doing this
  125.         // (we know what we're doing)
  126.         $this->preventSymfonyAutoCache($response);
  127.         $response->headers->set('Cache-Control'implode(', '$cachingDirectives));
  128.         if ($cacheConfig->getVary()) {
  129.             $response->headers->set('Vary'implode(','$cacheConfig->getVary()));
  130.         }
  131.         // An origin server SHOULD send Last-Modified for any selected
  132.         // representation for which a last modification date can be reasonably
  133.         // and consistently determined, since its use in conditional requests
  134.         // and evaluating cache freshness ([RFC7234]) results in a substantial
  135.         // reduction of HTTP traffic on the Internet and can be a significant
  136.         // factor in improving service scalability and reliability.
  137.         //
  138.         // RFC 7232 (https://tools.ietf.org/html/rfc7232#section-2.2.1)
  139.         // The Last-Modified date cannot be greater than the response message
  140.         // origination time (the Date header value). If Last-Modified evaluates
  141.         // to some time in the future, according to the origin server's clock,
  142.         // then the origin server MUST replace that value with the message
  143.         // origination date. This prevents a future modification date from
  144.         // having an adverse impact on cache validation.
  145.         //
  146.         // See RFC 7232 (https://tools.ietf.org/html/rfc7232#section-2.2.1)
  147.         $date = (string) $response->headers->get('Date');
  148.         /*if ($lastModified > $date) {
  149.             $lastModified = $date;
  150.         }*/
  151.     }
  152.     /**
  153.      * @param DTOInterface | null $entities
  154.      *
  155.      * @return bool
  156.      */
  157.     protected function isEmpty(?DTOInterface $entities): bool
  158.     {
  159.         if ($entities instanceof CollectionDTO) {
  160.             return $entities->isEmpty();
  161.         }
  162.         return empty($entities);
  163.     }
  164.     /**
  165.      * @param string[] $etags
  166.      *
  167.      * @return string
  168.      */
  169.     protected function generateETag(array $etags): string
  170.     {
  171.         return $this->etagGenerator->generateETag($etags);
  172.     }
  173.     /**
  174.      * @param DTOInterface $dto
  175.      *
  176.      * @return string[]
  177.      */
  178.     protected function generateETags($dto): array
  179.     {
  180.         $etags = [];
  181.         if ($dto instanceof CollectionDTO) {
  182.             foreach ($dto->items as $item) {
  183.                 $etags[] = $this->etagGenerator->generateETag($item);
  184.             }
  185.         } else if (null !== $dto) {
  186.             $etags[] = $this->etagGenerator->generateETag($dto);
  187.         }
  188.         return $etags;
  189.     }
  190.     /**
  191.      * @param ServiceNode $serviceNode
  192.      *
  193.      * @return CacheConfiguration
  194.      */
  195.     protected function getEffectiveCacheConfiguration(ServiceNode $serviceNode)
  196.     {
  197.         // Clone default configuration
  198.         $cacheConfiguration = clone $this->configuration;
  199.         // The cache configuration for the resource overrides the default
  200.         // cache configuration
  201.         if ($this->store->hasMetadataFor($serviceNode)) {
  202.             $metadata $this->store->getMetadataFor($serviceNode);
  203.             $cacheConfiguration->merge(CacheConfiguration::createFrom($metadata));
  204.             // The cache configuration for the operation overrides the
  205.             // generic resource configuration
  206.         }
  207.         return $cacheConfiguration;
  208.     }
  209.     /**
  210.      * @param Request $request
  211.      *
  212.      * @return bool
  213.      */
  214.     protected function isRequestMethodCacheable(Request $request): bool
  215.     {
  216.         if ($request->isMethodCacheable()) {
  217.             // Symfony's Request returns true just for GET and HEAD methods
  218.             return true;
  219.         }
  220.         if ('OPTIONS' === $request->getMethod()) {
  221.             if (CorsUtil::isPreFlight($request)) {
  222.                 // DO NOT cache pre-flight requests -- note that public caches
  223.                 // can still cache the response if it contains an "Access-
  224.                 // Control-Max-Age" header. See the CORS configuration for
  225.                 // details
  226.                 return false;
  227.             }
  228.             // Allow caching of resource options if the user stated so
  229.             return $this->configuration->isAllowResourceOptionsCache();
  230.         }
  231.         return false;
  232.     }
  233.     /**
  234.      * @param Response $response
  235.      *
  236.      * @return bool
  237.      */
  238.     protected function isResponseStatusCacheable(Response $response): bool
  239.     {
  240.         return CacheUtil::isCacheableStatus($response->getStatusCode());
  241.     }
  242.     /**
  243.      * @param Response $response
  244.      *
  245.      * @return bool
  246.      */
  247.     protected function isPermanentResponse(Response $response): bool
  248.     {
  249.         return Status::isPermanent($response->getStatusCode());
  250.     }
  251.     /**
  252.      * Disables response caching.
  253.      *
  254.      * @param Response $response
  255.      */
  256.     protected function disableCaching(Response $response): void
  257.     {
  258.         $cacheDirectives = ['no-store'];
  259.         if ($this->configuration->isCompatibilityMode()) {
  260.             $cacheDirectives = [
  261.                 'no-cache',
  262.                 'no-store',
  263.                 'must-revalidate',
  264.             ];
  265.             $response->headers->set(CacheHeader::PRAGMA'no-cache');
  266.         }
  267.         // Set Cache-Control directives disabling caching; also
  268.         // set an Expires header for proxies
  269.         $response->headers->set(CacheHeader::CACHE_CONTROLimplode(', '$cacheDirectives));
  270.         $response->headers->set(CacheHeader::EXPIRES'0');
  271.     }
  272.     /**
  273.      * @param Response $response
  274.      */
  275.     protected function mustRevalidate(Response $response): void
  276.     {
  277.         // Should revalidate each time
  278.         $response->headers->set(CacheHeader::CACHE_CONTROL'no-store');
  279.     }
  280.     /**
  281.      * @param Response $response
  282.      */
  283.     protected function immutable(Response $response): void
  284.     {
  285.         // Response is not going to change over time
  286.         $response->headers->set(CacheHeader::CACHE_CONTROL'public, max-age=31536000, immutable');
  287.     }
  288.     /**
  289.      * Prevents Symfony's SessionListener from messing around with the Cache-Headers.
  290.      *
  291.      * @see SessionListener
  292.      *
  293.      * @param Response $response
  294.      */
  295.     protected function preventSymfonyAutoCache(Response $response): void
  296.     {
  297.         $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADERtrue);
  298.     }
  299. }