From 3e0a691e853af599e4cd702fdf0d15233d7c34a8 Mon Sep 17 00:00:00 2001 From: Jorni Cornelese Date: Wed, 18 Mar 2026 22:51:30 +0100 Subject: [PATCH 1/2] feat: add deleteCache() to purge cache without sending a request Adds a deleteCache() method to the HasCaching trait that allows deleting a cached response without going through the middleware pipeline. Useful for applications that only want to delete the cache without immediately retrieving fresh data. --- src/Traits/HasCaching.php | 32 +++++++ tests/Feature/DeleteCacheTest.php | 140 ++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 tests/Feature/DeleteCacheTest.php diff --git a/src/Traits/HasCaching.php b/src/Traits/HasCaching.php index f96ad3d..0c8fae7 100644 --- a/src/Traits/HasCaching.php +++ b/src/Traits/HasCaching.php @@ -5,9 +5,12 @@ namespace Saloon\CachePlugin\Traits; use Saloon\Enums\Method; +use Saloon\Http\Request; +use Saloon\Http\Connector; use Saloon\Enums\PipeOrder; use Saloon\Http\PendingRequest; use Saloon\CachePlugin\Contracts\Cacheable; +use Saloon\CachePlugin\Helpers\CacheKeyHelper; use Saloon\CachePlugin\Exceptions\HasCachingException; use Saloon\CachePlugin\Http\Middleware\CacheMiddleware; @@ -111,6 +114,35 @@ public function invalidateCache(): static return $this; } + /** + * Delete the cached response without sending a request. + * + * When used on a Request, pass the Connector. + * When used on a Connector, pass the Request. + * + * @throws \JsonException + */ + public function deleteCache(Connector|Request $counterpart): void + { + if ($this instanceof Request) { + $pendingRequest = $counterpart->createPendingRequest($this); + } else { + $pendingRequest = $this->createPendingRequest($counterpart); + } + + $request = $pendingRequest->getRequest(); + $connector = $pendingRequest->getConnector(); + + $cacheDriver = $request instanceof Cacheable + ? $request->resolveCacheDriver() + : $connector->resolveCacheDriver(); + + $rawKey = $this->cacheKey($pendingRequest) ?? CacheKeyHelper::create($pendingRequest); + $cacheKey = hash('sha256', $rawKey); + + $cacheDriver->delete($cacheKey); + } + /** * Define the cacheable methods that can be used * diff --git a/tests/Feature/DeleteCacheTest.php b/tests/Feature/DeleteCacheTest.php new file mode 100644 index 0000000..aa00162 --- /dev/null +++ b/tests/Feature/DeleteCacheTest.php @@ -0,0 +1,140 @@ +deleteDirectory('/'); +}); + +test('deleteCache removes a cached response without sending a request', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + $request = new CachedUserRequest; + + // Send and cache the response + $responseA = $connector->send($request, $mockClient); + expect($responseA->isCached())->toBeFalse(); + + // Verify it is cached + $responseB = $connector->send(new CachedUserRequest); + expect($responseB->isCached())->toBeTrue(); + + // Delete the cache without sending a request + $request = new CachedUserRequest; + $request->deleteCache($connector); + + // Now sending should result in a cache miss + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael']), + ]); + + $responseC = $connector->send(new CachedUserRequest, $mockClient); + expect($responseC->isCached())->toBeFalse(); + expect($responseC->json())->toEqual(['name' => 'Michael']); +}); + +test('deleteCache on an uncached request does not throw', function () { + $connector = new TestConnector; + $request = new CachedUserRequest; + + // Should not throw + $request->deleteCache($connector); + + expect(true)->toBeTrue(); +}); + +test('deleteCache uses a custom cacheKey override', function () use ($filesystem) { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + // Send and cache with the custom key + $connector->send(new CustomKeyCachedUserRequest, $mockClient); + + $hash = hash('sha256', 'Howdy!'); + expect($filesystem->fileExists($hash))->toBeTrue(); + + // Delete using the custom key + $request = new CustomKeyCachedUserRequest; + $request->deleteCache($connector); + + expect($filesystem->fileExists($hash))->toBeFalse(); +}); + +test('after deleteCache the next send fetches fresh and repopulates cache', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new TestConnector; + + // Send and cache + $connector->send(new CachedUserRequest, $mockClient); + + // Confirm cached + $responseB = $connector->send(new CachedUserRequest); + expect($responseB->isCached())->toBeTrue(); + expect($responseB->json())->toEqual(['name' => 'Sam']); + + // Delete cache + $request = new CachedUserRequest; + $request->deleteCache($connector); + + // Send again - should be a fresh response + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Teo']), + ]); + + $responseC = $connector->send(new CachedUserRequest, $mockClient); + expect($responseC->isCached())->toBeFalse(); + expect($responseC->json())->toEqual(['name' => 'Teo']); + + // Verify the new response is cached + $responseD = $connector->send(new CachedUserRequest); + expect($responseD->isCached())->toBeTrue(); + expect($responseD->json())->toEqual(['name' => 'Teo']); +}); + +test('deleteCache works when called from the connector', function () { + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Sam']), + ]); + + $connector = new CachedConnector; + + // Send and cache + $connector->send(new CachedConnectorRequest, $mockClient); + + // Confirm cached + $responseB = $connector->send(new CachedConnectorRequest); + expect($responseB->isCached())->toBeTrue(); + + // Delete cache from the connector side + $connector->deleteCache(new CachedConnectorRequest); + + // Should be a cache miss now + $mockClient = new MockClient([ + MockResponse::make(['name' => 'Michael']), + ]); + + $responseC = $connector->send(new CachedConnectorRequest, $mockClient); + expect($responseC->isCached())->toBeFalse(); + expect($responseC->json())->toEqual(['name' => 'Michael']); +}); From a54dc23926cb41bd5ccb536344fe7ff66177447a Mon Sep 17 00:00:00 2001 From: Sammyjo20 <29132017+Sammyjo20@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:15:41 +0100 Subject: [PATCH 2/2] Simplified implementation --- src/Helpers/CacheKeyHelper.php | 10 ++++++ src/Http/Middleware/CacheMiddleware.php | 2 +- src/Traits/HasCaching.php | 29 ++++++++-------- ...DeleteCacheTest.php => ClearCacheTest.php} | 33 +++++++++++++------ 4 files changed, 49 insertions(+), 25 deletions(-) rename tests/Feature/{DeleteCacheTest.php => ClearCacheTest.php} (76%) diff --git a/src/Helpers/CacheKeyHelper.php b/src/Helpers/CacheKeyHelper.php index e5390fc..79f9d0e 100644 --- a/src/Helpers/CacheKeyHelper.php +++ b/src/Helpers/CacheKeyHelper.php @@ -22,4 +22,14 @@ public static function create(PendingRequest $pendingRequest): string return json_encode(compact('className', 'requestUrl', 'query', 'headers'), JSON_THROW_ON_ERROR); } + + /** + * Create a hashed cache key, falling back to the request-derived key when none is provided + * + * @throws \JsonException + */ + public static function createHashed(PendingRequest $pendingRequest, ?string $cacheKey = null): string + { + return hash('sha256', $cacheKey ?? static::create($pendingRequest)); + } } diff --git a/src/Http/Middleware/CacheMiddleware.php b/src/Http/Middleware/CacheMiddleware.php index 228695f..ae4c558 100644 --- a/src/Http/Middleware/CacheMiddleware.php +++ b/src/Http/Middleware/CacheMiddleware.php @@ -35,7 +35,7 @@ public function __construct( public function __invoke(PendingRequest $pendingRequest): ?FakeResponse { $driver = $this->driver; - $cacheKey = hash('sha256', $this->cacheKey ?? CacheKeyHelper::create($pendingRequest)); + $cacheKey = CacheKeyHelper::createHashed($pendingRequest, $this->cacheKey); $cachedResponse = $driver->get($cacheKey); diff --git a/src/Traits/HasCaching.php b/src/Traits/HasCaching.php index 0c8fae7..786a2be 100644 --- a/src/Traits/HasCaching.php +++ b/src/Traits/HasCaching.php @@ -115,32 +115,33 @@ public function invalidateCache(): static } /** - * Delete the cached response without sending a request. + * Clear the cached response without sending a request. * * When used on a Request, pass the Connector. * When used on a Connector, pass the Request. * * @throws \JsonException + * @throws \Saloon\CachePlugin\Exceptions\HasCachingException */ - public function deleteCache(Connector|Request $counterpart): void + public function clearCache(Connector|Request $counterpart): void { - if ($this instanceof Request) { - $pendingRequest = $counterpart->createPendingRequest($this); - } else { - $pendingRequest = $this->createPendingRequest($counterpart); + if ($this instanceof Request && ! $counterpart instanceof Connector) { + throw new HasCachingException('You must provide a Connector instance when calling clearCache() on a Request.'); } - $request = $pendingRequest->getRequest(); - $connector = $pendingRequest->getConnector(); + if ($this instanceof Connector && ! $counterpart instanceof Request) { + throw new HasCachingException('You must provide a Request instance when calling clearCache() on a Connector.'); + } - $cacheDriver = $request instanceof Cacheable - ? $request->resolveCacheDriver() - : $connector->resolveCacheDriver(); + $pendingRequest = $this instanceof Request + ? $counterpart->createPendingRequest($this) + : $this->createPendingRequest($counterpart); - $rawKey = $this->cacheKey($pendingRequest) ?? CacheKeyHelper::create($pendingRequest); - $cacheKey = hash('sha256', $rawKey); + $cacheDriver = $pendingRequest->getRequest() instanceof Cacheable + ? $pendingRequest->getRequest()->resolveCacheDriver() + : $pendingRequest->getConnector()->resolveCacheDriver(); - $cacheDriver->delete($cacheKey); + $cacheDriver->delete(CacheKeyHelper::createHashed($pendingRequest, $this->cacheKey($pendingRequest))); } /** diff --git a/tests/Feature/DeleteCacheTest.php b/tests/Feature/ClearCacheTest.php similarity index 76% rename from tests/Feature/DeleteCacheTest.php rename to tests/Feature/ClearCacheTest.php index aa00162..3067539 100644 --- a/tests/Feature/DeleteCacheTest.php +++ b/tests/Feature/ClearCacheTest.php @@ -6,6 +6,7 @@ use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; use League\Flysystem\Local\LocalFilesystemAdapter; +use Saloon\CachePlugin\Exceptions\HasCachingException; use Saloon\CachePlugin\Tests\Fixtures\Connectors\TestConnector; use Saloon\CachePlugin\Tests\Fixtures\Connectors\CachedConnector; use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedUserRequest; @@ -18,7 +19,7 @@ $filesystem->deleteDirectory('/'); }); -test('deleteCache removes a cached response without sending a request', function () { +test('clearCache removes a cached response without sending a request', function () { $mockClient = new MockClient([ MockResponse::make(['name' => 'Sam']), ]); @@ -36,7 +37,7 @@ // Delete the cache without sending a request $request = new CachedUserRequest; - $request->deleteCache($connector); + $request->clearCache($connector); // Now sending should result in a cache miss $mockClient = new MockClient([ @@ -48,17 +49,17 @@ expect($responseC->json())->toEqual(['name' => 'Michael']); }); -test('deleteCache on an uncached request does not throw', function () { +test('clearCache on an uncached request does not throw', function () { $connector = new TestConnector; $request = new CachedUserRequest; // Should not throw - $request->deleteCache($connector); + $request->clearCache($connector); expect(true)->toBeTrue(); }); -test('deleteCache uses a custom cacheKey override', function () use ($filesystem) { +test('clearCache uses a custom cacheKey override', function () use ($filesystem) { $mockClient = new MockClient([ MockResponse::make(['name' => 'Sam']), ]); @@ -73,12 +74,12 @@ // Delete using the custom key $request = new CustomKeyCachedUserRequest; - $request->deleteCache($connector); + $request->clearCache($connector); expect($filesystem->fileExists($hash))->toBeFalse(); }); -test('after deleteCache the next send fetches fresh and repopulates cache', function () { +test('after clearCache the next send fetches fresh and repopulates cache', function () { $mockClient = new MockClient([ MockResponse::make(['name' => 'Sam']), ]); @@ -95,7 +96,7 @@ // Delete cache $request = new CachedUserRequest; - $request->deleteCache($connector); + $request->clearCache($connector); // Send again - should be a fresh response $mockClient = new MockClient([ @@ -112,7 +113,7 @@ expect($responseD->json())->toEqual(['name' => 'Teo']); }); -test('deleteCache works when called from the connector', function () { +test('clearCache works when called from the connector', function () { $mockClient = new MockClient([ MockResponse::make(['name' => 'Sam']), ]); @@ -127,7 +128,7 @@ expect($responseB->isCached())->toBeTrue(); // Delete cache from the connector side - $connector->deleteCache(new CachedConnectorRequest); + $connector->clearCache(new CachedConnectorRequest); // Should be a cache miss now $mockClient = new MockClient([ @@ -138,3 +139,15 @@ expect($responseC->isCached())->toBeFalse(); expect($responseC->json())->toEqual(['name' => 'Michael']); }); + +test('clearCache throws when called on a connector with another connector', function () { + $connector = new CachedConnector; + + $connector->clearCache(new TestConnector); +})->throws(HasCachingException::class, 'You must provide a Request instance when calling clearCache() on a Connector.'); + +test('clearCache throws when called on a request with another request', function () { + $request = new CachedUserRequest; + + $request->clearCache(new CachedUserRequest); +})->throws(HasCachingException::class, 'You must provide a Connector instance when calling clearCache() on a Request.');