Skip to content

Commit 6552fb1

Browse files
Toflardbu
authored andcommitted
Added MaxHeaderValueLengthFormatter (#424)
Added a TagHeaderFormatter that limits the header values from another formatter to a given size
1 parent 508d232 commit 6552fb1

21 files changed

+386
-19
lines changed

.travis.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ cache:
77
env:
88
global:
99
- VARNISH_VERSION=5.1
10-
- DEPENDENCIES="toflar/psr6-symfony-http-cache-store:^1.0"
10+
- DEPENDENCIES="toflar/psr6-symfony-http-cache-store:^1.1.2"
1111

1212
matrix:
1313
fast_finish: true
@@ -26,15 +26,14 @@ matrix:
2626
- php: 7.2
2727
env: VARNISH_VERSION=4.1 VARNISH_MODULES_VERSION=0.9.1
2828

29-
# Test Symfony LTS versions
30-
- php: 7.2
31-
env: DEPENDENCIES="symfony/lts:^2"
29+
# Test Symfony LTS versions
3230
- php: 7.2
3331
env: DEPENDENCIES="symfony/lts:^3 toflar/psr6-symfony-http-cache-store:^1.0"
3432

3533
# Latest commit to master
3634
- php: 7.2
3735
env: STABILITY="dev"
36+
3837
allow_failures:
3938
# Dev-master is allowed to fail.
4039
- env: STABILITY="dev"

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ Changelog
33

44
See also the [GitHub releases page](https://github.com/FriendsOfSymfony/FOSHttpCache/releases).
55

6+
2.5.0
7+
-----
8+
9+
### Tagging
10+
11+
* Added: `MaxHeaderValueLengthFormatter` to allow splitting cache tag headers into
12+
multiple headers.
13+
614
2.4.0
715
-----
816

composer.json

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
],
2323
"require": {
2424
"php": "^5.6 || ^7.0.0",
25-
"symfony/event-dispatcher": "^2.3 || ^3.0 || ^4.0",
26-
"symfony/options-resolver": "^2.7 || ^3.0 || ^4.0",
25+
"symfony/event-dispatcher": "^3.4 || ^4.0",
26+
"symfony/options-resolver": "^3.4 || ^4.0",
2727
"php-http/client-implementation": "^1.0.0",
2828
"php-http/client-common": "^1.1.0",
2929
"php-http/message": "^1.0.0",
@@ -35,8 +35,11 @@
3535
"php-http/guzzle6-adapter": "^1.0.0",
3636
"php-http/mock-client": "^0.3.2",
3737
"phpunit/phpunit": "^5.7 || ^6.0",
38-
"symfony/process": "^2.3 || ^3.0 || ^4.0",
39-
"symfony/http-kernel": "^2.3 || ^3.0 || ^4.0"
38+
"symfony/process": "^3.4 || ^4.0",
39+
"symfony/http-kernel": "^3.4 || ^4.0"
40+
},
41+
"conflict": {
42+
"toflar/psr6-symfony-http-cache-store": "<1.1.2"
4043
},
4144
"suggest": {
4245
"friendsofsymfony/http-cache-bundle": "For integration with the Symfony framework",
@@ -54,7 +57,7 @@
5457
},
5558
"extra": {
5659
"branch-alias": {
57-
"dev-master": "2.4.x-dev"
60+
"dev-master": "2.5.x-dev"
5861
}
5962
}
6063
}

doc/response-tagging.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,35 @@ empty tags::
5656

5757
$responseTagger = new ResponseTagger(['strict' => true]);
5858

59+
60+
Working with large numbers of tags
61+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
62+
63+
Depending on how many tags your system usually generates your tags header value
64+
might get pretty long. In that case, again depending on your setup, you might run
65+
into server exceptions because the header value is too big to send. Mostly, this
66+
value seems to be about 4KB. The only thing you can do in such a case is to split
67+
up one header into multiple ones.
68+
69+
.. note::
70+
71+
Of course, your proxy then has to support multiple header values otherwise
72+
you'll end up with a proxy that only reads the first line of your tags.
73+
74+
This library ships with a ``MaxHeaderValueLengthFormatter`` that does
75+
the splitting for you. You give it an inner formatter and the maximum length like so::
76+
77+
78+
use FOS\HttpCache\TagHeaderFormatter\CommaSeparatedTagHeaderFormatter;
79+
use FOS\HttpCache\TagHeaderFormatter\MaxHeaderValueLengthFormatter
80+
81+
$inner = new CommaSeparatedTagHeaderFormatter('X-Cache-Tags', ',');
82+
$formatter new MaxHeaderValueLengthFormatter($inner, 4096);
83+
84+
.. note::
85+
86+
Both, Varnish and Symfony HttpCache support multiple cache tag headers.
87+
5988
Usage
6089
~~~~~
6190

doc/symfony-cache-configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ configure the HTTP method and header used for tag purging:
226226

227227
**default**: ``X-Cache-Tags``
228228

229+
* **tags_invalidate_path**: Path on the caching proxy to which the purge tags request should be sent.
230+
231+
**default**: ``/``
232+
229233
To get cache tagging support, register the ``PurgeTagsListener`` and use the
230234
``Psr6Store`` in your ``AppCache``::
231235

