Skip to content

Commit e993a98

Browse files
[HttpKernel] Add #[Cache] to describe the default HTTP cache headers on controllers
1 parent 2633877 commit e993a98

File tree

6 files changed

+630
-0
lines changed

6 files changed

+630
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver;
2525
use Symfony\Component\HttpKernel\Controller\ErrorController;
2626
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory;
27+
use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener;
2728
use Symfony\Component\HttpKernel\EventListener\DisallowRobotsIndexingListener;
2829
use Symfony\Component\HttpKernel\EventListener\ErrorListener;
2930
use Symfony\Component\HttpKernel\EventListener\LocaleListener;
@@ -117,5 +118,9 @@
117118
])
118119
->tag('kernel.event_subscriber')
119120
->tag('monolog.logger', ['channel' => 'request'])
121+
122+
->set('controller.cache_attribute_listener', CacheAttributeListener::class)
123+
->tag('kernel.event_subscriber')
124+
120125
;
121126
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Attribute;
13+
14+
/**
15+
* Describes the default HTTP cache headers on controllers.
16+
*
17+
* @author Fabien Potencier <fabien@symfony.com>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
20+
class Cache
21+
{
22+
public function __construct(
23+
/**
24+
* The expiration date as a valid date for the strtotime() function.
25+
*/
26+
public ?string $expires = null,
27+
28+
/**
29+
* The number of seconds that the response is considered fresh by a private
30+
* cache like a web browser.
31+
*/
32+
public int|string|null $maxage = null,
33+
34+
/**
35+
* The number of seconds that the response is considered fresh by a public
36+
* cache like a reverse proxy cache.
37+
*/
38+
public int|string|null $smaxage = null,
39+
40+
/**
41+
* Whether the response is public or not.
42+
*/
43+
public ?bool $public = null,
44+
45+
/**
46+
* Whether or not the response must be revalidated.
47+
*/
48+
public bool $mustRevalidate = false,
49+
50+
/**
51+
* Additional "Vary:"-headers.
52+
*/
53+
public array $vary = [],
54+
55+
/**
56+
* An expression to compute the Last-Modified HTTP header.
57+
*/
58+
public ?string $lastModified = null,
59+
60+
/**
61+
* An expression to compute the ETag HTTP header.
62+
*/
63+
public ?string $etag = null,
64+
65+
/**
66+
* max-stale Cache-Control header
67+
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
68+
*/
69+
public int|string|null $maxStale = null,
70+
71+
/**
72+
* stale-while-revalidate Cache-Control header
73+
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
74+
*/
75+
public int|string|null $staleWhileRevalidate = null,
76+
77+
/**
78+
* stale-if-error Cache-Control header
79+
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
80+
*/
81+
public int|string|null $staleIfError = null,
82+
) {
83+
}
84+
}

src/Symfony/Component/HttpKernel/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Add constructor argument `bool $catchThrowable` to `HttpKernel`
88
* Add `ControllerEvent::getAttributes()` to handle attributes on controllers
9+
* Add `#[Cache]` to describe the default HTTP cache headers on controllers
910

