diff --git a/README.md b/README.md index c11ff13..8bb1884 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ 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 - 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` 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`). + ### 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, 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()`. + +### 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, 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.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..b7e5aec 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. 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 fbc6482..b09c0d7 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. 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. 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; + 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..5ee9f36 100644 --- a/src/Cdn/Cache/Adapter/Fastly.php +++ b/src/Cdn/Cache/Adapter/Fastly.php @@ -8,20 +8,35 @@ 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 { + /** + * 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()); } @@ -35,29 +50,18 @@ public function purgePaths(string $domain, array $paths): void 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 +72,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 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]); } } + /** + * 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 +113,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 +142,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..f07f5da 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, 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..bfe25a4 --- /dev/null +++ b/tests/Cdn/Cache/AdapterTest.php @@ -0,0 +1,54 @@ +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 testBatchCeilingsAreNamedAlikeWhereBothProvidersBatch(): void + { + // 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 . '::KEYS_PER_PURGE'), $adapter . ' must declare KEYS_PER_PURGE'); + } + + $this->assertSame(256, Fastly::KEYS_PER_PURGE); + $this->assertSame(30, Cloudflare::KEYS_PER_PURGE); + $this->assertSame(30, Cloudflare::PATHS_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]); + } }; } }