From b4e4afd9ab5a5d6075e70d30d3b537adc354002f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Aug 2026 20:58:58 +0200 Subject: [PATCH 1/2] Fix Fastly domain purge, align both cache adapters purgeDomain() ran purge_all on Fastly and never sent the domain: the argument was validated and discarded, so two different domains produced byte-identical requests. On a service fronting many domains that evicts all of them, and Fastly documents purge_all as taking up to two minutes, spiking origin traffic, and being incompatible with soft purge -- so a caller asking for a soft purge of one domain got a hard purge of everything. Fastly has no purge-by-host operation; its API offers URL, surrogate key and whole-service purges and nothing else. A domain is therefore the surrogate key the origin attaches, and the adapter has to know how those keys are named, so domainKeyPrefix is required rather than optional. There is no configuration in which purgeDomain() means purge_all. Cloudflare needed no such fix -- it purges a hostname natively -- but it was missing a zone purge entirely. Cache\Adapter now declares purgeZone() alongside the other three, so both providers offer the same set and Cache exposes it. Naming is aligned across adapters: purgeZone() for the widest purge either provider has, and PATHS_PER_PURGE / KEYS_PER_PURGE for the per-request ceilings, provider-specific numbers behind identical names. purgeKeys() on Fastly uses the batch endpoint: keys in the request body, up to 256 per request rather than one request each. A body also means no percent-encoding, which would purge a key the origin never attached. tests/Cdn/Cache/AdapterTest.php asserts the contract itself, so a provider growing a purge the others lack, or spelling one differently, fails there rather than in a caller that assumed they behaved alike. Requests are byte-identical to main for Cloudflare's three operations and Fastly's path purge, headers included. Refs Fastly purging API and purging concepts, Cloudflare purge cache docs. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 23 ++++- src/Cdn/Cache.php | 8 ++ src/Cdn/Cache/Adapter.php | 19 +++++ src/Cdn/Cache/Adapter/Cloudflare.php | 93 +++++++++++--------- src/Cdn/Cache/Adapter/Fastly.php | 98 +++++++++++++++------- tests/Cdn/Cache/Adapter/CloudflareTest.php | 50 +++++++++-- tests/Cdn/Cache/Adapter/FastlyTest.php | 97 +++++++++++++++++++-- tests/Cdn/Cache/AdapterTest.php | 56 +++++++++++++ tests/Cdn/CacheTest.php | 6 ++ 9 files changed, 366 insertions(+), 84 deletions(-) create mode 100644 tests/Cdn/Cache/AdapterTest.php diff --git a/README.md b/README.md index c11ff13..6a573b1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ The cache API supports three purge modes: - path purges scoped to a domain - domain-wide purges - key or cache-tag purges for providers like Fastly and Cloudflare +- zone-wide purges, for when the whole cache has to go Domains are lowercase hostnames without a scheme or trailing slash. Paths begin with `/`; CDN resources are assumed to use HTTPS. @@ -51,6 +52,12 @@ $cache->purgeKeys([ ]); ``` +Cloudflare purges a hostname natively, so `purgeDomain()` evicts everything served for that hostname and nothing served for another. Every purge method is [available on all plans](https://developers.cloudflare.com/changelog/post/2025-04-01-purge-for-all/). `purgeKeys()` purges cache tags, which only match responses the origin tagged with a `Cache-Tag` header. + +URLs and tags are batched at `PATHS_PER_PURGE` and `KEYS_PER_PURGE` items per request. Cloudflare's own pages disagree about the ceiling — the purge overview says 100 items per request while the purge-by-hostname page says 30 — so the lower figure is used. + +`purgeZone()` purges every cached response in the zone (`purge_everything`). + ### Fastly ```php @@ -61,6 +68,7 @@ use Utopia\Cdn\Cache\Adapter\Fastly; $cache = new Cache(new Fastly( apiToken: 'YOUR_API_TOKEN', + domainKeyPrefix: 'domain-', serviceId: 'YOUR_SERVICE_ID', softPurge: false )); @@ -70,13 +78,26 @@ $cache->purgePaths('example.com', [ '/files/logo.svg', ]); +// Purges the surrogate key "domain-example.com". +$cache->purgeDomain('example.com'); + $cache->purgeKeys([ 'host-deadbeef', 'deployment-12345', ]); ``` -Fastly domain purges invalidate the entire configured service. Use one domain per Fastly service when calling `purgeDomain()`. Cloudflare hostname purging depends on the cache-purge features enabled for your plan. +`domainKeyPrefix` is required because [Fastly has no purge-by-host operation](https://www.fastly.com/documentation/reference/api/purging/) — its purge API offers URL, surrogate key and whole-service purges and nothing in between. A domain is addressed by the surrogate key the origin attaches to every response it serves for that domain, so the adapter has to know how those keys are named. Pass `''` when the key is the bare hostname. + +Keys are sent as given, in the request body, batched up to 256 per request. A Fastly adapter with no service ID can still purge paths; key and domain purges raise `Exception\UnsupportedOperation`. + +`purgeZone()` purges everything on the service (`purge_all`), which Fastly documents as taking up to two minutes, being incompatible with soft purge, and likely to spike origin traffic on a busy service. Prefer `purgeDomain()` or `purgeKeys()`. + +### The adapter contract + +`Cache\Adapter` declares the four purges every adapter offers — `purgePaths()`, `purgeDomain()`, `purgeKeys()` and `purgeZone()` — so a caller never has to know which provider is behind it. Providers differ in what they expose natively, and the adapter absorbs the difference: Cloudflare purges a hostname directly, while Fastly maps the same call onto a surrogate key. Where an adapter cannot serve an operation with the configuration it was given it raises `Exception\UnsupportedOperation`, rather than quietly doing something wider. + +Adapters name things alike: `PATHS_PER_PURGE` and `KEYS_PER_PURGE` are how many items one request may carry, with provider-specific numbers behind the same names — Fastly batches 256 keys and purges one URL at a time, Cloudflare takes 30 of either. ## Certificates diff --git a/src/Cdn/Cache.php b/src/Cdn/Cache.php index f89cd4c..5de886a 100644 --- a/src/Cdn/Cache.php +++ b/src/Cdn/Cache.php @@ -30,4 +30,12 @@ public function purgeKeys(array $keys): void { $this->adapter->purgeKeys($keys); } + + /** + * Purges the adapter's entire cache. Prefer purgeDomain() or purgeKeys(). + */ + public function purgeZone(): void + { + $this->adapter->purgeZone(); + } } diff --git a/src/Cdn/Cache/Adapter.php b/src/Cdn/Cache/Adapter.php index 90168cf..3f20213 100644 --- a/src/Cdn/Cache/Adapter.php +++ b/src/Cdn/Cache/Adapter.php @@ -2,6 +2,16 @@ namespace Utopia\Cdn\Cache; +/** + * The purge operations every provider adapter offers. + * + * Providers differ in what they expose natively — Cloudflare purges a hostname directly, Fastly has + * to be told which surrogate key stands for one — but a caller should not have to know which. Every + * operation here is therefore implemented by every adapter, or the adapter raises + * Exception\UnsupportedOperation to say it cannot serve this one with the configuration it was given. + * + * Naming follows the object being purged: paths, a domain, cache keys, the whole zone. + */ interface Adapter { /** @@ -15,4 +25,13 @@ public function purgeDomain(string $domain): void; * @param array $keys */ public function purgeKeys(array $keys): void; + + /** + * Purges everything the adapter is configured for, whatever domain it belongs to. + * + * The widest operation a provider offers: Cloudflare's purge_everything for the zone, Fastly's + * purge_all for the service. Expensive and, for both providers, disruptive to origin — reach for + * purgeDomain() or purgeKeys() unless the whole cache really has to go. + */ + public function purgeZone(): void; } diff --git a/src/Cdn/Cache/Adapter/Cloudflare.php b/src/Cdn/Cache/Adapter/Cloudflare.php index fbc6482..806e403 100644 --- a/src/Cdn/Cache/Adapter/Cloudflare.php +++ b/src/Cdn/Cache/Adapter/Cloudflare.php @@ -15,13 +15,26 @@ class Cloudflare implements Adapter { + /** + * URLs per purge request, kept to the lowest figure Cloudflare documents. + * + * Their pages disagree: the purge overview says 100 URLs per request (500 on Enterprise), while + * the purge-by-hostname page says 30 items at a time. The smaller number is within both. + */ + public const int PATHS_PER_PURGE = 30; + + /** + * Cache tags per purge request, on the same reading as PATHS_PER_PURGE. + */ + public const int KEYS_PER_PURGE = 30; + private ClientInterface $client; public function __construct( private string $zoneId, private string $apiToken, ?ClientInterface $client = null, - private string $apiBase = 'https://api.cloudflare.com/client/v4' + private string $apiBase = 'https://api.cloudflare.com/client/v4', ) { $this->client = $client ?? new Client(new CurlAdapter()); } @@ -35,34 +48,55 @@ public function purgePaths(string $domain, array $paths): void return; } - foreach (\array_chunk($paths, 30) as $chunk) { - $urls = \array_map(fn (string $path): string => 'https://' . $domain . $path, $chunk); - $result = $this->request( - method: Method::POST, - url: '/zones/' . $this->zoneId . '/purge_cache', - body: ['files' => $urls], - ); - - if (!$this->isSuccess($result)) { - throw new \RuntimeException($this->formatError('Cloudflare', $result)); - } + foreach (\array_chunk($paths, self::PATHS_PER_PURGE) as $chunk) { + $urls = \array_map(static fn (string $path): string => 'https://' . $domain . $path, $chunk); + $this->send(['files' => $urls]); } } + /** + * Purges every cached response served for the hostname, and nothing served for another. + */ public function purgeDomain(string $domain): void { - $result = $this->request( - method: Method::POST, - url: '/zones/' . $this->zoneId . '/purge_cache', - body: ['hosts' => [Domain::validate($domain)]], - ); + $this->send(['hosts' => [Domain::validate($domain)]]); + } + + public function purgeKeys(array $keys): void + { + if ($keys === []) { + return; + } + + // Cache tags only match responses the origin tagged with a Cache-Tag header. + foreach (\array_chunk($keys, self::KEYS_PER_PURGE) as $chunk) { + $this->send(['tags' => $chunk]); + } + } + + /** + * Purges every cached response in the zone, whatever hostname it was served for. + */ + public function purgeZone(): void + { + $this->send(['purge_everything' => true]); + } + + /** + * @param array $body + */ + private function send(array $body): void + { + $result = $this->request(Method::POST, '/zones/' . $this->zoneId . '/purge_cache', $body); if (!$this->isSuccess($result)) { - throw new \RuntimeException($this->formatError('Cloudflare', $result)); + throw new \RuntimeException($this->formatError($result)); } } /** + * A 2xx is not enough: Cloudflare reports a rejected purge in the body. + * * @param array{statusCode:int,response:array|string|null,error:string|null} $result */ private function isSuccess(array $result): bool @@ -73,29 +107,10 @@ private function isSuccess(array $result): bool && ($result['response']['success'] ?? false) === true; } - public function purgeKeys(array $keys): void - { - if ($keys === []) { - return; - } - - foreach (\array_chunk($keys, 30) as $chunk) { - $result = $this->request( - method: Method::POST, - url: '/zones/' . $this->zoneId . '/purge_cache', - body: ['tags' => $chunk], - ); - - if (!$this->isSuccess($result)) { - throw new \RuntimeException($this->formatError('Cloudflare', $result)); - } - } - } - /** * @param array{statusCode:int,response:array|string|null,error:string|null} $result */ - private function formatError(string $provider, array $result): string + private function formatError(array $result): string { $message = $result['error'] ?? null; @@ -105,7 +120,7 @@ private function formatError(string $provider, array $result): string $message ??= 'Unknown purge error'; - return $provider . ' purge failed with status ' . $result['statusCode'] . ': ' . $message; + return 'Cloudflare purge failed with status ' . $result['statusCode'] . ': ' . $message; } /** diff --git a/src/Cdn/Cache/Adapter/Fastly.php b/src/Cdn/Cache/Adapter/Fastly.php index b16f558..e165627 100644 --- a/src/Cdn/Cache/Adapter/Fastly.php +++ b/src/Cdn/Cache/Adapter/Fastly.php @@ -8,20 +8,40 @@ use Utopia\Client\Adapter\Curl\Client as CurlAdapter; use Utopia\Cdn\Cache\Adapter; use Utopia\Cdn\Domain; +use Utopia\Cdn\Exception\UnsupportedOperation; use Utopia\Psr7\Header; use Utopia\Psr7\Method; use Utopia\Psr7\Request\Factory as RequestFactory; class Fastly implements Adapter { + /** + * A URL purge addresses exactly one cached URL. + */ + public const int PATHS_PER_PURGE = 1; + + /** + * Fastly's documented ceiling for one batch surrogate key purge. + */ + public const int KEYS_PER_PURGE = 256; + private ClientInterface $client; + /** + * Fastly cannot purge by host: its purge API offers URL, surrogate key and whole-service purges + * and nothing in between. A domain is therefore addressed by the surrogate key the origin + * attaches to every response it serves for that domain, and this adapter has to be told how + * those keys are named — hence a required prefix rather than an optional one. + * + * @param string $domainKeyPrefix Prefix of the per-domain surrogate key. Pass '' when the key is the bare hostname. + */ public function __construct( private string $apiToken, + private string $domainKeyPrefix, private ?string $serviceId = null, private bool $softPurge = false, ?ClientInterface $client = null, - private string $apiBase = 'https://api.fastly.com' + private string $apiBase = 'https://api.fastly.com', ) { $this->client = $client ?? new Client(new CurlAdapter()); } @@ -31,33 +51,18 @@ public function purgePaths(string $domain, array $paths): void $domain = Domain::validate($domain); $paths = Domain::validatePaths($paths); - if ($paths === []) { - return; - } - + // A URL purge carries one URL, so there is nothing to batch. foreach ($paths as $path) { - $cachedUrl = $domain . $this->encodePath($path); - $result = $this->request(Method::POST, '/purge/' . $cachedUrl); - - if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { - throw new \RuntimeException($this->formatError($result)); - } + $this->send(Method::POST, '/purge/' . $domain . $this->encodePath($path)); } } /** - * Purges the entire configured service. The service is expected to be dedicated to the supplied domain. + * Purges the domain's surrogate key, leaving every other domain on the service cached. */ public function purgeDomain(string $domain): void { - Domain::validate($domain); - $this->requireServiceId('domain purging'); - - $result = $this->request(Method::POST, '/service/' . $this->serviceId . '/purge_all'); - - if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { - throw new \RuntimeException($this->formatError($result)); - } + $this->purgeKeys([$this->domainKeyPrefix . Domain::validate($domain)]); } public function purgeKeys(array $keys): void @@ -68,19 +73,35 @@ public function purgeKeys(array $keys): void $this->requireServiceId('cache key purging'); - foreach ($keys as $key) { - $result = $this->request(Method::POST, '/service/' . $this->serviceId . '/purge/' . $key); - - if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { - throw new \RuntimeException($this->formatError($result)); - } + // Keys travel in the request body, so they are sent as given: no encoding, + // and up to 256 of them per request instead of one request each. + foreach (\array_chunk($keys, self::KEYS_PER_PURGE) as $chunk) { + $this->send(Method::POST, '/service/' . $this->serviceId . '/purge', ['surrogate_keys' => $chunk]); } } + /** + * Purges every object on the service, whatever domain it belongs to. + * + * Fastly documents purge_all as taking up to two minutes, being incompatible with soft purge, + * and likely to spike origin traffic on a busy service. Prefer a surrogate key purge. + */ + public function purgeZone(): void + { + $this->requireServiceId('zone purging'); + + $this->send(Method::POST, '/service/' . $this->serviceId . '/purge_all'); + } + + /** + * Reported as an unsupported operation rather than a failure: a token without a service ID can + * still purge URLs, so this is a gap in what the adapter was configured for and not a purge that + * was attempted and went wrong. A caller can tell the two apart and decide whether to carry on. + */ private function requireServiceId(string $operation): void { if ($this->serviceId === null || $this->serviceId === '') { - throw new \RuntimeException('Fastly service ID is required for ' . $operation . '.'); + throw new UnsupportedOperation('Fastly service ID is required for ' . $operation . '.'); } } @@ -93,6 +114,18 @@ private function encodePath(string $path): string ); } + /** + * @param array|null $body + */ + private function send(string $method, string $url, ?array $body = null): void + { + $result = $this->request($method, $url, $body); + + if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { + throw new \RuntimeException($this->formatError($result)); + } + } + /** * @param array{statusCode:int,response:array|string|null,error:string|null} $result */ @@ -110,12 +143,17 @@ private function formatError(array $result): string } /** + * @param array|null $body * @return array{statusCode:int,response:array|string|null,error:string|null} */ - private function request(string $method, string $url): array + private function request(string $method, string $url, ?array $body = null): array { - $request = (new RequestFactory()) - ->createRequest($method, $this->apiBase . $url) + $factory = new RequestFactory(); + $request = $body === null + ? $factory->createRequest($method, $this->apiBase . $url) + : $factory->json($method, $this->apiBase . $url, $body); + + $request = $request ->withHeader(Header::USER_AGENT, 'Utopia CDN Fastly Adapter') ->withHeader('Fastly-Key', $this->apiToken) ->withHeader(Header::ACCEPT, 'application/json'); diff --git a/tests/Cdn/Cache/Adapter/CloudflareTest.php b/tests/Cdn/Cache/Adapter/CloudflareTest.php index 418bcd3..623981e 100644 --- a/tests/Cdn/Cache/Adapter/CloudflareTest.php +++ b/tests/Cdn/Cache/Adapter/CloudflareTest.php @@ -12,22 +12,29 @@ class CloudflareTest extends TestCase { public function testPurgesPathsAndDomain(): void { - $client = new TestClient([new Response(200, body: new Stream('{"success":true}')), new Response(200, body: new Stream('{"success":true}'))]); + $client = new TestClient(\array_fill(0, 2, new Response(200, body: new Stream('{"success":true}')))); $cdn = new Cloudflare('zone-id', 'token', $client); $cdn->purgePaths('example.com', ['/a', '/b?x=1']); $cdn->purgeDomain('example.com'); $this->assertSame(['files' => ['https://example.com/a', 'https://example.com/b?x=1']], $client->calls[0]['body']); + // Cloudflare purges a hostname natively, so the domain reaches the request + // and nothing served for another hostname is touched. $this->assertSame(['hosts' => ['example.com']], $client->calls[1]['body']); + $this->assertSame('https://api.cloudflare.com/client/v4/zones/zone-id/purge_cache', $client->calls[1]['url']); } public function testBatchesPaths(): void { - $client = new TestClient([new Response(200, body: new Stream('{"success":true}')), new Response(200, body: new Stream('{"success":true}'))]); + $client = new TestClient(\array_fill(0, 2, new Response(200, body: new Stream('{"success":true}')))); $cdn = new Cloudflare('zone', 'token', $client); - $cdn->purgePaths('example.com', \array_fill(0, 31, '/a')); + + $cdn->purgePaths('example.com', \array_fill(0, Cloudflare::PATHS_PER_PURGE + 1, '/a')); + $this->assertCount(2, $client->calls); + $this->assertCount(Cloudflare::PATHS_PER_PURGE, $client->calls[0]['body']['files']); + $this->assertCount(1, $client->calls[1]['body']['files']); } public function testPurgesCacheTags(): void @@ -41,10 +48,43 @@ public function testPurgesCacheTags(): void public function testBatchesCacheTags(): void { - $client = new TestClient([new Response(200, body: new Stream('{"success":true}')), new Response(200, body: new Stream('{"success":true}'))]); + $client = new TestClient(\array_fill(0, 2, new Response(200, body: new Stream('{"success":true}')))); - (new Cloudflare('zone', 'token', $client))->purgeKeys(\array_fill(0, 31, 'tag')); + (new Cloudflare('zone', 'token', $client))->purgeKeys(\array_fill(0, Cloudflare::KEYS_PER_PURGE + 1, 'tag')); $this->assertCount(2, $client->calls); } + + + public function testZonePurgeIsItsOwnOperation(): void + { + $client = new TestClient([new Response(200, body: new Stream('{"success":true}'))]); + + (new Cloudflare('zone', 'token', $client))->purgeZone(); + + $this->assertSame(['purge_everything' => true], $client->calls[0]['body']); + } + + + public function testRejectsAPurgeTheBodyReportsAsFailed(): void + { + // A 2xx alone does not mean the purge happened. + $client = new TestClient([new Response(200, body: new Stream('{"success":false,"errors":[{"message":"Invalid zone"}]}'))]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cloudflare purge failed with status 200: Invalid zone'); + (new Cloudflare('zone', 'token', $client))->purgeDomain('example.com'); + } + + public function testEmptyPurgesTouchNothing(): void + { + $client = new TestClient([]); + $cdn = new Cloudflare('zone', 'token', $client); + + $cdn->purgePaths('example.com', []); + $cdn->purgeKeys([]); + + $this->assertSame([], $client->calls); + } + } diff --git a/tests/Cdn/Cache/Adapter/FastlyTest.php b/tests/Cdn/Cache/Adapter/FastlyTest.php index b67240c..dc57cd3 100644 --- a/tests/Cdn/Cache/Adapter/FastlyTest.php +++ b/tests/Cdn/Cache/Adapter/FastlyTest.php @@ -4,31 +4,110 @@ use PHPUnit\Framework\TestCase; use Utopia\Cdn\Cache\Adapter\Fastly; +use Utopia\Cdn\Exception\UnsupportedOperation; use Utopia\Psr7\Response; use Utopia\Psr7\Stream; use Utopia\Tests\Cdn\TestClient; class FastlyTest extends TestCase { - public function testPurgesPathsDomainAndKeys(): void + public function testPurgesPathsAndKeys(): void { - $client = new TestClient(\array_fill(0, 3, new Response(200, body: new Stream('{"status":"ok"}')))); - $cdn = new Fastly('token', 'service-id', true, $client); + $client = new TestClient(\array_fill(0, 2, new Response(200, body: new Stream('{"status":"ok"}')))); + $cdn = new Fastly('token', 'domain-', 'service-id', true, $client); $cdn->purgePaths('example.com', ['/hello world?x=1']); - $cdn->purgeDomain('example.com'); $cdn->purgeKeys(['key']); $this->assertSame('https://api.fastly.com/purge/example.com/hello%20world?x=1', $client->calls[0]['url']); - $this->assertSame('https://api.fastly.com/service/service-id/purge_all', $client->calls[1]['url']); - $this->assertSame('https://api.fastly.com/service/service-id/purge/key', $client->calls[2]['url']); + $this->assertSame('https://api.fastly.com/service/service-id/purge', $client->calls[1]['url']); + $this->assertSame(['surrogate_keys' => ['key']], $client->calls[1]['body']); $this->assertSame('1', $client->headers['fastly-soft-purge'] ?? null); } - public function testDomainPurgeRequiresServiceId(): void + public function testDomainPurgeTargetsOnlyThatDomainsKey(): void + { + $client = new TestClient([new Response(200, body: new Stream('{"status":"ok"}'))]); + + (new Fastly('token', 'domain-', 'shared-service', client: $client))->purgeDomain('example.com'); + + // Fastly has no host purge, so a domain is addressed by the surrogate key + // the origin attaches. Every other domain on the shared service keeps its + // cached responses. + $this->assertSame('https://api.fastly.com/service/shared-service/purge', $client->calls[0]['url']); + $this->assertSame(['surrogate_keys' => ['domain-example.com']], $client->calls[0]['body']); + $this->assertCount(1, $client->calls); + } + + public function testDomainPurgeCanUseTheBareHostnameAsTheKey(): void + { + $client = new TestClient([new Response(200, body: new Stream('{"status":"ok"}'))]); + + (new Fastly('token', '', 'service-id', client: $client))->purgeDomain('example.com'); + + $this->assertSame(['surrogate_keys' => ['example.com']], $client->calls[0]['body']); + } + + public function testKeysAreSentUnencoded(): void + { + $client = new TestClient([new Response(200, body: new Stream('{"status":"ok"}'))]); + + (new Fastly('token', 'domain-', 'service-id', client: $client))->purgeKeys(['domain-example.com-summer sale']); + + // A key is a JSON value now, not a path segment, so percent-encoding it + // would purge a key the origin never attached. + $this->assertSame(['surrogate_keys' => ['domain-example.com-summer sale']], $client->calls[0]['body']); + } + + public function testKeysArePurgedInBatches(): void + { + $client = new TestClient(\array_fill(0, 2, new Response(200, body: new Stream('{"status":"ok"}')))); + $keys = \array_map(static fn (int $i): string => 'key-' . $i, \range(1, Fastly::KEYS_PER_PURGE + 1)); + + (new Fastly('token', 'domain-', 'service-id', client: $client))->purgeKeys($keys); + + // 257 keys is two requests, not 257. + $this->assertCount(2, $client->calls); + $this->assertCount(Fastly::KEYS_PER_PURGE, $client->calls[0]['body']['surrogate_keys']); + $this->assertSame(['key-257'], $client->calls[1]['body']['surrogate_keys']); + } + + public function testZonePurgeIsItsOwnOperation(): void + { + $client = new TestClient([new Response(200, body: new Stream('{"status":"ok"}'))]); + + (new Fastly('token', 'domain-', 'service-id', client: $client))->purgeZone(); + + $this->assertSame('https://api.fastly.com/service/service-id/purge_all', $client->calls[0]['url']); + } + + + public function testZonePurgeRequiresServiceId(): void + { + $this->expectException(UnsupportedOperation::class); + (new Fastly('token', 'domain-', null, false, new TestClient([])))->purgeZone(); + } + + public function testKeyPurgeRequiresServiceId(): void { - $this->expectException(\RuntimeException::class); + $this->expectException(UnsupportedOperation::class); $this->expectExceptionMessage('service ID'); - (new Fastly('token', null, false, new TestClient([])))->purgeDomain('example.com'); + (new Fastly('token', 'domain-', null, false, new TestClient([])))->purgeKeys(['key']); } + + public function testDomainPurgeRequiresServiceId(): void + { + $this->expectException(UnsupportedOperation::class); + (new Fastly('token', 'domain-', null, false, new TestClient([])))->purgeDomain('example.com'); + } + + public function testPathPurgeWorksWithoutServiceId(): void + { + $client = new TestClient([new Response(200, body: new Stream('{"status":"ok"}'))]); + + (new Fastly('token', 'domain-', null, false, $client))->purgePaths('example.com', ['/a.png']); + + $this->assertSame('https://api.fastly.com/purge/example.com/a.png', $client->calls[0]['url']); + } + } diff --git a/tests/Cdn/Cache/AdapterTest.php b/tests/Cdn/Cache/AdapterTest.php new file mode 100644 index 0000000..344cff6 --- /dev/null +++ b/tests/Cdn/Cache/AdapterTest.php @@ -0,0 +1,56 @@ +assertSame(self::OPERATIONS, \get_class_methods(Adapter::class)); + + foreach ([Fastly::class, Cloudflare::class] as $adapter) { + $this->assertContains(Adapter::class, \class_implements($adapter), $adapter . ' must implement the adapter interface'); + + foreach (self::OPERATIONS as $operation) { + $this->assertTrue(\method_exists($adapter, $operation), $adapter . ' is missing ' . $operation . '()'); + } + } + } + + public function testTheFacadeExposesEveryOperation(): void + { + // A purge reachable on an adapter but not through Cache would push callers + // back to holding concrete adapters, which is what the interface avoids. + foreach (self::OPERATIONS as $operation) { + $this->assertTrue(\method_exists(Cache::class, $operation), 'Cache is missing ' . $operation . '()'); + } + } + + public function testProviderAdaptersNameTheirBatchCeilingsAlike(): void + { + // Same names, provider-specific numbers: Fastly batches 256 keys per request + // and purges one URL at a time, Cloudflare takes 30 of either. + foreach ([Fastly::class, Cloudflare::class] as $adapter) { + $this->assertTrue(\defined($adapter . '::PATHS_PER_PURGE'), $adapter . ' must declare PATHS_PER_PURGE'); + $this->assertTrue(\defined($adapter . '::KEYS_PER_PURGE'), $adapter . ' must declare KEYS_PER_PURGE'); + } + + $this->assertSame(1, Fastly::PATHS_PER_PURGE); + $this->assertSame(256, Fastly::KEYS_PER_PURGE); + $this->assertSame(30, Cloudflare::PATHS_PER_PURGE); + $this->assertSame(30, Cloudflare::KEYS_PER_PURGE); + } +} diff --git a/tests/Cdn/CacheTest.php b/tests/Cdn/CacheTest.php index 9d53d88..9dcfa02 100644 --- a/tests/Cdn/CacheTest.php +++ b/tests/Cdn/CacheTest.php @@ -16,11 +16,13 @@ public function testDelegatesCacheOperations(): void $cache->purgePaths('example.com', ['/file.png']); $cache->purgeDomain('example.com'); $cache->purgeKeys(['key']); + $cache->purgeZone(); $this->assertSame([ ['paths' => ['example.com', ['/file.png']]], ['domain' => 'example.com'], ['keys' => ['key']], + ['zone' => true], ], $calls->getArrayCopy()); } @@ -52,6 +54,10 @@ public function purgeKeys(array $keys): void { $this->calls->append(['keys' => $keys]); } + public function purgeZone(): void + { + $this->calls->append(['zone' => true]); + } }; } } From b12ddf7093a74369960a7c21ca61184030362cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Aug 2026 21:03:12 +0200 Subject: [PATCH 2/2] Correct the adapter comments and docs Audit of what the diff actually claims, against the code and the provider documentation. - Fastly::PATHS_PER_PURGE was read by nothing but a test. A URL purge takes one URL, so there is no ceiling to name; only Cloudflare batches paths. - Cloudflare's batch constants claimed the provider's pages disagree about the ceiling. They do about hostnames and prefixes, not about the URLs and tags this adapter batches: those are documented at 100 per request, 500 on Enterprise for URLs. 30 is a conservative default and, more to the point, the number this adapter already sent -- the constants name existing behaviour rather than change it. - purgeZone() said the purge is disruptive to origin "for both providers". Fastly documents that; for Cloudflare it was my inference. Replaced with the consequence that holds either way. - The README still advertised three purge modes while listing four. - The Fastly note omitted zone purges from the operations that need a service ID. - Dropped a "now" that dated a comment to this change rather than describing the code, and restored the empty-paths guard so both adapters read alike. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 ++++---- src/Cdn/Cache/Adapter.php | 4 ++-- src/Cdn/Cache/Adapter/Cloudflare.php | 10 +++++----- src/Cdn/Cache/Adapter/Fastly.php | 13 ++++++------- tests/Cdn/Cache/Adapter/FastlyTest.php | 4 ++-- tests/Cdn/Cache/AdapterTest.php | 10 ++++------ 6 files changed, 23 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6a573b1..8bb1884 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ composer require utopia-php/cdn ## Cache Purging -The cache API supports three purge modes: +The cache API supports four purge modes: - path purges scoped to a domain - domain-wide purges @@ -54,7 +54,7 @@ $cache->purgeKeys([ Cloudflare purges a hostname natively, so `purgeDomain()` evicts everything served for that hostname and nothing served for another. Every purge method is [available on all plans](https://developers.cloudflare.com/changelog/post/2025-04-01-purge-for-all/). `purgeKeys()` purges cache tags, which only match responses the origin tagged with a `Cache-Tag` header. -URLs and tags are batched at `PATHS_PER_PURGE` and `KEYS_PER_PURGE` items per request. Cloudflare's own pages disagree about the ceiling — the purge overview says 100 items per request while the purge-by-hostname page says 30 — so the lower figure is used. +URLs and tags are batched at `PATHS_PER_PURGE` and `KEYS_PER_PURGE` per request, both 30. Cloudflare documents higher ceilings — 100 URLs per request, 500 on Enterprise, and 100 operations for tags — so 30 is a conservative default rather than a limit, and unchanged from what this adapter has always sent. `purgeZone()` purges every cached response in the zone (`purge_everything`). @@ -89,7 +89,7 @@ $cache->purgeKeys([ `domainKeyPrefix` is required because [Fastly has no purge-by-host operation](https://www.fastly.com/documentation/reference/api/purging/) — its purge API offers URL, surrogate key and whole-service purges and nothing in between. A domain is addressed by the surrogate key the origin attaches to every response it serves for that domain, so the adapter has to know how those keys are named. Pass `''` when the key is the bare hostname. -Keys are sent as given, in the request body, batched up to 256 per request. A Fastly adapter with no service ID can still purge paths; key and domain purges raise `Exception\UnsupportedOperation`. +Keys are sent as given, in the request body, batched up to 256 per request. A Fastly adapter with no service ID can still purge paths; key, domain and zone purges raise `Exception\UnsupportedOperation`. `purgeZone()` purges everything on the service (`purge_all`), which Fastly documents as taking up to two minutes, being incompatible with soft purge, and likely to spike origin traffic on a busy service. Prefer `purgeDomain()` or `purgeKeys()`. @@ -97,7 +97,7 @@ Keys are sent as given, in the request body, batched up to 256 per request. A Fa `Cache\Adapter` declares the four purges every adapter offers — `purgePaths()`, `purgeDomain()`, `purgeKeys()` and `purgeZone()` — so a caller never has to know which provider is behind it. Providers differ in what they expose natively, and the adapter absorbs the difference: Cloudflare purges a hostname directly, while Fastly maps the same call onto a surrogate key. Where an adapter cannot serve an operation with the configuration it was given it raises `Exception\UnsupportedOperation`, rather than quietly doing something wider. -Adapters name things alike: `PATHS_PER_PURGE` and `KEYS_PER_PURGE` are how many items one request may carry, with provider-specific numbers behind the same names — Fastly batches 256 keys and purges one URL at a time, Cloudflare takes 30 of either. +Adapters name things alike, with provider-specific numbers behind the same names: `KEYS_PER_PURGE` is how many cache keys one request may carry, 256 on Fastly and 30 on Cloudflare. Only Cloudflare declares `PATHS_PER_PURGE`, because a Fastly URL purge takes one URL and has nothing to batch. ## Certificates diff --git a/src/Cdn/Cache/Adapter.php b/src/Cdn/Cache/Adapter.php index 3f20213..b7e5aec 100644 --- a/src/Cdn/Cache/Adapter.php +++ b/src/Cdn/Cache/Adapter.php @@ -30,8 +30,8 @@ public function purgeKeys(array $keys): void; * Purges everything the adapter is configured for, whatever domain it belongs to. * * The widest operation a provider offers: Cloudflare's purge_everything for the zone, Fastly's - * purge_all for the service. Expensive and, for both providers, disruptive to origin — reach for - * purgeDomain() or purgeKeys() unless the whole cache really has to go. + * purge_all for the service. Everything cached is then re-fetched from origin, including all the + * content that did not need to be, so reach for purgeDomain() or purgeKeys() first. */ public function purgeZone(): void; } diff --git a/src/Cdn/Cache/Adapter/Cloudflare.php b/src/Cdn/Cache/Adapter/Cloudflare.php index 806e403..b09c0d7 100644 --- a/src/Cdn/Cache/Adapter/Cloudflare.php +++ b/src/Cdn/Cache/Adapter/Cloudflare.php @@ -16,15 +16,15 @@ class Cloudflare implements Adapter { /** - * URLs per purge request, kept to the lowest figure Cloudflare documents. - * - * Their pages disagree: the purge overview says 100 URLs per request (500 on Enterprise), while - * the purge-by-hostname page says 30 items at a time. The smaller number is within both. + * URLs per purge request. Names the batch size this adapter has always used rather than + * changing it: Cloudflare documents 100 URLs per request, 500 on Enterprise, so 30 is within + * every plan and can be raised deliberately. */ public const int PATHS_PER_PURGE = 30; /** - * Cache tags per purge request, on the same reading as PATHS_PER_PURGE. + * Cache tags per purge request. Cloudflare documents 100 operations per request for tags on + * every plan; 30 is what this adapter has always sent. */ public const int KEYS_PER_PURGE = 30; diff --git a/src/Cdn/Cache/Adapter/Fastly.php b/src/Cdn/Cache/Adapter/Fastly.php index e165627..5ee9f36 100644 --- a/src/Cdn/Cache/Adapter/Fastly.php +++ b/src/Cdn/Cache/Adapter/Fastly.php @@ -15,11 +15,6 @@ class Fastly implements Adapter { - /** - * A URL purge addresses exactly one cached URL. - */ - public const int PATHS_PER_PURGE = 1; - /** * Fastly's documented ceiling for one batch surrogate key purge. */ @@ -51,6 +46,10 @@ public function purgePaths(string $domain, array $paths): void $domain = Domain::validate($domain); $paths = Domain::validatePaths($paths); + if ($paths === []) { + return; + } + // A URL purge carries one URL, so there is nothing to batch. foreach ($paths as $path) { $this->send(Method::POST, '/purge/' . $domain . $this->encodePath($path)); @@ -73,8 +72,8 @@ public function purgeKeys(array $keys): void $this->requireServiceId('cache key purging'); - // Keys travel in the request body, so they are sent as given: no encoding, - // and up to 256 of them per request instead of one request each. + // Keys travel in the request body rather than the URL, so they are sent as + // given: percent-encoding one would purge a key the origin never attached. foreach (\array_chunk($keys, self::KEYS_PER_PURGE) as $chunk) { $this->send(Method::POST, '/service/' . $this->serviceId . '/purge', ['surrogate_keys' => $chunk]); } diff --git a/tests/Cdn/Cache/Adapter/FastlyTest.php b/tests/Cdn/Cache/Adapter/FastlyTest.php index dc57cd3..f07f5da 100644 --- a/tests/Cdn/Cache/Adapter/FastlyTest.php +++ b/tests/Cdn/Cache/Adapter/FastlyTest.php @@ -54,8 +54,8 @@ public function testKeysAreSentUnencoded(): void (new Fastly('token', 'domain-', 'service-id', client: $client))->purgeKeys(['domain-example.com-summer sale']); - // A key is a JSON value now, not a path segment, so percent-encoding it - // would purge a key the origin never attached. + // A key is a JSON value, not a path segment, so percent-encoding it would + // purge a key the origin never attached. $this->assertSame(['surrogate_keys' => ['domain-example.com-summer sale']], $client->calls[0]['body']); } diff --git a/tests/Cdn/Cache/AdapterTest.php b/tests/Cdn/Cache/AdapterTest.php index 344cff6..bfe25a4 100644 --- a/tests/Cdn/Cache/AdapterTest.php +++ b/tests/Cdn/Cache/AdapterTest.php @@ -39,18 +39,16 @@ public function testTheFacadeExposesEveryOperation(): void } } - public function testProviderAdaptersNameTheirBatchCeilingsAlike(): void + public function testBatchCeilingsAreNamedAlikeWhereBothProvidersBatch(): void { - // Same names, provider-specific numbers: Fastly batches 256 keys per request - // and purges one URL at a time, Cloudflare takes 30 of either. + // Same name either side, provider-specific number behind it. Fastly batches + // keys and purges URLs one at a time, so only Cloudflare names a path ceiling. foreach ([Fastly::class, Cloudflare::class] as $adapter) { - $this->assertTrue(\defined($adapter . '::PATHS_PER_PURGE'), $adapter . ' must declare PATHS_PER_PURGE'); $this->assertTrue(\defined($adapter . '::KEYS_PER_PURGE'), $adapter . ' must declare KEYS_PER_PURGE'); } - $this->assertSame(1, Fastly::PATHS_PER_PURGE); $this->assertSame(256, Fastly::KEYS_PER_PURGE); - $this->assertSame(30, Cloudflare::PATHS_PER_PURGE); $this->assertSame(30, Cloudflare::KEYS_PER_PURGE); + $this->assertSame(30, Cloudflare::PATHS_PER_PURGE); } }