Skip to content

Commit df322aa

Browse files
authored
Allow cache_flexible() to use a callback (#834)
* Allow cache_flexible() to use a callback * Rector fix * Test for ok * CHANGELOG * Allow key customization for http client caching * CHANGELOG
1 parent 3c3ed70 commit df322aa

File tree

7 files changed

+140
-38
lines changed

7 files changed

+140
-38
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
### Added
11+
12+
- Added closure support for the stale and expire TTLs in the flexible cache
13+
middleware.
14+
- Added custom cache key support to the HTTP Client cache middleware and flexible cache
15+
middleware.
16+
817
## v1.14.0
918

1019
### Added

rector.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
use Rector\EarlyReturn\Rector\If_\ChangeOrIfContinueToMultiContinueRector;
2222
use Rector\EarlyReturn\Rector\Return_\ReturnBinaryOrToEarlyReturnRector;
23+
use Rector\Php70\Rector\StmtsAwareInterface\IfIssetToCoalescingRector;
2324
use Rector\Php71\Rector\FuncCall\RemoveExtraParametersRector;
2425
use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector;
2526
use Rector\Php80\Rector\NotIdentical\StrContainsRector;
@@ -124,4 +125,5 @@
124125
FlipTypeControlToUseExclusiveTypeRector::class,
125126
FunctionLikeToFirstClassCallableRector::class,
126127
RemoveNullArgOnNullDefaultParamRector::class,
128+
IfIssetToCoalescingRector::class,
127129
] );

