Skip to content

[RFC] Added MaxHeaderValueLengthFormatter #424

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Aug 29, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ cache:
env:
global:
- VARNISH_VERSION=5.1
- DEPENDENCIES="toflar/psr6-symfony-http-cache-store:^1.0"
- DEPENDENCIES="toflar/psr6-symfony-http-cache-store:^1.1.2"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should add a conflicts section to composer.json for older versions of the store, if the library does not work correctly with older versions. as this is an optional dependency, we can not control the version being installed otherwise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


matrix:
fast_finish: true
Expand All @@ -26,15 +26,14 @@ matrix:
- php: 7.2
env: VARNISH_VERSION=4.1 VARNISH_MODULES_VERSION=0.9.1

# Test Symfony LTS versions
- php: 7.2
env: DEPENDENCIES="symfony/lts:^2"
# Test Symfony LTS versions
- php: 7.2
env: DEPENDENCIES="symfony/lts:^3 toflar/psr6-symfony-http-cache-store:^1.0"

# Latest commit to master
- php: 7.2
env: STABILITY="dev"

allow_failures:
# Dev-master is allowed to fail.
- env: STABILITY="dev"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ Changelog

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

2.5.0
-----

### Tagging

* Added: `MaxHeaderValueLengthFormatter` to allow splitting cache tag headers into
multiple headers.

2.4.0
-----

Expand Down
13 changes: 8 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
],
"require": {
"php": "^5.6 || ^7.0.0",
"symfony/event-dispatcher": "^2.3 || ^3.0 || ^4.0",
"symfony/options-resolver": "^2.7 || ^3.0 || ^4.0",
"symfony/event-dispatcher": "^3.4 || ^4.0",
"symfony/options-resolver": "^3.4 || ^4.0",
"php-http/client-implementation": "^1.0.0",
"php-http/client-common": "^1.1.0",
"php-http/message": "^1.0.0",
Expand All @@ -35,8 +35,11 @@
"php-http/guzzle6-adapter": "^1.0.0",
"php-http/mock-client": "^0.3.2",
"phpunit/phpunit": "^5.7 || ^6.0",
"symfony/process": "^2.3 || ^3.0 || ^4.0",
"symfony/http-kernel": "^2.3 || ^3.0 || ^4.0"
"symfony/process": "^3.4 || ^4.0",
"symfony/http-kernel": "^3.4 || ^4.0"
},
"conflict": {
"toflar/psr6-symfony-http-cache-store": "<1.1.2"
},
"suggest": {
"friendsofsymfony/http-cache-bundle": "For integration with the Symfony framework",
Expand All @@ -54,7 +57,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "2.4.x-dev"
"dev-master": "2.5.x-dev"
}
}
}
29 changes: 29 additions & 0 deletions doc/response-tagging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ empty tags::

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


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd like to add a sub heading here to separate this a bit. "Working with large numbers of tags"?

Working with large numbers of tags
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Depending on how many tags your system usually generates your tags header value
might get pretty long. In that case, again depending on your setup, you might run
into server exceptions because the header value is too big to send. Mostly, this
value seems to be about 4KB. The only thing you can do in such a case is to split
up one header into multiple ones.

.. note::

Of course, your proxy then has to support multiple header values otherwise
you'll end up with a proxy that only reads the first line of your tags.

This library ships with a ``MaxHeaderValueLengthFormatter`` that does
the splitting for you. You give it an inner formatter and the maximum length like so::


use FOS\HttpCache\TagHeaderFormatter\CommaSeparatedTagHeaderFormatter;
use FOS\HttpCache\TagHeaderFormatter\MaxHeaderValueLengthFormatter

$inner = new CommaSeparatedTagHeaderFormatter('X-Cache-Tags', ',');
$formatter new MaxHeaderValueLengthFormatter($inner, 4096);

.. note::

Both, Varnish and Symfony HttpCache support multiple cache tag headers.

Usage
~~~~~

Expand Down
4 changes: 4 additions & 0 deletions doc/symfony-cache-configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ configure the HTTP method and header used for tag purging:

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

* **tags_invalidate_path**: Path on the caching proxy to which the purge tags request should be sent.

**default**: ``/``

To get cache tagging support, register the ``PurgeTagsListener`` and use the
``Psr6Store`` in your ``AppCache``::

