From e3663739d2b326eefa4159032622907aa2171aa0 Mon Sep 17 00:00:00 2001 From: Kevin Sliedrecht Date: Thu, 11 Sep 2025 08:42:31 +0200 Subject: [PATCH] Introduce response based cache expiry --- src/Contracts/Cacheable.php | 7 +++- src/Contracts/Driver.php | 3 -- src/Data/CachedResponse.php | 10 ++++- src/Drivers/LaravelCacheDriver.php | 2 +- src/Drivers/PsrCacheDriver.php | 2 +- src/Http/Middleware/CacheMiddleware.php | 3 +- .../Middleware/CacheRecorderMiddleware.php | 20 ++++++++-- src/Traits/HasCaching.php | 8 +--- tests/Feature/CacheTest.php | 17 +++++++++ tests/Fixtures/Connectors/CachedConnector.php | 3 +- .../Requests/AllowedCachedPostRequest.php | 3 +- .../Fixtures/Requests/BodyCacheKeyRequest.php | 3 +- tests/Fixtures/Requests/CachedPostRequest.php | 3 +- tests/Fixtures/Requests/CachedUserRequest.php | 3 +- .../CachedUserRequestOnCachedConnector.php | 3 +- .../Requests/CustomKeyCachedUserRequest.php | 3 +- .../Requests/LaravelCachedUserRequest.php | 3 +- .../Requests/PsrCachedUserRequest.php | 3 +- .../Requests/ResponseBasedExpiryRequest.php | 37 +++++++++++++++++++ .../Requests/ShortLivedCachedUserRequest.php | 3 +- 20 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 tests/Fixtures/Requests/ResponseBasedExpiryRequest.php diff --git a/src/Contracts/Cacheable.php b/src/Contracts/Cacheable.php index 9a10b3d..be81c65 100644 --- a/src/Contracts/Cacheable.php +++ b/src/Contracts/Cacheable.php @@ -4,6 +4,9 @@ namespace Saloon\CachePlugin\Contracts; +use DateTimeImmutable; +use Saloon\Http\Response; + interface Cacheable { /** @@ -12,7 +15,7 @@ interface Cacheable public function resolveCacheDriver(): Driver; /** - * Define the cache expiry in seconds + * Resolve the cache expiry in seconds or as an DateTimeImmutable */ - public function cacheExpiryInSeconds(): int; + public function resolveCacheExpiry(Response $response): DateTimeImmutable|int; } diff --git a/src/Contracts/Driver.php b/src/Contracts/Driver.php index 019a3c4..7a454c1 100644 --- a/src/Contracts/Driver.php +++ b/src/Contracts/Driver.php @@ -4,7 +4,6 @@ namespace Saloon\CachePlugin\Contracts; -use Saloon\Data\RecordedResponse; use Saloon\CachePlugin\Data\CachedResponse; interface Driver @@ -16,8 +15,6 @@ public function set(string $key, CachedResponse $cachedResponse): void; /** * Get the cached response from the driver. - * - * @return RecordedResponse|null */ public function get(string $cacheKey): ?CachedResponse; diff --git a/src/Data/CachedResponse.php b/src/Data/CachedResponse.php index fcffb93..d6d83f7 100644 --- a/src/Data/CachedResponse.php +++ b/src/Data/CachedResponse.php @@ -4,6 +4,7 @@ namespace Saloon\CachePlugin\Data; +use DateInterval; use DateTimeImmutable; use Saloon\Data\RecordedResponse; use Saloon\Http\Faking\FakeResponse; @@ -17,7 +18,6 @@ class CachedResponse public function __construct( readonly public RecordedResponse $recordedResponse, readonly public DateTimeImmutable $expiresAt, - readonly public int $ttl, ) { // } @@ -38,6 +38,14 @@ public function hasNotExpired(): bool return ! $this->hasExpired(); } + /** + * Get the cache TTL as an interval based on the expiry date. + */ + public function getTtl(): DateInterval + { + return (new DateTimeImmutable())->diff($this->expiresAt); + } + /** * Create a fake response */ diff --git a/src/Drivers/LaravelCacheDriver.php b/src/Drivers/LaravelCacheDriver.php index 731712f..d5e7bd4 100644 --- a/src/Drivers/LaravelCacheDriver.php +++ b/src/Drivers/LaravelCacheDriver.php @@ -26,7 +26,7 @@ public function __construct( */ public function set(string $key, CachedResponse $cachedResponse): void { - $this->store->set($key, serialize($cachedResponse), $cachedResponse->ttl); + $this->store->set($key, serialize($cachedResponse), $cachedResponse->getTtl()); } /** diff --git a/src/Drivers/PsrCacheDriver.php b/src/Drivers/PsrCacheDriver.php index 1fb50b9..9fe1601 100644 --- a/src/Drivers/PsrCacheDriver.php +++ b/src/Drivers/PsrCacheDriver.php @@ -29,7 +29,7 @@ public function __construct( */ public function set(string $key, CachedResponse $cachedResponse): void { - $this->store->set($key, serialize($cachedResponse), $cachedResponse->ttl); + $this->store->set($key, serialize($cachedResponse), $cachedResponse->getTtl()); } /** diff --git a/src/Http/Middleware/CacheMiddleware.php b/src/Http/Middleware/CacheMiddleware.php index 228695f..d5d9f28 100644 --- a/src/Http/Middleware/CacheMiddleware.php +++ b/src/Http/Middleware/CacheMiddleware.php @@ -20,7 +20,6 @@ class CacheMiddleware implements RequestMiddleware */ public function __construct( protected Driver $driver, - protected int $ttl, protected ?string $cacheKey, protected bool $invalidate = false, ) { @@ -63,7 +62,7 @@ public function __invoke(PendingRequest $pendingRequest): ?FakeResponse // the prepend option, so it runs first. $pendingRequest->middleware()->onResponse( - callable: new CacheRecorderMiddleware($driver, $this->ttl, $cacheKey), + callable: new CacheRecorderMiddleware($driver, $cacheKey), order: PipeOrder::FIRST ); diff --git a/src/Http/Middleware/CacheRecorderMiddleware.php b/src/Http/Middleware/CacheRecorderMiddleware.php index 4e23342..4137ff0 100644 --- a/src/Http/Middleware/CacheRecorderMiddleware.php +++ b/src/Http/Middleware/CacheRecorderMiddleware.php @@ -9,7 +9,9 @@ use Saloon\Data\RecordedResponse; use Saloon\CachePlugin\Contracts\Driver; use Saloon\Contracts\ResponseMiddleware; +use Saloon\CachePlugin\Contracts\Cacheable; use Saloon\CachePlugin\Data\CachedResponse; +use Saloon\CachePlugin\Exceptions\HasCachingException; class CacheRecorderMiddleware implements ResponseMiddleware { @@ -18,7 +20,6 @@ class CacheRecorderMiddleware implements ResponseMiddleware */ public function __construct( protected Driver $driver, - protected int $ttl, protected string $cacheKey, ) { // @@ -35,11 +36,24 @@ public function __invoke(Response $response): void return; } - $expiresAt = new DateTimeImmutable('+' . $this->ttl .' seconds'); + $request = $response->getRequest(); + $connector = $response->getConnector(); + + if (! $request instanceof Cacheable && ! $connector instanceof Cacheable) { + throw new HasCachingException(sprintf('Your connector or request must implement %s to use the HasCaching plugin', Cacheable::class)); + } + + $expiresAt = $request instanceof Cacheable + ? $request->resolveCacheExpiry($response) + : $connector->resolveCacheExpiry($response); + + if (is_int($expiresAt)) { + $expiresAt = new DateTimeImmutable('+' . $expiresAt .' seconds'); + } $this->driver->set( key: $this->cacheKey, - cachedResponse: new CachedResponse(RecordedResponse::fromResponse($response), $expiresAt, $this->ttl) + cachedResponse: new CachedResponse(RecordedResponse::fromResponse($response), $expiresAt) ); } } diff --git a/src/Traits/HasCaching.php b/src/Traits/HasCaching.php index f96ad3d..1a1c1fd 100644 --- a/src/Traits/HasCaching.php +++ b/src/Traits/HasCaching.php @@ -50,20 +50,16 @@ public function bootHasCaching(PendingRequest $pendingRequest): void ? $request->resolveCacheDriver() : $connector->resolveCacheDriver(); - $cacheExpiryInSeconds = $request instanceof Cacheable - ? $request->cacheExpiryInSeconds() - : $connector->cacheExpiryInSeconds(); - // Register a request middleware which wil handle the caching // and recording of real responses for caching. - $pendingRequest->middleware()->onRequest(function (PendingRequest $middlewarePendingRequest) use ($cacheDriver, $cacheExpiryInSeconds) { + $pendingRequest->middleware()->onRequest(function (PendingRequest $middlewarePendingRequest) use ($cacheDriver) { // We'll call the cache middleware invokable class with the $middlewarePendingRequest // because this $pendingRequest has everything loaded, unlike the instance that // the plugin is provided. This allows us to have access to body and merged // properties. - return call_user_func(new CacheMiddleware($cacheDriver, $cacheExpiryInSeconds, $this->cacheKey($middlewarePendingRequest), $this->invalidateCache), $middlewarePendingRequest); + return call_user_func(new CacheMiddleware($cacheDriver, $this->cacheKey($middlewarePendingRequest), $this->invalidateCache), $middlewarePendingRequest); }, order: PipeOrder::FIRST); } diff --git a/tests/Feature/CacheTest.php b/tests/Feature/CacheTest.php index 9b76585..183488f 100644 --- a/tests/Feature/CacheTest.php +++ b/tests/Feature/CacheTest.php @@ -15,6 +15,7 @@ use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedConnectorRequest; use Saloon\CachePlugin\Tests\Fixtures\Requests\AllowedCachedPostRequest; use Saloon\CachePlugin\Tests\Fixtures\Requests\CustomKeyCachedUserRequest; +use Saloon\CachePlugin\Tests\Fixtures\Requests\ResponseBasedExpiryRequest; use Saloon\CachePlugin\Tests\Fixtures\Requests\ShortLivedCachedUserRequest; use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedUserRequestWithoutCacheable; use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedUserRequestOnCachedConnector; @@ -249,6 +250,22 @@ expect($responseC->json())->toEqual(['name' => 'Michael']); }); +test('you can define a cache expiry based on a response', function () { + $expectedExpiry = 90; + $mockClient = new MockClient([ + MockResponse::make(['expiry' => $expectedExpiry]), + ]); + + $connector = new TestConnector; + + $request = new ResponseBasedExpiryRequest(); + $response = $connector->send($request, $mockClient); + + $expiry = $request->resolveCacheExpiry($response); + + expect($expiry)->toEqual($expectedExpiry); +}); + test('you can define a cache on the connector and it returns a cached response', function () { $mockClient = new MockClient([ MockResponse::make(['name' => 'Sam']), diff --git a/tests/Fixtures/Connectors/CachedConnector.php b/tests/Fixtures/Connectors/CachedConnector.php index 3b2da2a..c629c56 100644 --- a/tests/Fixtures/Connectors/CachedConnector.php +++ b/tests/Fixtures/Connectors/CachedConnector.php @@ -4,6 +4,7 @@ namespace Saloon\CachePlugin\Tests\Fixtures\Connectors; +use Saloon\Http\Response; use Saloon\Http\Connector; use League\Flysystem\Filesystem; use Saloon\CachePlugin\Contracts\Driver; @@ -26,7 +27,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath()))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/AllowedCachedPostRequest.php b/tests/Fixtures/Requests/AllowedCachedPostRequest.php index b17a212..6a64cfe 100644 --- a/tests/Fixtures/Requests/AllowedCachedPostRequest.php +++ b/tests/Fixtures/Requests/AllowedCachedPostRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use League\Flysystem\Filesystem; use Saloon\Contracts\Body\HasBody; use Saloon\Traits\Body\HasJsonBody; @@ -39,7 +40,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath()))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/BodyCacheKeyRequest.php b/tests/Fixtures/Requests/BodyCacheKeyRequest.php index 493a069..92fb721 100644 --- a/tests/Fixtures/Requests/BodyCacheKeyRequest.php +++ b/tests/Fixtures/Requests/BodyCacheKeyRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use Saloon\Http\PendingRequest; use League\Flysystem\Filesystem; use Saloon\Contracts\Body\HasBody; @@ -36,7 +37,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath()))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/CachedPostRequest.php b/tests/Fixtures/Requests/CachedPostRequest.php index b1dacde..d314c08 100644 --- a/tests/Fixtures/Requests/CachedPostRequest.php +++ b/tests/Fixtures/Requests/CachedPostRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use League\Flysystem\Filesystem; use Saloon\Contracts\Body\HasBody; use Saloon\Traits\Body\HasJsonBody; @@ -39,7 +40,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath()))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/CachedUserRequest.php b/tests/Fixtures/Requests/CachedUserRequest.php index de6317a..b9d8722 100644 --- a/tests/Fixtures/Requests/CachedUserRequest.php +++ b/tests/Fixtures/Requests/CachedUserRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use League\Flysystem\Filesystem; use Saloon\CachePlugin\Contracts\Driver; use Saloon\CachePlugin\Traits\HasCaching; @@ -41,7 +42,7 @@ public function resolveCacheDriver(): Driver /** * Define the cache expiry in seconds */ - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/CachedUserRequestOnCachedConnector.php b/tests/Fixtures/Requests/CachedUserRequestOnCachedConnector.php index cd9c597..b317831 100644 --- a/tests/Fixtures/Requests/CachedUserRequestOnCachedConnector.php +++ b/tests/Fixtures/Requests/CachedUserRequestOnCachedConnector.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use League\Flysystem\Filesystem; use Saloon\CachePlugin\Contracts\Driver; use Saloon\CachePlugin\Traits\HasCaching; @@ -29,7 +30,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath() . '/custom'))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 30; } diff --git a/tests/Fixtures/Requests/CustomKeyCachedUserRequest.php b/tests/Fixtures/Requests/CustomKeyCachedUserRequest.php index 6f1d6f7..6e84d84 100644 --- a/tests/Fixtures/Requests/CustomKeyCachedUserRequest.php +++ b/tests/Fixtures/Requests/CustomKeyCachedUserRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use Saloon\Http\PendingRequest; use League\Flysystem\Filesystem; use Saloon\CachePlugin\Contracts\Driver; @@ -33,7 +34,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath()))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/LaravelCachedUserRequest.php b/tests/Fixtures/Requests/LaravelCachedUserRequest.php index 69c1905..19125f1 100644 --- a/tests/Fixtures/Requests/LaravelCachedUserRequest.php +++ b/tests/Fixtures/Requests/LaravelCachedUserRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use Illuminate\Support\Facades\Cache; use Saloon\CachePlugin\Contracts\Driver; use Saloon\CachePlugin\Traits\HasCaching; @@ -28,7 +29,7 @@ public function resolveCacheDriver(): Driver return new LaravelCacheDriver(Cache::store('file')); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/PsrCachedUserRequest.php b/tests/Fixtures/Requests/PsrCachedUserRequest.php index f820ed0..0a7b40b 100644 --- a/tests/Fixtures/Requests/PsrCachedUserRequest.php +++ b/tests/Fixtures/Requests/PsrCachedUserRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use Saloon\CachePlugin\Contracts\Driver; use Saloon\CachePlugin\Traits\HasCaching; use Saloon\CachePlugin\Contracts\Cacheable; @@ -33,7 +34,7 @@ public function resolveCacheDriver(): Driver return new PsrCacheDriver($this->cache); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 60; } diff --git a/tests/Fixtures/Requests/ResponseBasedExpiryRequest.php b/tests/Fixtures/Requests/ResponseBasedExpiryRequest.php new file mode 100644 index 0000000..b4cd9e0 --- /dev/null +++ b/tests/Fixtures/Requests/ResponseBasedExpiryRequest.php @@ -0,0 +1,37 @@ +json()['expiry']; + } +} diff --git a/tests/Fixtures/Requests/ShortLivedCachedUserRequest.php b/tests/Fixtures/Requests/ShortLivedCachedUserRequest.php index d5e473d..3a8004d 100644 --- a/tests/Fixtures/Requests/ShortLivedCachedUserRequest.php +++ b/tests/Fixtures/Requests/ShortLivedCachedUserRequest.php @@ -6,6 +6,7 @@ use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use League\Flysystem\Filesystem; use Saloon\CachePlugin\Contracts\Driver; use Saloon\CachePlugin\Traits\HasCaching; @@ -29,7 +30,7 @@ public function resolveCacheDriver(): Driver return new FlysystemDriver(new Filesystem(new LocalFilesystemAdapter(cachePath()))); } - public function cacheExpiryInSeconds(): int + public function resolveCacheExpiry(Response $response): int { return 2; }