Skip to content

Commit 471185d

Browse files
soyukadarkweak
andauthored
feat(symfony): agnostic cache purger + souin support (api-platform#5273)
* feat: add Souin as a new http_cache provider * feat(symfony): agnostic cache purger Co-authored-by: darkweak <[email protected]>
1 parent c145ec7 commit 471185d

File tree

11 files changed

+427
-102
lines changed

11 files changed

+427
-102
lines changed

src/HttpCache/EventListener/AddTagsListener.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424
use Symfony\Component\HttpKernel\Event\ResponseEvent;
2525

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

4848
/**
49-
* Adds the "Cache-Tags" and "xkey" headers.
49+
* Adds the configured HTTP cache tag and "xkey" headers.
5050
*/
5151
public function onKernelResponse(ResponseEvent $event): void
5252
{

src/HttpCache/SouinPurger.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\HttpCache;
15+
16+
use Symfony\Contracts\HttpClient\HttpClientInterface;
17+
18+
/**
19+
* Purges Souin.
20+
*
21+
* @author Sylvain Combraque <[email protected]>
22+
*/
23+
class SouinPurger extends SurrogateKeysPurger
24+
{
25+
private const MAX_HEADER_SIZE_PER_BATCH = 1500;
26+
private const SEPARATOR = ', ';
27+
private const HEADER = 'Surrogate-Key';
28+
29+
/**
30+
* @param HttpClientInterface[] $clients
31+
*/
32+
public function __construct(array $clients)
33+
{
34+
parent::__construct($clients, self::MAX_HEADER_SIZE_PER_BATCH, self::HEADER, self::SEPARATOR);
35+
}
36+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
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+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\HttpCache;
15+
16+
use ApiPlatform\Exception\RuntimeException;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Contracts\HttpClient\HttpClientInterface;
19+
20+
/**
21+
* Surrogate keys purger.
22+
*
23+
* @author Sylvain Combraque <[email protected]>
24+
*/
25+
class SurrogateKeysPurger implements PurgerInterface
26+
{
27+
private const MAX_HEADER_SIZE_PER_BATCH = 1500;
28+
private const SEPARATOR = ', ';
29+
private const HEADER = 'Surrogate-Key';
30+
31+
/**
32+
* @param HttpClientInterface[] $clients
33+
*/
34+
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)
35+
{
36+
}
37+
38+
/**
39+
* @return \Iterator<string>
40+
*/
41+
private function getChunkedIris(array $iris): \Iterator
42+
{
43+
if (!$iris) {
44+
return;
45+
}
46+
47+
$chunk = array_shift($iris);
48+
foreach ($iris as $iri) {
49+
$nextChunk = sprintf('%s%s%s', $chunk, $this->separator, $iri);
50+
if (\strlen($nextChunk) <= $this->maxHeaderLength) {
51+
$chunk = $nextChunk;
52+
continue;
53+
}
54+
55+
yield $chunk;
56+
$chunk = $iri;
57+
}
58+
59+
yield $chunk;
60+
}
61+
62+
/**
63+
* {@inheritdoc}
64+
*/
65+
public function purge(array $iris): void
66+
{
67+
foreach ($this->getChunkedIris($iris) as $chunk) {
68+
if (\strlen((string) $chunk) > $this->maxHeaderLength) {
69+
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));
70+
}
71+
72+
foreach ($this->clients as $client) {
73+
$client->request(
74+
Request::METHOD_PURGE,
75+
'',
76+
['headers' => [$this->header => $chunk]]
77+
);
78+
}
79+
}
80+
}
81+
82+
/**
83+
* {@inheritdoc}
84+
*/
85+
public function getResponseHeaders(array $iris): array
86+
{
87+
return [$this->header => implode($this->separator, $iris)];
88+
}
89+
}

src/HttpCache/VarnishXKeyPurger.php

