8000 feature #46880 [HttpKernel] Add `#[Cache()]` to describe the default … · symfony/symfony@96667d3 · GitHub
[go: up one dir, main page]

Skip to content

Commit 96667d3

Browse files
committed
feature #46880 [HttpKernel] Add #[Cache()] to describe the default HTTP cache headers on controllers (nicolas-grekas)
This PR was merged into the 6.2 branch. Discussion ---------- [HttpKernel] Add `#[Cache()]` to describe the default HTTP cache headers on controllers | Q | A | ------------- | --- | Branch? | 6.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | Part of #44705 | License | MIT | Doc PR | - Extracted from #45415 (and modernized a lot). I'd appreciate any help for porting the other attributes following this leading PR 🙏 Commits ------- acd4fa7 [HttpKernel] Add `#[Cache]` to describe the default HTTP cache headers on controllers
2 parents 338daf2 + acd4fa7 commit 96667d3

File tree

8 files changed

+650
-3
lines changed

8 files changed

+650
-3
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+
final 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
---

src/Symfony/Component/HttpKernel/ControllerMetadata/ArgumentMetadataFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ final class ArgumentMetadataFactory implements ArgumentMetadataFactoryInterface
2121
/**
2222
* {@inheritdoc}
2323
*/
24-
public function createArgumentMetadata(string|object|array $controller, \ReflectionClass $class = null, \ReflectionFunction $reflection = null): array
24+
public function createArgumentMetadata(string|object|array $controller, \ReflectionClass $class = null, \ReflectionFunctionAbstract $reflection = null): array
2525
{
2626
$arguments = [];
2727

src/Symfony/Component/HttpKernel/Event/ControllerEvent.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,18 @@ public function setController(callable $controller, array $attributes = null): v
6161
unset($this->attributes);
6262
}
6363

64-
$action = new \ReflectionFunction($controller(...));
65-
$this->getRequest()->attributes->set('_controller_reflectors', [str_contains($action->name, '{closure}') ? null : $action->getClosureScopeClass(), $action]);
64+
if (\is_array($controller) && method_exists(...$controller)) {
65+
$action = new \ReflectionMethod(...$controller);
66+
$class = new \ReflectionClass($controller[0]);
67+
} elseif (\is_string($controller) && false !== $i = strpos($controller, '::')) {
68+
$action = new \ReflectionMethod($controller);
69+
$class = new \ReflectionClass(substr($controller, 0, $i));
70+
} else {
71+
$action = new \ReflectionFunction($controller(...));
72+
$class = str_contains($action->name, '{closure}') ? null : $action->getClosureScopeClass();
73+
}
74+
75+
$this->getRequest()->attributes->set('_controller_reflectors', [$class, $action]);
6676
$this->controller = $controller;
6777
}
6878

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

0 commit comments

Comments
 (0)
0