Skip to content

Commit 3309794

Browse files
committed
support for targetted cache control
https://datatracker.ietf.org/doc/rfc9213/ defines additional headers to target cache-control to specific reverse proxies. this PR refactors the header bag: * extract cache control handling into separate class * support having additional targets with cache control
1 parent b6c7abc commit 3309794

File tree

10 files changed

+274
-78
lines changed

10 files changed

+274
-78
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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\HttpFoundation;
13+
14+
/**
15+
* CacheControl is a container for HTTP cache instructions.
16+
*
17+
* @author Fabien Potencier <fabien@symfony.com>
18+
*
19+
* @implements \IteratorAggregate<string, list<string|null>>
20+
*/
21+
final class CacheControl
22+
{
23+
/**
24+
* @var array<string, string|bool>
25+
*/
26+
private array $directives = [];
27+
28+
public function __construct(array $directives = [])
29+
{
30+
$this->directives = $directives;
31+
}
32+
33+
public static function fromHeader(string $header): self
34+
{
35+
return new self(self::parseCacheControl($header));
36+
}
37+
38+
public function empty(): bool
39+
{
40+
return 0 === count($this->directives);
41+
}
42+
43+
public function all(): array
44+
{
45+
return $this->directives;
46+
}
47+
48+
/**
49+
* Add or replace many Cache-Control directives.
50+
*
51+
* @param array<string, bool|string> $directives
52+
*/
53+
public function addCacheControlDirectives(array $directives): void
54+
{
55+
foreach ($directives as $key => $value) {
56+
$this->addCacheControlDirective($key, $value);
57+
}
58+
}
59+
60+
/**
61+
* Add or replace a Cache-Control directive.
62+
*/
63+
public function addCacheControlDirective(string $key, bool|string $value = true): void
64+
{
65+
$this->directives[$key] = $value;
66+
}
67+
68+
/**
69+
* Returns true if the Cache-Control directive is defined.
70+
*/
71+
public function hasCacheControlDirective(string $key): bool
72+
{
73+
return \array_key_exists($key, $this->directives);
74+
}
75+
76+
/**
77+
* Returns a Cache-Control directive value by name.
78+
*/
79+
public function getCacheControlDirective(string $key): bool|string|null
80+
{
81+
return $this->directives[$key] ?? null;
82+
}
83+
84+
/**
85+
* Removes a Cache-Control directive.
86+
*/
87+
public function removeCacheControlDirective(string $key): void
88+
{
89+
unset($this->directives[$key]);
90+
}
91+
92+
public function getCacheControlHeader(): string
93+
{
94+
ksort($this->directives);
95+
96+
return HeaderUtils::toString($this->directives, ',');
97+
}
98+
99+
/**
100+
* Parses a Cache-Control HTTP header.
101+
*/
102+
public static function parseCacheControl(string $header): array
103+
{
104+
$parts = HeaderUtils::split($header, ',=');
105+
106+
return HeaderUtils::combine($parts);
107+
}
108+
}

src/Symfony/Component/HttpFoundation/HeaderBag.php