Lines changed: 4 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -20,86 +20,16 @@
2020
*
2121
* @author Kévin Dunglas <[email protected]>
2222
*/
23-
final class VarnishXKeyPurger implements PurgerInterface
23+
final class VarnishXKeyPurger extends SurrogateKeysPurger
2424
{
2525
private const VARNISH_MAX_HEADER_LENGTH = 8000;
26+
private const VARNISH_SEPARATOR = ' ';
2627

2728
/**
2829
* @param HttpClientInterface[] $clients
2930
*/
30-
public function __construct(private readonly array $clients, private readonly int $maxHeaderLength = self::VARNISH_MAX_HEADER_LENGTH, private readonly string $xkeyGlue = ' ')
31+
public function __construct(array $clients, int $maxHeaderLength = self::VARNISH_MAX_HEADER_LENGTH, string $xkeyGlue = self::VARNISH_SEPARATOR)
3132
{
32-
}
33-
34-
/**
35-
* {@inheritdoc}
36-
*/
37-
public function purge(array $iris): void
38-
{
39-
if (!$iris) {
40-
return;
41-
}
42-
43-
$irisChunks = array_chunk($iris, \count($iris));
44-
45-
foreach ($irisChunks as $irisChunk) {
46-
$this->purgeIris($irisChunk);
47-
}
48-
}
49-
50-
/**
51-
* {@inheritdoc}
52-
*/
53-
public function getResponseHeaders(array $iris): array
54-
{
55-
return ['xkey' => implode($this->xkeyGlue, $iris)];
56-
}
57-
58-
private function purgeIris(array $iris): void
59-
{
60-
foreach ($this->chunkKeys($iris) as $keys) {
61-
$this->purgeKeys($keys);
62-
}
63-
}
64-
65-
private function purgeKeys(string $keys): void
66-
{
67-
foreach ($this->clients as $client) {
68-
$client->request('PURGE', '', ['headers' => ['xkey' => $keys]]);
69-
}
70-
}
71-
72-
private function chunkKeys(array $keys): iterable
73-
{
74-
$concatenatedKeys = implode($this->xkeyGlue, $keys);
75-
76-
// If all keys fit in the header, we can return them
77-
if (\strlen($concatenatedKeys) <= $this->maxHeaderLength) {
78-
yield $concatenatedKeys;
79-
80-
return;
81-
}
82-
83-
$currentHeader = '';
84-
85-
foreach ($keys as $position => $key) {
86-
if (\strlen((string) $key) > $this->maxHeaderLength) {
87-
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));
88-
}
89-
90-
$headerCandidate = sprintf('%s%s%s', $currentHeader, $position > 0 ? $this->xkeyGlue : '', $key);
91-
92-
if (\strlen($headerCandidate) > $this->maxHeaderLength) {
93-
$nextKeys = \array_slice($keys, $position, \count($keys) - $position);
94-
95-
yield $currentHeader;
96-
yield from $this->chunkKeys($nextKeys);
97-
98-
break;
99-
}
100-
101-
// Key can be added to header
102-
$currentHeader .= sprintf('%s%s', $position > 0 ? $this->xkeyGlue : '', $key);
103-
}
33+
parent::__construct($clients, $maxHeaderLength, 'xkey', $xkeyGlue);
10434
}
10535
}

src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -598,23 +598,27 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr
598598

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

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

606-
$definitions[] = $definition;
606+
if (!($urls = $config['http_cache']['invalidation']['urls'])) {
607+
$urls = $config['http_cache']['invalidation']['varnish_urls'];
607608
}
608609

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

616617
$serviceName = $config['http_cache']['invalidation']['purger'];
617-
$container->setAlias('api_platform.http_cache.purger', $serviceName);
618+
619+
if (!$container->hasDefinition('api_platform.http_cache.purger')) {
620+
$container->setAlias('api_platform.http_cache.purger', $serviceName);
621+
}
618622
}
619623

620624
/**

src/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,13 +316,24 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void
316316
->canBeEnabled()
317317
->children()
318318
->arrayNode('varnish_urls')
319+
->setDeprecated('api-platform/core', '3.0', 'The "varnish_urls" configuration is deprecated, use "urls" or "scoped_clients".')
319320
->defaultValue([])
320321
->prototype('scalar')->end()
321322
->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.')
322323
->end()
324+
->arrayNode('urls')
325+
->defaultValue([])
326+
->prototype('scalar')->end()
327+
->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.')
328+
->end()
329+
->arrayNode('scoped_clients')
330+
->defaultValue([])
331+
->prototype('scalar')->end()
332+
->info('Service names of scoped client to use by the cache purger.')
333+
->end()
323334
->integerNode('max_header_length')
324335
->defaultValue(7500)
325-
->info('Max header length supported by the server')
336+
->info('Max header length supported by the cache server.')
326337
->end()
327338
->variableNode('request_options')
328339
->defaultValue([])
@@ -334,9 +345,10 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void
334345
->end()
335346
->scalarNode('purger')
336347
->defaultValue('api_platform.http_cache.purger.varnish')
337-
->info('Specify a varnish purger to use (available values: "api_platform.http_cache.purger.varnish.ban" or "api_platform.http_cache.purger.varnish.xkey").')
348+
->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").')
338349
->end()
339350
->arrayNode('xkey')
351+
->setDeprecated('api-platform/core', '3.0', 'The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters.')
340352
->addDefaultsIfNotSet()
341353
->children()
342354
->scalarNode('glue')

src/Symfony/Bundle/Resources/config/http_cache_purger.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@
88
<service id="api_platform.http_cache.purger.varnish" alias="api_platform.http_cache.purger.varnish.ban" public="false" />
99
<service id="api_platform.http_cache.purger.varnish.ban" class="ApiPlatform\HttpCache\VarnishPurger" public="false" />
1010
<service id="api_platform.http_cache.purger.varnish.xkey" class="ApiPlatform\HttpCache\VarnishXKeyPurger" public="false">
11-
<argument type="collection"/>
11+
<argument type="tagged" tag="api_platform.http_cache.http_client" />
1212
<argument>%api_platform.http_cache.invalidation.max_header_length%</argument>
1313
<argument>%api_platform.http_cache.invalidation.xkey.glue%</argument>
1414
</service>
15+
<service id="api_platform.http_cache.purger.souin" class="ApiPlatform\HttpCache\SouinPurger" public="false">
16+
<argument type="tagged" tag="api_platform.http_cache.http_client" />
17+
<argument>%api_platform.http_cache.invalidation.max_header_length%</argument>
18+
</service>
1519

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

0 commit comments

Comments
 (0)