Expand Down
2 changes: 1 addition & 1 deletion src/Exception/InvalidTagException.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
namespace FOS\HttpCache\Exception;

/**
* Thrown during tagging with an empty value.
* Thrown during tagging with an invalid value.
*/
class InvalidTagException extends \InvalidArgumentException implements HttpCacheException
{
Expand Down
4 changes: 3 additions & 1 deletion src/ProxyClient/Symfony.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ protected function configureOptions()
'purge_method' => PurgeListener::DEFAULT_PURGE_METHOD,
'tags_method' => PurgeTagsListener::DEFAULT_TAGS_METHOD,
'tags_header' => PurgeTagsListener::DEFAULT_TAGS_HEADER,
'tags_invalidate_path' => '/',
'header_length' => 7500,
]);
$resolver->setAllowedTypes('purge_method', 'string');
$resolver->setAllowedTypes('tags_method', 'string');
$resolver->setAllowedTypes('tags_header', 'string');
$resolver->setAllowedTypes('tags_invalidate_path', 'string');
$resolver->setAllowedTypes('header_length', 'int');

return $resolver;
Expand All @@ -84,7 +86,7 @@ public function invalidateTags(array $tags)
foreach (array_chunk($escapedTags, $chunkSize) as $tagchunk) {
$this->queueRequest(
$this->options['tags_method'],
'/',
$this->options['tags_invalidate_path'],
[$this->options['tags_header'] => implode(',', $tagchunk)],
false
);
Expand Down
8 changes: 7 additions & 1 deletion src/SymfonyCache/PurgeTagsListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,13 @@ public function handlePurgeTags(CacheEvent $event)
return;
}

$tags = explode(',', $request->headers->get($this->tagsHeader));
$tags = [];

foreach ($request->headers->get($this->tagsHeader, '', false) as $v) {
foreach (explode(',', $v) as $tag) {
$tags[] = $tag;
}
}

if ($store->invalidateTags($tags)) {
$response->setStatusCode(200, 'Purged');
Expand Down
120 changes: 120 additions & 0 deletions src/TagHeaderFormatter/MaxHeaderValueLengthFormatter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

/*
* This file is part of the FOSHttpCache package.
*
* (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace FOS\HttpCache\TagHeaderFormatter;

use FOS\HttpCache\Exception\InvalidTagException;

/**
* A header formatter that splits the value(s) from a given
* other header formatter (e.g. the CommaSeparatedTagHeaderFormatter)
* into multiple headers making sure none of the header values
* exceeds the configured limit.
*
* @author Yanick Witschi <[email protected]>
*/
class MaxHeaderValueLengthFormatter implements TagHeaderFormatter
{
/**
* @var TagHeaderFormatter
*/
private $inner;

/**
* @var int
*/
private $maxHeaderValueLength;

/**
* The default value of the maximum header length is 4096 because most
* servers limit header values to 4kb.
* HTTP messages cannot carry characters outside the ISO-8859-1 standard so they all
* use up just one byte.
*
* @param TagHeaderFormatter $inner
* @param int $maxHeaderValueLength
*/
public function __construct(TagHeaderFormatter $inner, $maxHeaderValueLength = 4096)
{
$this->inner = $inner;
$this->maxHeaderValueLength = $maxHeaderValueLength;
}

/**
* {@inheritdoc}
*/
public function getTagsHeaderName()
{
$this->inner->getTagsHeaderName();
}

/**
* {@inheritdoc}
*/
public function getTagsHeaderValue(array $tags)
{
$values = (array) $this->inner->getTagsHeaderValue($tags);
$newValues = [[]];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this not be a single []?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, this is because of array_merge(...$newValues);. You could also call array_merge() in every loop but it's a performance gain if you only merge it once :)


foreach ($values as $value) {
if ($this->isHeaderTooLong($value)) {
list($firstTags, $secondTags) = $this->splitTagsInHalves($tags);

$newValues[] = (array) $this->getTagsHeaderValue($firstTags);
$newValues[] = (array) $this->getTagsHeaderValue($secondTags);
} else {
$newValues[] = [$value];
}
}

$newValues = array_merge(...$newValues);

if (1 === count($newValues)) {
return $newValues[0];
}

return $newValues;
}

/**
* @param string $value
*
* @return bool
*/
private function isHeaderTooLong($value)
{
return mb_strlen($value) > $this->maxHeaderValueLength;
}

/**
* Split an array of tags in two more or less equal sized arrays.
*
* @param array $tags
*
* @return array
*
* @throws InvalidTagException
*/
private function splitTagsInHalves(array $tags)
{
if (1 === count($tags)) {
throw new InvalidTagException(sprintf(
'You configured a maximum header length of %d but the tag "%s" is too long.',
$this->maxHeaderValueLength,
$tags[0]
));
}

$size = ceil(count($tags) / 2);

return array_chunk($tags, $size);
}
}
2 changes: 1 addition & 1 deletion src/TagHeaderFormatter/TagHeaderFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function getTagsHeaderName();
*
* @param array $tags
*
* @return string
* @return string|string[]
*/
public function getTagsHeaderValue(array $tags);
}
12 changes: 12 additions & 0 deletions src/Test/PHPUnit/AbstractCacheConstraintTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ public function matches($other)
$message .= sprintf("\nStatus code of response is %s.", $other->getStatusCode());
}