src/Exception/InvalidTagException.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
namespace FOS\HttpCache\Exception;
1313

1414
/**
15-
* Thrown during tagging with an empty value.
15+
* Thrown during tagging with an invalid value.
1616
*/
1717
class InvalidTagException extends \InvalidArgumentException implements HttpCacheException
1818
{

src/ProxyClient/Symfony.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@ protected function configureOptions()
5858
'purge_method' => PurgeListener::DEFAULT_PURGE_METHOD,
5959
'tags_method' => PurgeTagsListener::DEFAULT_TAGS_METHOD,
6060
'tags_header' => PurgeTagsListener::DEFAULT_TAGS_HEADER,
61+
'tags_invalidate_path' => '/',
6162
'header_length' => 7500,
6263
]);
6364
$resolver->setAllowedTypes('purge_method', 'string');
6465
$resolver->setAllowedTypes('tags_method', 'string');
6566
$resolver->setAllowedTypes('tags_header', 'string');
67+
$resolver->setAllowedTypes('tags_invalidate_path', 'string');
6668
$resolver->setAllowedTypes('header_length', 'int');
6769

6870
return $resolver;
@@ -84,7 +86,7 @@ public function invalidateTags(array $tags)
8486
foreach (array_chunk($escapedTags, $chunkSize) as $tagchunk) {
8587
$this->queueRequest(
8688
$this->options['tags_method'],
87-
'/',
89+
$this->options['tags_invalidate_path'],
8890
[$this->options['tags_header'] => implode(',', $tagchunk)],
8991
false
9092
);

src/SymfonyCache/PurgeTagsListener.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,13 @@ public function handlePurgeTags(CacheEvent $event)
117117
return;
118118
}
119119

120-
$tags = explode(',', $request->headers->get($this->tagsHeader));
120+
$tags = [];
121+
122+
foreach ($request->headers->get($this->tagsHeader, '', false) as $v) {
123+
foreach (explode(',', $v) as $tag) {
124+
$tags[] = $tag;
125+
}
126+
}
121127

122128
if ($store->invalidateTags($tags)) {
123129
$response->setStatusCode(200, 'Purged');
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the FOSHttpCache package.
5+
*
6+
* (c) FriendsOfSymfony <http://friendsofsymfony.github.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 FOS\HttpCache\TagHeaderFormatter;
13+
14+
use FOS\HttpCache\Exception\InvalidTagException;
15+
16+
/**
17+
* A header formatter that splits the value(s) from a given
18+
* other header formatter (e.g. the CommaSeparatedTagHeaderFormatter)
19+
* into multiple headers making sure none of the header values
20+
* exceeds the configured limit.
21+
*
22+
* @author Yanick Witschi <[email protected]>
23+
*/
24+
class MaxHeaderValueLengthFormatter implements TagHeaderFormatter
25+
{
26+
/**
27+
* @var TagHeaderFormatter
28+
*/
29+
private $inner;
30+
31+
/**
32+
* @var int
33+
*/
34+
private $maxHeaderValueLength;
35+
36+
/**
37+
* The default value of the maximum header length is 4096 because most
38+
* servers limit header values to 4kb.
39+
* HTTP messages cannot carry characters outside the ISO-8859-1 standard so they all
40+
* use up just one byte.
41+
*
42+
* @param TagHeaderFormatter $inner
43+
* @param int $maxHeaderValueLength
44+
*/
45+
public function __construct(TagHeaderFormatter $inner, $maxHeaderValueLength = 4096)
46+
{
47+
$this->inner = $inner;
48+
$this->maxHeaderValueLength = $maxHeaderValueLength;
49+
}
50+
51+
/**
52+
* {@inheritdoc}
53+
*/
54+
public function getTagsHeaderName()
55+
{
56+
$this->inner->getTagsHeaderName();
57+
}
58+
59+
/**
60+
* {@inheritdoc}
61+
*/
62+
public function getTagsHeaderValue(array $tags)
63+
{
64+
$values = (array) $this->inner->getTagsHeaderValue($tags);
65+
$newValues = [[]];
66+
67+
foreach ($values as $value) {
68+
if ($this->isHeaderTooLong($value)) {
69+
list($firstTags, $secondTags) = $this->splitTagsInHalves($tags);
70+
71+
$newValues[] = (array) $this->getTagsHeaderValue($firstTags);
72+
$newValues[] = (array) $this->getTagsHeaderValue($secondTags);
73+
} else {
74+
$newValues[] = [$value];
75+
}
76+
}
77+
78+
$newValues = array_merge(...$newValues);
79+
80+
if (1 === count($newValues)) {
81+
return $newValues[0];
82+
}
83+
84+
return $newValues;
85+
}
86+
87+
/**
88+
* @param string $value
89+
*
90+
* @return bool
91+
*/
92+
private function isHeaderTooLong($value)
93+
{
94+
return mb_strlen($value) > $this->maxHeaderValueLength;
95+
}
96+
97+
/**
98+
* Split an array of tags in two more or less equal sized arrays.
99+
*
100+
* @param array $tags
101+
*
102+
* @return array
103+
*
104+
* @throws InvalidTagException
105+
*/
106+
private function splitTagsInHalves(array $tags)
107+
{
108+
if (1 === count($tags)) {
109+
throw new InvalidTagException(sprintf(
110+
'You configured a maximum header length of %d but the tag "%s" is too long.',
111+
$this->maxHeaderValueLength,
112+
$tags[0]
113+
));
114+
}
115+
116+
$size = ceil(count($tags) / 2);
117+
118+
return array_chunk($tags, $size);
119+
}
120+
}

src/TagHeaderFormatter/TagHeaderFormatter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public function getTagsHeaderName();
3939
*
4040
* @param array $tags
4141
*
42-
* @return string
42+
* @return string|string[]
4343
*/
4444
public function getTagsHeaderValue(array $tags);
4545
}

