diff --git a/README.md b/README.md index 9788895..6d4e123 100644 --- a/README.md +++ b/README.md @@ -90,14 +90,22 @@ This setting specifies the amount of time responses will be cached before being Defaults to `60 * 60 * 24`, or exactly 24 hours. ### `store` (Environment variable: `RESPONSE_CACHE_STORE`) -Here you may pass any cache store defined in your `config/cache.php` file. Please note that not all stores support tagging: For better control over and -increased performance of your cache, we strongly recommend using a store with tag support, like Redis or APC. +Here you may pass any cache store defined in your `config/cache.php` file. Tags work on every store, including `file`, `database` and `dynamodb`; the store +does not need to support Laravel's cache tags. The `failover` store is not supported, because it can lose flushes during an outage (see +[Limitations](#limitations)). -Defaults to `file`. +Defaults to `null`, which uses your application's default cache store. + +### `tag_ttl` (Environment variable: `RESPONSE_CACHE_TAG_TTL`) +Every tag has a version, which is replaced when the tag is flushed (see [Cache Tags](#cache-tags)). This setting specifies how long versions are kept, in +seconds. Versions are never refreshed: once one expires, all responses carrying that tag become misses, and the next request starts a new version. Cached +responses therefore never outlive `tag_ttl`, even if their own TTL is longer, and responses cached without a TTL expire after `tag_ttl`. + +Defaults to `60 * 60 * 24 * 30`, or 30 days. ### `tags` (Environment variable: `RESPONSE_CACHE_TAGS`) -If your store supports tags, all responses cached will also be tagged with these tags by default. Additional tags may be used depending on the middleware and -strategy. +Tags added to every cached response. Additional tags may be used depending on the middleware and strategy. Flushing one of the default tags invalidates every +cached response. Defaults to `[]`. @@ -122,16 +130,16 @@ Route::get('/kitten')->middleware('bypass_cache'); ``` ### Flushing the cache manually -To clear the cache manually, you can use the `response-cache:flush` artisan command: +To clear the cache manually, you can use the `response-cache:clear` artisan command (or its alias, `response-cache:flush`): ```bash -php artisan response-cache:flush +php artisan response-cache:clear ``` -This will flush the entire response cache. To delete a given set of cache tags only, add them to the command: +This will flush the entire response cache, and nothing else in the cache store. To flush a given set of cache tags only, add them to the command: ```bash -php artisan response-cache:flush tag-1 tag-2 ... tag-n +php artisan response-cache:clear tag-1 tag-2 ... tag-n ``` ### Flushing the cache programmatically @@ -154,7 +162,21 @@ class ExampleObserver { } ``` -By invoking the `flush` method, the entire store (for the given tags) will be purged. To be more selective, use the `delete` method: +Calling `flush()` without arguments (or with `null`) invalidates every cached response. Passing tags invalidates every response carrying at least one of +them, regardless of its other tags, the user it was cached for, or its query string. Passing an empty array flushes nothing, so a computed tag list that +happens to be empty can never clear the whole cache. + +Flushing is cheap: it writes one small cache key per tag, so it's safe to call many times from observers. Invalidated responses are not deleted; they stay in +the store until their TTL expires. If you need the space back immediately, run `php artisan cache:clear` on that store. + +**Flushes run after database transactions end.** If you flush while a transaction is open, the flush is deferred until every open transaction, on every +connection, has ended, so other requests can't cache uncommitted data in the meantime. The flush runs after a rollback, too, since the data may have been +written outside that transaction or on another connection; that only costs some cache misses. Outside a transaction, the flush runs immediately. + +If the cache store fails to write a flush, `flush()` throws a `RuntimeException`. A deferred flush runs while Laravel finishes the transaction, so a failure +there is reported to your exception handler instead, and the rest of your after-commit callbacks still run. + +To be more selective, use the `delete` method: ```php use Matchory\ResponseCache\Facades\ResponseCache; @@ -163,15 +185,20 @@ ResponseCache::delete('/example/url'); ResponseCache::delete(['/example/1', '/example/2']); ``` -> **Note:** This will not work for cache items that require context information, such as user authentication. If you hit this problem, you'll probably want to -> work with tags instead. +> **Note:** This only deletes the response cached for the current context, such as the authenticated user, because cache keys include it. To clear a URL +> for everyone, work with tags instead. #### Cache Tags Cache tags are a simple, yet very powerful mechanism for efficient cache usage. By applying tags to a set of cached items, you can purge all of them, without ever knowing the individual cache keys of these items. Say, there are lots of cached data points all related to "flights": plane schedules, arrival dates, passenger records, etc. All is fine and well, until a flight is cancelled! Now, we will need to purge the cache for anything that includes the flight in -question. This would be a logical nightmare, hadn't we set up cache tags: Instead of deleting lots of items manually, we instruct Laravel to purge all items -tagged with the flight number, and we're good to go! +question. This would be a logical nightmare, hadn't we set up cache tags: Instead of deleting lots of items manually, we flush the tag with the flight +number, and we're good to go! + +Tags work on every cache store; they don't use Laravel's cache tags. Instead, every tag has a version: a random token stored in the cache. Each cached response +records the versions of its tags when it is built, and it is only served while all of them are still current. Flushing a tag writes a new version, which +makes every response carrying it a miss. The versions are read before your controller runs, so a flush that happens while a response is being built +invalidates that response, too. This library is built heavily around the concept of cache tags: By tagging properly, you can make granular cache flushing a breeze. Tags can be added to routes using several approaches: @@ -243,7 +270,23 @@ class MyStrategy extends BaseStrategy - The `hash` method builds a hash of the given cache key (by default it uses `md5`), so the key length stays consistent. #### Customizing cache bypass -To customize whether a response is cached or not, you can implement one or more helpers: +Strategies decide in two steps whether to cache. Before the response exists, `isCacheable` decides whether a request may be served from or stored in the +cache at all. Requests it rejects skip the cache entirely, without reading anything. By default, it accepts `GET` and `HEAD` requests: + +```php +use Illuminate\Http\Request; +use Matchory\ResponseCache\Support\BaseStrategy; + +class MyStrategy extends BaseStrategy +{ + public function isCacheable(Request $request): bool + { + return $this->isMethodCachable($request); + } +} +``` + +Once the response exists, `shouldCache` decides whether to store it: ```php use Illuminate\Http\Request; @@ -254,7 +297,7 @@ class MyStrategy extends BaseStrategy { public function shouldCache(Request $request, Response $response): bool { - if (! $this->isMethodCachable($request)) { + if (! $this->isCacheable($request)) { return false; } @@ -269,16 +312,16 @@ By default, this will cache any request with the `GET` or `HEAD` methods, and re Tagging cached responses is an immensely powerful feature that allows you to flush a collection of cache entries if something happens. So if a single member of a collection is deleted, for example, you can remove all cached instances of the same collection, without having to know the exact cache keys used to retrieve them! -To make this as easy as possible, strategies provide a method to pull tags from a request and response: +To make this as easy as possible, strategies provide a method to pull tags from a request. Tags must be derivable from the request alone, because they +are needed to look up a cached response before any response exists: ```php use Illuminate\Http\Request; use Matchory\ResponseCache\Support\BaseStrategy; -use Symfony\Component\HttpFoundation\Response; class MyStrategy extends BaseStrategy { - public function tags(Request $request, Response|null $response = null): array + public function tags(Request $request): array { return [ $request->attributes->get('customer.id') @@ -314,47 +357,12 @@ This library exposes some events you can listen for and act accordingly. This is Emitted if a response was found in the cache. Includes the request instance. #### `Miss` -Emitted if a response could not be found in the cache or was [indicated as non-cachable by the caching strategy](#caching-strategies) and thus had to be -generated by the application. Includes the request instance. +Emitted if a response could not be found in the cache and thus had to be generated by the application. Includes the request instance. Requests the +[caching strategy](#caching-strategies) rejects in `isCacheable`, such as `POST` requests, bypass the cache without emitting any event. #### `Flush` Emitted if the cache was flushed. Includes the tags that were flushed, or `null` if all tags were flushed. -### Manual response serialization -By default, the response cache uses a [cache repository implementation](./src/Repository.php) with a very simple serialization method: _Doing nothing_. Any -serialization is deferred to Laravel's cache implementation. In rare cases, you may need to change this behavior and modify a response before serializing or -after hydrating it. -To do so, start by extending the `Repository` class: - -```php -use Matchory\ResponseCache\Repository; -use Symfony\Component\HttpFoundation\Response; - -class CustomRepository extends Repository -{ - protected function serialize(Response $response) : mixed - { - return serialize($response); - } - - protected function hydrate(mixed $responseData): Response - { - return unserialize($responseData, [Response::class]) - } -} -``` - -Override the container binding in your `AppServiceProvider`, so your repository will be used instead of the default: - -```php -use Matchory\ResponseCache\Repository; - -protected function register():void - { - $this->app->bind(Repository::class, CustomRepository::class); - } -``` - ### Debugging Cache issues can be a little annoying to debug. This library has several facilities built in to make the process as simple as possible: @@ -363,5 +371,50 @@ Cache issues can be a little annoying to debug. This library has several facilit information will automatically show up in your browser's developer tools! 2. **Listen to cache events**: By listening to [the cache events](#reacting-to-cache-events), you can make sure everything is working as intended. 3. **Temporarily or conditionally disable caching**: By changing the `response-cache.enabled` setting, you can quickly determine whether caching is the culprit. -4. **Use a custom repository**: [Override the built-in serialization method](#manual-response-serialization) to take control of response serialization and - hydration. + +Limitations +----------- +Tag versioning never serves a response after a flush that it knows about, but some situations are outside its control: + +- **Data read before the versions.** The cache middleware runs after authentication and route binding, so a bound model or the authenticated user is loaded + before the tag versions are read. A write that commits and flushes in those few milliseconds produces a response built from the old data but stamped with + the new version. It is served until the next flush of one of its tags, or until its TTL expires. +- **Read replicas.** A request that reads from a lagging replica after a flush can cache old data under the new version, just like above. +- **Store replication and failover.** If the cache store loses a write, for example during a Redis failover with asynchronous replication, or when + Memcached remaps a dead server, an old version can come back and make stale responses valid again. This is inherent to those stores. For the same reason, + Laravel's `failover` cache store is refused. +- **DynamoDB** reads are eventually consistent, which leaves a short window after a flush. The version of the internal global tag is read on every request, + which makes it a hot partition. +- **Local stores.** The `file` and `apc` stores are local to a host, and the `array` store to a process. A flush only reaches the host or worker that runs + it, so use them on a single host only. +- **Failed commits.** Laravel runs no after-commit or rollback callbacks when a commit itself fails, and none after an earlier callback of the same + transaction throws. A flush deferred in such a transaction is skipped, just like your other after-commit callbacks. A failed commit normally means the + database rolled the transaction back, so nothing changed; only a connection lost during the commit leaves it unknown whether the data was written. +- **Hit rate.** Flushing a broad tag like `users` on every write keeps all responses carrying it cold on write-heavy tables, and flushing everything after a + deployment causes a burst of misses. The same burst happens every `tag_ttl` (30 days by default) after the first response was cached, when the version + of the internal global tag expires. There is no lock against many requests rebuilding the same response at once. + +Upgrading to v2 +--------------- +Version 2 replaces Laravel's cache tags with tag versioning. Existing cache entries are not read anymore, which just causes misses after the upgrade. + +- **Strategies:** `CacheStrategy::tags()` takes the request only: `tags(Request $request): array`. Tags derived from the response are no longer supported. +- **Strategies:** `CacheStrategy` has a new method, `isCacheable(Request $request): bool`. Custom strategies extending `BaseStrategy` inherit it. +- **Events:** requests rejected by `isCacheable` (by default, anything but `GET` and `HEAD`) no longer emit a `Miss` event. +- **Flushing:** `ResponseCache::flush($tags)` no longer adds the default tags, so it flushes exactly the given tags. `flush([])` now does nothing; use + `flush()` to flush everything. +- **Flushing:** flushes while a database transaction is open run once all open transactions have ended, whether they commit or roll back. +- **Flushing:** `flush()` throws a `RuntimeException` if the cache store fails to write. Failures of deferred flushes are reported to the exception handler. +- **Flushing:** invalidated responses are no longer deleted from the store; they expire with their TTL. +- **`ResponseCache` methods:** `has()` was removed; use `get()` or `lookup()`. `put()` requires a `Snapshot` of the tag versions, taken before the response + is built, in place of the tags: `put($request, $response, $snapshot, $ttl)`. Take one with `snapshot($request, $tags)`, or use the one returned by + `lookup($request, $tags)` on a miss. Either is `null` if the tag versions could not be established; don't cache the response then. `get()` and + `lookup()` take a tags array (`[]` by default) instead of `null`. `delete()` no longer takes tags. +- **Cached responses:** responses are stored as their status, headers and content, so caching works even if `serializable_classes` is restricted in + `config/cache.php` (the default in Laravel 13). A cached response is returned as an `Illuminate\Http\Response` with the same status, headers and + content, whatever class the original response had. Responses without content, such as streamed responses and file downloads, are not cached. +- **`Repository`:** the methods `get()`, `has()`, `getStore()`, `supportsTags()`, `serialize()` and `hydrate()` were removed, along with custom + serialization through a `Repository` subclass. Its constructor takes the tag TTL as a third argument. +- **Configuration:** `store` defaults to `null` (your default cache store) instead of `file`. The new `tag_ttl` option sets how long tag versions are kept + (30 days by default) and must be at least one second. The `failover` cache store is refused with an `InvalidArgumentException`. +- **Stores:** tags work on every cache store; a store with tag support is no longer needed. diff --git a/config/response-cache.php b/config/response-cache.php index 8058bae..0adce39 100644 --- a/config/response-cache.php +++ b/config/response-cache.php @@ -32,21 +32,32 @@ |-------------------------------------------------------------------------- | | Here you may pass any cache store defined in your config/cache.php file. - | Please note that not all stores support tagging: For better control over - | and increased performance of your cache, we strongly recommend using a - | store with tag support, like Redis or APC. + | Tags work on every store. null uses your default cache store. | */ - 'store' => env('RESPONSE_CACHE_STORE', 'file'), + 'store' => env('RESPONSE_CACHE_STORE'), + + /* + |-------------------------------------------------------------------------- + | Tag Version Time to Live + |-------------------------------------------------------------------------- + | + | Every tag has a version, which is replaced when the tag is flushed. This + | setting specifies how long versions are kept, in seconds. Once a version + | expires, all responses carrying that tag are treated as stale, so cached + | responses never outlive it. + | + */ + 'tag_ttl' => env('RESPONSE_CACHE_TAG_TTL', 60 * 60 * 24 * 30), /* |-------------------------------------------------------------------------- | Default Tags |-------------------------------------------------------------------------- | - | If your store supports tags, all responses cached will also be tagged - | with these tags by default. Additional tags may be used depending on the - | middleware and strategy. + | All responses cached will also be tagged with these tags by default, so + | flushing one of them clears every response. Additional tags may be used + | depending on the middleware and strategy. | */ 'tags' => ($tags = env('RESPONSE_CACHE_TAGS')) diff --git a/src/Commands/ClearCacheCommand.php b/src/Commands/ClearCacheCommand.php index f1053f4..47cb8b6 100644 --- a/src/Commands/ClearCacheCommand.php +++ b/src/Commands/ClearCacheCommand.php @@ -12,9 +12,10 @@ namespace Matchory\ResponseCache\Commands; -use BadMethodCallException; use Illuminate\Console\Command; -use Matchory\ResponseCache\Repository; +use Matchory\ResponseCache\ResponseCache; +use Random\RandomException; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; /** @@ -33,11 +34,14 @@ class ClearCacheCommand extends Command protected $aliases = ['response-cache:flush']; /** - * @throws BadMethodCallException + * @throws RandomException + * @throws RuntimeException */ - public function handle(Repository $repository): int + public function handle(ResponseCache $responseCache): int { - $repository->flush($this->argument('tags')); + $tags = $this->argument('tags'); + + $responseCache->flush($tags === [] ? null : $tags); $this->components->info('Response cache flushed successfully.'); diff --git a/src/Contracts/CacheStrategy.php b/src/Contracts/CacheStrategy.php index b84cfc1..5583b76 100644 --- a/src/Contracts/CacheStrategy.php +++ b/src/Contracts/CacheStrategy.php @@ -25,18 +25,23 @@ interface CacheStrategy */ public function key(Request $request): string; + /** + * Determines, before a response exists, whether a request may be served + * from or stored in the cache. + */ + public function isCacheable(Request $request): bool; + /** * Determines whether the response should be cached. */ public function shouldCache(Request $request, Response $response): bool; /** - * Generates a list of tags for a request. If the cache is missed, this - * method will be invoked with the generated response passed, providing an - * opportunity to add tags dependent on the generated response, for example - * the ID of a customer associated to the request, or similar. + * Generates a list of tags for a request. Tags must be derivable from the + * request alone, as they are needed to look up a cached response before + * any response exists. * * @return string[] */ - public function tags(Request $request, ?Response $response = null): array; + public function tags(Request $request): array; } diff --git a/src/Facades/ResponseCache.php b/src/Facades/ResponseCache.php index 7f87496..667ee78 100644 --- a/src/Facades/ResponseCache.php +++ b/src/Facades/ResponseCache.php @@ -16,11 +16,13 @@ /** * Response Cache Facade * - * @method static \Symfony\Component\HttpFoundation\Response|null get(\Illuminate\Http\Request $request, string[]|null $tags = null) Retrieves a cached response - * @method static bool has(\Illuminate\Http\Request $request, string[]|null $tags = null) Checks whether a response is cached - * @method static void put(\Illuminate\Http\Request $request, \Symfony\Component\HttpFoundation\Response $response, string[]|null $tags = null, int|null $ttl = null) Caches a response + * @method static bool isCacheable(\Illuminate\Http\Request $request) Checks whether a request may be cached + * @method static \Matchory\ResponseCache\Lookup lookup(\Illuminate\Http\Request $request, string[] $tags = []) Looks up a cached response + * @method static \Symfony\Component\HttpFoundation\Response|null get(\Illuminate\Http\Request $request, string[] $tags = []) Retrieves a cached response + * @method static \Matchory\ResponseCache\Snapshot|null snapshot(\Illuminate\Http\Request $request, string[] $tags = []) Captures the current tag versions + * @method static void put(\Illuminate\Http\Request $request, \Symfony\Component\HttpFoundation\Response $response, \Matchory\ResponseCache\Snapshot $snapshot, int|null $ttl = null) Caches a response * @method static void flush(string[]|null $tags = null) Flushes the cache - * @method static void delete(string|string[] $uri, string[] $tags = []) Deletes one or more URIs + * @method static void delete(string|string[] $uri) Deletes one or more URIs * @method static bool enabled() Checks whether the cache is enabled * * @bundle Matchory\ResponseCache diff --git a/src/Http/Middleware/CacheResponse.php b/src/Http/Middleware/CacheResponse.php index cfc6d36..f360e51 100644 --- a/src/Http/Middleware/CacheResponse.php +++ b/src/Http/Middleware/CacheResponse.php @@ -10,13 +10,13 @@ namespace Matchory\ResponseCache\Http\Middleware; -use BadMethodCallException; use Closure; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Http\Request; use Matchory\ResponseCache\Events\{Hit, Miss}; use Matchory\ResponseCache\ResponseCache; use Psr\SimpleCache\InvalidArgumentException; +use Random\RandomException; use Symfony\Component\HttpFoundation\Response; use function array_shift; @@ -46,15 +46,18 @@ public function __construct( /** * Handles requests. * - * @throws BadMethodCallException * @throws InvalidArgumentException + * @throws RandomException */ public function handle( Request $request, Closure $next, string ...$args, ): Response { - if (! $this->responseCache->enabled()) { + if ( + ! $this->responseCache->enabled() + || ! $this->responseCache->isCacheable($request) + ) { return $next($request); } @@ -65,25 +68,28 @@ public function handle( : null; $tags = $args; - if ( - $this->responseCache->has($request, $tags) - && ($response = $this->responseCache->get($request, $tags)) !== null - ) { + // The lookup happens before the response is built, so its snapshot + // predates any flush that happens while it is being built + $lookup = $this->responseCache->lookup($request, $tags); + + if ($lookup->response !== null) { $this->eventDispatcher->dispatch(new Hit($request)); - return $response; + return $lookup->response; } $response = $next($request); $this->eventDispatcher->dispatch(new Miss($request)); - $this->responseCache->put( - $request, - clone $response, - $tags, - $ttl, - ); + if ($lookup->snapshot !== null) { + $this->responseCache->put( + $request, + clone $response, + $lookup->snapshot, + $ttl, + ); + } if (config('response-cache.cache_status_enabled')) { $response->headers->set('Response-Cache-Status', 'miss'); diff --git a/src/Lookup.php b/src/Lookup.php new file mode 100644 index 0000000..9749582 --- /dev/null +++ b/src/Lookup.php @@ -0,0 +1,30 @@ + + */ + +declare(strict_types=1); + +namespace Matchory\ResponseCache; + +use Symfony\Component\HttpFoundation\Response; + +/** + * Lookup + * + * The result of looking up a request in the cache: the response on a hit, or + * the snapshot to stamp the new entry with on a miss. The snapshot is `null` if + * the tag versions could not be established, in which case nothing is stored. + * + * @bundle Matchory\ResponseCache + */ +final readonly class Lookup +{ + public function __construct( + public ?Response $response, + public ?Snapshot $snapshot, + ) {} +} diff --git a/src/Repository.php b/src/Repository.php index 4ade5e6..ed938fe 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -10,153 +10,348 @@ namespace Matchory\ResponseCache; -use BadMethodCallException; -use Illuminate\Cache\TaggableStore; +use Illuminate\Cache\FailoverStore; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Contracts\Events\Dispatcher; +use Illuminate\Http\Response as CachedResponse; use Matchory\ResponseCache\Events\Flush; +use InvalidArgumentException as InvalidConfigurationException; use Psr\SimpleCache\InvalidArgumentException; +use Random\RandomException; +use RuntimeException; use Symfony\Component\HttpFoundation\Response; +use function array_map; +use function bin2hex; +use function count; +use function is_array; +use function is_int; +use function is_string; +use function iterator_to_array; +use function min; +use function random_bytes; +use function sha1; + /** * Cache Repository * - * Responsible for cache persistence. + * Responsible for cache persistence. Every tag has a version, a random token + * stored under its own key; flushing a tag writes a new token, which makes all + * entries stamped with the previous one invalid. * * @bundle Matchory\ResponseCache */ readonly class Repository { + protected const ENTRY_KEY_PREFIX = 'response-cache:entry:'; + + protected const VERSION_KEY_PREFIX = 'response-cache:tag:'; + + /** + * Version key of the internal tag every entry carries. No sha1 output can + * equal it, so it never collides with a user tag. + */ + protected const GLOBAL_VERSION_KEY = self::VERSION_KEY_PREFIX . '*'; + + /** + * @throws InvalidConfigurationException + */ public function __construct( protected CacheRepository $store, protected Dispatcher $eventDispatcher, - ) {} + protected int $tagTtl, + ) { + // During an outage, it would write flushes to the secondary store only + if ($store->getStore() instanceof FailoverStore) { + throw new InvalidConfigurationException( + 'The response cache does not support the failover cache store, because it can lose flushes during an outage.', + ); + } + + // Versions could never be created, so nothing would be cached + if ($tagTtl < 1) { + throw new InvalidConfigurationException( + 'The response cache tag_ttl must be at least one second.', + ); + } + } /** - * Retrieves a response from the cache. + * Looks up a cached response. It is only returned if every tag version it + * was stamped with is still current; otherwise, the lookup carries the + * snapshot to stamp a new entry with. * - * @param string[]|null $tags + * @param string[] $tags + * + * @throws InvalidArgumentException + * @throws RandomException + */ + public function lookup(string $key, array $tags): Lookup + { + [$entry, $versions] = $this->read($key, $tags); + + if ($this->isValid($entry, $versions)) { + return new Lookup($this->hydrate($entry), null); + } + + return new Lookup(null, $this->establish($versions)); + } + + /** + * Retrieves a cached response if every tag version it was stamped with is + * still current. Unlike {@see lookup()}, this never writes to the store. + * + * @param string[] $tags * - * @throws BadMethodCallException * @throws InvalidArgumentException */ - public function get(string $key, ?array $tags = null): ?Response + public function get(string $key, array $tags): ?Response { - $response = $this->getStore($tags)->get($key); + [$entry, $versions] = $this->read($key, $tags); + + return $this->isValid($entry, $versions) + ? $this->hydrate($entry) + : null; + } + + /** + * Puts a response into the cache, stamped with the versions of a snapshot. + * The entry expires after the tag TTL at the latest, as its versions + * expire by then. Responses without content, like streamed responses or + * file downloads, are not cached. + * The response is stored as plain data, which works regardless of the + * cache's `serializable_classes` setting; on a hit, it is restored as an + * {@see CachedResponse} with the same status, headers, and content. + * + * @param int|null $ttl Time-to-live in seconds, or `null` for the tag TTL + */ + public function put( + string $key, + Response $response, + Snapshot $snapshot, + ?int $ttl, + ): void { + $content = $response->getContent(); - if (! $response) { - return null; + if ($content === false) { + return; } - return $this->hydrate($response); + $this->store->put($this->entryKey($key), [ + 'versions' => $snapshot->versions, + 'status' => $response->getStatusCode(), + 'headers' => $response->headers->allPreserveCase(), + 'content' => $content, + ], min($ttl ?? $this->tagTtl, $this->tagTtl)); } /** * Deletes a cached response. + */ + public function delete(string $key): void + { + $this->store->forget($this->entryKey($key)); + } + + /** + * Captures the current versions of the given tags and the global tag, + * creating those that don't exist yet. * - * @param string[]|null $tags + * @param string[] $tags + * @return Snapshot|null Snapshot, or `null` if a version could not be + * established. * - * @throws BadMethodCallException + * @throws InvalidArgumentException + * @throws RandomException */ - public function delete(string $key, ?array $tags = null): void + public function snapshot(array $tags): ?Snapshot { - $this->getStore($tags)->forget($key); + return $this->establish($this->many($this->versionKeys($tags))); } /** - * Flushes the response cache. + * Flushes the response cache by writing new versions for the given tags. + * Passing `null` flushes all entries, an empty array flushes nothing. * * @param string[]|null $tags * - * @throws BadMethodCallException + * @throws InvalidArgumentException + * @throws RandomException + * @throws RuntimeException If the store fails to write a version */ public function flush(?array $tags = null): void { - // Not clear(): on tagged caches, it would flush the entire store - // @phpstan-ignore method.notFound (Illuminate\Cache\Repository proxies it to the store) - $this->getStore($tags)->flush(); + if ($tags === []) { + return; + } + + $keys = $tags === null + ? [self::GLOBAL_VERSION_KEY] + : array_map($this->versionKey(...), $tags); + $versions = []; + + foreach ($keys as $key) { + $versions[$key] = $this->newToken(); + } + + if (! $this->store->setMultiple($versions, $this->tagTtl)) { + throw new RuntimeException( + 'Failed to write the response cache tag versions', + ); + } $this->eventDispatcher->dispatch(new Flush($tags)); } /** - * Checks whether a response is cached. + * Completes a set of versions read from the store: missing versions are + * created and read again, so whichever token is stored wins. Existing + * versions are never overwritten here. * - * @param string[]|null $tags + * @param array $versions Version key → token + * @return Snapshot|null Snapshot, or `null` if a version is still missing. * - * @throws BadMethodCallException * @throws InvalidArgumentException + * @throws RandomException */ - public function has(string $key, ?array $tags = null): bool + protected function establish(array $versions): ?Snapshot { - return $this->getStore($tags)->has($key); + $missing = []; + + foreach ($versions as $key => $version) { + if ($version === null) { + $missing[] = $key; + $this->store->add($key, $this->newToken(), $this->tagTtl); + } + } + + if ($missing !== []) { + foreach ($this->many($missing) as $key => $version) { + if ($version === null) { + return null; + } + + $versions[$key] = $version; + } + } + + return new Snapshot($versions); } /** - * Puts a response into the cache. + * Reads an entry and the current versions of its tags in one call. * - * @param string[]|null $tags + * @param string[] $tags + * @return array{mixed, array} Entry and versions * - * @throws BadMethodCallException + * @throws InvalidArgumentException */ - public function put( - string $key, - Response $response, - ?array $tags = null, - ?int $ttl = null, - ): void { - $serialized = $this->serialize($response); + protected function read(string $key, array $tags): array + { + $entryKey = $this->entryKey($key); + $versions = $this->many([ + $entryKey, + ...$this->versionKeys($tags), + ]); + $entry = $versions[$entryKey]; + unset($versions[$entryKey]); - $this->getStore($tags)->put($key, $serialized, $ttl); + return [$entry, $versions]; } /** - * Retrieves the store instance. If it supports tags, a tagged instance for - * the given tags will be returned, the untagged store otherwise. - * - * @param string[]|null $tags + * Checks whether a stored entry is valid: it must have been stamped with + * exactly the current tags, and all of their versions must be unchanged. * - * @throws BadMethodCallException + * @param array $versions Current versions */ - protected function getStore(?array $tags = null): CacheRepository + protected function isValid(mixed $entry, array $versions): bool { - return $this->supportsTags() - // @phpstan-ignore method.notFound (guarded by supportsTags()) - ? $this->store->tags($tags) - : $this->store; + if ( + ! is_array($entry) + || ! is_array($entry['versions'] ?? null) + || ! is_int($entry['status'] ?? null) + || ! is_array($entry['headers'] ?? null) + || ! is_string($entry['content'] ?? null) + || count($entry['versions']) !== count($versions) + ) { + return false; + } + + foreach ($versions as $key => $version) { + if ($version === null || ($entry['versions'][$key] ?? null) !== $version) { + return false; + } + } + + return true; } /** - * Hydrates a serialized response. This method may be overridden by children - * implementations to add custom hydration mechanisms. + * Reads multiple keys in one call. Missing keys yield `null`. + * + * @param string[] $keys + * @return array * - * @param mixed $cached Cached representation of the response. - * @return Response Hydrated response instance. + * @throws InvalidArgumentException */ - protected function hydrate(mixed $cached): Response + protected function many(array $keys): array { - return $cached; + return iterator_to_array($this->store->getMultiple($keys)); } /** - * Serializes the response. This method may be overridden by children - * implementations to add custom serialization mechanisms. + * Restores a response from a valid entry. * - * @param Response $response Response instance. - * @return mixed Serialized response representation. + * @param array{status: int, headers: array>, content: string} $entry + */ + protected function hydrate(array $entry): Response + { + return new CachedResponse( + $entry['content'], + $entry['status'], + $entry['headers'], + ); + } + + /** + * Retrieves the cache key of an entry. + */ + protected function entryKey(string $key): string + { + return self::ENTRY_KEY_PREFIX . $key; + } + + /** + * Retrieves the version keys for a set of tags, including the global tag. * - * @noinspection PhpMixedReturnTypeCanBeReducedInspection + * @param string[] $tags + * @return string[] + */ + protected function versionKeys(array $tags): array + { + $keys = array_map($this->versionKey(...), $tags); + $keys[] = self::GLOBAL_VERSION_KEY; + + return $keys; + } + + /** + * Retrieves the version key of a tag. Hashing keeps keys of any tag within + * the key length limits of all stores. */ - protected function serialize(Response $response): mixed + protected function versionKey(string $tag): string { - return $response; + return self::VERSION_KEY_PREFIX . sha1($tag); } /** - * Checks whether the store supports tags. + * Generates a new version token. + * + * @throws RandomException */ - protected function supportsTags(): bool + protected function newToken(): string { - return $this->store->getStore() instanceof TaggableStore; + return bin2hex(random_bytes(16)); } } diff --git a/src/ResponseCache.php b/src/ResponseCache.php index fdc5d2e..31d4a7a 100644 --- a/src/ResponseCache.php +++ b/src/ResponseCache.php @@ -10,7 +10,6 @@ namespace Matchory\ResponseCache; -use BadMethodCallException; use Closure; use Illuminate\Contracts\Config\Repository as Config; use Illuminate\Contracts\Routing\{UrlGenerator, UrlRoutable}; @@ -18,12 +17,17 @@ use Illuminate\Support\{Carbon, Collection}; use Matchory\ResponseCache\Contracts\CacheStrategy; use Psr\SimpleCache\InvalidArgumentException; +use Random\RandomException; +use RuntimeException; use Stringable; use Symfony\Component\HttpFoundation\Response; use function array_filter; use function array_map; use function array_merge; +use function array_unique; +use function array_values; +use function filter_var; use function is_scalar; use function preg_match_all; use function sprintf; @@ -39,66 +43,97 @@ class ResponseCache */ private readonly Closure $configResolver; + /** + * @var Closure(callable): void + */ + private readonly Closure $afterTransactions; + /** * Creates a new response cache instance. * - * @param callable(): Config $configResolver + * @param callable(): Config $configResolver + * @param callable(callable): void $afterTransactions Runs a callback once + * all open database + * transactions have + * ended * * @internal Should only be invoked by the DI container */ public function __construct( callable $configResolver, + callable $afterTransactions, protected readonly UrlGenerator $urlGenerator, protected readonly Repository $cache, protected readonly CacheStrategy $strategy, ) { - $this->configResolver = $configResolver; + $this->configResolver = $configResolver(...); + $this->afterTransactions = $afterTransactions(...); + } + + /** + * Checks, before a response exists, whether a request may be served from + * or stored in the cache. + */ + public function isCacheable(Request $request): bool + { + return $this->strategy->isCacheable($request); + } + + /** + * Looks up the response to a request in the cache. On a miss, the lookup + * carries the snapshot to pass to {@see put()} once the response is built. + * + * @param Request $request Request to look up a response for + * @param string[] $tags Additional tags of the cache entry + * + * @throws InvalidArgumentException + * @throws RandomException + */ + public function lookup(Request $request, array $tags = []): Lookup + { + return $this->cache->lookup( + $this->strategy->key($request), + $this->resolveTags($tags, $request), + ); } /** * Retrieves a response from the cache. If there is no cached response for * the given request, `null` will be returned. * - * @param Request $request Request to retrieve a response from the - * cache for - * @param string[]|null $tags Tags the cache entry is tagged with + * @param Request $request Request to retrieve a response from the cache + * for + * @param string[] $tags Additional tags of the cache entry * * @return Response|null Response if cached, `null` otherwise * - * @throws BadMethodCallException * @throws InvalidArgumentException */ - public function get(Request $request, ?array $tags = null): ?Response + public function get(Request $request, array $tags = []): ?Response { - $key = $this->strategy->key($request); - $tags = $this->resolveTags($tags, $request); - - return $this->cache->get($key, $tags); + return $this->cache->get( + $this->strategy->key($request), + $this->resolveTags($tags, $request), + ); } /** - * Deletes one or more specific URLs from the cache. Note that this can't - * take bound parameters or tags specific to request instances into account. + * Deletes one or more specific URLs from the cache. As cache keys depend + * on the strategy, this only removes the variant the strategy computes for + * the current context, for example the authenticated user. * - * @param string|string[] $uri URI or URIs to remove from the cache. - * @param string[] $tags Tags to flush the cache for. - * - * @throws BadMethodCallException - * @throws InvalidArgumentException + * @param string|string[] $uri URI or URIs to remove from the cache. * * @noinspection PhpDocMissingThrowsInspection */ - public function delete(array|string $uri, array $tags = []): void + public function delete(array|string $uri): void { - Collection::wrap($uri)->each(function (string $uri) use ($tags) { + Collection::wrap($uri)->each(function (string $uri) { $url = $this->urlGenerator->to($uri); /** @noinspection PhpUnhandledExceptionInspection */ $request = Request::create($url); - $key = $this->strategy->key($request); - if ($this->cache->has($key, $tags)) { - $this->cache->delete($key, $tags); - } + $this->cache->delete($this->strategy->key($request)); }); } @@ -109,56 +144,68 @@ public function delete(array|string $uri, array $tags = []): void */ public function enabled(): bool { - return $this->config()->get('response-cache.enabled', true); + // Values from the environment may be strings; unset or unrecognized + // values keep the default + return filter_var( + $this->config()->get('response-cache.enabled') ?? true, + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE, + ) ?? true; } /** - * Flushes all cached items, or all items with one or more tags. + * Flushes all cached items, or all items with one or more tags. If database + * transactions are open, the flush runs once all of them have ended; if it + * fails then, the exception is reported instead of thrown. * - * @param string[]|null $tags Tags to flush. Pass `null` to flush all tags. + * @param string[]|null $tags Tags to flush. Pass `null` to flush all + * items; an empty array flushes nothing. * - * @throws BadMethodCallException + * @throws RandomException + * @throws RuntimeException If the store fails to write a tag version while + * no transaction is open */ public function flush(?array $tags = null): void { - $tags = array_merge($this->getDefaultTags(), $tags ?? []); + if ($tags === []) { + return; + } - $this->cache->flush($tags); + ($this->afterTransactions)(fn() => $this->cache->flush($tags)); } /** - * Checks whether the response for a request has already been cached. + * Captures the current tag versions for a request. Pass the snapshot to + * {@see put()} once the response is built, so a flush that happens in the + * meantime invalidates it. * - * @param Request $request Request to check the cache status for - * @param string[]|null $tags Tags the cached entry is tagged with + * @param Request $request Request to take a snapshot for + * @param string[] $tags Additional tags of the cache entry * - * @return bool `true` if a cached response exists, `false` otherwise + * @return Snapshot|null Snapshot, or `null` if the tag versions could not + * be established. * - * @throws BadMethodCallException * @throws InvalidArgumentException + * @throws RandomException */ - public function has(Request $request, ?array $tags = null): bool + public function snapshot(Request $request, array $tags = []): ?Snapshot { - $key = $this->strategy->key($request); - $tags = $this->resolveTags($tags, $request); - - return $this->cache->has($key, $tags); + return $this->cache->snapshot($this->resolveTags($tags, $request)); } /** * Caches the response to a given request, if it matches the cache strategy. * - * @param Request $request Request to cache a response for - * @param Response $response Response to cache - * @param string[]|null $tags Tags to tag the cache entry with - * @param int|null $ttl Time-to-live for the cache entry - * - * @throws BadMethodCallException + * @param Request $request Request to cache a response for + * @param Response $response Response to cache + * @param Snapshot $snapshot Tag versions captured before the response was + * built, see {@see lookup()} and {@see snapshot()} + * @param int|null $ttl Time-to-live for the cache entry */ public function put( Request $request, Response $response, - ?array $tags = null, + Snapshot $snapshot, ?int $ttl = null, ): void { // Escape hatch to bypass caching via middleware @@ -174,16 +221,11 @@ public function put( $ttl = $ttl ?? $this->getDefaultTtl(); $key = $this->strategy->key($request); - $tags = $this->resolveTags( - $tags, - $request, - $response, - ); $response = $this->addServerTiming($response); $response = $this->addCacheStatus($response); - $this->cache->put($key, $response, $tags, $ttl); + $this->cache->put($key, $response, $snapshot, $ttl); } /** @@ -241,7 +283,10 @@ protected function getDefaultTags(): array */ protected function getDefaultTtl(): ?int { - return $this->config()->get('response-cache.ttl'); + $ttl = $this->config()->get('response-cache.ttl'); + + // Values from the environment are strings + return $ttl === null ? null : (int) $ttl; } /** @@ -311,37 +356,33 @@ protected function replaceBinding(string $tag, Request $request): string // Replace the matched expression (`{foo}`) with the value found in // the request inside the tag - $tag = str_replace($match, $value, $tag); + $tag = str_replace($match, (string) $value, $tag); } return $tag; } /** - * Resolves all tags to apply to a given request/response pair, and replaces - * bound parameters in all cache tags. + * Resolves all tags to apply to a given request, and replaces bound + * parameters in all cache tags. * - * @param string[]|null $tags Individual tags to add to this response. - * @param Request $request Request instance. - * @param Response|null $response Response instance. + * @param string[] $tags Individual tags to add to this request. + * @param Request $request Request instance. * * @return string[] List of finalized tags. */ - protected function resolveTags( - ?array $tags, - Request $request, - ?Response $response = null, - ): array { + protected function resolveTags(array $tags, Request $request): array + { $resolved = array_merge( - $this->strategy->tags($request, $response), + $this->strategy->tags($request), $this->getDefaultTags(), - $tags ?? [], + $tags, ); - return array_map(fn(string $tag) => $this->replaceBinding( - $tag, - $request, - ), array_filter($resolved)); + return array_values(array_unique(array_map( + fn(string $tag) => $this->replaceBinding($tag, $request), + array_filter($resolved), + ))); } private function config(): Config diff --git a/src/ResponseCacheProvider.php b/src/ResponseCacheProvider.php index 5d1b03d..b3fde62 100644 --- a/src/ResponseCacheProvider.php +++ b/src/ResponseCacheProvider.php @@ -17,6 +17,7 @@ use LogicException; use Matchory\ResponseCache\Commands\ClearCacheCommand; use Matchory\ResponseCache\Contracts\CacheStrategy; +use Matchory\ResponseCache\Support\AfterTransactions; use Matchory\ResponseCache\Support\BaseStrategy; use function config; @@ -78,6 +79,12 @@ protected function registerCache(): void ->give(fn(Application $app) => static fn(): Config => $app->make( 'config', )); + + // Flushes are deferred until all open database transactions have ended + $this->app + ->when(ResponseCache::class) + ->needs('$afterTransactions') + ->give(fn(Application $app) => new AfterTransactions($app)); } protected function registerCommands(): void @@ -119,6 +126,10 @@ protected function registerRepository(): void ->make(Factory::class) ->store(config('response-cache.store')), ); + $this->app + ->when(Repository::class) + ->needs('$tagTtl') + ->give(fn(): int => (int) config('response-cache.tag_ttl')); $this->app->bind(Repository::class); $this->app->alias( diff --git a/src/Snapshot.php b/src/Snapshot.php new file mode 100644 index 0000000..1524152 --- /dev/null +++ b/src/Snapshot.php @@ -0,0 +1,27 @@ + + */ + +declare(strict_types=1); + +namespace Matchory\ResponseCache; + +/** + * Snapshot + * + * The tag versions captured before a response is built. An entry stamped with + * them is only valid as long as none of its tags has been flushed since. + * + * @bundle Matchory\ResponseCache + */ +final readonly class Snapshot +{ + /** + * @param array $versions Version key → token + */ + public function __construct(public array $versions) {} +} diff --git a/src/Support/AfterTransactions.php b/src/Support/AfterTransactions.php new file mode 100644 index 0000000..e01c53c --- /dev/null +++ b/src/Support/AfterTransactions.php @@ -0,0 +1,102 @@ + + */ + +declare(strict_types=1); + +namespace Matchory\ResponseCache\Support; + +use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Database\DatabaseTransactionRecord; +use Illuminate\Support\Collection; +use Throwable; + +/** + * After Transactions + * + * Runs a callback once all open database transactions, on any connection, have + * ended. It runs after a rollback, too: the data the callback is about may have + * been written outside the transaction, or on another connection, so skipping + * it could never be safe. + * If no transaction is open, the callback runs immediately and its exceptions + * propagate. A deferred callback runs while Laravel finishes the transaction, + * so its exceptions are reported instead of interrupting the commit. + * + * @bundle Matchory\ResponseCache + */ +readonly class AfterTransactions +{ + public function __construct(protected Container $container) {} + + public function __invoke(callable $callback): void + { + $transaction = $this->openTransactions()->last(); + + if ($transaction === null) { + $callback(); + + return; + } + + $this->defer($transaction, $callback); + } + + /** + * Defers a callback until a transaction ends, then until the transactions + * still open at that point have ended, and so on. + */ + protected function defer( + DatabaseTransactionRecord $transaction, + callable $callback, + ): void { + $ended = function () use ($callback): void { + $next = $this->openTransactions()->last(); + + if ($next !== null) { + $this->defer($next, $callback); + + return; + } + + try { + $callback(); + } catch (Throwable $exception) { + $this->container->make(ExceptionHandler::class)->report($exception); + } + }; + + $transaction->addCallback($ended); + $transaction->addCallbackForRollback($ended); + } + + /** + * Retrieves the transactions that are still open. Laravel lowers a + * connection's transaction level before it runs commit or rollback + * callbacks, or when a commit fails, but may still list the transaction + * as pending then. Transactions opened by testing traits are excluded. + * + * @return Collection + */ + protected function openTransactions(): Collection + { + // Resolved on every call, never once, for Octane + if (! $this->container->bound('db.transactions')) { + return new Collection(); + } + + $database = $this->container->make('db'); + + return $this->container + ->make('db.transactions') + ->callbackApplicableTransactions() + ->filter(fn(DatabaseTransactionRecord $transaction) => $database + ->connection($transaction->connection) + ->transactionLevel() >= $transaction->level) + ->values(); + } +} diff --git a/src/Support/BaseStrategy.php b/src/Support/BaseStrategy.php index 3e4a46f..0fafe62 100644 --- a/src/Support/BaseStrategy.php +++ b/src/Support/BaseStrategy.php @@ -37,12 +37,20 @@ public function key(Request $request): string return $this->hash($identifier . $suffix); } + /** + * {@inheritDoc} + */ + public function isCacheable(Request $request): bool + { + return $this->isMethodCachable($request); + } + /** * {@inheritDoc} */ public function shouldCache(Request $request, Response $response): bool { - if (! $this->isMethodCachable($request)) { + if (! $this->isCacheable($request)) { return false; } @@ -52,7 +60,7 @@ public function shouldCache(Request $request, Response $response): bool /** * {@inheritDoc} */ - public function tags(Request $request, ?Response $response = null): array + public function tags(Request $request): array { return []; } diff --git a/tests/Feature/DatabaseTransactionsTraitTest.php b/tests/Feature/DatabaseTransactionsTraitTest.php new file mode 100644 index 0000000..3002f4c --- /dev/null +++ b/tests/Feature/DatabaseTransactionsTraitTest.php @@ -0,0 +1,31 @@ +get('/users/1')->assertContent('1'); + + ResponseCache::flush(['users']); + + test()->get('/users/1')->assertContent('2'); +}); + +it('flushes after a commit inside a test transaction', function () { + test()->get('/users/1')->assertContent('1'); + + DB::transaction(fn() => ResponseCache::flush(['users'])); + + test()->get('/users/1')->assertContent('2'); +}); diff --git a/tests/Feature/InvalidationTest.php b/tests/Feature/InvalidationTest.php new file mode 100644 index 0000000..0a23a3b --- /dev/null +++ b/tests/Feature/InvalidationTest.php @@ -0,0 +1,306 @@ +app['cache']->flush(); +}); + +it('clears everything via the command', function (array $defaultTags) { + $this->app['config']->set('response-cache.tags', $defaultTags); + + test()->get('/cached')->assertContent('1'); + test()->get('/cached')->assertContent('1'); + + Artisan::call('response-cache:clear'); + + test()->get('/cached')->assertContent('2'); +})->with([ + 'without default tags' => [[]], + 'with default tags' => [['api']], +]); + +it('flushes one tag without clearing others when default tags are set', function () { + $this->app['config']->set('response-cache.tags', ['api']); + + test()->get('/users/1')->assertContent('1'); + test()->get('/users/2')->assertContent('2'); + + ResponseCache::flush(['user:2']); + + test()->get('/users/1')->assertContent('1'); + test()->get('/users/2')->assertContent('3'); +}); + +it('parses the leading middleware argument as TTL and the rest as tags', function () { + test()->get('/users/1')->assertContent('1'); + test()->get('/users/2')->assertContent('2'); + + ResponseCache::flush(['users']); + + test()->get('/users/1')->assertContent('3'); + test()->get('/users/2')->assertContent('4'); + + Carbon::setTestNow(Carbon::now()->addSeconds(61)); + + test()->get('/users/1')->assertContent('5'); + + Carbon::setTestNow(); +}); + +it('deletes a URL with default tags configured', function () { + $this->app['config']->set('response-cache.tags', ['api']); + + test()->get('/cached')->assertContent('1'); + + ResponseCache::delete('/cached'); + + test()->get('/cached')->assertContent('2'); +}); + +it('leaves foreign cache keys alone on flush-all', function () { + Cache::store('array')->put('foreign', 'x'); + + ResponseCache::flush(); + + expect(Cache::store('array')->get('foreign'))->toBe('x'); +}); + +it('never caches a response whose tags were flushed while it was built', function () { + test()->get('/racing')->assertContent('1'); + test()->get('/racing')->assertContent('2'); + test()->get('/racing')->assertContent('2'); +}); + +it('flushes after commit', function () { + test()->get('/users/1')->assertContent('1'); + + DB::transaction(function () { + ResponseCache::flush(['users']); + + test()->get('/users/1')->assertContent('1'); + }); + + test()->get('/users/1')->assertContent('2'); +}); + +it('flushes after rollback', function () { + test()->get('/users/1')->assertContent('1'); + + try { + DB::transaction(function () { + ResponseCache::flush(['users']); + + throw new RuntimeException('Rollback'); + }); + } catch (RuntimeException) { + } + + test()->get('/users/1')->assertContent('2'); +}); + +it('flushes when an unrelated transaction rolls back', function () { + test()->get('/users/1')->assertContent('1'); + + // The data was written outside the transaction that is open on another + // connection, so rolling that one back must not lose the flush + try { + DB::connection('other')->transaction(function () { + ResponseCache::flush(['users']); + + test()->get('/users/1')->assertContent('1'); + + throw new RuntimeException('Rollback'); + }); + } catch (RuntimeException) { + } + + test()->get('/users/1')->assertContent('2'); +}); + +it('flushes once transactions on all connections have ended', function () { + test()->get('/users/1')->assertContent('1'); + + DB::transaction(function () { + DB::connection('other')->transaction(function () { + ResponseCache::flush(['users']); + }); + + test()->get('/users/1')->assertContent('1'); + }); + + test()->get('/users/1')->assertContent('2'); +}); + +it('flushes after a nested transaction commits with its parent', function () { + test()->get('/users/1')->assertContent('1'); + + DB::transaction(function () { + DB::transaction(function () { + ResponseCache::flush(['users']); + }); + + test()->get('/users/1')->assertContent('1'); + }); + + test()->get('/users/1')->assertContent('2'); +}); + +it('waits for the parent when a nested transaction rolls back', function () { + test()->get('/users/1')->assertContent('1'); + + DB::transaction(function () { + try { + DB::transaction(function () { + ResponseCache::flush(['users']); + + throw new RuntimeException('Rollback'); + }); + } catch (RuntimeException) { + } + + test()->get('/users/1')->assertContent('1'); + }); + + test()->get('/users/1')->assertContent('2'); +}); + +it('flushes when a rollback callback flushes', function () { + test()->get('/users/1')->assertContent('1'); + + try { + DB::transaction(function () { + DB::afterRollBack(fn() => ResponseCache::flush(['users'])); + + throw new RuntimeException('Rollback'); + }); + } catch (RuntimeException) { + } + + test()->get('/users/1')->assertContent('2'); +}); + +it('does not defer flushes to a transaction whose commit failed', function () { + test()->get('/users/1')->assertContent('1'); + + // Laravel lowers the transaction level when a commit fails, but leaves the + // transaction pending in its manager + try { + DB::transaction(fn($connection) => $connection->getPdo()->rollBack()); + } catch (\PDOException) { + } + + ResponseCache::flush(['users']); + + test()->get('/users/1')->assertContent('2'); +}); + +it('reports a failed deferred flush without breaking the commit', function () { + Exceptions::fake(); + Cache::extend('rejecting', fn() => new Repository(new class extends ArrayStore { + public function put($key, $value, $seconds) + { + return false; + } + })); + $this->app['config']->set('cache.stores.rejecting', ['driver' => 'rejecting']); + $this->app['config']->set('response-cache.store', 'rejecting'); + $committed = false; + + DB::transaction(function () use (&$committed) { + ResponseCache::flush(['users']); + + DB::afterCommit(function () use (&$committed) { + $committed = true; + }); + }); + + expect($committed)->toBeTrue(); + Exceptions::assertReported(RuntimeException::class); +}); + +it('throws a failed flush outside transactions', function () { + Cache::extend('rejecting', fn() => new Repository(new class extends ArrayStore { + public function put($key, $value, $seconds) + { + return false; + } + })); + $this->app['config']->set('cache.stores.rejecting', ['driver' => 'rejecting']); + $this->app['config']->set('response-cache.store', 'rejecting'); + + ResponseCache::flush(['users']); +})->throws(RuntimeException::class); + +it('does not write to the store when reading', function () { + expect(ResponseCache::get(Request::create('/cached')))->toBeNull(); + + $keys = array_filter( + array_keys(Cache::store('array')->getStore()->all()), + static fn(string $key) => str_starts_with($key, 'response-cache:'), + ); + + expect($keys)->toBe([]); +}); + +it('flushes every audience and query variant', function () { + $user = (new User())->forceFill(['id' => 7]); + + test()->get('/users/1')->assertContent('1'); + test()->get('/users/1?a=1')->assertContent('2'); + test()->actingAs($user)->get('/users/1')->assertContent('3'); + test()->get('/users/1')->assertContent('3'); + + ResponseCache::flush(['user:1']); + + test()->get('/users/1')->assertContent('4'); + Auth::forgetUser(); + test()->get('/users/1')->assertContent('5'); + test()->get('/users/1?a=1')->assertContent('6'); +}); + +it('does not touch versions for non-cacheable requests', function () { + test()->post('/cached-post')->assertContent('1'); + + $keys = array_filter( + array_keys(Cache::store('array')->getStore()->all()), + static fn(string $key) => str_starts_with($key, 'response-cache:'), + ); + + expect($keys)->toBe([]); +}); + +it('works on a non-taggable store', function () { + $this->app['config']->set('response-cache.store', 'file'); + + try { + test()->get('/users/1')->assertContent('1'); + test()->get('/users/1')->assertContent('1'); + + ResponseCache::flush(['user:1']); + + test()->get('/users/1')->assertContent('2'); + } finally { + Cache::store('file')->flush(); + } +}); diff --git a/tests/Feature/StoreTest.php b/tests/Feature/StoreTest.php new file mode 100644 index 0000000..056640b --- /dev/null +++ b/tests/Feature/StoreTest.php @@ -0,0 +1,39 @@ +app['config']->set('cache.stores.failover', [ + 'driver' => 'failover', + 'stores' => ['array'], + ]); + $this->app['config']->set('response-cache.store', 'failover'); + + $this->app->make(Repository::class); +})->throws(InvalidArgumentException::class, 'failover'); + +it('refuses a tag TTL below one second', function (mixed $ttl) { + $this->app['config']->set('response-cache.tag_ttl', $ttl); + + $this->app->make(Repository::class); +})->with([0, '', null])->throws(InvalidArgumentException::class, 'tag_ttl'); + +it('caches responses when the store may not unserialize objects', function () { + // The default in new Laravel 13 applications + $this->app['config']->set('cache.serializable_classes', false); + $this->app['config']->set('response-cache.store', 'file'); + + try { + test()->get('/cached')->assertContent('1'); + test()->get('/cached')->assertContent('1')->assertHeader('Response-Cache-Status', 'hit'); + } finally { + Cache::store('file')->flush(); + } +}); diff --git a/tests/Feature/TagBindingTest.php b/tests/Feature/TagBindingTest.php index 4f5d161..b4ae716 100644 --- a/tests/Feature/TagBindingTest.php +++ b/tests/Feature/TagBindingTest.php @@ -22,6 +22,16 @@ test()->get('/tagged/2')->assertContent('3'); }); +it('resolves bound models with integer keys in tags', function () { + test()->get('/models/1')->assertContent('1'); + test()->get('/models/2')->assertContent('2'); + + ResponseCache::flush(['models.2']); + + test()->get('/models/1')->assertContent('1'); + test()->get('/models/2')->assertContent('3'); +}); + it('resolves query parameters in tags', function () { test()->get('/tagged/1?filter=a')->assertContent('1'); test()->get('/tagged/1?filter=b')->assertContent('2'); diff --git a/tests/TestCase.php b/tests/TestCase.php index 7f2a6d4..d05aec3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,11 @@ namespace Matchory\ResponseCache\Tests; +use Illuminate\Contracts\Routing\UrlRoutable; +use Illuminate\Routing\Middleware\SubstituteBindings; use InvalidArgumentException; use Matchory\ResponseCache\Http\Middleware\BypassCache; +use Matchory\ResponseCache\Facades\ResponseCache; use Matchory\ResponseCache\Http\Middleware\CacheResponse; use Matchory\ResponseCache\ResponseCacheProvider; use Orchestra\Testbench\TestCase as BaseTestCase; @@ -47,6 +50,67 @@ protected function defineRoutes($router): void }) ->middleware(CacheResponse::class . ':items.{item},filter.{filter}') ->name('tagged'); + // Binds like an Eloquent model with an integer key + $router->bind('model', fn(string $value) => new class ((int) $value) implements UrlRoutable { + public function __construct(private readonly int $key) {} + + public function getRouteKey(): int + { + return $this->key; + } + + public function getRouteKeyName(): string + { + return 'id'; + } + + public function resolveRouteBinding($value, $field = null): ?self + { + return null; + } + + public function resolveChildRouteBinding($childType, $value, $field): ?self + { + return null; + } + }); + $router + ->get('/models/{model}', function () { + static $i = 0; + + return ++$i; + }) + ->middleware([SubstituteBindings::class, CacheResponse::class . ':models.{model}']) + ->name('models'); + $router + ->get('/users/{user}', function () { + static $i = 0; + + return ++$i; + }) + ->middleware(CacheResponse::class . ':60,users,user:{user}') + ->name('users.show'); + $router + ->get('/racing', function () { + static $i = 0; + + // Simulates a write that flushes while the response is built + if (++$i === 1) { + ResponseCache::flush(['race']); + } + + return $i; + }) + ->middleware(CacheResponse::class . ':race') + ->name('racing'); + $router + ->post('/cached-post', function () { + static $i = 0; + + return ++$i; + }) + ->middleware(CacheResponse::class) + ->name('cached-post'); } protected function getPackageProviders($app): array @@ -59,5 +123,14 @@ protected function getPackageProviders($app): array protected function defineEnvironment($app): void { $app['config']->set('response-cache.store', 'array'); + $app['config']->set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $app['config']->set('database.connections.other', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); } } diff --git a/tests/Unit/RepositoryTest.php b/tests/Unit/RepositoryTest.php new file mode 100644 index 0000000..822cd8d --- /dev/null +++ b/tests/Unit/RepositoryTest.php @@ -0,0 +1,358 @@ + + */ + +declare(strict_types=1); + +namespace Matchory\ResponseCache\Tests\Unit; + +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\Repository as CacheRepositoryImplementation; +use Illuminate\Events\Dispatcher; +use Illuminate\Http\Response; +use Symfony\Component\HttpFoundation\Cookie; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Illuminate\Support\Carbon; +use Matchory\ResponseCache\Events\Flush; +use Matchory\ResponseCache\Repository; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RuntimeException; + +use function array_keys; +use function serialize; +use function str_repeat; + +#[CoversClass(Repository::class)] +class RepositoryTest extends TestCase +{ + private const TAG_TTL = 3600; + + private ArrayStore $arrayStore; + + private CacheRepositoryImplementation $store; + + private Dispatcher $events; + + /** + * @var Flush[] + */ + private array $flushes = []; + + protected function setUp(): void + { + $this->arrayStore = new ArrayStore(); + $this->store = new CacheRepositoryImplementation($this->arrayStore); + $this->events = new Dispatcher(); + $this->events->listen(Flush::class, function (Flush $event) { + $this->flushes[] = $event; + }); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + } + + #[Test] + public function snapshotCreatesMissingVersions(): void + { + $repository = $this->createRepository(); + $first = $repository->snapshot(['users']); + $second = $repository->snapshot(['users']); + + self::assertNotNull($first); + self::assertCount(2, $first->versions); + self::assertEquals($first, $second); + } + + #[Test] + public function flushChangesOnlyTheGivenTag(): void + { + $repository = $this->createRepository(); + $before = $repository->snapshot(['users', 'posts'])->versions; + $repository->flush(['users']); + $after = $repository->snapshot(['users', 'posts'])->versions; + + $users = $repository->versionKeyFor('users'); + $posts = $repository->versionKeyFor('posts'); + $global = $repository->versionKeyFor(null); + + self::assertNotSame($before[$users], $after[$users]); + self::assertSame($before[$posts], $after[$posts]); + self::assertSame($before[$global], $after[$global]); + } + + #[Test] + public function flushWithoutTagsChangesOnlyGlobalVersion(): void + { + $repository = $this->createRepository(); + $before = $repository->snapshot(['users'])->versions; + $repository->flush(); + $after = $repository->snapshot(['users'])->versions; + + $users = $repository->versionKeyFor('users'); + $global = $repository->versionKeyFor(null); + + self::assertSame($before[$users], $after[$users]); + self::assertNotSame($before[$global], $after[$global]); + } + + #[Test] + public function flushWithEmptyArrayIsNoop(): void + { + $repository = $this->createRepository(); + $before = $repository->snapshot(['users']); + $repository->flush([]); + + self::assertEquals($before, $repository->snapshot(['users'])); + self::assertSame([], $this->flushes); + } + + #[Test] + public function flushDispatchesEvent(): void + { + $repository = $this->createRepository(); + $repository->flush(['users']); + $repository->flush(); + + self::assertCount(2, $this->flushes); + self::assertSame(['users'], $this->flushes[0]->tags); + self::assertNull($this->flushes[1]->tags); + } + + #[Test] + public function flushThrowsWhenStoreRejectsWrite(): void + { + $this->store = new CacheRepositoryImplementation(new class extends ArrayStore { + public function put($key, $value, $seconds) + { + return false; + } + }); + $repository = $this->createRepository(); + + $this->expectException(RuntimeException::class); + + $repository->flush(['users']); + } + + #[Test] + public function versionKeysAreHashed(): void + { + $repository = $this->createRepository(); + $tag = str_repeat('a tag with spaces ', 20); + $before = $repository->snapshot([$tag])->versions; + $repository->flush([$tag]); + $after = $repository->snapshot([$tag])->versions; + $key = $repository->versionKeyFor($tag); + + self::assertNotSame($before[$key], $after[$key]); + + foreach (array_keys($this->arrayStore->all()) as $stored) { + self::assertMatchesRegularExpression( + '/^response-cache:tag:([0-9a-f]{40}|\*)$/', + $stored, + ); + } + } + + #[Test] + public function versionsAreWrittenWithTagTtl(): void + { + $repository = $this->createRepository(); + $before = $repository->snapshot(['users']); + + Carbon::setTestNow(Carbon::now()->addSeconds(self::TAG_TTL + 1)); + + self::assertNull($this->store->get($repository->versionKeyFor('users'))); + self::assertNotEquals($before, $repository->snapshot(['users'])); + } + + #[Test] + public function lookupMissesThenHits(): void + { + $repository = $this->createRepository(); + $response = new Response('foo'); + $lookup = $repository->lookup('key', ['users']); + + self::assertNull($lookup->response); + self::assertNotNull($lookup->snapshot); + + $repository->put('key', $response, $lookup->snapshot, 60); + + self::assertSame('foo', $repository->lookup('key', ['users'])->response?->getContent()); + } + + #[Test] + public function restoresStatusHeadersAndContent(): void + { + $repository = $this->createRepository(); + $response = new Response('{"a":1}', 201, ['Content-Type' => 'application/json', 'X-Custom' => 'yes']); + $response->headers->setCookie(Cookie::create('flavor', 'oat')); + $repository->put('key', $response, $repository->lookup('key', [])->snapshot, 60); + + $cached = $repository->lookup('key', [])->response; + + self::assertInstanceOf(Response::class, $cached); + self::assertSame(201, $cached->getStatusCode()); + self::assertSame('{"a":1}', $cached->getContent()); + self::assertSame('application/json', $cached->headers->get('Content-Type')); + self::assertSame('yes', $cached->headers->get('X-Custom')); + self::assertSame('oat', $cached->headers->getCookies()[0]->getValue()); + } + + #[Test] + public function storesPlainDataOnly(): void + { + $repository = $this->createRepository(); + $repository->put('key', new Response('foo'), $repository->lookup('key', [])->snapshot, 60); + + // Stores that may not unserialize objects must still be able to read it + $stored = serialize($this->store->get('response-cache:entry:key')); + + self::assertStringNotContainsString('O:', $stored); + } + + #[Test] + public function doesNotCacheResponsesWithoutContent(): void + { + $repository = $this->createRepository(); + $repository->put('key', new StreamedResponse(fn() => null), $repository->lookup('key', [])->snapshot, 60); + + self::assertNull($repository->lookup('key', [])->response); + } + + #[Test] + public function entriesInAnotherFormatMiss(): void + { + $repository = $this->createRepository(); + $snapshot = $repository->lookup('key', [])->snapshot; + $this->store->put('response-cache:entry:key', [ + 'versions' => $snapshot->versions, + 'response' => new Response('foo'), + ], 60); + + self::assertNull($repository->lookup('key', [])->response); + } + + #[Test] + public function flushInvalidatesEntry(): void + { + $repository = $this->createRepository(); + $this->putEntry($repository, ['users', 'user:42']); + $repository->flush(['user:42']); + + self::assertNull($repository->lookup('key', ['users', 'user:42'])->response); + } + + #[Test] + public function flushAllInvalidatesEntryButNotForeignKeys(): void + { + $repository = $this->createRepository(); + $this->store->put('foreign', 'x', 60); + $this->putEntry($repository, ['users']); + $repository->flush(); + + self::assertNull($repository->lookup('key', ['users'])->response); + self::assertSame('x', $this->store->get('foreign')); + } + + #[Test] + public function staleSnapshotIsNeverValid(): void + { + $repository = $this->createRepository(); + $lookup = $repository->lookup('key', ['users']); + $repository->flush(['users']); + $repository->put('key', new Response('foo'), $lookup->snapshot, 60); + + self::assertNull($repository->lookup('key', ['users'])->response); + } + + #[Test] + public function differentTagSetMisses(): void + { + $repository = $this->createRepository(); + $this->putEntry($repository, ['a']); + + self::assertNull($repository->lookup('key', ['a', 'b'])->response); + } + + #[Test] + public function evictedVersionMisses(): void + { + $repository = $this->createRepository(); + $lookup = $repository->lookup('key', ['users']); + $this->store->forget($repository->versionKeyFor('users')); + $repository->put('key', new Response('foo'), $lookup->snapshot, 60); + + self::assertNull($repository->lookup('key', ['users'])->response); + } + + #[Test] + public function entryOutlivingTagTtlMisses(): void + { + $repository = $this->createRepository(); + $this->putEntry($repository, ['users'], self::TAG_TTL * 2); + + Carbon::setTestNow(Carbon::now()->addSeconds(self::TAG_TTL + 1)); + + self::assertNull($repository->lookup('key', ['users'])->response); + } + + #[Test] + public function entryWithoutTtlExpiresWithTagTtl(): void + { + $repository = $this->createRepository(); + $snapshot = $repository->lookup('key', ['users'])->snapshot; + $repository->put('key', new Response('foo'), $snapshot, null); + + Carbon::setTestNow(Carbon::now()->addSeconds(self::TAG_TTL + 1)); + + self::assertNull($this->store->get('response-cache:entry:key')); + } + + #[Test] + public function deleteRemovesEntry(): void + { + $repository = $this->createRepository(); + $this->putEntry($repository, ['users']); + $repository->delete('key'); + + self::assertNull($repository->lookup('key', ['users'])->response); + } + + /** + * @param string[] $tags + */ + private function putEntry(Repository $repository, array $tags, int $ttl = 60): void + { + $snapshot = $repository->lookup('key', $tags)->snapshot; + $repository->put('key', new Response('foo'), $snapshot, $ttl); + + self::assertNotNull($repository->lookup('key', $tags)->response); + } + + private function createRepository(): TestRepository + { + return new TestRepository($this->store, $this->events, self::TAG_TTL); + } +} + +/** + * Exposes the version key derivation to the tests. + */ +readonly class TestRepository extends Repository +{ + public function versionKeyFor(?string $tag): string + { + return $tag === null + ? self::GLOBAL_VERSION_KEY + : $this->versionKey($tag); + } +} diff --git a/tests/Unit/ResponseCacheTest.php b/tests/Unit/ResponseCacheTest.php index 3039dac..a406482 100644 --- a/tests/Unit/ResponseCacheTest.php +++ b/tests/Unit/ResponseCacheTest.php @@ -64,16 +64,81 @@ public function cachesResponses(): void { $responseCache = $this->createResponseCache(); $response = new Response('foo'); + $lookup = $responseCache->lookup(Request::create('/')); - self::assertFalse($responseCache->has(Request::create('/'))); - $responseCache->put(Request::create('/'), $response); - self::assertTrue($responseCache->has(Request::create('/'))); + self::assertNull($lookup->response); + self::assertNotNull($lookup->snapshot); + $responseCache->put(Request::create('/'), $response, $lookup->snapshot); self::assertSame( - $response, - $responseCache->get(Request::create('/')), + 'foo', + $responseCache->get(Request::create('/'))?->getContent(), ); } + /** + * @throws BadMethodCallException + * @throws ClassIsEnumerationException + * @throws ClassIsFinalException + * @throws DuplicateMethodException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws InvalidMethodNameException + * @throws NameAlreadyInUseException + * @throws ReflectionException + * @throws RuntimeException + * @throws UnknownTypeException + * @throws \Psr\SimpleCache\InvalidArgumentException + * @throws BadRequestException + */ + #[Test] + public function flushIsDeferredUntilTransactionsEnd(): void + { + $deferred = []; + $responseCache = $this->createResponseCache( + afterTransactions: static function (callable $callback) use (&$deferred) { + $deferred[] = $callback; + }, + ); + $request = Request::create('/'); + $snapshot = $responseCache->snapshot($request, ['users']); + $responseCache->put($request, new Response('foo'), $snapshot); + + $responseCache->flush(['users']); + self::assertNotNull($responseCache->get($request, ['users'])); + self::assertCount(1, $deferred); + + $deferred[0](); + self::assertNull($responseCache->get($request, ['users'])); + } + + /** + * @throws BadMethodCallException + * @throws ClassIsEnumerationException + * @throws ClassIsFinalException + * @throws DuplicateMethodException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws InvalidMethodNameException + * @throws NameAlreadyInUseException + * @throws ReflectionException + * @throws RuntimeException + * @throws UnknownTypeException + */ + #[Test] + public function flushWithEmptyArrayDoesNothing(): void + { + $deferred = []; + $responseCache = $this->createResponseCache( + afterTransactions: static function (callable $callback) use (&$deferred) { + $deferred[] = $callback; + }, + ); + + $responseCache->flush([]); + + self::assertSame([], $deferred); + } + /** * @throws BadMethodCallException * @throws BadRequestException @@ -95,7 +160,12 @@ public function addsStatusHeader(): void $responseCache = $this->createResponseCache([ 'response-cache.cache_status_enabled' => true, ]); - $responseCache->put(Request::create('/'), new Response('foo')); + $request = Request::create('/'); + $responseCache->put( + $request, + new Response('foo'), + $responseCache->snapshot($request), + ); self::assertSame( 'hit', @@ -106,6 +176,46 @@ public function addsStatusHeader(): void ); } + #[Test] + public function acceptsTtlFromEnvironmentAsString(): void + { + $responseCache = $this->createResponseCache([ + 'response-cache.ttl' => '42', + ]); + $request = Request::create('/'); + $responseCache->put( + $request, + new Response('foo'), + $responseCache->snapshot($request), + ); + + self::assertNotNull($responseCache->get(Request::create('/'))); + } + + #[Test] + public function acceptsEnabledFromEnvironmentAsString(): void + { + foreach (['1' => true, 'on' => true, '0' => false, 'off' => false] as $value => $expected) { + $responseCache = $this->createResponseCache([ + 'response-cache.enabled' => (string) $value, + ]); + + self::assertSame($expected, $responseCache->enabled()); + } + } + + #[Test] + public function treatsUnsetOrInvalidEnabledAsEnabled(): void + { + foreach ([null, 'banana'] as $value) { + $responseCache = $this->createResponseCache([ + 'response-cache.enabled' => $value, + ]); + + self::assertTrue($responseCache->enabled()); + } + } + /** * @param array|null $values * @return callable(): Config @@ -148,7 +258,7 @@ private function createRepository(): Repository $cache = new CacheRepositoryImplementation(new ArrayStore()); $dispatcher = $this->getMockBuilder(Dispatcher::class)->getMock(); - return new Repository($cache, $dispatcher); + return new Repository($cache, $dispatcher, 3600); } /** @@ -183,8 +293,10 @@ private function createStrategy(): BaseStrategy * @throws RuntimeException * @throws UnknownTypeException */ - private function createResponseCache(?array $config = null): ResponseCache - { + private function createResponseCache( + ?array $config = null, + ?callable $afterTransactions = null, + ): ResponseCache { $config ??= [ 'response-cache.enabled' => true, 'response-cache.ttl' => 42, @@ -196,8 +308,11 @@ private function createResponseCache(?array $config = null): ResponseCache $cache = $this->createRepository(); $strategy = $this->createStrategy(); + $afterTransactions ??= static fn(callable $callback) => $callback(); + return new ResponseCache( $configResolver, + $afterTransactions, $urlGenerator, $cache, $strategy, diff --git a/tests/Unit/Support/BaseStrategyTest.php b/tests/Unit/Support/BaseStrategyTest.php index e85efbb..ba4681f 100644 --- a/tests/Unit/Support/BaseStrategyTest.php +++ b/tests/Unit/Support/BaseStrategyTest.php @@ -179,4 +179,56 @@ public function id(): int self::assertSame('9350d47085691dcd1698e672fb894b45', $key); } + + /** + * @throws BadRequestException + * @throws ClassIsEnumerationException + * @throws ClassIsFinalException + * @throws DuplicateMethodException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws InvalidMethodNameException + * @throws NameAlreadyInUseException + * @throws ReflectionException + * @throws RuntimeException + * @throws UnknownTypeException + */ + public function testIsCacheableForSafeMethods(): void + { + $mock = $this->getMockBuilder(AuthManager::class); + $mock->disableOriginalConstructor(); + $auth = $mock->getMock(); + $strategy = new BaseStrategy($auth); + + foreach (['GET', 'HEAD'] as $method) { + self::assertTrue($strategy->isCacheable(Request::create('/', $method))); + } + + foreach (['POST', 'PUT', 'PATCH', 'DELETE'] as $method) { + self::assertFalse($strategy->isCacheable(Request::create('/', $method))); + } + } + + /** + * @throws BadRequestException + * @throws ClassIsEnumerationException + * @throws ClassIsFinalException + * @throws DuplicateMethodException + * @throws ExpectationFailedException + * @throws InvalidArgumentException + * @throws InvalidMethodNameException + * @throws NameAlreadyInUseException + * @throws ReflectionException + * @throws RuntimeException + * @throws UnknownTypeException + */ + public function testTagsAreEmptyByDefault(): void + { + $mock = $this->getMockBuilder(AuthManager::class); + $mock->disableOriginalConstructor(); + $auth = $mock->getMock(); + $strategy = new BaseStrategy($auth); + + self::assertSame([], $strategy->tags(Request::create('/'))); + } }