Lines changed: 103 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,21 @@
2020
*/
2121
class HeaderBag implements \IteratorAggregate, \Countable, \Stringable
2222
{
23+
public const DEFAULT_CACHE_CONTROL_TARGET = '';
2324
protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
2425
protected const LOWER = '-abcdefghijklmnopqrstuvwxyz';
2526

2627
/**
2728
* @var array<string, list<string|null>>
2829
*/
2930
protected array $headers = [];
30-
protected array $cacheControl = [];
31+
32+
/**
33+
* Map of target to cache control instructions.
34+
*
35+
* @var array<string, CacheControl>
36+
*/
37+
protected array $cacheControls = [];
3138

3239
public function __construct(array $headers = [])
3340
{
@@ -68,10 +75,21 @@ public function __toString(): string
6875
public function all(?string $key = null): array
6976
{
7077
if (null !== $key) {
71-
return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? [];
78+
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
79+
if (str_ends_with($uniqueKey, 'cache-control')) {
80+
return [$this->getCacheControl($this->extractCacheControlTarget($key))->getCacheControlHeader()];
81+
}
82+
83+
return $this->headers[$uniqueKey] ?? [];
7284
}
7385

74-
return $this->headers;
86+
$headers = $this->headers;
87+
// edge case: what if extending class directly changed Cache-Control in the $headers array?
88+
foreach ($this->cacheControls as $target => $cacheControl) {
89+
$headers[self::DEFAULT_CACHE_CONTROL_TARGET === $target ? 'cache-control' : $target.'-cache-control'] = [$cacheControl->getCacheControlHeader()];
90+
}
91+
92+
return $headers;
7593
}
7694

7795
/**
@@ -89,6 +107,7 @@ public function keys(): array
89107
*/
90108
public function replace(array $headers = []): void
91109
{
110+
$this->cacheControls = [];
92111
$this->headers = [];
93112
$this->add($headers);
94113
}
@@ -129,27 +148,43 @@ public function get(string $key, ?string $default = null): ?string
129148
*/
130149
public function set(string $key, string|array|null $values, bool $replace = true): void
131150
{
132-
$key = strtr($key, self::UPPER, self::LOWER);
151+
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
152+
153+
if (str_ends_with($uniqueKey, 'cache-control')) {
154+
$this->setCacheControlFromHeader($key, $values, $replace);
155+
156+
return;
157+
}
133158

134159
if (\is_array($values)) {
135160
$values = array_values($values);
136161

137-
if (true === $replace || !isset($this->headers[$key])) {
138-
$this->headers[$key] = $values;
162+
if (true === $replace || !isset($this->headers[$uniqueKey])) {
163+
$this->headers[$uniqueKey] = $values;
139164
} else {
140-
$this->headers[$key] = array_merge($this->headers[$key], $values);
165+
$this->headers[$uniqueKey] = array_merge($this->headers[$uniqueKey], $values);
141166
}
142167
} else {
143-
if (true === $replace || !isset($this->headers[$key])) {
144-
$this->headers[$key] = [$values];
168+
if (true === $replace || !isset($this->headers[$uniqueKey])) {
169+
$this->headers[$uniqueKey] = [$values];
145170
} else {
146-
$this->headers[$key][] = $values;
171+
$this->headers[$uniqueKey][] = $values;
147172
}
148173
}
174+
}
149175

150-
if ('cache-control' === $key) {
151-
$this->cacheControl = $this->parseCacheControl(implode(', ', $this->headers[$key]));
176+
private function setCacheControlFromHeader(string $key, string|array|null $values, bool $replace = true): void
177+
{
178+
if (is_array($values)) {
179+
$values = implode(', ', $values);
180+
}
181+
$target = $this->extractCacheControlTarget($key);
182+
if ($replace) {
183+
$this->cacheControls[$target] = CacheControl::fromHeader($values);
184+
185+
return;
152186
}
187+
$this->getCacheControl($target)->addCacheControlDirectives(CacheControl::parseCacheControl($values));
153188
}
154189

155190
/**
@@ -173,12 +208,12 @@ public function contains(string $key, string $value): bool
173208
*/
174209
public function remove(string $key): void
175210
{
176-
$key = strtr($key, self::UPPER, self::LOWER);
211+
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
177212

178-
unset($this->headers[$key]);
213+
unset($this->headers[$uniqueKey]);
179214

180-
if ('cache-control' === $key) {
181-
$this->cacheControl = [];
215+
if (str_ends_with($uniqueKey, 'cache-control')) {
216+
$this->removeCacheControl($this->extractCacheControlTarget($key));
182217
}
183218
}
184219

@@ -200,40 +235,65 @@ public function getDate(string $key, ?\DateTimeInterface $default = null): ?\Dat
200235
return $date;
201236
}
202237

238+
/**
239+
* Get the default or a targeted cache control instruction set.
240+
*
241+
* If the set did not exist yet, it is created.
242+
*/
243+
public function getCacheControl(string $target = self::DEFAULT_CACHE_CONTROL_TARGET): CacheControl
244+
{
245+
// TODO do we need to lowercase the targets as well, and track the desired case as we do for the headers in ResponseHeaderBag
246+
if (! \array_key_exists($target, $this->cacheControls)) {
247+
$this->cacheControls[$target] = new CacheControl();
248+
}
249+
250+
return $this->cacheControls[$target];
251+
}
252+
253+
/**
254+
* Remove cache control settings.
255+
*/
256+
public function removeCacheControl(string $target = self::DEFAULT_CACHE_CONTROL_TARGET): void
257+
{
258+
unset($this->cacheControls[$target]);
259+
}
260+
203261
/**
204262
* Adds a custom Cache-Control directive.
263+
*
264+
* @deprecated Use getCacheControl()->addCacheControlDirective instead
205265
*/
206266
public function addCacheControlDirective(string $key, bool|string $value = true): void
207267
{
208-
$this->cacheControl[$key] = $value;
209-
210-
$this->set('Cache-Control', $this->getCacheControlHeader());
268+
$this->getCacheControl()->addCacheControlDirective($key, $value);
211269
}
212270

213271
/**
214272
* Returns true if the Cache-Control directive is defined.
273+
*
274+
* @deprecated Use getCacheControl()->hasCacheControlDirective instead
215275
*/
216276
public function hasCacheControlDirective(string $key): bool
217277
{
218-
return \array_key_exists($key, $this->cacheControl);
278+
return $this->getCacheControl()->hasCacheControlDirective($key);
219279
}
220280

221281
/**
222282
* Returns a Cache-Control directive value by name.
283+
*
284+
* @deprecated Use getCacheControl()->getCacheControlDirective instead
223285
*/
224286
public function getCacheControlDirective(string $key): bool|string|null
225287
{
226-
return $this->cacheControl[$key] ?? null;
288+
return $this->getCacheControl()->getCacheControlDirective($key);
227289
}
228290

229291
/**
230292
* Removes a Cache-Control directive.
231293
*/
232294
public function removeCacheControlDirective(string $key): void
233295
{
234-
unset($this->cacheControl[$key]);
235-
236-
$this->set('Cache-Control', $this->getCacheControlHeader());
296+
$this->getCacheControl()->removeCacheControlDirective($key);
237297
}
238298

239299
/**
@@ -254,20 +314,36 @@ public function count(): int
254314
return \count($this->headers);
255315
}
256316

317+
/**
318+
*
319+
* @deprecated Use getCacheControl()->getCacheControlHeader instead
320+
*/
257321
protected function getCacheControlHeader(): string
258322
{
259-
ksort($this->cacheControl);
260-
261-
return HeaderUtils::toString($this->cacheControl, ',');
323+
return $this->getCacheControl()->getCacheControlHeader();
262324
}
263325

264326
/**
265327
* Parses a Cache-Control HTTP header.
328+
*
329+
* @deprecated Use CacheControl::fromHeader instead
266330
*/
267331
protected function parseCacheControl(string $header): array
268332
{
269333
$parts = HeaderUtils::split($header, ',=');
270334

271335
return HeaderUtils::combine($parts);
272336
}
337+
338+
/**
339+
* Get the cache-control target from the header name.
340+
*/
341+
private function extractCacheControlTarget(string $headerName): string
342+
{
343+
if ('cache-control' === strtolower($headerName)) {
344+
return self::DEFAULT_CACHE_CONTROL_TARGET;
345+
}
346+
347+
return substr($headerName, 0, -strlen('-cache-control'));
348+
}
273349
}

0 commit comments

Comments
 (0)