Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/Contracts/Cacheable.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace Saloon\CachePlugin\Contracts;

use DateTimeImmutable;
use Saloon\Http\Response;

interface Cacheable
{
/**
Expand All @@ -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;
}
3 changes: 0 additions & 3 deletions src/Contracts/Driver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

namespace Saloon\CachePlugin\Contracts;

use Saloon\Data\RecordedResponse;
use Saloon\CachePlugin\Data\CachedResponse;

interface Driver
Expand All @@ -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;

Expand Down
10 changes: 9 additions & 1 deletion src/Data/CachedResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Saloon\CachePlugin\Data;

use DateInterval;
use DateTimeImmutable;
use Saloon\Data\RecordedResponse;
use Saloon\Http\Faking\FakeResponse;
Expand All @@ -17,7 +18,6 @@ class CachedResponse
public function __construct(
public readonly RecordedResponse $recordedResponse,
public readonly DateTimeImmutable $expiresAt,
public readonly int $ttl,
) {
//
}
Expand All @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Drivers/LaravelCacheDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/Drivers/PsrCacheDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down
3 changes: 1 addition & 2 deletions src/Http/Middleware/CacheMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ class CacheMiddleware implements RequestMiddleware
*/
public function __construct(
protected Driver $driver,
protected int $ttl,
protected ?string $cacheKey,
protected bool $invalidate = false,
) {
Expand Down Expand Up @@ -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
);

Expand Down
20 changes: 17 additions & 3 deletions src/Http/Middleware/CacheRecorderMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -18,7 +20,6 @@ class CacheRecorderMiddleware implements ResponseMiddleware
*/
public function __construct(
protected Driver $driver,
protected int $ttl,
protected string $cacheKey,
) {
//
Expand All @@ -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)
);
}
}
8 changes: 2 additions & 6 deletions src/Traits/HasCaching.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
114 changes: 114 additions & 0 deletions tests/Feature/CacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
use League\Flysystem\Filesystem;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
use Saloon\CachePlugin\Data\CachedResponse;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Saloon\CachePlugin\Exceptions\HasCachingException;
use Saloon\CachePlugin\Tests\Fixtures\Stores\ArrayCache;
use Saloon\CachePlugin\Tests\Fixtures\Connectors\TestConnector;
use Saloon\CachePlugin\Tests\Fixtures\Connectors\CachedConnector;
use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedPostRequest;
Expand All @@ -15,7 +17,9 @@
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\ResolveCacheExpiryCachedRequest;
use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedUserRequestWithoutCacheable;
use Saloon\CachePlugin\Tests\Fixtures\Requests\CachedUserRequestOnCachedConnector;

Expand Down Expand Up @@ -249,6 +253,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']),
Expand Down Expand Up @@ -372,3 +392,97 @@

$connector->send($request, $mockClient);
});

test('driver stores correct DateTimeImmutable expiry', function () {
$cache = new ArrayCache;
$expiry = new DateTimeImmutable('+60 seconds');

$mockClient = new MockClient([
MockResponse::make(['name' => 'Sam']),
]);

$connector = new TestConnector;

$request = new ResolveCacheExpiryCachedRequest($cache, $expiry);
$connector->send($request, $mockClient);

$raw = $cache->get($connector->getCacheKey($request));
expect($raw)->not->toBeNull();

$cachedResponse = unserialize($raw, ['allowed_classes' => true]);

expect($cachedResponse)->toBeInstanceOf(CachedResponse::class);
expect($cachedResponse->expiresAt)->toBeInstanceOf(DateTimeImmutable::class);
expect($cachedResponse->expiresAt->getTimestamp())->toEqual($expiry->getTimestamp());
});

test('driver converts integer expiry into correct DateTimeImmutable', function () {
$cache = new ArrayCache;
$ttl = 120;

$mockClient = new MockClient([
MockResponse::make(['name' => 'Sam']),
]);

$connector = new TestConnector;

$before = time();
$request = new ResolveCacheExpiryCachedRequest($cache, $ttl);
$connector->send($request, $mockClient);
$after = time();

$raw = $cache->get($connector->getCacheKey($request));
expect($raw)->not->toBeNull();

$cachedResponse = unserialize($raw, ['allowed_classes' => true]);

expect($cachedResponse)->toBeInstanceOf(CachedResponse::class);
expect($cachedResponse->expiresAt)->toBeInstanceOf(DateTimeImmutable::class);
expect($cachedResponse->expiresAt->getTimestamp())->toBeBetween($before + $ttl, $after + $ttl);
});

test('driver does not store item when expiry is zero', function () {
$cache = new ArrayCache;

$mockClient = new MockClient([
MockResponse::make(['name' => 'Sam']),
]);

$connector = new TestConnector;

$request = new ResolveCacheExpiryCachedRequest($cache, 0);
$connector->send($request, $mockClient);

expect($cache->get($connector->getCacheKey($request)))->toBeNull();
});

test('driver does not store item when expiry is negative', function () {
$cache = new ArrayCache;

$mockClient = new MockClient([
MockResponse::make(['name' => 'Sam']),
]);

$connector = new TestConnector;

$request = new ResolveCacheExpiryCachedRequest($cache, -10);
$connector->send($request, $mockClient);

expect($cache->get($connector->getCacheKey($request)))->toBeNull();
});

test('driver does not store item when DateTimeImmutable is in the past', function () {
$cache = new ArrayCache;
$past = new DateTimeImmutable('-60 seconds');

$mockClient = new MockClient([
MockResponse::make(['name' => 'Sam']),
]);

$connector = new TestConnector;

$request = new ResolveCacheExpiryCachedRequest($cache, $past);
$connector->send($request, $mockClient);

expect($cache->get($connector->getCacheKey($request)))->toBeNull();
});
3 changes: 2 additions & 1 deletion tests/Fixtures/Connectors/CachedConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions tests/Fixtures/Connectors/TestConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@

namespace Saloon\CachePlugin\Tests\Fixtures\Connectors;

use Saloon\Http\Request;
use Saloon\Http\Connector;
use Saloon\CachePlugin\Helpers\CacheKeyHelper;

class TestConnector extends Connector
{
public function resolveBaseUrl(): string
{
return testApi();
}

public function getCacheKey(Request $request): string
{
return hash('sha256', CacheKeyHelper::create($this->createPendingRequest($request, $this->mockClient)));
}
}
3 changes: 2 additions & 1 deletion tests/Fixtures/Requests/AllowedCachedPostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion tests/Fixtures/Requests/BodyCacheKeyRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading