Merge branch 'prefix-invalidation' into 3.x

This commit is contained in:
David Buchmann
2026-03-04 17:25:47 +01:00
12 changed files with 192 additions and 15 deletions

View File

@@ -6,6 +6,11 @@ See also the [GitHub releases page](https://github.com/FriendsOfSymfony/FOSHttpC
3.x
===
3.2.0
-----
* Added support for prefix invalidation, a special case of banning for cloudflare enterprise.
3.1.2
-----

View File

@@ -19,18 +19,19 @@ Supported invalidation methods
Not all clients support all :ref:`invalidation methods <invalidation methods>`.
This table provides of methods supported by each proxy client:
============= ======= ======= ======= ======= =======
Client Purge Refresh Ban Tagging Clear
============= ======= ======= ======= ======= =======
Varnish ✓ ✓ ✓ ✓
Fastly ✓ ✓ ✓ ✓
============= ======= ======= ======= ======= ========= =======
Client Purge Refresh Ban Tagging Prefix(*) Clear
============= ======= ======= ======= ======= ========= =======
Varnish ✓ ✓ ✓ ✓
Fastly ✓ ✓ ✓
NGINX ✓ ✓
Symfony Cache ✓ ✓ ✓ (1) ✓ (1)
Cloudflare ✓ ✓ (2) ✓
Noop ✓ ✓ ✓ ✓ ✓
Multiplexer ✓ ✓ ✓ ✓ ✓
============= ======= ======= ======= ======= =======
Symfony Cache ✓ ✓ ✓ (1) ✓ (1)
Cloudflare ✓ ✓ (2) ✓ (2) ✓
Noop ✓ ✓ ✓ ✓ ✓
Multiplexer ✓ ✓ ✓ ✓ ✓
============= ======= ======= ======= ======= ========= =======
| (*): A limited version of Ban, that allows to invalidate by the beginning of a path
| (1): Only when using `Toflar Psr6Store`_.
| (2): Only available with `Cloudflare Enterprise`_.
@@ -357,7 +358,7 @@ the HttpDispatcher is not available for Cloudflare)::
Cloudflare supports different cache purge methods depending on your account.
All Cloudflare accounts support purging the cache by URL and clearing all
cache items. You need a `Cloudflare Enterprise`_ account to purge by cache
tags.
tags or prefixes.
Zone identifier
^^^^^^^^^^^^^^^

View File

@@ -18,6 +18,7 @@ use FOS\HttpCache\Exception\ProxyUnreachableException;
use FOS\HttpCache\Exception\UnsupportedProxyOperationException;
use FOS\HttpCache\ProxyClient\Invalidation\BanCapable;
use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PrefixCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable;
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
@@ -216,6 +217,20 @@ class CacheInvalidator
return $this;
}
public function invalidatePrefixes(array $prefixes): static
{
if (!$this->cache instanceof PrefixCapable) {
throw UnsupportedProxyOperationException::cacheDoesNotImplement('Prefixes');
}
if (!$prefixes) {
return $this;
}
$this->cache->invalidatePrefixes($prefixes);
return $this;
}
/**
* Invalidate URLs based on a regular expression for the URI, an optional
* content type and optional limit to certain hosts.

View File

@@ -12,6 +12,7 @@
namespace FOS\HttpCache\ProxyClient;
use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PrefixCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
use Psr\Http\Message\RequestFactoryInterface;
@@ -27,7 +28,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*
* @author Simon Jones <simon@studio24.net>
*/
class Cloudflare extends HttpProxyClient implements ClearCapable, PurgeCapable, TagCapable
class Cloudflare extends HttpProxyClient implements ClearCapable, PrefixCapable, PurgeCapable, TagCapable
{
/**
* @see https://api.cloudflare.com/#getting-started-endpoints
@@ -87,6 +88,32 @@ class Cloudflare extends HttpProxyClient implements ClearCapable, PurgeCapable,
return $this;
}
/**
* {@inheritdoc}
*
* URL prefix only available with Cloudflare enterprise plans.
*
* The prefixes need to include the domain name, but not the protocol, e.g. "www.example.com/path"
*
* @see https://developers.cloudflare.com/api/resources/cache/methods/purge/
*/
public function invalidatePrefixes(array $prefixes): static
{
if (!$prefixes) {
return $this;
}
$this->queueRequest(
'POST',
sprintf(self::API_ENDPOINT.'/zones/%s/purge_cache', $this->options['zone_identifier']),
[],
false,
json_encode(['prefixes' => $prefixes], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)
);
return $this;
}
/**
* @see https://api.cloudflare.com/#zone-purge-files-by-url
* @see https://developers.cloudflare.com/cache/how-to/purge-cache#purge-by-single-file-by-url For details on headers you can pass to clear the cache correctly

View File

@@ -0,0 +1,28 @@
<?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\ProxyClient\Invalidation;
use FOS\HttpCache\ProxyClient\ProxyClient;
/**
* An HTTP cache that supports invalidation by a prefix, that is, removing
* or expiring objects from the cache starting with the given string or strings.
*/
interface PrefixCapable extends ProxyClient
{
/**
* Remove/Expire cache objects based on URL prefixes.
*
* @param string[] $prefixes Prefixed objects that should be removed/expired from the cache. An empty prefix list should be ignored.
*/
public function invalidatePrefixes(array $prefixes): static;
}

View File

@@ -14,6 +14,7 @@ namespace FOS\HttpCache\ProxyClient;
use FOS\HttpCache\Exception\InvalidArgumentException;
use FOS\HttpCache\ProxyClient\Invalidation\BanCapable;
use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PrefixCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable;
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
@@ -23,7 +24,7 @@ use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
*
* @author Emanuele Panzeri <thepanz@gmail.com>
*/
class MultiplexerClient implements BanCapable, PurgeCapable, RefreshCapable, TagCapable, ClearCapable
class MultiplexerClient implements BanCapable, PrefixCapable, PurgeCapable, RefreshCapable, TagCapable, ClearCapable
{
/**
* @var ProxyClient[]
@@ -93,6 +94,21 @@ class MultiplexerClient implements BanCapable, PurgeCapable, RefreshCapable, Tag
return $this;
}
/**
* Forwards prefix invalidation request to all clients.
*
* {@inheritdoc}
*/
public function invalidatePrefixes(array $prefixes): static
{
if (!$prefixes) {
return $this;
}
$this->invoke(PrefixCapable::class, 'invalidatePrefixes', [$prefixes]);
return $this;
}
/**
* Forwards to all clients.
*

View File

@@ -13,6 +13,7 @@ namespace FOS\HttpCache\ProxyClient;
use FOS\HttpCache\ProxyClient\Invalidation\BanCapable;
use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PrefixCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable;
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
@@ -26,7 +27,7 @@ use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
*
* @author Gavin Staniforth <gavin@gsdev.me>
*/
class Noop implements ProxyClient, BanCapable, PurgeCapable, RefreshCapable, TagCapable, ClearCapable
class Noop implements ProxyClient, BanCapable, PrefixCapable, PurgeCapable, RefreshCapable, TagCapable, ClearCapable
{
public function ban(array $headers): static
{
@@ -43,6 +44,11 @@ class Noop implements ProxyClient, BanCapable, PurgeCapable, RefreshCapable, Tag
return $this;
}
public function invalidatePrefixes(array $prefixes): static
{
return $this;
}
public function purge(string $url, array $headers = []): static
{
return $this;

View File

@@ -13,6 +13,7 @@ namespace FOS\HttpCache\ProxyClient;
use FOS\HttpCache\Exception\InvalidArgumentException;
use FOS\HttpCache\ProxyClient\Invalidation\BanCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PrefixCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable;
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
@@ -36,7 +37,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*
* @author David de Boer <david@driebit.nl>
*/
class Varnish extends HttpProxyClient implements BanCapable, PurgeCapable, RefreshCapable, TagCapable
class Varnish extends HttpProxyClient implements BanCapable, PrefixCapable, PurgeCapable, RefreshCapable, TagCapable
{
public const HTTP_METHOD_BAN = 'BAN';
@@ -127,6 +128,22 @@ class Varnish extends HttpProxyClient implements BanCapable, PurgeCapable, Refre
return $this->ban($headers);
}
public function invalidatePrefixes(array $prefixes): static
{
if (!$prefixes) {
return $this;
}
foreach ($prefixes as $prefix) {
$parts = explode('/', $prefix, 2);
$host = $parts[0];
$path = isset($parts[1]) ? '/'.$parts[1] : '/';
$this->banPath($path, null, $host);
}
return $this;
}
public function purge(string $url, array $headers = []): static
{
$this->queueRequest(self::HTTP_METHOD_PURGE, $url, $headers);

View File

@@ -71,6 +71,28 @@ class CloudflareTest extends TestCase
$cloudflare->invalidateTags(['tag-one', 'tag-two']);
}
public function testInvalidatePrefixesPurge(): void
{
$cloudflare = $this->getProxyClient();
$this->httpDispatcher->shouldReceive('invalidate')->once()->with(
\Mockery::on(
function (RequestInterface $request) {
$this->assertEquals('POST', $request->getMethod());
$this->assertEquals('Bearer '.self::AUTH_TOKEN, current($request->getHeader('Authorization')));
$this->assertEquals(sprintf('/client/v4/zones/%s/purge_cache', self::ZONE_IDENTIFIER), $request->getRequestTarget());
$this->assertEquals('{"prefixes":["example.com/one/","example.com/two/"]}', $request->getBody()->getContents());
return true;
}
),
false
);
$cloudflare->invalidatePrefixes(['example.com/one/', 'example.com/two/']);
}
public function testPurge(): void
{
$cloudflare = $this->getProxyClient();

View File

@@ -14,6 +14,7 @@ namespace FOS\HttpCache\Tests\Unit\ProxyClient;
use FOS\HttpCache\Exception\InvalidArgumentException;
use FOS\HttpCache\ProxyClient\Invalidation\BanCapable;
use FOS\HttpCache\ProxyClient\Invalidation\ClearCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PrefixCapable;
use FOS\HttpCache\ProxyClient\Invalidation\PurgeCapable;
use FOS\HttpCache\ProxyClient\Invalidation\RefreshCapable;
use FOS\HttpCache\ProxyClient\Invalidation\TagCapable;
@@ -103,6 +104,21 @@ class MultiplexerClientTest extends TestCase
$this->assertSame($multiplexer, $multiplexer->invalidateTags($tags));
}
public function testInvalidatePrefixes(): void
{
$prefixes = ['example.com/one/', 'example.com/two/'];
$mockClient = \Mockery::mock(PrefixCapable::class)
->shouldReceive('invalidatePrefixes')
->once()
->with($prefixes)
->getMock();
$multiplexer = new MultiplexerClient([$mockClient]);
$this->assertSame($multiplexer, $multiplexer->invalidatePrefixes($prefixes));
}
public function testRefresh(): void
{
$url = 'example.com';

View File

@@ -33,6 +33,11 @@ class NoopTest extends TestCase
$this->assertSame($this->noop, $this->noop->invalidateTags(['tag123']));
}
public function testInvalidatePrefixes(): void
{
$this->assertSame($this->noop, $this->noop->invalidatePrefixes(['example.com/one/']));
}
public function testBanPath(): void
{
$this->assertSame($this->noop, $this->noop->banPath('/123'));

View File

@@ -236,4 +236,23 @@ class VarnishTest extends TestCase
$varnish->refresh('/fresh');
}
public function testInvalidatePrefixes(): void
{
$varnish = new Varnish($this->httpDispatcher);
$this->httpDispatcher->shouldReceive('invalidate')->once()->with(
\Mockery::on(
function (RequestInterface $request) {
$this->assertEquals('BAN', $request->getMethod());
$this->assertEquals('example.org', $request->getHeaderLine('X-Host'));
$this->assertEquals('/one/', $request->getHeaderLine('X-Url'));
$this->assertEquals('.*', $request->getHeaderLine('X-Content-Type'));
return true;
}
),
false
);
$varnish->invalidatePrefixes(['example.org/one/']);
}
}