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 f96ad3d..786a2be 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,36 @@ public function invalidateCache(): static return $this; } + /** + * 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 clearCache(Connector|Request $counterpart): void + { + if ($this instanceof Request && ! $counterpart instanceof Connector) { + throw new HasCachingException('You must provide a Connector instance when calling clearCache() on a Request.'); + } + + if ($this instanceof Connector && ! $counterpart instanceof Request) { + throw new HasCachingException('You must provide a Request instance when calling clearCache() on a Connector.'); + } + + $pendingRequest = $this instanceof Request + ? $counterpart->createPendingRequest($this) + : $this->createPendingRequest($counterpart); + + $cacheDriver = $pendingRequest->getRequest() instanceof Cacheable + ? $pendingRequest->getRequest()->resolveCacheDriver() + : $pendingRequest->getConnector()->resolveCacheDriver(); + + $cacheDriver->delete(CacheKeyHelper::createHashed($pendingRequest, $this->cacheKey($pendingRequest))); + } + /** * Define the cacheable methods that can be used * diff --git a/tests/Feature/ClearCacheTest.php b/tests/Feature/ClearCacheTest.php new file mode 100644 index 0000000..3067539 --- /dev/null +++ b/tests/Feature/ClearCacheTest.php @@ -0,0 +1,153 @@ +deleteDirectory('/'); +}); + +test('clearCache 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->clearCache($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('clearCache on an uncached request does not throw', function () { + $connector = new TestConnector; + $request = new CachedUserRequest; + + // Should not throw + $request->clearCache($connector); + + expect(true)->toBeTrue(); +}); + +test('clearCache 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->clearCache($connector); + + expect($filesystem->fileExists($hash))->toBeFalse(); +}); + +test('after clearCache 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->clearCache($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('clearCache 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->clearCache(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']); +}); + +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.');