src/Test/PHPUnit/AbstractCacheConstraintTrait.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ public function matches($other)
5454
$message .= sprintf("\nStatus code of response is %s.", $other->getStatusCode());
5555
}
5656

57+
$message .= "\nThe response headers are:\n\n";
58+
59+
foreach ($other->getHeaders() as $name => $values) {
60+
foreach ($values as $value) {
61+
$message .= $name.': '.$value;
62+
}
63+
}
64+
65+
$body = $other->getBody();
66+
$body->rewind();
67+
$message .= sprintf("\nThe response body is:\n\n %s", $body->getContents());
68+
5769
throw new \RuntimeException($message);
5870
}
5971

src/Test/SymfonyTest.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use FOS\HttpCache\ProxyClient\HttpDispatcher;
1515
use FOS\HttpCache\ProxyClient\Symfony;
1616
use FOS\HttpCache\Test\Proxy\SymfonyProxy;
17+
use Toflar\Psr6HttpCacheStore\Psr6Store;
1718

1819
/**
1920
* Clears the Symfony HttpCache proxy between tests.
@@ -121,10 +122,16 @@ protected function getProxyClient()
121122
$this->getHostName().':'.$this->getCachingProxyPort()
122123
);
123124

124-
$this->proxyClient = new Symfony($httpDispatcher, [
125-
'purge_method' => 'NOTIFY',
126-
]
127-
);
125+
$config = [
126+
'purge_method' => 'NOTIFY',
127+
];
128+
129+
if (class_exists(Psr6Store::class)) {
130+
$config['tags_method'] = 'UNSUBSCRIBE';
131+
$config['tags_invalidate_path'] = '/symfony.php/';
132+
}
133+
134+
$this->proxyClient = new Symfony($httpDispatcher, $config);
128135
}
129136

130137
return $this->proxyClient;

tests/Functional/Fixtures/Symfony/AppCache.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
use FOS\HttpCache\SymfonyCache\DebugListener;
1717
use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache;
1818
use FOS\HttpCache\SymfonyCache\PurgeListener;
19+
use FOS\HttpCache\SymfonyCache\PurgeTagsListener;
1920
use FOS\HttpCache\SymfonyCache\RefreshListener;
2021
use FOS\HttpCache\SymfonyCache\UserContextListener;
2122
use Symfony\Component\HttpFoundation\Request;
2223
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
2324
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
2425
use Symfony\Component\HttpKernel\HttpCache\SurrogateInterface;
2526
use Symfony\Component\HttpKernel\HttpKernelInterface;
27+
use Toflar\Psr6HttpCacheStore\Psr6Store;
2628

2729
class AppCache extends HttpCache implements CacheInvalidation
2830
{
@@ -34,6 +36,11 @@ public function __construct(HttpKernelInterface $kernel, StoreInterface $store,
3436

3537
$this->addSubscriber(new CustomTtlListener());
3638
$this->addSubscriber(new PurgeListener(['purge_method' => 'NOTIFY']));
39+
40+
if (class_exists(Psr6Store::class)) {
41+
$this->addSubscriber(new PurgeTagsListener(['tags_method' => 'UNSUBSCRIBE']));
42+
}
43+
3744
$this->addSubscriber(new RefreshListener());
3845
$this->addSubscriber(new UserContextListener());
3946
if (isset($options['debug']) && $options['debug']) {

tests/Functional/Fixtures/Symfony/AppKernel.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQ
3232
$response = new Response(microtime(true));
3333
$response->setCache(['max_age' => 3600, 'public' => true]);
3434

35+
return $response;
36+
case '/tags':
37+
$response = new Response(microtime(true));
38+
$response->setCache(['max_age' => 3600, 'public' => true]);
39+
$response->headers->set('X-Cache-Tags', 'tag1,tag2');
40+
41+
return $response;
42+
case '/tags_multi_header':
43+
$response = new Response(microtime(true));
44+
$response->setCache(['max_age' => 3600, 'public' => true]);
45+
$response->headers->set('X-Cache-Tags', ['tag1', 'tag2']);
46+
3547
return $response;
3648
case '/negotiation':
3749
$response = new Response(microtime(true));

0 commit comments

Comments
 (0)