1011
6.1
1112
---
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\EventListener;
13+
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\Attribute\Cache;
18+
use Symfony\Component\HttpKernel\Event\ControllerEvent;
19+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20+
use Symfony\Component\HttpKernel\KernelEvents;
21+
22+
/**
23+
* Handles HTTP cache headers configured via the Cache attribute.
24+
*
25+
* @author Fabien Potencier <fabien@symfony.com>
26+
*/
27+
class CacheAttributeListener implements EventSubscriberInterface
28+
{
29+
private ?ExpressionLanguage $expressionLanguage;
30+
private \SplObjectStorage $lastModified;
31+
private \SplObjectStorage $etags;
32+
33+
public function __construct(ExpressionLanguage $expressionLanguage = null)
34+
{
35+
$this->expressionLanguage = $expressionLanguage;
36+
$this->lastModified = new \SplObjectStorage();
37+
$this->etags = new \SplObjectStorage();
38+
}
39+
40+
/**
41+
* Handles HTTP validation headers.
42+
*/
43+
public function onKernelController(ControllerEvent $event)
44+
{
45+
$request = $event->getRequest();
46+
47+
if (!\is_array($attributes = $request->attributes->get('_cache') ?? $event->getAttributes()[Cache::class] ?? [])) {
48+
return;
49+
}
50+
51+
$request->attributes->set('_cache', $attributes);
52+
$response = null;
53+
$lastModified = null;
54+
$etag = null;
55+
56+
/** @var Cache[] $attributes */
57+
foreach ($attributes as $cache) {
58+
if (null !== $cache->lastModified) {
59+
$lastModified = $this->getExpressionLanguage()->evaluate($cache->lastModified, $request->attributes->all());
60+
($response ??= new Response())->setLastModified($lastModified);
61+
}
62+
63+
if (null !== $cache->etag) {
64+
$etag = hash('sha256', $this->getExpressionLanguage()->evaluate($cache->etag, $request->attributes->all()));
65+
($response ??= new Response())->setEtag($etag);
66+
}
67+
}
68+
69+
if ($response?->isNotModified($request)) {
70+
$event->setController(static fn () => $response);
71+
$event->stopPropagation();
72+
} else {
73+
if (null !== $etag) {
74+
$this->etags[$request] = $etag;
75+
}
76+
if (null !== $lastModified) {
77+
$this->lastModified[$request] = $lastModified;
78+
}
79+
}
80+
}
81+
82+
/**
83+
* Modifies the response to apply HTTP cache headers when needed.
84+
*/
85+
public function onKernelResponse(ResponseEvent $event)
86+
{
87+
$request = $event->getRequest();
88+
89+
/** @var Cache[] $attributes */
90+
if (!\is_array($attributes = $request->attributes->get('_cache'))) {
91+
return;
92+
}
93+
$response = $event->getResponse();
94+
95+
// http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-12#section-3.1
96+
if (!\in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 304, 404, 410])) {
97+
unset($this->lastModified[$request]);
98+
unset($this->etags[$request]);
99+
100+
return;
101+
}
102+
103+
if (isset($this->lastModified[$request]) && !$response->headers->has('Last-Modified')) {
104+
$response->setLastModified($this->lastModified[$request]);
105+
}
106+
107+
if (isset($this->etags[$request]) && !$response->headers->has('Etag')) {
108+
$response->setEtag($this->etags[$request]);
109+
}
110+
111+
unset($this->lastModified[$request]);
112+
unset($this->etags[$request]);
113+
$hasVary = $response->headers->has('Vary');
114+
115+
foreach (array_reverse($attributes) as $cache) {
116+
if (null !== $cache->smaxage && !$response->headers->hasCacheControlDirective('s-maxage')) {
117+
$response->setSharedMaxAge($this->toSeconds($cache->smaxage));
118+
}
119+
120+
if ($cache->mustRevalidate) {
121+
$response->headers->addCacheControlDirective('must-revalidate');
122+
}
123+
124+
if (null !== $cache->maxage && !$response->headers->hasCacheControlDirective('max-age')) {
125+
$response->setMaxAge($this->toSeconds($cache->maxage));
126+
}
127+
128+
if (null !== $cache->maxStale && !$response->headers->hasCacheControlDirective('max-stale')) {
129+
$response->headers->addCacheControlDirective('max-stale', $this->toSeconds($cache->maxStale));
130+
}
131+
132+
if (null !== $cache->staleWhileRevalidate && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) {
133+
$response->headers->addCacheControlDirective('stale-while-revalidate', $this->toSeconds($cache->staleWhileRevalidate));
134+
}
135+
136+
if (null !== $cache->staleIfError && !$response->headers->hasCacheControlDirective('stale-if-error')) {
137+
$response->headers->addCacheControlDirective('stale-if-error', $this->toSeconds($cache->staleIfError));
138+
}
139+
140+
if (null !== $cache->expires && !$response->headers->has('Expires')) {
141+
$response->setExpires(new \DateTimeImmutable('@'.strtotime($cache->expires, time())));
142+
}
143+
144+
if (!$hasVary && $cache->vary) {
145+
$response->setVary($cache->vary, false);
146+
}
147+
}
148+
149+
foreach ($attributes as $cache) {
150+
if (true === $cache->public) {
151+
$response->setPublic();
152+
}
153+
154+
if (false === $cache->public) {
155+
$response->setPrivate();
156+
}
157+
}
158+
}
159+
160+
public static function getSubscribedEvents(): array
161+
{
162+
return [
163+
KernelEvents::CONTROLLER => 'onKernelController',
164+
KernelEvents::RESPONSE => 'onKernelResponse',
165+
];
166+
}
167+
168+
private function getExpressionLanguage(): ExpressionLanguage
169+
{
170+
if (!$this->expressionLanguage && !class_exists(ExpressionLanguage::class)) {
171+
throw new \LogicException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".');
172+
}
173+
174+
return $this->expressionLanguage ??= new ExpressionLanguage();
175+
}
176+
177+
private function toSeconds(int|string $time): int
178+
{
179+
if (!is_numeric($time)) {
180+
$now = time();
181+
$time = strtotime($time, $now) - $now;
182+
}
183+
184+
return $time;
185+
}
186+
}

0 commit comments

Comments
 (0)