Skip to content

Commit

Permalink
feat(symfony): agnostic cache purger + souin support (#5273)
Browse files Browse the repository at this point in the history
* feat: add Souin as a new http_cache provider

* feat(symfony): agnostic cache purger

Co-authored-by: darkweak <[email protected]>
  • Loading branch information
soyuka and darkweak authored Dec 17, 2022
1 parent c145ec7 commit 471185d
Show file tree
Hide file tree
Showing 11 changed files with 427 additions and 102 deletions.
8 changes: 4 additions & 4 deletions src/HttpCache/EventListener/AddTagsListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@
use Symfony\Component\HttpKernel\Event\ResponseEvent;

/**
* Sets the list of resources' IRIs included in this response in the "Cache-Tags" and/or "xkey" HTTP headers.
* Sets the list of resources' IRIs included in this response in the configured cache tag HTTP header and/or "xkey" HTTP headers.
*
* The "Cache-Tags" is used because it is supported by CloudFlare.
* By default the "Cache-Tags" HTTP header is used because it is supported by CloudFlare.
*
* @see https://support.cloudflare.com/hc/en-us/articles/206596608-How-to-Purge-Cache-Using-Cache-Tags-Enterprise-only-
* @see https://developers.cloudflare.com/cache/how-to/purge-cache#add-cache-tag-http-response-headers
*
* The "xkey" is used because it is supported by Varnish.
* @see https://docs.varnish-software.com/varnish-cache-plus/vmods/ykey/
Expand All @@ -46,7 +46,7 @@ public function __construct(private readonly IriConverterInterface $iriConverter
}

/**
* Adds the "Cache-Tags" and "xkey" headers.
* Adds the configured HTTP cache tag and "xkey" headers.
*/
public function onKernelResponse(ResponseEvent $event): void
{
Expand Down
36 changes: 36 additions & 0 deletions src/HttpCache/SouinPurger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\HttpCache;

use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Purges Souin.
*
* @author Sylvain Combraque <[email protected]>
*/
class SouinPurger extends SurrogateKeysPurger
{
private const MAX_HEADER_SIZE_PER_BATCH = 1500;
private const SEPARATOR = ', ';
private const HEADER = 'Surrogate-Key';

/**
* @param HttpClientInterface[] $clients
*/
public function __construct(array $clients)
{
parent::__construct($clients, self::MAX_HEADER_SIZE_PER_BATCH, self::HEADER, self::SEPARATOR);
}
}
89 changes: 89 additions & 0 deletions src/HttpCache/SurrogateKeysPurger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace ApiPlatform\HttpCache;

use ApiPlatform\Exception\RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Surrogate keys purger.
*
* @author Sylvain Combraque <[email protected]>
*/
class SurrogateKeysPurger implements PurgerInterface
{
private const MAX_HEADER_SIZE_PER_BATCH = 1500;
private const SEPARATOR = ', ';
private const HEADER = 'Surrogate-Key';

/**
* @param HttpClientInterface[] $clients
*/
public function __construct(protected readonly array $clients, protected readonly int $maxHeaderLength = self::MAX_HEADER_SIZE_PER_BATCH, protected readonly string $header = self::HEADER, protected readonly string $separator = self::SEPARATOR)
{
}

/**
* @return \Iterator<string>
*/
private function getChunkedIris(array $iris): \Iterator
{
if (!$iris) {
return;
}

$chunk = array_shift($iris);
foreach ($iris as $iri) {
$nextChunk = sprintf('%s%s%s', $chunk, $this->separator, $iri);
if (\strlen($nextChunk) <= $this->maxHeaderLength) {
$chunk = $nextChunk;
continue;
}

yield $chunk;
$chunk = $iri;
}

yield $chunk;
}

/**
* {@inheritdoc}
*/
public function purge(array $iris): void
{
foreach ($this->getChunkedIris($iris) as $chunk) {
if (\strlen((string) $chunk) > $this->maxHeaderLength) {
throw new RuntimeException(sprintf('IRI "%s" is too long to fit current max header length (currently set to "%s"). You can increase it using the "api_platform.http_cache.invalidation.max_header_length" parameter.', $chunk, $this->maxHeaderLength));
}

foreach ($this->clients as $client) {
$client->request(
Request::METHOD_PURGE,
'',
['headers' => [$this->header => $chunk]]
);
}
}
}

/**
* {@inheritdoc}
*/
public function getResponseHeaders(array $iris): array
{
return [$this->header => implode($this->separator, $iris)];
}
}
78 changes: 4 additions & 74 deletions src/HttpCache/VarnishXKeyPurger.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,86 +20,16 @@
*
* @author Kévin Dunglas <[email protected]>
*/
final class VarnishXKeyPurger implements PurgerInterface
final class VarnishXKeyPurger extends SurrogateKeysPurger
{
private const VARNISH_MAX_HEADER_LENGTH = 8000;
private const VARNISH_SEPARATOR = ' ';

/**
* @param HttpClientInterface[] $clients
*/
public function __construct(private readonly array $clients, private readonly int $maxHeaderLength = self::VARNISH_MAX_HEADER_LENGTH, private readonly string $xkeyGlue = ' ')
public function __construct(array $clients, int $maxHeaderLength = self::VARNISH_MAX_HEADER_LENGTH, string $xkeyGlue = self::VARNISH_SEPARATOR)
{
}

/**
* {@inheritdoc}
*/
public function purge(array $iris): void
{
if (!$iris) {
return;
}

$irisChunks = array_chunk($iris, \count($iris));

foreach ($irisChunks as $irisChunk) {
$this->purgeIris($irisChunk);
}
}

/**
* {@inheritdoc}
*/
public function getResponseHeaders(array $iris): array
{
return ['xkey' => implode($this->xkeyGlue, $iris)];
}

private function purgeIris(array $iris): void
{
foreach ($this->chunkKeys($iris) as $keys) {
$this->purgeKeys($keys);
}
}

private function purgeKeys(string $keys): void
{
foreach ($this->clients as $client) {
$client->request('PURGE', '', ['headers' => ['xkey' => $keys]]);
}
}

private function chunkKeys(array $keys): iterable
{
$concatenatedKeys = implode($this->xkeyGlue, $keys);

// If all keys fit in the header, we can return them
if (\strlen($concatenatedKeys) <= $this->maxHeaderLength) {
yield $concatenatedKeys;

return;
}

$currentHeader = '';

foreach ($keys as $position => $key) {
if (\strlen((string) $key) > $this->maxHeaderLength) {
throw new \Exception(sprintf('IRI "%s" is too long to fit current max header length (currently set to "%s"). You can increase it using the "api_platform.http_cache.invalidation.max_header_length" parameter.', $key, $this->maxHeaderLength));
}

$headerCandidate = sprintf('%s%s%s', $currentHeader, $position > 0 ? $this->xkeyGlue : '', $key);

if (\strlen($headerCandidate) > $this->maxHeaderLength) {
$nextKeys = \array_slice($keys, $position, \count($keys) - $position);

yield $currentHeader;
yield from $this->chunkKeys($nextKeys);

break;
}

// Key can be added to header
$currentHeader .= sprintf('%s%s', $position > 0 ? $this->xkeyGlue : '', $key);
}
parent::__construct($clients, $maxHeaderLength, 'xkey', $xkeyGlue);
}
}
26 changes: 15 additions & 11 deletions src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -598,23 +598,27 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr

$loader->load('http_cache_purger.xml');

$definitions = [];
foreach ($config['http_cache']['invalidation']['varnish_urls'] as $key => $url) {
$definition = new Definition(ScopingHttpClient::class, [new Reference('http_client'), $url, ['base_uri' => $url] + $config['http_cache']['invalidation']['request_options']]);
$definition->setFactory([ScopingHttpClient::class, 'forBaseUri']);
foreach ($config['http_cache']['invalidation']['scoped_clients'] as $client) {
$definition = $container->getDefinition($client);
$definition->addTag('api_platform.http_cache.http_client');
}

$definitions[] = $definition;
if (!($urls = $config['http_cache']['invalidation']['urls'])) {
$urls = $config['http_cache']['invalidation']['varnish_urls'];
}

foreach (['api_platform.http_cache.purger.varnish.ban', 'api_platform.http_cache.purger.varnish.xkey'] as $serviceName) {
$container->findDefinition($serviceName)->setArguments([
$definitions,
$config['http_cache']['invalidation']['max_header_length'],
]);
foreach ($urls as $key => $url) {
$definition = new Definition(ScopingHttpClient::class, [new Reference('http_client'), $url, ['base_uri' => $url] + $config['http_cache']['invalidation']['request_options']]);
$definition->setFactory([ScopingHttpClient::class, 'forBaseUri']);
$definition->addTag('api_platform.http_cache.http_client');
$container->setDefinition('api_platform.invalidation_http_client.'.$key, $definition);
}

$serviceName = $config['http_cache']['invalidation']['purger'];
$container->setAlias('api_platform.http_cache.purger', $serviceName);

if (!$container->hasDefinition('api_platform.http_cache.purger')) {
$container->setAlias('api_platform.http_cache.purger', $serviceName);
}
}

/**
Expand Down
16 changes: 14 additions & 2 deletions src/Symfony/Bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -316,13 +316,24 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void
->canBeEnabled()
->children()
->arrayNode('varnish_urls')
->setDeprecated('api-platform/core', '3.0', 'The "varnish_urls" configuration is deprecated, use "urls" or "scoped_clients".')
->defaultValue([])
->prototype('scalar')->end()
->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.')
->end()
->arrayNode('urls')
->defaultValue([])
->prototype('scalar')->end()
->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.')
->end()
->arrayNode('scoped_clients')
->defaultValue([])
->prototype('scalar')->end()
->info('Service names of scoped client to use by the cache purger.')
->end()
->integerNode('max_header_length')
->defaultValue(7500)
->info('Max header length supported by the server')
->info('Max header length supported by the cache server.')
->end()
->variableNode('request_options')
->defaultValue([])
Expand All @@ -334,9 +345,10 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void
->end()
->scalarNode('purger')
->defaultValue('api_platform.http_cache.purger.varnish')
->info('Specify a varnish purger to use (available values: "api_platform.http_cache.purger.varnish.ban" or "api_platform.http_cache.purger.varnish.xkey").')
->info('Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin").')
->end()
->arrayNode('xkey')
->setDeprecated('api-platform/core', '3.0', 'The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters.')
->addDefaultsIfNotSet()
->children()
->scalarNode('glue')
Expand Down
6 changes: 5 additions & 1 deletion src/Symfony/Bundle/Resources/config/http_cache_purger.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
<service id="api_platform.http_cache.purger.varnish" alias="api_platform.http_cache.purger.varnish.ban" public="false" />
<service id="api_platform.http_cache.purger.varnish.ban" class="ApiPlatform\HttpCache\VarnishPurger" public="false" />
<service id="api_platform.http_cache.purger.varnish.xkey" class="ApiPlatform\HttpCache\VarnishXKeyPurger" public="false">
<argument type="collection"/>
<argument type="tagged" tag="api_platform.http_cache.http_client" />
<argument>%api_platform.http_cache.invalidation.max_header_length%</argument>
<argument>%api_platform.http_cache.invalidation.xkey.glue%</argument>
</service>
<service id="api_platform.http_cache.purger.souin" class="ApiPlatform\HttpCache\SouinPurger" public="false">
<argument type="tagged" tag="api_platform.http_cache.http_client" />
<argument>%api_platform.http_cache.invalidation.max_header_length%</argument>
</service>

<service id="api_platform.http_cache.listener.response.add_tags" class="ApiPlatform\HttpCache\EventListener\AddTagsListener">
<argument type="service" id="api_platform.iri_converter" />
Expand Down
Loading

0 comments on commit 471185d

Please sign in to comment.