$message .= "\nThe response headers are:\n\n";

foreach ($other->getHeaders() as $name => $values) {
foreach ($values as $value) {
$message .= $name.': '.$value;
}
}

$body = $other->getBody();
$body->rewind();
$message .= sprintf("\nThe response body is:\n\n %s", $body->getContents());

throw new \RuntimeException($message);
}

Expand Down
15 changes: 11 additions & 4 deletions src/Test/SymfonyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use FOS\HttpCache\ProxyClient\HttpDispatcher;
use FOS\HttpCache\ProxyClient\Symfony;
use FOS\HttpCache\Test\Proxy\SymfonyProxy;
use Toflar\Psr6HttpCacheStore\Psr6Store;

/**
* Clears the Symfony HttpCache proxy between tests.
Expand Down Expand Up @@ -121,10 +122,16 @@ protected function getProxyClient()
$this->getHostName().':'.$this->getCachingProxyPort()
);

$this->proxyClient = new Symfony($httpDispatcher, [
'purge_method' => 'NOTIFY',
]
);
$config = [
'purge_method' => 'NOTIFY',
];

if (class_exists(Psr6Store::class)) {
$config['tags_method'] = 'UNSUBSCRIBE';
$config['tags_invalidate_path'] = '/symfony.php/';
}

$this->proxyClient = new Symfony($httpDispatcher, $config);
}

return $this->proxyClient;
Expand Down
7 changes: 7 additions & 0 deletions tests/Functional/Fixtures/Symfony/AppCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
use FOS\HttpCache\SymfonyCache\DebugListener;
use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache;
use FOS\HttpCache\SymfonyCache\PurgeListener;
use FOS\HttpCache\SymfonyCache\PurgeTagsListener;
use FOS\HttpCache\SymfonyCache\RefreshListener;
use FOS\HttpCache\SymfonyCache\UserContextListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpKernel\HttpCache\SurrogateInterface;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Toflar\Psr6HttpCacheStore\Psr6Store;

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

$this->addSubscriber(new CustomTtlListener());
$this->addSubscriber(new PurgeListener(['purge_method' => 'NOTIFY']));

if (class_exists(Psr6Store::class)) {
$this->addSubscriber(new PurgeTagsListener(['tags_method' => 'UNSUBSCRIBE']));
}

$this->addSubscriber(new RefreshListener());
$this->addSubscriber(new UserContextListener());
if (isset($options['debug']) && $options['debug']) {
Expand Down
12 changes: 12 additions & 0 deletions tests/Functional/Fixtures/Symfony/AppKernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQ
$response = new Response(microtime(true));
$response->setCache(['max_age' => 3600, 'public' => true]);

return $response;
case '/tags':
$response = new Response(microtime(true));
$response->setCache(['max_age' => 3600, 'public' => true]);
$response->headers->set('X-Cache-Tags', 'tag1,tag2');

return $response;
case '/tags_multi_header':
$response = new Response(microtime(true));
$response->setCache(['max_age' => 3600, 'public' => true]);
$response->headers->set('X-Cache-Tags', ['tag1', 'tag2']);

return $response;
case '/negotiation':
$response = new Response(microtime(true));
Expand Down
Loading