src/mantle/http-client/class-cache-flexible-middleware.php

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ class Cache_Flexible_Middleware extends Cache_Middleware {
3232
/**
3333
* Constructor.
3434
*
35-
* @param int|\DateInterval|\DateTimeInterface $stale Time to consider a cached response stale.
36-
* @param int|\DateInterval|\DateTimeInterface $expire Time to consider a cached response expired.
35+
* @param int|\DateInterval|\DateTimeInterface|\Closure $stale Time to consider a cached response stale.
36+
* @param int|\DateInterval|\DateTimeInterface|\Closure $expire Time to consider a cached response expired.
37+
* @param string|null $key Cache key to use.
3738
*/
38-
public function __construct( protected int|\DateInterval|\DateTimeInterface $stale, protected int|\DateInterval|\DateTimeInterface $expire ) {}
39+
public function __construct( protected int|\DateInterval|\DateTimeInterface|\Closure $stale, protected int|\DateInterval|\DateTimeInterface|\Closure $expire, public readonly ?string $key = null ) {}
3940

4041
/**
4142
* Invoke the middleware.
@@ -57,7 +58,9 @@ public function __invoke( Pending_Request $request, Closure $next ): Response {
5758
if ( $cache->is_stale() ) {
5859
$fresh_request = ( clone $request )->without_middleware( Cache_Middleware::class );
5960

60-
defer( fn () => $this->store_response( $fresh_request->send() ) );
61+
defer(
62+
fn () => $this->store_response( $fresh_request, $fresh_request->send() ),
63+
);
6164
}
6265

6366
$response->cached = true;
@@ -69,15 +72,13 @@ public function __invoke( Pending_Request $request, Closure $next ): Response {
6972

7073
assert( $response instanceof Response );
7174

72-
$this->store_response( $response );
75+
$this->store_response( request: $request, response: $response );
7376

7477
return $response;
7578
}
7679

7780
/**
7881
* Retrieve a cached response if available.
79-
*
80-
* @return SWR_Storage|null Cached response or null if not found.
8182
*/
8283
private function get_cached_response(): ?SWR_Storage {
8384
try {
@@ -98,9 +99,18 @@ private function get_cached_response(): ?SWR_Storage {
9899
*
99100
* @throws \InvalidArgumentException If the stale time is not less than the expire time.
100101
*
101-
* @param Response $response Response to store.
102+
* @param Pending_Request $request Request associated with the response.
103+
* @param Response $response Response to store.
102104
*/
103-
private function store_response( Response $response ): bool {
105+
private function store_response( Pending_Request $request, Response $response ): bool {
106+
if ( $this->stale instanceof Closure ) {
107+
$this->stale = $this->invoke_expiration_callback( $this->stale, $request, $response );
108+
}
109+
110+
if ( $this->expire instanceof Closure ) {
111+
$this->expire = $this->invoke_expiration_callback( $this->expire, $request, $response );
112+
}
113+
104114
$stale_time = normalize_cache_ttl( $this->stale );
105115
$expire_time = normalize_cache_ttl( $this->expire );
106116

src/mantle/http-client/class-cache-middleware.php

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,10 @@ class Cache_Middleware {
2626
/**
2727
* Constructor.
2828
*
29-
* @throws \InvalidArgumentException If the TTL is not valid.
30-
*
31-
* @param int|DateTimeInterface|callable $ttl Time to live for the cache.
29+
* @param int|DateTimeInterface|Closure $ttl Time to live for the cache.
30+
* @param string|null $key Cache key to use.
3231
*/
33-
public function __construct( protected mixed $ttl ) {
34-
if ( ! is_int( $ttl ) && ! $ttl instanceof DateTimeInterface && ! is_callable( $ttl ) ) { // @phpstan-ignore-line
35-
throw new \InvalidArgumentException(
36-
'TTL must be an integer, DateTimeInterface, or a callable that returns an integer.'
37-
);
38-
}
39-
}
32+
public function __construct( protected int|DateTimeInterface|Closure $ttl, public readonly ?string $key = null ) {}
4033

4134
/**
4235
* Invoke the middleware.
@@ -77,7 +70,7 @@ public function purge( Pending_Request $request ): bool {
7770
* @param Pending_Request $request Request to retrieve the cache key for.
7871
*/
7972
protected function get_cache_key( Pending_Request $request ): string {
80-
return md5( (string) json_encode( [ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
73+
return $this->key ?? md5( (string) json_encode( [ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
8174
$request->base_url(),
8275
$request->url(),
8376
$request->method(),
@@ -89,24 +82,35 @@ protected function get_cache_key( Pending_Request $request ): string {
8982
/**
9083
* Calculate the time to live for the cache in seconds.
9184
*
92-
* @throws \InvalidArgumentException If the TTL callback returns an invalid value.
93-
*
9485
* @param Pending_Request $request Request to calculate the TTL for.
9586
* @param Response $response Response to calculate the TTL for.
9687
*/
9788
private function calculate_ttl( Pending_Request $request, Response $response ): int {
98-
if ( is_callable( $this->ttl ) ) {
99-
$callback = $this->ttl;
89+
$ttl = $this->ttl;
10090

101-
$value = $callback( $request, $response );
91+
if ( $ttl instanceof Closure ) {
92+
$ttl = $this->invoke_expiration_callback( $ttl, $request, $response );
93+
}
10294

103-
if ( ! is_numeric( $value ) || (int) $value < 0 ) {
104-
throw new \InvalidArgumentException( 'TTL callback must return a non-negative integer.' );
105-
}
95+
return normalize_cache_ttl( $ttl );
96+
}
97+
98+
/**
99+
* Invoke the callback with the request and response and validate the return type.
100+
*
101+
* @throws \InvalidArgumentException If the callback does not return an integer or DateTimeInterface.
102+
*
103+
* @param Closure $callback Callback to invoke.
104+
* @param Pending_Request $request Request to pass to the callback.
105+
* @param Response $response Response to pass to the callback.
106+
*/
107+
protected function invoke_expiration_callback( Closure $callback, Pending_Request $request, Response $response ): int|DateTimeInterface {
108+
$value = $callback( $request, $response );
106109

107-
return (int) $value;
110+
if ( ! is_int( $value ) && ! $value instanceof DateTimeInterface ) {
111+
throw new \InvalidArgumentException( 'Callback must return an integer or DateTimeInterface.' );
108112
}
109113

110-
return normalize_cache_ttl( $this->ttl );
114+
return $value;
111115
}
112116
}

src/mantle/http-client/concerns/trait-caches-requests.php

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
namespace Mantle\Http_Client\Concerns;
99

10+
use Closure;
1011
use DateTimeInterface;
1112
use InvalidArgumentException;
1213
use Mantle\Http_Client\Cache_Flexible_Middleware;
@@ -26,13 +27,14 @@ trait Caches_Requests {
2627
/**
2728
* Enable caching for the request.
2829
*
29-
* @param int|DateTimeInterface|callable $ttl Time to live for the cache.
30-
* @phpstan-param int|DateTimeInterface|(callable(Pending_Request $request, Response $response): int) $ttl
30+
* @param int|DateTimeInterface|Closure $ttl Time to live for the cache.
31+
* @param string|null $key Cache key to use.
32+
* @phpstan-param int|DateTimeInterface|(Closure(Pending_Request $request, Response $response): int|DateTimeInterface) $ttl
3133
*/
32-
public function cache( int|DateTimeInterface|callable $ttl = 3600 ): static {
34+
public function cache( int|DateTimeInterface|Closure $ttl = 3600, ?string $key = null ): static {
3335
return $this
3436
->filter_middleware( fn ( callable $middleware ) => ! $middleware instanceof Cache_Middleware )
35-
->prepend_middleware( new Cache_Middleware( $ttl ) );
37+
->prepend_middleware( new Cache_Middleware( $ttl, $key ) );
3638
}
3739

3840
/**
@@ -41,13 +43,17 @@ public function cache( int|DateTimeInterface|callable $ttl = 3600 ): static {
4143
* This will use a stale-while-revalidate strategy to return a cached response if it exists,
4244
* even if it is stale, while refreshing the cache in the background.
4345
*
44-
* @param int|\DateInterval|\DateTimeInterface $stale Time to consider a cached response stale.
45-
* @param int|\DateInterval|\DateTimeInterface $expire Time to consider a cached response expired.
46+
* @param int|\DateInterval|\DateTimeInterface|Closure $stale Time to consider a cached response stale.
47+
* @param int|\DateInterval|\DateTimeInterface|Closure $expire Time to consider a cached response expired.
48+
* @param string|null $key Cache key to use.
49+
*
50+
* @phpstan-param int|DateTimeInterface|(Closure(Pending_Request $request, Response $response): int|DateTimeInterface) $stale
51+
* @phpstan-param int|DateTimeInterface|(Closure(Pending_Request $request, Response $response): int|DateTimeInterface) $expire
4652
*/
47-
public function cache_flexible( int|\DateInterval|\DateTimeInterface $stale, int|\DateInterval|\DateTimeInterface $expire ): static {
53+
public function cache_flexible( int|\DateInterval|\DateTimeInterface|Closure $stale, int|\DateInterval|\DateTimeInterface|Closure $expire, ?string $key = null ): static {
4854
return $this
4955
->filter_middleware( fn ( callable $middleware ) => ! $middleware instanceof Cache_Middleware )
50-
->prepend_middleware( new Cache_Flexible_Middleware( $stale, $expire ) );
56+
->prepend_middleware( new Cache_Flexible_Middleware( $stale, $expire, $key ) );
5157
}
5258

5359
/**

tests/HttpClient/CachedHttpClientTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,73 @@ public function test_it_throws_an_exception_when_passing_a_lower_stale_time_than
199199

200200
$this->client->get( 'https://example.com' );
201201
}
202+
203+
public function test_it_can_cache_flexible_with_a_callback(): void {
204+
$stale_called = false;
205+
$expire_called = false;
206+
207+
$this->client = Factory::create()->cache_flexible(
208+
stale: function ( Pending_Request $request, Response $response ) use ( &$stale_called ): \DateTimeInterface {
209+
$stale_called = true;
210+
211+
$this->assertEquals( 'https://example.com', $request->url() );
212+
213+
return now()->addHour();
214+
},
215+
expire: function ( Pending_Request $request, Response $response ) use ( &$expire_called ): \DateTimeInterface {
216+
$expire_called = true;
217+
218+
$this->assertEquals( 'https://example.com', $request->url() );
219+
220+
return now()->addDay();
221+
},
222+
);
223+
224+
$i = 0;
225+
226+
$this->fake_request( function () use ( &$i ) {
227+
$i++;
228+
229+
return mock_http_response()->with_json( [ 'request' => $i ] );
230+
} );
231+
232+
$this->client->get( 'https://example.com' );
233+
$this->client->get( 'https://example.com' );
234+
235+
$this->assertRequestCount( 1 );
236+
$this->assertTrue( $stale_called );
237+
$this->assertTrue( $expire_called );
238+
}
239+
240+
public function test_it_can_use_a_custom_cache_key(): void {
241+
$this->client = Factory::create()->cache( key: 'custom-cache-key' );
242+
243+
$this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) );
244+
245+
$this->assertEmpty( wp_cache_get( 'custom-cache-key', Cache_Middleware::CACHE_GROUP ) );
246+
247+
$this->client->get( 'https://example.com' );
248+
$this->client->get( 'https://example.com' );
249+
250+
$this->assertRequestCount( 1 );
251+
$this->assertNotEmpty( wp_cache_get( 'custom-cache-key', Cache_Middleware::CACHE_GROUP ) );
252+
}
253+
254+
public function test_it_can_use_a_custom_cache_key_with_flexible_caching(): void {
255+
$this->client = Factory::create()->cache_flexible(
256+
stale: now()->addHour(),
257+
expire: now()->addDay(),
258+
key: 'custom-flexible-cache-key',
259+
);
260+
261+
$this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) );
262+
263+
$this->assertEmpty( wp_cache_get( 'custom-flexible-cache-key', Cache_Middleware::CACHE_GROUP ) );
264+
265+
$this->client->get( 'https://example.com' );
266+
$this->client->get( 'https://example.com' );
267+
268+
$this->assertRequestCount( 1 );
269+
$this->assertNotEmpty( wp_cache_get( 'custom-flexible-cache-key', Cache_Middleware::CACHE_GROUP ) );
270+
}
202271
}

tests/HttpClient/HttpClientTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,8 @@ public function test_wp_error_response() {
321321

322322
$this->assertTrue( $response->is_wp_error() );
323323
$this->assertTrue( $response->failed() );
324+
$this->assertFalse( $response->successful() );
325+
$this->assertFalse( $response->ok() );
324326

325327
$this->assertEquals( 'An error occurred.', $response->body() );
326328
}

0 commit comments

Comments
 (0)