diff --git a/CHANGELOG.md b/CHANGELOG.md index e724f17a..bcd6ef8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added dynamic creation of post type/taxonomy factories. - Added `Reset_Server` trait to reset the server between tests. - Add `with_https()` to control if the request being tested is over HTTPS. +- Add cached HTTP response support using the `cache()` method. ### Changed +- **Breaking:** Http Client pools should now be built using `->method()` and `->url()` instead. - Dropped support for Redis as a cache backend in favor of the default object cache drop-in. - Allow returning falsey from `Collection::map_to_dictionary()`. diff --git a/src/mantle/http-client/autoload.php b/src/mantle/http-client/autoload.php index c68b2ef2..c2eb8ff8 100644 --- a/src/mantle/http-client/autoload.php +++ b/src/mantle/http-client/autoload.php @@ -12,6 +12,8 @@ declare( strict_types=1 ); +namespace Mantle\Http_Client; + use Mantle\Http_Client\Pending_Request; use Mantle\Http_Client\Response; diff --git a/src/mantle/http-client/class-cache-middleware.php b/src/mantle/http-client/class-cache-middleware.php new file mode 100644 index 00000000..41ebfa69 --- /dev/null +++ b/src/mantle/http-client/class-cache-middleware.php @@ -0,0 +1,95 @@ +get_cache_key( $request ); + $cache = wp_cache_get( $cache_key, self::CACHE_GROUP ); + + if ( $cache && $cache instanceof Response ) { + return $cache; + } + + $response = $next( $request ); + + wp_cache_set( $cache_key, $response, self::CACHE_GROUP, $this->calculate_ttl( $request ) ); // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined + + return $response; + } + + /** + * Purge the cache for a request. + * + * @param Pending_Request $request Request to purge the cache for. + */ + public function purge( Pending_Request $request ): bool { + return wp_cache_delete( $this->get_cache_key( $request ), self::CACHE_GROUP ); + } + + /** + * Retrieve the cache key for the request. + * + * @param Pending_Request $request Request to retrieve the cache key for. + */ + protected function get_cache_key( Pending_Request $request ): string { + return md5( json_encode( [ // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode + $request->base_url(), + $request->url(), + $request->method(), + $request->body(), + $request->headers(), + ] ) ); + } + + /** + * Calculate the time to live for the cache in seconds. + * + * @param Pending_Request $request Request to calculate the TTL for. + */ + protected function calculate_ttl( Pending_Request $request ): int { + if ( is_int( $this->ttl ) ) { + return $this->ttl; + } + + if ( $this->ttl instanceof DateTimeInterface ) { + return $this->ttl->getTimestamp() - time(); + } + + $callback = $this->ttl; + + return (int) $callback( $request ); + } +} diff --git a/src/mantle/http-client/class-pending-request.php b/src/mantle/http-client/class-pending-request.php index e655f110..766c6df5 100644 --- a/src/mantle/http-client/class-pending-request.php +++ b/src/mantle/http-client/class-pending-request.php @@ -2,15 +2,20 @@ /** * Pending_Request class file * + * phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag, Squiz.Commenting.FunctionComment.ParamNameNoMatch + * * @package Mantle */ namespace Mantle\Http_Client; +use DateTimeInterface; +use InvalidArgumentException; use Mantle\Support\Pipeline; use Mantle\Support\Traits\Conditionable; use Mantle\Support\Traits\Macroable; +use function Mantle\Support\Helpers\collect; use function Mantle\Support\Helpers\retry; use function Mantle\Support\Helpers\tap; @@ -29,7 +34,7 @@ class Pending_Request { /** * Method for the request. */ - public string $method; + public Http_Method $method = Http_Method::GET; /** * URL for the request. @@ -102,12 +107,59 @@ public function as_json(): static { ->content_type( 'application/json' ); } + /** + * Enable caching for the request. + * + * @param int|DateTimeInterface|callable(Pending_Request $request): int $ttl Time to live for the cache. + */ + public function cache( int|DateTimeInterface|callable $ttl = 3600 ): static { + // Check if there is a caching middleware. + if ( collect( $this->middleware )->contains( fn ( $middleware ) => $middleware instanceof Cache_Middleware ) ) { + return $this; + } + + return $this->prepend_middleware( new Cache_Middleware( $ttl ) ); + } + + /** + * Purge the cache for the request. + * + * @throws InvalidArgumentException If the request has no URL or is not cached. + * + * @param string|null $url URL to purge, optional. + * @param string|Http_Method|null $method Method to purge, optional. + */ + public function purge( ?string $url = null, string|Http_Method|null $method = null ): bool { + if ( ! is_null( $url ) ) { + $this->url( $url ); + } + + if ( ! is_null( $method ) ) { + $this->method( $method ); + } + + if ( empty( $this->url ) ) { + throw new InvalidArgumentException( 'Cannot purge cache for a request that has no URL. Call url() first.' ); + } + $middleware = collect( $this->middleware )->first( fn ( $middleware ) => $middleware instanceof Cache_Middleware ); + + if ( ! $middleware ) { + throw new InvalidArgumentException( 'Cannot purge cache for a request that is not cached. Call cache() first.' ); + } + + return $middleware->purge( $this ); + } + /** * Set the base URL for the pending request. * - * @param string $url Base URL. + * @param string|null $url Base URL. */ - public function base_url( string $url ): static { + public function base_url( string $url = null ): static|string { + if ( is_null( $url ) ) { + return $this->base_url; + } + $this->base_url = $url; return $this; @@ -116,15 +168,14 @@ public function base_url( string $url ): static { /** * Set or get the URL for the request. * - * @param string $url URL for the request, optional. - * @return static|string + * @param string|null $url URL for the request, optional. */ - public function url( string $url = null ) { + public function url( string|null $url = null ): static|string { if ( is_null( $url ) ) { return $this->url; } - $this->url = $url; + $this->url = ltrim( rtrim( $this->base_url, '/' ) . '/' . ltrim( $url, '/' ), '/' ); return $this; } @@ -132,14 +183,17 @@ public function url( string $url = null ) { /** * Set or get the method for the request. * - * @param string $method Http Method for the request, optional. - * @return static|string + * @param string|Http_Method|null $method Http Method for the request, optional. */ - public function method( string $method = null ) { + public function method( string|Http_Method|null $method = null ): static|Http_Method { if ( is_null( $method ) ) { return $this->method; } + if ( is_string( $method ) ) { + $method = Http_Method::from( strtoupper( $method ) ); + } + $this->method = $method; return $this; @@ -395,20 +449,40 @@ public function timeout( int $seconds ): static { } /** - * Add middleware for the request. + * Add middleware for the request to the end of the stack. * * @param callable $middleware Middleware to call. */ - public function middleware( $middleware ): static { + public function middleware( callable $middleware ): static { $this->middleware[] = $middleware; + + return $this; + } + + /** + * Prepend middleware for the request to the beginning of the stack. + * + * @param callable $middleware Middleware to call. + */ + public function prepend_middleware( callable $middleware ): static { + array_unshift( $this->middleware, $middleware ); + return $this; } + /** + * Retrieve the middleware for the request. + */ + public function get_middleware(): array { + return $this->middleware; + } + /** * Clear all middleware for the request. */ public function without_middleware(): static { $this->middleware = []; + return $this; } @@ -466,13 +540,18 @@ public function dont_throw_exception(): static { /** * Issue a GET request to the given URL. * + * @throws InvalidArgumentException If the request is pooled. + * * @param string $url URL to retrieve. * @param array|string|null $query Query parameters (assumed to be urlencoded). - * @return Response|static */ - public function get( string $url, array|string|null $query = null ) { + public function get( string $url, array|string|null $query = null ): Response { + if ( $this->pooled ) { + throw new InvalidArgumentException( 'Cannot call get() on a pooled request.' ); + } + return $this->send( - 'GET', + Http_Method::GET, $url, ! is_null( $query ) ? [ 'query' => $query ] : [], ); @@ -481,13 +560,18 @@ public function get( string $url, array|string|null $query = null ) { /** * Issue a HEAD request to the given URL. * + * @throws InvalidArgumentException If the request is pooled. + * * @param string $url * @param array|string|null $query - * @return Response|static */ - public function head( string $url, array|string|null $query = null ) { + public function head( string $url, array|string|null $query = null ): Response { + if ( $this->pooled ) { + throw new InvalidArgumentException( 'Cannot call head() on a pooled request.' ); + } + return $this->send( - 'HEAD', + Http_Method::HEAD, $url, ! is_null( $query ) ? [ 'query' => $query ] : [], ); @@ -496,13 +580,18 @@ public function head( string $url, array|string|null $query = null ) { /** * Issue a POST request to the given URL. * + * @throws InvalidArgumentException If the request is pooled. + * * @param string $url * @param array $data - * @return Response|static */ - public function post( string $url, ?array $data = null ) { + public function post( string $url, ?array $data = null ): Response { + if ( $this->pooled ) { + throw new InvalidArgumentException( 'Cannot call post() on a pooled request.' ); + } + return $this->send( - 'POST', + Http_Method::POST, $url, ! is_null( $data ) ? [ $this->body_format => $data ] : [], ); @@ -511,13 +600,18 @@ public function post( string $url, ?array $data = null ) { /** * Issue a PATCH request to the given URL. * + * @throws InvalidArgumentException If the request is pooled. + * * @param string $url * @param array $data - * @return Response|static */ - public function patch( string $url, ?array $data = null ) { + public function patch( string $url, ?array $data = null ): Response { + if ( $this->pooled ) { + throw new InvalidArgumentException( 'Cannot call patch() on a pooled request.' ); + } + return $this->send( - 'PATCH', + Http_Method::PATCH, $url, ! is_null( $data ) ? [ $this->body_format => $data ] : [], ); @@ -526,13 +620,18 @@ public function patch( string $url, ?array $data = null ) { /** * Issue a PUT request to the given URL. * + * @throws InvalidArgumentException If the request is pooled. + * * @param string $url * @param array $data - * @return Response|static */ - public function put( string $url, ?array $data = null ) { + public function put( string $url, ?array $data = null ): Response { + if ( $this->pooled ) { + throw new InvalidArgumentException( 'Cannot call put() on a pooled request.' ); + } + return $this->send( - 'PUT', + Http_Method::PUT, $url, ! is_null( $data ) ? [ $this->body_format => $data ] : [], ); @@ -541,13 +640,18 @@ public function put( string $url, ?array $data = null ) { /** * Issue a DELETE request to the given URL. * + * @throws InvalidArgumentException If the request is pooled. + * * @param string $url * @param array $data - * @return Response|static */ - public function delete( string $url, ?array $data = [] ) { + public function delete( string $url, ?array $data = [] ): Response { + if ( $this->pooled ) { + throw new InvalidArgumentException( 'Cannot call delete() on a pooled request.' ); + } + return $this->send( - 'DELETE', + Http_Method::DELETE, $url, ! is_null( $data ) ? [ $this->body_format => $data ] : [], ); @@ -556,15 +660,28 @@ public function delete( string $url, ?array $data = [] ) { /** * Issue a single request to the given URL. * - * @param string $method HTTP Method. - * @param string $url URL for the request. - * @param array $options Options for the request. + * @throws InvalidArgumentException If the request is pooled. + * @throws InvalidArgumentException If the request does not have a URL set. + * + * @param string|Http_Method|null $method HTTP Method, optional. + * @param string $url URL for the request, optional. + * @param array $options Options for the request. * @return Response|static */ - public function send( string $method, string $url, array $options = [] ) { - $this->url = ltrim( rtrim( $this->base_url, '/' ) . '/' . ltrim( $url, '/' ), '/' ); + public function send( string|Http_Method|null $method = null, ?string $url = null, array $options = [] ): mixed { + if ( $url ) { + $this->url( $url ); + } + + if ( ! $this->url ) { + throw new InvalidArgumentException( 'A URL must be provided for the request.' ); + } + + if ( $method ) { + $this->method( $method ); + } + $this->options = array_merge( $this->options, $options ); - $this->method = $method; // Ensure some options are always set. $this->options['throw_exception'] ??= false; @@ -674,7 +791,7 @@ public function get_request_args(): array { $args = [ 'cookies' => $this->options['cookies'] ?? [], 'headers' => $this->options['headers'] ?? [], - 'method' => $this->method, + 'method' => $this->method->value, 'redirection' => $this->options['allow_redirects'], 'sslverify' => $this->options['verify'] ?? true, 'timeout' => $this->options['timeout'] ?? 5, diff --git a/src/mantle/http-client/class-request.php b/src/mantle/http-client/class-request.php index 66ffceb0..f9536288 100644 --- a/src/mantle/http-client/class-request.php +++ b/src/mantle/http-client/class-request.php @@ -44,6 +44,13 @@ public function method(): string { return strtoupper( $this->args['method'] ?? '' ); } + /** + * Retrieve the enum method of the request. + */ + public function enum_method(): Http_Method { + return Http_Method::from( $this->method() ); + } + /** * Check if the request has a set of headers. * diff --git a/src/mantle/http-client/enum-http-method.php b/src/mantle/http-client/enum-http-method.php new file mode 100644 index 00000000..5e11f88e --- /dev/null +++ b/src/mantle/http-client/enum-http-method.php @@ -0,0 +1,24 @@ +client = Factory::create()->cache(); + + $this->prevent_stray_requests(); + } + + public function test_can_create_cached_client() { + $this->assertInstanceOf( Pending_Request::class, $this->client ); + $this->assertTrue( + collect( $this->client->get_middleware() )->contains( fn( $middleware ) => $middleware instanceof Cache_Middleware ) + ); + } + + public function test_it_can_make_http_request() { + $this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) ); + + $this->client->get( 'https://example.com' ); + $this->client->get( 'https://example.com' ); + + $this->assertRequestCount( 1 ); + } + + public function test_it_can_detect_different_http_methods() { + $this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) ); + + $this->client->get( 'https://example.com' ); + $this->client->post( 'https://example.com' ); + + $this->assertRequestCount( 2 ); + } + + public function test_it_can_detect_different_bodies() { + $this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) ); + + $this->client->post( 'https://example.com', [ 'body' => [ 'example' => 'value' ] ] ); + $this->client->post( 'https://example.com', [ 'body' => [ 'example' => 'value' ] ] ); + + $this->assertRequestCount( 1 ); + + $this->client->post( 'https://example.com', [ 'body' => [ 'example' => 'value2' ] ] ); + + $this->assertRequestCount( 2 ); + } + + public function test_it_can_control_the_cache_ttl() { + $_SERVER['__ttl_called'] = false; + + $this->client = Factory::create()->cache( function () { + $_SERVER['__ttl_called'] = true; + + return DAY_IN_SECONDS; + } ); + + $this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) ); + + $this->client->get( 'https://example.com' ); + + $this->assertRequestCount( 1 ); + $this->assertTrue( $_SERVER['__ttl_called'] ); + } + + public function test_it_can_purge_cache() { + $this->fake_request( mock_http_response()->with_json( [ 'example' => 'value' ] ) ); + + $this->client->get( 'https://example.com' ); + $this->client->get( 'https://example.com' ); + + $this->assertRequestCount( 1 ); + + $this->assertTrue( $this->client->url( 'https://example.com' )->purge() ); + + $this->client->get( 'https://example.com' ); + + $this->assertRequestCount( 2 ); + } +} diff --git a/tests/HttpClient/HttpClientTest.php b/tests/HttpClient/HttpClientTest.php index 31e80827..23170622 100644 --- a/tests/HttpClient/HttpClientTest.php +++ b/tests/HttpClient/HttpClientTest.php @@ -11,6 +11,7 @@ use Mantle\Facade\Http; use Mantle\Http_Client\Factory; use Mantle\Http_Client\Http_Client_Exception; +use Mantle\Http_Client\Http_Method; use Mantle\Http_Client\Pending_Request; use Mantle\Http_Client\Pool; use Mantle\Http_Client\Request; @@ -18,6 +19,8 @@ use Mantle\Testing\Framework_Test_Case; use Mantle\Testing\Mock_Http_Response; +use function Mantle\Http_Client\http_client; + class HttpClientTest extends Framework_Test_Case { protected Factory $http_factory; @@ -70,6 +73,19 @@ public function test_make_get_request_with_query() { ); } + public function test_make_request_enum() { + $this->fake_request(); + + $this->http_factory->method( Http_Method::PUT )->url( 'https://example.com/' )->send(); + + $this->assertRequestSent( 'https://example.com/' ); + $this->assertRequestSent( + fn ( Request $request ) => 'https://example.com/' === $request->url() + && 'PUT' === $request->method() + && Http_Method::PUT === $request->enum_method() + ); + } + public function test_make_request_with_cookies() { $cookie = new \WP_Http_Cookie( [ 'name' => 'example', @@ -348,8 +364,8 @@ public function test_pool_requests() { ] ); $response = $this->http_factory->pool( fn ( Pool $pool ) => [ - $pool->get( 'https://example.com/async/' ), - $pool->get( 'https://example.com/second-async/' ), + $pool->method( 'get' )->url( 'https://example.com/async/' ), + $pool->method( 'get' )->url( 'https://example.com/second-async/' ), ] ); $this->assertEquals( 200, $response[0]->status() ); @@ -363,8 +379,8 @@ public function test_pool_requests_name() { ] ); $response = $this->http_factory->pool( fn ( Pool $pool ) => [ - $pool->as( 'first' )->get( 'https://example.com/async/' ), - $pool->as( 'second' )->post( 'https://example.com/second-async/' ), + $pool->as( 'first' )->url( 'https://example.com/async/' ), + $pool->as( 'second' )->method( 'post' )->url( 'https://example.com/second-async/' ), ] ); $this->assertEquals( 200, $response['first']->status() ); @@ -391,8 +407,8 @@ public function test_pool_forward_base_url() { ->with_header( 'X-Foo', 'Bar' ); $response = $githubClient->pool( fn ( Pool $githubPool ) => [ - $githubPool->get( '/endpoint-a/' ), - $githubPool->post( '/endpoint-b/' ), + $githubPool->url( '/endpoint-a/' ), + $githubPool->url( '/endpoint-b/' )->method( 'post' ), ] ); $this->assertEquals( 200, $response[0]->status() );