From 09a3c4d78243c70221b90159b4fb438c6bb7e87d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 24 May 2025 19:57:14 +0200 Subject: [PATCH 1/7] Prototype HttpCache as a separate Client implementation that's, effectively, a middleware --- components/HttpClient/CacheClient.php | 427 +++++++++++++++++++++++++ components/HttpClient/CacheEntry.php | 34 -- components/HttpClient/CachePolicy.php | 96 ------ components/HttpClient/CacheStorage.php | 34 -- components/HttpClient/Client.php | 4 + components/HttpClient/Request.php | 2 + 6 files changed, 433 insertions(+), 164 deletions(-) create mode 100644 components/HttpClient/CacheClient.php delete mode 100644 components/HttpClient/CacheEntry.php delete mode 100644 components/HttpClient/CachePolicy.php delete mode 100644 components/HttpClient/CacheStorage.php diff --git a/components/HttpClient/CacheClient.php b/components/HttpClient/CacheClient.php new file mode 100644 index 00000000..53a43d8e --- /dev/null +++ b/components/HttpClient/CacheClient.php @@ -0,0 +1,427 @@ + */ + private array $replay = []; + + /** writers keyed by spl_object_hash(req) */ + private array $tempHandle = []; + private array $tempPath = []; + + /* snapshot for getters */ + private ?string $event = null; + private ?Request $request = null; + private ?Response $response = null; + private ?string $cache_key = null; + + public function __construct( Client $upstream, string $cacheDir ) { + $this->upstream = $upstream; + $this->dir = rtrim( $cacheDir, '/' ); + if ( ! is_dir( $this->dir ) && ! mkdir( $this->dir, 0777, true ) ) { + throw new RuntimeException( "cannot create cache dir {$this->dir}" ); + } + } + + /*---------------- enqueue ----------------*/ + public function enqueue( Request|array $requests ): void { + $list = is_array( $requests ) ? $requests : [ $requests ]; + $cache_misses = []; + foreach ( $list as $request ) { + if ( ! $request instanceof Request ) { + continue; + } + $meth = strtoupper( $request->method ); + if ( ! in_array( $meth, [ 'GET', 'HEAD' ], true ) ) { + $this->invalidateCache( $request ); + $cache_misses[] = $request; + continue; + } + [ $key, $meta ] = $this->lookup( $request ); + $request->cache_key = $key; + if ( $meta && $this->fresh( $meta ) ) { + $this->startReplay( $request, $meta ); + continue; + } + if ( $meta ) { + $this->addValidators( $request, $meta ); + } + $cache_misses[] = $request; + } + if ( $cache_misses ) { + $this->upstream->enqueue( $cache_misses ); + } + } + + /*---------------- await ----------------*/ + public function await_next_event( array $query = [] ): bool { + /* serve cached replay first */ + foreach ( $this->replay as $id => $context ) { + if ( $context['done'] ) { + fclose( $context['file'] ); + unset( $this->replay[ $id ] ); + continue; + } + $this->fromCache( $id ); + + return true; + } + /* drive upstream */ + while ( true ) { + if ( ! $this->upstream->await_next_event( $query ) ) { + return false; + } + if ( $this->handleNetwork() ) { + return true; + } + /* loop if event was swallowed (e.g., 304 turned into replay) */ + } + } + + /*---------------- getters --------------*/ + public function get_event(): ?string { + return $this->event; + } + + public function get_request(): ?Request { + return $this->request; + } + + public function get_response(): ?Response { + return $this->response; + } + + public function get_response_body_chunk(): ?string { + return $this->cache_key; + } + + /*============ CACHE REPLAY ============*/ + private function startReplay( Request $request, array $meta ): void { + $id = spl_object_hash( $request ); + $file_handle = fopen( $this->bodyPath( $request->cache_key ), 'rb' ); + $this->replay[ $id ] = [ + 'req' => $request, + 'meta' => $meta, + 'file' => $file_handle, + 'headerDone' => false, + 'done' => false, + ]; + } + + private function fromCache( string $id ): void { + $context =& $this->replay[ $id ]; + $this->request = $context['req']; + if ( ! $context['headerDone'] ) { + $resp = new Response( $context['req'] ); + $resp->status_code = $context['meta']['status']; + $resp->headers = $context['meta']['headers']; + $this->event = self::EVENT_HEADERS; + $this->response = $resp; + $this->cache_key = null; + $context['headerDone'] = true; + + return; + } + $chunk = fread( $context['file'], 8192 ); + if ( $chunk !== '' && $chunk !== false ) { + $this->event = self::EVENT_BODY; + $this->cache_key = $chunk; + $this->response = null; + + return; + } + $context['done'] = true; + $this->event = self::EVENT_FINISH; + $this->response = $this->cache_key = null; + } + + /*============ NETWORK HANDLING ============*/ + private function handleNetwork(): bool { + $event = $this->upstream->get_event(); + $request = $this->upstream->get_request(); + /* HEADERS */ + if ( $event === self::EVENT_HEADERS ) { + $response = $this->upstream->get_response(); + if ( $response->status_code === 304 && isset( $request->cache_key ) ) { + [ , $meta ] = $this->lookup( $request, $request->cache_key ); + if ( $meta ) { + $this->startReplay( $request, $meta ); /* swallow 304 events */ + + return false; + } + } + if ( $response->status_code === 200 && $this->cacheable( $response ) ) { + $tmp = $this->tempPath( $request->cache_key ); + + $this->tempPath[ spl_object_hash( $request ) ] = $tmp; + $this->tempHandle[ spl_object_hash( $request ) ] = fopen( $tmp, 'wb' ); + } + $this->event = $event; + $this->request = $request; + $this->response = $response; + $this->cache_key = null; + + return true; + } + /* BODY */ + if ( $event === self::EVENT_BODY ) { + $chunk = $this->upstream->get_response_body_chunk(); + $hash = spl_object_hash( $request ); + if ( isset( $this->tempHandle[ $hash ] ) ) { + fwrite( $this->tempHandle[ $hash ], $chunk ); + } + $this->event = $event; + $this->request = $request; + $this->cache_key = $chunk; + $this->response = null; + + return true; + } + /* FINISH */ + if ( $event === self::EVENT_FINISH ) { + $hash = spl_object_hash( $request ); + if ( isset( $this->tempHandle[ $hash ] ) ) { + fclose( $this->tempHandle[ $hash ] ); + $this->commit( $request, $this->upstream->get_response(), $this->tempPath[ $hash ] ); + unset( $this->tempHandle[ $hash ], $this->tempPath[ $hash ] ); + } + $this->event = $event; + $this->request = $request; + $this->response = $this->upstream->get_response(); + $this->cache_key = null; + + return true; + } + /* passthrough others */ + $this->event = $event; + $this->request = $request; + $this->response = $event === self::EVENT_BODY ? null : $this->upstream->get_response(); + $this->cache_key = $event === self::EVENT_BODY ? $this->upstream->get_response_body_chunk() : null; + + return true; + } + + /*============ CACHE UTILITIES ============*/ + private function metaPath( string $key ): string { + return "$this->dir/{$key}.json"; + } + + private function bodyPath( string $key ): string { + return "$this->dir/{$key}.body"; + } + + private function tempPath( string $key ): string { + return "$this->dir/{$key}.tmp"; + } + + private function varyKey( Request $request, ?array $vary_keys ): string { + $parts = [ $request->url ]; + if ( $vary_keys ) { + foreach ( $vary_keys as $header_name ) { + $parts[] = strtolower( $header_name ) . ':' . $request->get_header( $header_name ); + } + } + + return sha1( implode( '|', $parts ) ); + } + + /** @return array{string,array|null} */ + private function lookup( Request $request, ?string $forced = null ): array { + if ( $forced && is_file( $this->metaPath( $forced ) ) ) { + return [ $forced, json_decode( file_get_contents( $this->metaPath( $forced ) ), true ) ]; + } + $glob = glob( $this->dir . '/' . sha1( $request->url ) . '*.json' ); + foreach ( $glob as $meta_path ) { + $meta = json_decode( file_get_contents( $meta_path ), true ); + if ( basename( $meta_path, '.json' ) === $this->varyKey( $request, $meta['vary'] ?? [] ) ) { + return [ basename( $meta_path, '.json' ), $meta ]; + } + } + + return [ $this->varyKey( $request, null ), null ]; + } + + private function fresh( array $meta ): bool { + $now = time(); + + // If explicit expiry timestamp is set, use it + if ( isset( $meta['expires'] ) ) { + $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); + if ( $expires !== false ) { + return $expires > $now; + } + } + + // If explicit TTL (absolute timestamp) is set, use it + if ( isset( $meta['ttl'] ) ) { + if ( is_numeric( $meta['ttl'] ) ) { + return (int)$meta['ttl'] > $now; + } + } + + // If max_age is set, check if still valid + if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['max_age']) > $now; + } + + // If s-maxage is set, check if still valid + if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + } + + // Heuristic: if Last-Modified is present, cache for 10% of its age at storage time + if ( isset( $meta['last_modified'] ) && isset( $meta['stored_at'] ) ) { + $lm = is_numeric( $meta['last_modified'] ) ? (int)$meta['last_modified'] : strtotime( $meta['last_modified'] ); + if ( $lm !== false ) { + $age = $meta['stored_at'] - $lm; + $heuristic_lifetime = (int) max( 0, $age / 10 ); + return ($meta['stored_at'] + $heuristic_lifetime) > $now; + } + } + + // Not fresh by any rule + return false; + } + + private function cacheable( Response $response ): bool { + return self::response_is_cacheable( $response ); + } + + private function addValidators( Request $request, array $meta ): void { + if ( ! empty( $meta['etag'] ) ) { + $request->headers['If-None-Match'] = $meta['etag']; + } + if ( ! empty( $meta['last_modified'] ) ) { + $request->headers['If-Modified-Since'] = $meta['last_modified']; + } + } + + protected function commit( Request $request ) { + $url = $request->url; + $meta = [ + 'url' => $url, + 'status' => $request->response->status_code, + 'headers' => $request->response->headers, + 'stored_at' => time(), + 'etag' => $request->response->get_header( 'ETag' ), + 'last_modified' => $request->response->get_header( 'Last-Modified' ), + ]; + // Parse Cache-Control for max-age, if present + $cacheControl = $request->response->get_header( 'Cache-Control' ); + if ( $cacheControl ) { + $directives = self::directives( $cacheControl ); + if ( isset( $directives['max-age'] ) && is_int( $directives['max-age'] ) ) { + $meta['max_age'] = $directives['max-age']; + } + } + + // Determine file paths + $key = $request->cache_key; + $bodyFile = $this->bodyPath( $key ); + $tempFile = $this->tempPath( $key ); + $metaFile = $this->metaPath( $key ); + + // Close the temp body stream if open (flushes data) + $file_handle = $this->tempHandle[ spl_object_hash( $request ) ]; + if ( $file_handle && is_resource( $file_handle ) ) { + fclose( $file_handle ); + } + unset( $this->tempHandle[ spl_object_hash( $request ) ] ); + + // Atomically replace/rename the temp body file to final cache file + if ( ! rename( $tempFile, $bodyFile ) ) { + // Handle error (e.g., log failure and abort caching) + return; + } + + // Write metadata with exclusive lock + $fp = fopen( $metaFile, 'c' ); + if ( $fp ) { + flock( $fp, LOCK_EX ); + ftruncate( $fp, 0 ); + // Serialize or encode CacheEntry (e.g., JSON) + $metaData = json_encode( $meta ); + fwrite( $fp, $metaData ); + fflush( $fp ); + flock( $fp, LOCK_UN ); + fclose( $fp ); + } + } + + public function invalidateCache( Request $request ): void { + $key = $request->cache_key; + $bodyFile = $this->bodyPath( $key ); + $metaFile = $this->metaPath( $key ); + + // Optionally, acquire lock on meta file to prevent concurrent writes + if ( $fp = @fopen( $metaFile, 'c' ) ) { + flock( $fp, LOCK_EX ); + } + // Delete cache files if they exist + @unlink( $bodyFile ); + @unlink( $metaFile ); + // Also remove any temp files for this entry + foreach ( glob( $bodyFile . '.tmp*' ) as $tmp ) { + @unlink( $tmp ); + } + if ( isset( $fp ) && $fp ) { + flock( $fp, LOCK_UN ); + fclose( $fp ); + } + } + + + /** return ['no-store'=>true, 'max-age'=>60, …] */ + public static function directives( ?string $value ): array { + if ( $value === null ) { + return []; + } + $out = []; + foreach ( explode( ',', $value ) as $part ) { + $part = trim( $part ); + if ( $part === '' ) { + continue; + } + if ( strpos( $part, '=' ) !== false ) { + [ $k, $v ] = array_map( 'trim', explode( '=', $part, 2 ) ); + $out[ strtolower( $k ) ] = ctype_digit( $v ) ? (int) $v : strtolower( $v ); + } else { + $out[ strtolower( $part ) ] = true; + } + } + + return $out; + } + + public static function response_is_cacheable( Response $r ): bool { + $req = $r->request; + if ( $req->method !== 'GET' ) { + return false; + } + if ( $r->status_code !== 200 && $r->status_code !== 206 ) { + return false; + } + + $d = self::directives( $r->get_header( 'cache-control' ) ); + if ( isset( $d['no-store'] ) ) { + return false; + } + if ( $r->get_header( 'expires' ) || isset( $d['max-age'] ) || isset( $d['s-maxage'] ) ) { + return true; + } + + // heuristic: if Last-Modified present and older than 24 h cache for 10 % + return (bool) $r->get_header( 'last-modified' ); + } + +} diff --git a/components/HttpClient/CacheEntry.php b/components/HttpClient/CacheEntry.php deleted file mode 100644 index 74a57597..00000000 --- a/components/HttpClient/CacheEntry.php +++ /dev/null @@ -1,34 +0,0 @@ -true, 'max-age'=>60, …] */ - public static function directives( ?string $value ): array { - if ( $value === null ) { - return []; - } - $out = []; - foreach ( explode( ',', $value ) as $part ) { - $part = trim( $part ); - if ( $part === '' ) { - continue; - } - if ( strpos( $part, '=' ) !== false ) { - [ $k, $v ] = array_map( 'trim', explode( '=', $part, 2 ) ); - $out[ strtolower( $k ) ] = ctype_digit( $v ) ? (int) $v : strtolower( $v ); - } else { - $out[ strtolower( $part ) ] = true; - } - } - - return $out; - } - - public static function response_is_cacheable( Response $r ): bool { - $req = $r->request; - if ( $req->method !== 'GET' ) { - return false; - } - if ( $r->status_code !== 200 && $r->status_code !== 206 ) { - return false; - } - - $d = self::directives( $r->get_header( 'cache-control' ) ); - if ( isset( $d['no-store'] ) ) { - return false; - } - if ( $r->get_header( 'expires' ) || isset( $d['max-age'] ) || isset( $d['s-maxage'] ) ) { - return true; - } - - // heuristic: if Last-Modified present and older than 24 h cache for 10 % - return (bool) $r->get_header( 'last-modified' ); - } - - public static function freshness_lifetime( CacheEntry $e ): int { - $h = $e->headers; - $d = self::directives( $h['cache-control'] ?? null ); - if ( isset( $d['s-maxage'] ) ) { - return $d['s-maxage']; - } - if ( isset( $d['max-age'] ) ) { - return $d['max-age']; - } - if ( isset( $h['expires'] ) ) { - return max( 0, strtotime( $h['expires'] ) - $e->stored_at ); - } - - if ( isset( $h['last-modified'] ) ) { - $age = $e->stored_at - strtotime( $h['last-modified'] ); - - return (int) max( 0, $age / 10 ); - } - - return 0; // treat as immediately stale - } - - public static function is_fresh( CacheEntry $e, ?int $now = null ): bool { - $now = $now ?? time(); - $age_hdr = (int) ( $e->headers['age'] ?? 0 ); - $current_age = $age_hdr + ( $now - $e->stored_at ); - - return $current_age < self::freshness_lifetime( $e ); - } - - public static function must_revalidate( CacheEntry $e ): bool { - $d = self::directives( $e->headers['cache-control'] ?? null ); - - return isset( $d['must-revalidate'] ) || isset( $d['proxy-revalidate'] ); - } - - public static function parse_max_age( string $cc ): ?int { - foreach ( explode( ',', $cc ) as $d ) { - $d = trim( $d ); - if ( strncmp( $d, 'max-age=', strlen( 'max-age=' ) ) === 0 ) { - return (int) substr( $d, 8 ); - } - } - - return null; - } -} diff --git a/components/HttpClient/CacheStorage.php b/components/HttpClient/CacheStorage.php deleted file mode 100644 index 5a95584f..00000000 --- a/components/HttpClient/CacheStorage.php +++ /dev/null @@ -1,34 +0,0 @@ -request; } + public function get_response() { + return $this->get_request()->response; + } + /** * Returns the response body chunk associated with the EVENT_BODY_CHUNK_AVAILABLE * event found by await_next_event(). diff --git a/components/HttpClient/Request.php b/components/HttpClient/Request.php index 86d979b7..1f6c59c1 100644 --- a/components/HttpClient/Request.php +++ b/components/HttpClient/Request.php @@ -34,6 +34,8 @@ class Request { public $redirected_from; public $redirected_to; + public $cache_key; + /** * @var HttpError */ From 49167bfaf70a2d5203942e082c649627919fd5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 May 2025 00:59:43 +0200 Subject: [PATCH 2/7] Add RedirectingClient to handle http redirections as a middelware --- components/HttpClient/CacheClient.php | 2 +- components/HttpClient/Client.php | 119 +-- components/HttpClient/ClientInterface.php | 15 + components/HttpClient/RedirectingClient.php | 267 ++++++ components/HttpClient/Tests/ClientTest.php | 104 -- .../Tests/RedirectingClientTest.php | 905 ++++++++++++++++++ 6 files changed, 1214 insertions(+), 198 deletions(-) create mode 100644 components/HttpClient/ClientInterface.php create mode 100644 components/HttpClient/RedirectingClient.php create mode 100644 components/HttpClient/Tests/RedirectingClientTest.php diff --git a/components/HttpClient/CacheClient.php b/components/HttpClient/CacheClient.php index 53a43d8e..9f615140 100644 --- a/components/HttpClient/CacheClient.php +++ b/components/HttpClient/CacheClient.php @@ -99,7 +99,7 @@ public function get_request(): ?Request { public function get_response(): ?Response { return $this->response; - } + } public function get_response_body_chunk(): ?string { return $this->cache_key; diff --git a/components/HttpClient/Client.php b/components/HttpClient/Client.php index fe6bcbc1..f846dfd0 100644 --- a/components/HttpClient/Client.php +++ b/components/HttpClient/Client.php @@ -57,7 +57,7 @@ * @package WordPress * @subpackage Async_HTTP */ -class Client { +class Client implements ClientInterface { /** * The maximum number of concurrent connections allowed. @@ -71,16 +71,6 @@ class Client { */ protected $concurrency; - /** - * The maximum number of redirects to follow for a single request. - * - * This prevents infinite redirect loops and provides a degree of control over the client's behavior. - * Setting it too high might lead to unexpected navigation paths. - * - * @var int - */ - protected $max_redirects = 3; - /** * All the HTTP requests ever enqueued with this Client. * @@ -112,10 +102,9 @@ class Client { protected $requests_started_at = array(); public function __construct( $options = array() ) { - $this->concurrency = $options['concurrency'] ?? 10; - $this->max_redirects = $options['max_redirects'] ?? 3; + $this->concurrency = $options['concurrency'] ?? 10; $this->request_timeout_ms = $options['timeout_ms'] ?? 30000; - $this->requests = array(); + $this->requests = array(); } /** @@ -123,12 +112,13 @@ public function __construct( $options = array() ) { * given request. * * @param Request $request The request to stream. + * @param array $options Options for the request. * * @return RequestReadStream */ - public function fetch( $request, $options = array() ) { + public function fetch( Request $request, array $options = [] ) { return new RequestReadStream( $request, - array_merge( [ 'client' => $this ], is_array( $options ) ? $options : iterator_to_array( $options ) ) ); + array_merge( [ 'client' => $this ], $options ) ); } /** @@ -136,10 +126,11 @@ public function fetch( $request, $options = array() ) { * of the given requests. * * @param Request[] $requests The requests to stream. + * @param array $options Options for the requests. * * @return RequestReadStream[] */ - public function fetch_many( array $requests, $options = array() ) { + public function fetch_many( array $requests, array $options = [] ) { $streams = array(); foreach ( $requests as $request ) { @@ -155,11 +146,11 @@ public function fetch_many( array $requests, $options = array() ) { * an internal queue. Network transmission is delayed until one of the returned * streams is read from. * - * @param Request|Request[] $requests The HTTP request(s) to enqueue. Can be a single request or an array of requests. + * @param Request[] $requests The HTTP request(s) to enqueue. */ public function enqueue( $requests ) { - if ( ! is_array( $requests ) ) { - $requests = array( $requests ); + if(!is_array($requests)) { + $requests = [$requests]; } foreach ( $requests as $request ) { @@ -199,7 +190,6 @@ public function enqueue( $requests ) { * * * `Client::EVENT_GOT_HEADERS` * * `Client::EVENT_BODY_CHUNK_AVAILABLE` - * * `Client::EVENT_REDIRECT` * * `Client::EVENT_FAILED` * * `Client::EVENT_FINISHED` * @@ -227,7 +217,7 @@ public function enqueue( $requests ) { * $request = new Request( "https://w.org" ); * * $client = new HttpClientClient(); - * $client->enqueue( $request ); + * $client->enqueue( [$request] ); * $event = $client->await_next_event( [ * 'request_id' => $request->id, * ] ); @@ -239,15 +229,14 @@ public function enqueue( $requests ) { * request #1 has finished before you started awaiting * events for request #2. * - * @param $query + * @param array $query Query parameters for filtering events. * * @return bool */ - public function await_next_event( $query = array() ) { + public function await_next_event( array $query = [] ) { $ordered_events = array( self::EVENT_GOT_HEADERS, self::EVENT_BODY_CHUNK_AVAILABLE, - self::EVENT_REDIRECT, self::EVENT_FAILED, self::EVENT_FINISHED, ); @@ -269,10 +258,6 @@ public function await_next_event( $query = array() ) { $events = array(); foreach ( $query['requests'] as $query_request ) { $events[] = $query_request->id; - while ( $query_request->redirected_to ) { - $query_request = $query_request->redirected_to; - $events[] = $query_request->id; - } } } @@ -308,7 +293,7 @@ public function await_next_event( $query = array() ) { return false; } - public function has_pending_event( $request, $event_type ) { + public function has_pending_event( Request $request, string $event_type ): bool { return $this->events[ $request->id ][ $event_type ] ?? false; } @@ -403,7 +388,11 @@ protected function event_loop_tick() { $this->get_active_requests( Request::STATE_RECEIVING_HEADERS ) ); - $this->handle_redirects( + $this->receive_response_body( + $this->get_active_requests( Request::STATE_RECEIVING_BODY ) + ); + + $this->mark_requests_finished( $this->get_active_requests( Request::STATE_RECEIVED ) ); @@ -427,11 +416,6 @@ protected function event_loop_tick() { return true; } - $this->receive_response_body( - $this->get_active_requests( Request::STATE_RECEIVING_BODY ) - ); - - return true; } @@ -779,8 +763,6 @@ protected function receive_response_headers( $requests ) { } /** - * Reads the next received portion of HTTP response headers for multiple requests. - * * @param array $requests An array of requests. */ protected function receive_response_body( $requests ) { @@ -807,60 +789,6 @@ protected function receive_response_body( $requests ) { } } - /** - * @param array $requests An array of requests. - */ - protected function handle_redirects( $requests ) { - foreach ( $requests as $request ) { - $response = $request->response; - if ( ! $response ) { - continue; - } - $code = $response->status_code; - $this->mark_finished( $request ); - if ( ! ( $code >= 300 && $code < 400 ) ) { - continue; - } - - $location = $response->get_header( 'location' ); - if ( null === $location ) { - continue; - } - - $redirects_so_far = 0; - $cause = $request; - while ( $cause->redirected_from ) { - ++ $redirects_so_far; - $cause = $cause->redirected_from; - } - - if ( $redirects_so_far >= $this->max_redirects ) { - $this->set_error( $request, new HttpError( 'Too many redirects' ) ); - continue; - } - - $redirect_url = $location; - $parsed = WPURL::parse($redirect_url, $request->url); - if(false === $parsed) { - $this->set_error( $request, new HttpError( sprintf( 'Invalid redirect URL: %s', $redirect_url ) ) ); - continue; - } - $redirect_url = $parsed->toString(); - - $this->events[ $request->id ][ self::EVENT_REDIRECT ] = true; - $this->enqueue( - new Request( - $redirect_url, - array( - // Redirects are always GET requests - 'method' => 'GET', - 'redirected_from' => $request, - ) - ) - ); - } - } - /** * Parses an HTTP headers string into an array containing the status and headers. * @@ -1082,7 +1010,6 @@ protected function stream_select( $requests, $mode ) { const EVENT_GOT_HEADERS = 'EVENT_GOT_HEADERS'; const EVENT_BODY_CHUNK_AVAILABLE = 'EVENT_BODY_CHUNK_AVAILABLE'; - const EVENT_REDIRECT = 'EVENT_REDIRECT'; const EVENT_FAILED = 'EVENT_FAILED'; const EVENT_FINISHED = 'EVENT_FINISHED'; @@ -1097,4 +1024,10 @@ protected function stream_select( $requests, $mode ) { * 5/100th of a second */ const NONBLOCKING_TIMEOUT_MICROSECONDS = 0.05 * self::MICROSECONDS_TO_SECONDS; + + protected function mark_requests_finished( $requests ) { + foreach ( $requests as $request ) { + $this->mark_finished( $request ); + } + } } diff --git a/components/HttpClient/ClientInterface.php b/components/HttpClient/ClientInterface.php new file mode 100644 index 00000000..067ba90a --- /dev/null +++ b/components/HttpClient/ClientInterface.php @@ -0,0 +1,15 @@ +max_redirects = $options['max_redirects'] ?? 3; + + // Remove max_redirects from options passed to underlying client to avoid conflicts + $client_options = $options; + unset( $client_options['max_redirects'] ); + + $this->client = $client ?? new Client( $client_options ); + } + + /** + * Returns a RemoteFileReader that streams the response body of the + * given request. + * + * @param Request $request The request to stream. + * @param array $options Options for the request. + * + * @return RequestReadStream + */ + public function fetch( Request $request, array $options = [] ): \WordPress\HttpClient\ByteStream\RequestReadStream { + return $this->client->fetch( $request, $options ); + } + + /** + * Returns an array of RemoteFileReader instances that stream the response bodies + * of the given requests. + * + * @param Request[] $requests The requests to stream. + * @param array $options Options for the requests. + * + * @return RequestReadStream[] + */ + public function fetch_many( array $requests, array $options = [] ): array { + return $this->client->fetch_many( $requests, $options ); + } + + /** + * Enqueues one or multiple HTTP requests for asynchronous processing. + * + * @param Request[] $requests The HTTP request(s) to enqueue. + */ + public function enqueue( array $requests ): void { + $this->client->enqueue( $requests ); + } + + /** + * Returns the next event related to any of the HTTP requests enqueued in this client. + * + * This method handles redirect events automatically and creates new requests for redirects. + * + * @param array $query Query parameters for filtering events. + * + * @return bool + */ + public function await_next_event( array $query = [] ): bool { + // Add support for following redirected requests in the query + if ( !empty( $query['requests'] ) ) { + $all_requests = []; + foreach ( $query['requests'] as $query_request ) { + $all_requests[] = $query_request; + // Follow the redirect chain + while ( $query_request->redirected_to ) { + $query_request = $query_request->redirected_to; + $all_requests[] = $query_request; + } + } + $query['requests'] = $all_requests; + } + + $has_event = $this->client->await_next_event( $query ); + + if ( $has_event ) { + $event = $this->client->get_event(); + $request = $this->client->get_request(); + + // Check if this is a redirect response + if ( $event === Client::EVENT_FINISHED && $this->is_redirect_response( $request ) ) { + $this->handle_redirect( $request ); + // Return a redirect event instead of finished + return true; + } + } + + return $has_event; + } + + /** + * Check if a request has a pending event. + * + * @param Request $request The request to check. + * @param string $event_type The event type to check for. + * + * @return bool + */ + public function has_pending_event( Request $request, string $event_type ): bool { + return $this->client->has_pending_event( $request, $event_type ); + } + + /** + * Returns the next event found by await_next_event(). + * + * @return string|bool The next event, or false if no event is set. + */ + public function get_event() { + $event = $this->client->get_event(); + $request = $this->client->get_request(); + + // Convert finished redirect responses to redirect events + if ( $event === Client::EVENT_FINISHED && $this->is_redirect_response( $request ) ) { + return self::EVENT_REDIRECT; + } + + return $event; + } + + /** + * Returns the request associated with the last event found by await_next_event(). + * + * @return Request|false + */ + public function get_request() { + return $this->client->get_request(); + } + + /** + * Returns the response associated with the last request. + * + * @return Response|false + */ + public function get_response() { + return $this->client->get_response(); + } + + /** + * Returns the response body chunk associated with the EVENT_BODY_CHUNK_AVAILABLE event. + * + * @return string|false + */ + public function get_response_body_chunk() { + return $this->client->get_response_body_chunk(); + } + + /** + * Check if a request response is a redirect. + * + * @param Request $request The request to check. + * + * @return bool + */ + protected function is_redirect_response( Request $request ): bool { + if ( ! $request->response ) { + return false; + } + + $code = $request->response->status_code; + return $code >= 300 && $code < 400 && $request->response->get_header( 'location' ); + } + + /** + * Handle HTTP redirects for requests. + * + * @param Request $request The request that received a redirect response. + */ + protected function handle_redirect( Request $request ): void { + $response = $request->response; + $location = $response->get_header( 'location' ); + + if ( ! $location ) { + return; + } + + $redirects_so_far = 0; + $cause = $request; + while ( $cause->redirected_from ) { + ++$redirects_so_far; + $cause = $cause->redirected_from; + } + + if ( $redirects_so_far >= $this->max_redirects ) { + $this->set_error( $request, new HttpError( 'Too many redirects' ) ); + return; + } + + $redirect_url = $location; + $parsed = WPURL::parse( $redirect_url, $request->url ); + if ( false === $parsed ) { + $this->set_error( $request, new HttpError( sprintf( 'Invalid redirect URL: %s', $redirect_url ) ) ); + return; + } + $redirect_url = $parsed->toString(); + + $redirect_request = new Request( + $redirect_url, + [ + // Redirects are always GET requests + 'method' => 'GET', + 'redirected_from' => $request, + ] + ); + + // Set up the redirect relationship + $request->redirected_to = $redirect_request; + + $this->client->enqueue( [ $redirect_request ] ); + } + + /** + * Set an error on a request. + * + * @param Request $request The request to set the error on. + * @param HttpError $error The error to set. + */ + protected function set_error( Request $request, HttpError $error ): void { + $request->error = $error; + $request->state = Request::STATE_FAILED; + } + + /** + * Event constants + */ + const EVENT_GOT_HEADERS = Client::EVENT_GOT_HEADERS; + const EVENT_BODY_CHUNK_AVAILABLE = Client::EVENT_BODY_CHUNK_AVAILABLE; + const EVENT_REDIRECT = 'EVENT_REDIRECT'; + const EVENT_FAILED = Client::EVENT_FAILED; + const EVENT_FINISHED = Client::EVENT_FINISHED; +} diff --git a/components/HttpClient/Tests/ClientTest.php b/components/HttpClient/Tests/ClientTest.php index 0a29aa43..09fb2ca5 100644 --- a/components/HttpClient/Tests/ClientTest.php +++ b/components/HttpClient/Tests/ClientTest.php @@ -435,110 +435,6 @@ public function streamingProvider() { ]; } - /** - * Test redirect chaining. - */ - public function test_redirect_chain() { - $this->withServer( function ( $url ) { - $client = new Client(); - $request = new Request( "$url/redirect/chain-1" ); - $body1 = $this->consume_entire_body( $client, $request ); - $this->assertEquals( 'Redirect 1', $body1 ); - $this->assertEquals( 302, $request->response->status_code ); // Original request should have 302 - - $body2 = $this->consume_entire_body( $client, $request->redirected_to ); - $this->assertEquals( 'Redirect 2', $body2 ); - $this->assertEquals( 302, $request->redirected_to->response->status_code ); // First redirect should have 302 - - $body3 = $this->consume_entire_body( $client, $request->redirected_to->redirected_to ); - $this->assertEquals( 'Final Redirected Content!', $body3 ); - $this->assertEquals( 200, $request->redirected_to->redirected_to->response->status_code ); - }, 'redirect' ); - } - - /** - * Test redirect loop with max_redirects limit. - */ - public function test_redirect_loop() { - $this->withServer( function ( $url ) { - $client = new Client( [ 'max_redirects' => 2, 'timeout_ms' => 20000 ] ); // Set a low redirect limit - $request = new Request( "$url/redirect/loop" ); - $client->enqueue( $request ); - - $error_occurred = false; - while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { - switch ( $client->get_event() ) { - case Client::EVENT_FAILED: - $this->assertNotNull( $request->latest_redirect()->error ); - $this->assertStringContainsString( 'Too many redirects', $request->latest_redirect()->error->message ); - $error_occurred = true; - break 2; - } - } - $this->assertTrue( $error_occurred, 'Redirect loop should have resulted in an error.' ); - }, 'redirect' ); - } - - /** - * Test POST request redirected to GET (303 See Other). - */ - public function test_post_to_get_redirect() { - $this->withServer( function ( $url ) { - $client = new Client(); - $request = new Request( "$url/redirect/post-to-get", [ 'method' => 'POST', 'body_stream' => new StringReadStream('test body') ] ); - $original_body = $this->consume_entire_body( $client, $request ); - $this->assertEquals( 'POST', $request->method ); - $this->assertEquals( 'Redirecting POST to GET', $original_body ); - - $redirected = $request->redirected_to; - $this->consume_entire_body( $client, $redirected ); - $this->assertEquals( 'GET', $redirected->method ); // The final request method should be GET - $this->assertEquals( 200, $redirected->response->status_code ); - }, 'redirect' ); - } - - /** - * Test invalid redirect URL. - */ - public function test_invalid_redirect_url() { - $this->withServer( function ( $url ) { - $client = new Client(); - $request = new Request( "$url/redirect/invalid-location" ); - $client->enqueue( $request ); - - $error_occurred = false; - while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { - switch ( $client->get_event() ) { - case Client::EVENT_FAILED: - $this->assertNotNull( $request->latest_redirect()->error ); - $this->assertStringContainsString( 'Invalid URL', $request->latest_redirect()->error->message ); - $error_occurred = true; - break 2; - } - } - $this->assertTrue( $error_occurred, 'Invalid redirect URL should have resulted in an error.' ); - }, 'redirect' ); - } - - /** - * Test Arrived at /new-path/resource.html. - */ - public function test_relative_path_redirect() { - $this->withServer( function ( $url ) { - $client = new Client(); - $request = new Request( "$url/redirect/relative-path-redirect" ); - - $body = $this->consume_entire_body( $client, $request ); - $this->assertEquals( 'Redirecting to new-path/resource.html', $body ); - $this->assertEquals( 302, $request->response->status_code ); - $this->assertStringContainsString( '/redirect/new-path/resource.html', $request->redirected_to->url ); - - $redirected_body = $this->consume_entire_body( $client, $request->redirected_to ); - $this->assertEquals( 'Arrived at /redirect/new-path/resource.html.', $redirected_body ); - $this->assertEquals( 200, $request->redirected_to->response->status_code ); - }, 'redirect' ); - } - /** * Test no body for 204 No Content status. */ diff --git a/components/HttpClient/Tests/RedirectingClientTest.php b/components/HttpClient/Tests/RedirectingClientTest.php new file mode 100644 index 00000000..2c19d505 --- /dev/null +++ b/components/HttpClient/Tests/RedirectingClientTest.php @@ -0,0 +1,905 @@ +start(); + for ($i = 0; $i < 20 && !@fsockopen('127.0.0.1', $port); $i++) { + usleep(50000); + } + try { $cb("http://127.0.0.1:$port"); } + finally { $p->stop(0); @unlink($tmp); } + } + + /** server that accepts and closes immediately – provokes fwrite() errors */ + private function withDroppingServer(callable $cb, int $port = 8971): void { + $tmp = tempnam(sys_get_temp_dir(), 'srv').'.php'; + file_put_contents($tmp, + <<start(); + for ($i = 0; $i < 20 && !@fsockopen('127.0.0.1', $port); $i++) usleep(50000); + try { $cb("http://127.0.0.1:$port"); } + finally { $p->stop(0); @unlink($tmp); } + } + + /** server that never answers – forces stream_select timeout */ + private function withSilentServer(callable $cb, int $port = 8972): void { + $tmp = tempnam(sys_get_temp_dir(), 'srv').'.php'; + file_put_contents($tmp, + <<start(); + for ($i = 0; $i < 20 && !@fsockopen('127.0.0.1', $port); $i++) usleep(50000); + try { $cb("http://127.0.0.1:$port"); } + finally { $p->stop(0); @unlink($tmp); } + } + + protected function withServer( callable $callback, $scenario = 'default', $host = '127.0.0.1', $port = 8950 ) { + $serverRoot = __DIR__ . '/test-server'; + $server = new Process( [ + 'php', + "$serverRoot/run.php", + $host, + $port, + $scenario, + ], $serverRoot ); + $server->start(); + try { + $attempts = 0; + while ( $server->isRunning() ) { + $output = $server->getIncrementalOutput(); + if ( strncmp( $output, 'Server started on http://', strlen( 'Server started on http://' ) ) === 0 ) { + break; + } + usleep( 40000 ); + if ( ++ $attempts > 20 ) { + $this->fail( 'Server did not start' ); + } + } + $callback( "http://{$host}:{$port}" ); + } finally { + $server->stop( 0 ); + } + } + + /** + * Helper to consume the entire response body for a request using the event loop. + * RedirectingClient emits body chunks from all requests in the redirect chain. + */ + protected function consume_entire_body( RedirectingClient $client, Request $request ) { + if($request->state === Request::STATE_CREATED) { + $client->enqueue( [ $request ] ); + } + $body = ''; + + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_BODY_CHUNK_AVAILABLE: + $chunk = $client->get_response_body_chunk(); + if ( $chunk !== false ) { // Ensure chunk is not false + $body .= $chunk; + } + break; + case RedirectingClient::EVENT_FAILED: + throw $request->latest_redirect()->error ?? $request->error; + case RedirectingClient::EVENT_FINISHED: + return $body; + case RedirectingClient::EVENT_REDIRECT: + // Continue processing redirects + break; + } + } + // If the loop finishes without EVENT_FINISHED, it means timeout or no more events + return $body; + } + + + + /** + * Test constructor with default options + */ + public function test_constructor_default() { + $client = new RedirectingClient(); + $this->assertInstanceOf( RedirectingClient::class, $client ); + } + + /** + * Test constructor with custom client and options + */ + public function test_constructor_with_options() { + $base_client = new Client(); + $client = new RedirectingClient( $base_client, [ 'max_redirects' => 5 ] ); + $this->assertInstanceOf( RedirectingClient::class, $client ); + } + + /** + * @dataProvider httpMethodProvider + */ + public function test_http_methods( $method ) { + $this->withServer( function ( $url ) use ( $method ) { + $client = new RedirectingClient(); + $request = new Request( "$url/echo-method", [ 'method' => $method ] ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( $method, $body ); + }, 'echo-method' ); + } + + public function httpMethodProvider() { + return [ + [ 'GET' ], + [ 'POST' ], + [ 'PUT' ], + [ 'DELETE' ], + [ 'PATCH' ], + [ 'OPTIONS' ], + [ 'HEAD' ], + ]; + } + + /** + * @dataProvider statusCodeProvider + */ + public function test_status_codes( $status, $expectedBody ) { + $this->withServer( function ( $url ) use ( $status, $expectedBody ) { + $client = new RedirectingClient(); + $request = new Request( "$url/status/$status" ); + + // For redirect status codes, we expect the RedirectingClient to handle them + if ( $status >= 300 && $status < 400 ) { + // These should be handled as redirects, not returned as-is + $this->markTestSkipped( 'Redirect status codes are handled automatically by RedirectingClient' ); + return; + } + + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( $status, $request->response->status_code ); + + if ( $expectedBody !== null ) { + $this->assertEquals( $expectedBody, $body ); + } + }, 'status' ); + } + + public function statusCodeProvider() { + return [ + [ 200, 'OK' ], + [ 204, '' ], // 204 No Content should have empty body + [ 400, 'Bad Request' ], + [ 404, 'Not Found' ], + [ 500, 'Internal Server Error' ], + ]; + } + + /** + * @dataProvider encodingProvider + */ + public function test_encodings( $encoding, $expectedBody ) { + $this->withServer( function ( $url ) use ( $encoding, $expectedBody ) { + $client = new RedirectingClient(); + $request = new Request( "$url/encoding/$encoding" ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( $expectedBody, $body ); + }, 'encoding' ); + } + + public function encodingProvider() { + return [ + [ 'identity', 'plain' ], + [ 'chunked', 'chunked' ], + [ 'gzip', 'gzipped' ], + [ 'deflate', 'deflated' ], + ]; + } + + public function test_unsupported_encoding() { + $this->withServer(function (string $base) { + $request = new Request( "$base/encoding/rot13" ); + $this->expectClientError($request, 300, [ + 'message' => 'Unsupported transfer encoding received from the server: rot13' + ]); + }, 'encoding'); + } + + /** + * @dataProvider errorProvider + */ + public function test_errors( $scenario, $expectedErrorSubstring ) { + $this->withServer( function ( $url ) use ( $scenario, $expectedErrorSubstring ) { + $client = new RedirectingClient( null, [ 'timeout_ms' => 1000 ] ); // Increased timeout for timeout tests + $request = new Request( "$url/error/$scenario" ); + $client->enqueue( [ $request ] ); + + $error_occurred = false; + while ( $client->await_next_event( [ 'requests' => [ $request ], 'timeout_ms' => 2000 ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_FAILED: + $error_occurred = true; + $this->assertNotNull( $request->error ); + $this->assertStringContainsString( $expectedErrorSubstring, $request->error->message ); + break 2; // Break out of switch and while + } + } + $this->assertTrue( $error_occurred, 'Request should have errored for scenario: ' . $scenario ); + }, 'error' ); + } + + public function errorProvider() { + return [ + 'Broken Connection' => [ 'broken-connection', 'Connection closed while reading response headers.' ], + 'Invalid Response' => [ 'invalid-response', 'Malformed HTTP headers received from the server.' ], + 'Timeout' => [ 'timeout', 'Request timed out' ], // Client-side timeout + 'Timeout Read Body' => [ 'timeout-read-body', 'Request timed out' ], // Timeout during body read + 'Unsupported Encoding' => [ 'unsupported-encoding', 'Unsupported transfer encoding received from the server: unsupported' ], + 'Incomplete Status Line' => [ 'incomplete-status-line', 'Malformed HTTP headers received from the server.' ], + 'Early EOF Headers' => [ 'early-eof-headers', 'Connection closed while reading response headers.' ], + ]; + } + + /** + * Test for connection refused. + */ + public function test_connection_refused() { + // Use a port that is highly unlikely to be in use + $port = 9999; + $host = '127.0.0.1'; + + $client = new RedirectingClient( null, [ 'timeout_ms' => 1000 ] ); // Short timeout for connection attempt + $request = new Request( "http://{$host}:{$port}/" ); + $client->enqueue( [ $request ] ); + + $error_occurred = false; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_FAILED: + $this->assertNotNull( $request->error ); + if( + false === strpos($request->error->message, 'Request timed out') && + false === strpos($request->error->message, 'Failed to write request bytes') && + false === strpos($request->error->message, 'Connection closed while reading response headers') + ) { + $this->fail('Unexpected error: ' . $request->error->message); + } + $error_occurred = true; + break 2; + } + } + $this->assertTrue( $error_occurred, 'Connection refused should have resulted in an error.' ); + } + + /** + * @dataProvider headerProvider + */ + public function test_headers( $headerName, $headerValue ) { + $this->withServer( function ( $url ) use ( $headerName, $headerValue ) { + $client = new RedirectingClient(); + $request = new Request( "$url/headers/$headerName" ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertStringContainsString( $headerValue, $body ); + }, 'headers' ); + } + + public function headerProvider() { + return [ + [ 'X-Test-Header', 'X-Test-Header: test-value' ], + [ 'X-Long-Header', 'X-Long-Header: ' . str_repeat( 'a', 1000 ) ], + [ 'X-Multi-Header', 'X-Multi-Header: value1,value2' ], + [ 'case-insensitivity', 'X-Test-Case: Value' ], // Test receiving case-insensitive header + ]; + } + + /** + * Test that multiple Set-Cookie headers are parsed correctly (as an array). + */ + public function test_multiple_set_cookie_headers() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/headers/multiple-set-cookie" ); + $client->enqueue( [ $request ] ); + + $headers_received = false; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_GOT_HEADERS: + $response = $request->response; + $this->assertNotNull( $response ); + $this->assertArrayHasKey( 'set-cookie', $response->headers ); + $this->assertEquals( 'cookie2=value2', $response->headers['set-cookie'] ); + $headers_received = true; + break; + case RedirectingClient::EVENT_FINISHED: + break 2; + } + } + $this->assertTrue( $headers_received, 'Set-Cookie headers should have been received.' ); + }, 'headers' ); + } + + /** + * Test receiving a very large header. + */ + public function test_large_response_header() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/error/large-headers" ); // Using error scenario for large header + $body = $this->consume_entire_body( $client, $request ); + + $this->assertEquals( 200, $request->response->status_code ); + $this->assertStringContainsString( 'Large headers sent.', $body ); + $this->assertArrayHasKey( 'x-large-header', $request->response->headers ); + $this->assertEquals( 8192, strlen($request->response->headers['x-large-header']) ); + }, 'error' ); // Using 'error' scenario to simulate a server sending large headers + } + + /** + * @dataProvider bodyProvider + */ + public function test_body_types( $type, $expectedLength ) { + $this->withServer( function ( $url ) use ( $type, $expectedLength ) { + $client = new RedirectingClient(); + $request = new Request( "$url/body/$type" ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( $expectedLength, strlen( $body ) ); + }, 'body' ); + } + + public function bodyProvider() { + return [ + [ 'empty', 0 ], + [ 'small', 5 ], + [ 'large', 10000 ], + [ 'binary', 256 ], + ]; + } + + /** + * @dataProvider streamingProvider + */ + public function test_streaming( $type, $expectedChunks ) { + $this->withServer( function ( $url ) use ( $type, $expectedChunks ) { + $client = new RedirectingClient(); + $request = new Request( "$url/stream/$type" ); + $client->enqueue( [ $request ] ); + $chunks = []; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_BODY_CHUNK_AVAILABLE: + $chunk = $client->get_response_body_chunk(); + if ( $chunk !== false ) { + $chunks[] = $chunk; + } + break; + case RedirectingClient::EVENT_FAILED: + throw $request->error; + case RedirectingClient::EVENT_FINISHED: + break 2; + } + } + $this->assertCount( $expectedChunks, $chunks ); + }, 'stream' ); + } + + public function streamingProvider() { + return [ + [ 'slow', 5 ], + [ 'fast', 10 ], + ]; + } + + // ========== REDIRECT-SPECIFIC TESTS ========== + + /** + * Test simple redirect (302). + */ + public function test_simple_redirect() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/redirect/chain-1" ); + $client->enqueue( [ $request ] ); + + $redirect_events = 0; + $final_body = ''; + + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_REDIRECT: + $redirect_events++; + break; + case RedirectingClient::EVENT_BODY_CHUNK_AVAILABLE: + $chunk = $client->get_response_body_chunk(); + if ( $chunk !== false ) { + $final_body .= $chunk; + } + break; + case RedirectingClient::EVENT_FINISHED: + break 2; + case RedirectingClient::EVENT_FAILED: + throw $request->error; + } + } + + // Should have followed redirects automatically + $this->assertGreaterThan( 0, $redirect_events ); + $this->assertEquals( 'Redirect 1Redirect 2Final Redirected Content!', $final_body ); + + // Check redirect chain + $this->assertNotNull( $request->redirected_to ); + $this->assertNotNull( $request->redirected_to->redirected_to ); + $this->assertEquals( 200, $request->redirected_to->redirected_to->response->status_code ); + }, 'redirect' ); + } + + /** + * Test redirect chain following. + */ + public function test_redirect_chain() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/redirect/chain-1" ); + $client->enqueue( [ $request ] ); + + $redirect_count = 0; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_REDIRECT: + $redirect_count++; + break; + case RedirectingClient::EVENT_FINISHED: + break 2; + case RedirectingClient::EVENT_FAILED: + throw $request->error; + } + } + + $this->assertEquals( 2, $redirect_count ); // chain-1 -> chain-2 -> final + + // Verify the redirect chain structure + $this->assertNotNull( $request->redirected_to ); + $this->assertEquals( 302, $request->response->status_code ); + + $this->assertNotNull( $request->redirected_to->redirected_to ); + $this->assertEquals( 302, $request->redirected_to->response->status_code ); + + $this->assertEquals( 200, $request->redirected_to->redirected_to->response->status_code ); + }, 'redirect' ); + } + + /** + * Test redirect loop with max_redirects limit. + */ + public function test_redirect_loop() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient( null, [ 'max_redirects' => 2, 'timeout_ms' => 20000 ] ); // Set a low redirect limit + $request = new Request( "$url/redirect/loop" ); + $client->enqueue( [ $request ] ); + + $error_occurred = false; + $redirect_count = 0; + + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_REDIRECT: + $redirect_count++; + break; + case RedirectingClient::EVENT_FAILED: + $latest_request = $request->latest_redirect(); + $this->assertNotNull( $latest_request->error ); + $this->assertStringContainsString( 'Too many redirects', $latest_request->error->message ); + $error_occurred = true; + break 2; + case RedirectingClient::EVENT_FINISHED: + break 2; + } + } + + // Check if any request in the chain has an error (redirect limit may be enforced without EVENT_FAILED) + if ( ! $error_occurred ) { + $current = $request; + while ( $current ) { + if ( $current->error ) { + $error_occurred = true; + $this->assertStringContainsString( 'Too many redirects', $current->error->message ); + break; + } + $current = $current->redirected_to; + } + } + + $this->assertTrue( $error_occurred, 'Redirect loop should have resulted in an error.' ); + $this->assertGreaterThanOrEqual( 2, $redirect_count, 'Should have attempted at least max_redirects redirects.' ); + }, 'redirect' ); + } + + /** + * Test POST request redirected to GET (303 See Other). + */ + public function test_post_to_get_redirect() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/redirect/post-to-get", [ 'method' => 'POST', 'body_stream' => new MemoryPipe('test body') ] ); + $client->enqueue( [ $request ] ); + + $redirect_occurred = false; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_REDIRECT: + $redirect_occurred = true; + break; + case RedirectingClient::EVENT_FINISHED: + break 2; + case RedirectingClient::EVENT_FAILED: + throw $request->error; + } + } + + $this->assertTrue( $redirect_occurred ); + $this->assertEquals( 'POST', $request->method ); + $this->assertEquals( 303, $request->response->status_code ); + + $redirected = $request->redirected_to; + $this->assertNotNull( $redirected ); + $this->assertEquals( 'GET', $redirected->method ); // The redirected request should be GET + }, 'redirect' ); + } + + /** + * Test invalid redirect URL. + */ + public function test_invalid_redirect_url() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/redirect/invalid-location" ); + $client->enqueue( [ $request ] ); + + $error_occurred = false; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_FAILED: + $this->assertNotNull( $request->latest_redirect()->error ); + // The error could be either "Invalid redirect URL" or "only HTTP and HTTPS URLs are supported" + $error_message = $request->latest_redirect()->error->message; + $this->assertTrue( + strpos($error_message, 'Invalid redirect URL') !== false || + strpos($error_message, 'only HTTP and HTTPS URLs are supported') !== false, + "Expected error about invalid URL, got: " . $error_message + ); + $error_occurred = true; + break 2; + } + } + $this->assertTrue( $error_occurred, 'Invalid redirect URL should have resulted in an error.' ); + }, 'redirect' ); + } + + /** + * Test relative path redirect. + */ + public function test_relative_path_redirect() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/redirect/relative-path-redirect" ); + $client->enqueue( [ $request ] ); + + $redirect_occurred = false; + $final_body = ''; + + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + switch ( $client->get_event() ) { + case RedirectingClient::EVENT_REDIRECT: + $redirect_occurred = true; + break; + case RedirectingClient::EVENT_BODY_CHUNK_AVAILABLE: + $chunk = $client->get_response_body_chunk(); + if ( $chunk !== false ) { + $final_body .= $chunk; + } + break; + case RedirectingClient::EVENT_FINISHED: + break 2; + case RedirectingClient::EVENT_FAILED: + throw $request->error; + } + } + + $this->assertTrue( $redirect_occurred ); + $this->assertEquals( 302, $request->response->status_code ); + $this->assertStringContainsString( '/redirect/new-path/resource.html', $request->redirected_to->url ); + $this->assertEquals( 'Redirecting to new-path/resource.htmlArrived at /redirect/new-path/resource.html.', $final_body ); + $this->assertEquals( 200, $request->redirected_to->response->status_code ); + }, 'redirect' ); + } + + /** + * Test that RedirectingClient properly handles await_next_event with redirect chains. + */ + public function test_await_next_event_with_redirect_chain() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/redirect/chain-1" ); + $client->enqueue( [ $request ] ); + + // Test that querying for the original request also includes redirected requests + $events = []; + while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + $events[] = $client->get_event(); + if ( $client->get_event() === RedirectingClient::EVENT_FINISHED ) { + break; + } + } + + $this->assertContains( RedirectingClient::EVENT_REDIRECT, $events ); + $this->assertContains( RedirectingClient::EVENT_FINISHED, $events ); + }, 'redirect' ); + } + + /** + * Test fetch method delegation. + */ + public function test_fetch_delegation() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/body/small" ); + $stream = $client->fetch( $request ); + + $this->assertInstanceOf( \WordPress\HttpClient\ByteStream\RequestReadStream::class, $stream ); + }, 'body' ); + } + + /** + * Test fetch_many method delegation. + */ + public function test_fetch_many_delegation() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $requests = [ + new Request( "$url/body/small" ), + new Request( "$url/body/empty" ), + ]; + $streams = $client->fetch_many( $requests ); + + $this->assertIsArray( $streams ); + $this->assertCount( 2, $streams ); + foreach ( $streams as $stream ) { + $this->assertInstanceOf( \WordPress\HttpClient\ByteStream\RequestReadStream::class, $stream ); + } + }, 'body' ); + } + + /** + * Test has_pending_event method delegation. + */ + public function test_has_pending_event_delegation() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/body/small" ); + $client->enqueue( [ $request ] ); + + // Initially should not have pending events + $this->assertFalse( $client->has_pending_event( $request, RedirectingClient::EVENT_FINISHED ) ); + }, 'body' ); + } + + /** + * Test event constants are properly defined. + */ + public function test_event_constants() { + $this->assertEquals( Client::EVENT_GOT_HEADERS, RedirectingClient::EVENT_GOT_HEADERS ); + $this->assertEquals( Client::EVENT_BODY_CHUNK_AVAILABLE, RedirectingClient::EVENT_BODY_CHUNK_AVAILABLE ); + $this->assertEquals( Client::EVENT_FAILED, RedirectingClient::EVENT_FAILED ); + $this->assertEquals( Client::EVENT_FINISHED, RedirectingClient::EVENT_FINISHED ); + $this->assertEquals( 'EVENT_REDIRECT', RedirectingClient::EVENT_REDIRECT ); + } + + /** + * Test no body for 204 No Content status. + */ + public function test_no_body_204() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/edge-cases/no-body-204" ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( 204, $request->response->status_code ); + $this->assertEmpty( $body ); + $this->assertNull( $request->response->total_bytes ); // Content-Length usually absent for 204 + }, 'edge-cases' ); + } + + /** + * Test no body for 304 Not Modified status. + */ + public function test_no_body_304() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/edge-cases/no-body-304" ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( 304, $request->response->status_code ); + $this->assertEmpty( $body ); + $this->assertNull( $request->response->total_bytes ); // Content-Length usually absent for 304 + }, 'edge-cases' ); + } + + /** + * Test response with Content-Length: 0. + */ + public function test_content_length_zero() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/edge-cases/content-length-zero" ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( 200, $request->response->status_code ); + $this->assertEquals( 0, $request->response->total_bytes ); + $this->assertEmpty( $body ); + }, 'edge-cases' ); + } + + /** + * Test HEAD request. + */ + public function test_head_request() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/edge-cases/head-request", [ 'method' => 'HEAD' ] ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( 200, $request->response->status_code ); + $this->assertEquals( 100, $request->response->total_bytes ); // Content-Length should be parsed + $this->assertEmpty( $body ); // Body should be empty for HEAD + }, 'edge-cases' ); + } + + /** + * Test Range request. + */ + public function test_range_request() { + $this->withServer( function ( $url ) { + $client = new RedirectingClient(); + $request = new Request( "$url/edge-cases/range-request", [ 'headers' => [ 'Range' => 'bytes=0-9' ] ] ); + $body = $this->consume_entire_body( $client, $request ); + $this->assertEquals( 206, $request->response->status_code ); + $this->assertEquals( '0123456789', $body ); + $this->assertEquals( 'bytes 0-9/100', $request->response->get_header( 'content-range' ) ); + }, 'edge-cases' ); + } + + public function test_invalid_scheme() { + $this->expectClientError(new Request('gopher://x'), 300, [ + 'message' => 'only HTTP and HTTPS URLs are supported:' + ]); + } + + public function test_dns_failure() { + $this->expectClientError(new Request('http://nope.' . uniqid() . '/'), 300, [ + 'message' => ['unable to open a stream to http://nope.', 'Request timed out'] + ]); + } + + /** + * @small + */ + public function test_ssl_handshake_failure() { + $this->withServer(function (string $base) { + $url = str_replace('http://', 'https://', $base).'/body/small'; + $this->expectClientError(new Request($url), 250, [ + 'message' => ['Request timed out', 'Failed to enable crypto'] + ]); + }, 'body'); + } + + public function test_write_failure() { + $this->withDroppingServer(function (string $base) { + $req = new Request("$base/submit", [ + 'body_stream' => new MemoryPipe(str_repeat('A', 262144)) + ]); + $req->method = 'POST'; + $this->expectClientError($req, null, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Broken pipe', 'Request timed out'] + ]); + }); + } + + public function test_malformed_status_line() { + $this->withRawResponse("HTP/1.1 200 OK\r\n\r\n", function (string $base) { + $this->expectClientError(new Request("$base/"), null, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Request timed out'] + ]); + }); + } + + public function test_malformed_headers() { + $this->withRawResponse("HTTP/1.1 200 OK\r\nBadHeader\r\n\r\n", function (string $base) { + $this->expectClientError(new Request("$base/"), null, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Request timed out'] + ]); + }); + } + + public function test_eof_mid_headers() { + $this->withRawResponse("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n", function (string $base) { + $this->expectClientError(new Request("$base/"), null, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Request timed out'] + ]); + }); + } + + public function test_invalid_chunk_size() { + $body = "Z\r\nHELLO\r\n0\r\n\r\n"; + $this->withRawResponse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n$body", function (string $base) { + $this->expectClientError(new Request("$base/"), null, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Request timed out'] + ]); + }); + } + + public function test_missing_last_chunk() { + $body = "5\r\nHELLO\r\n"; // no terminating 0-chunk + $this->withRawResponse("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n$body", function (string $base) { + $this->expectClientError(new Request("$base/"), 300, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Request timed out'] + ]); + }); + } + + public function test_corrupted_gzip() { + $raw = "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: 4\r\n\r\nBAD!"; + $this->withRawResponse($raw, function (string $base) { + $this->expectClientError(new Request("$base/"), null, [ + 'message' => ['Failed to write request bytes', 'Connection closed while reading response headers', 'Request timed out'] + ]); + }); + } + + /* ---------- tiny glue ---------- */ + + private function expectClientError(Request $req, ?float $timeout_ms = null, array $opts = []): void { + if ($timeout_ms !== null) $opts['timeout_ms'] = $timeout_ms; + $client = new RedirectingClient(null, $opts); + try { + $this->consume_entire_body($client, $req); + $this->fail('Expected error not thrown'); + } catch (HttpError $e) { + if (isset($opts['message']) && is_array($opts['message'])) { + $found = false; + foreach ($opts['message'] as $msg) { + if (strpos($e->message, $msg) !== false) { + $found = true; + break; + } + } + $this->assertTrue($found, "None of the expected messages found in error: " . $e->message); + } else { + $this->assertStringContainsString($opts['message'] ?? 'Error', $e->message); + } + } + } +} \ No newline at end of file From a3c5f82b4bc3a8ad103bd1b703a98d3047ec5f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 May 2025 01:34:11 +0200 Subject: [PATCH 3/7] Test CacheClient --- components/HttpClient/CacheClient.php | 155 ++++- .../HttpClient/Tests/CacheClientTest.php | 644 ++++++++++++++++++ .../HttpClient/Tests/test-server/run.php | 108 +++ 3 files changed, 880 insertions(+), 27 deletions(-) create mode 100644 components/HttpClient/Tests/CacheClientTest.php diff --git a/components/HttpClient/CacheClient.php b/components/HttpClient/CacheClient.php index 9f615140..54a6317f 100644 --- a/components/HttpClient/CacheClient.php +++ b/components/HttpClient/CacheClient.php @@ -28,8 +28,13 @@ final class CacheClient { public function __construct( Client $upstream, string $cacheDir ) { $this->upstream = $upstream; $this->dir = rtrim( $cacheDir, '/' ); - if ( ! is_dir( $this->dir ) && ! mkdir( $this->dir, 0777, true ) ) { - throw new RuntimeException( "cannot create cache dir {$this->dir}" ); + if ( ! is_dir( $this->dir ) ) { + if ( file_exists( $this->dir ) ) { + throw new RuntimeException( "cannot create cache dir {$this->dir}: path exists but is not a directory" ); + } + if ( ! mkdir( $this->dir, 0777, true ) ) { + throw new RuntimeException( "cannot create cache dir {$this->dir}" ); + } } } @@ -118,6 +123,19 @@ private function startReplay( Request $request, array $meta ): void { ]; } + private function start304Replay( Request $request, array $meta ): void { + $id = spl_object_hash( $request ); + $file_handle = fopen( $this->bodyPath( $request->cache_key ), 'rb' ); + $this->replay[ $id ] = [ + 'req' => $request, + 'meta' => $meta, + 'file' => $file_handle, + 'headerDone' => true, // Skip header emission for 304 + 'done' => false, + 'is304' => true, // Mark as 304 replay + ]; + } + private function fromCache( string $id ): void { $context =& $this->replay[ $id ]; $this->request = $context['req']; @@ -143,6 +161,11 @@ private function fromCache( string $id ): void { $context['done'] = true; $this->event = self::EVENT_FINISH; $this->response = $this->cache_key = null; + + // For 304 replays, we don't want to override the response + if ( isset( $context['is304'] ) && $context['is304'] ) { + $this->response = null; + } } /*============ NETWORK HANDLING ============*/ @@ -155,12 +178,23 @@ private function handleNetwork(): bool { if ( $response->status_code === 304 && isset( $request->cache_key ) ) { [ , $meta ] = $this->lookup( $request, $request->cache_key ); if ( $meta ) { - $this->startReplay( $request, $meta ); /* swallow 304 events */ - - return false; + // For 304, expose the 304 response and start a special replay that serves cached body + $this->event = $event; + $this->request = $request; + $this->response = $response; + $this->cache_key = null; + $this->start304Replay( $request, $meta ); + return true; } } - if ( $response->status_code === 200 && $this->cacheable( $response ) ) { + if ( $this->cacheable( $response ) ) { + // Update cache key based on vary headers if present + $vary = $response->get_header( 'Vary' ); + if ( $vary ) { + $vary_keys = array_map( 'trim', explode( ',', $vary ) ); + $request->cache_key = $this->varyKey( $request, $vary_keys ); + } + $tmp = $this->tempPath( $request->cache_key ); $this->tempPath[ spl_object_hash( $request ) ] = $tmp; @@ -228,7 +262,8 @@ private function varyKey( Request $request, ?array $vary_keys ): string { $parts = [ $request->url ]; if ( $vary_keys ) { foreach ( $vary_keys as $header_name ) { - $parts[] = strtolower( $header_name ) . ':' . $request->get_header( $header_name ); + $header_value = $request->get_header( strtolower( $header_name ) ); + $parts[] = strtolower( $header_name ) . ':' . ( $header_value ?? '' ); } } @@ -254,6 +289,27 @@ private function lookup( Request $request, ?string $forced = null ): array { private function fresh( array $meta ): bool { $now = time(); + // Check for must-revalidate directive - if present, never consider fresh without explicit expiry + $cache_control = $meta['headers']['cache-control'] ?? ''; + $directives = self::directives( $cache_control ); + if ( isset( $directives['must-revalidate'] ) ) { + // With must-revalidate, only consider fresh if we have explicit expiry info + if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['max_age']) > $now; + } + if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + } + if ( isset( $meta['expires'] ) ) { + $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); + if ( $expires !== false ) { + return $expires > $now; + } + } + // With must-revalidate, don't use heuristic caching + return false; + } + // If explicit expiry timestamp is set, use it if ( isset( $meta['expires'] ) ) { $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); @@ -306,38 +362,40 @@ private function addValidators( Request $request, array $meta ): void { } } - protected function commit( Request $request ) { + protected function commit( Request $request, Response $response, string $tempFile ) { $url = $request->url; $meta = [ 'url' => $url, - 'status' => $request->response->status_code, - 'headers' => $request->response->headers, + 'status' => $response->status_code, + 'headers' => $response->headers, 'stored_at' => time(), - 'etag' => $request->response->get_header( 'ETag' ), - 'last_modified' => $request->response->get_header( 'Last-Modified' ), + 'etag' => $response->get_header( 'ETag' ), + 'last_modified' => $response->get_header( 'Last-Modified' ), ]; + + // Check for Vary header and store vary keys + $vary = $response->get_header( 'Vary' ); + if ( $vary ) { + $meta['vary'] = array_map( 'trim', explode( ',', $vary ) ); + } + // Parse Cache-Control for max-age, if present - $cacheControl = $request->response->get_header( 'Cache-Control' ); + $cacheControl = $response->get_header( 'Cache-Control' ); if ( $cacheControl ) { $directives = self::directives( $cacheControl ); if ( isset( $directives['max-age'] ) && is_int( $directives['max-age'] ) ) { $meta['max_age'] = $directives['max-age']; } + if ( isset( $directives['s-maxage'] ) && is_int( $directives['s-maxage'] ) ) { + $meta['s_maxage'] = $directives['s-maxage']; + } } // Determine file paths $key = $request->cache_key; $bodyFile = $this->bodyPath( $key ); - $tempFile = $this->tempPath( $key ); $metaFile = $this->metaPath( $key ); - // Close the temp body stream if open (flushes data) - $file_handle = $this->tempHandle[ spl_object_hash( $request ) ]; - if ( $file_handle && is_resource( $file_handle ) ) { - fclose( $file_handle ); - } - unset( $this->tempHandle[ spl_object_hash( $request ) ] ); - // Atomically replace/rename the temp body file to final cache file if ( ! rename( $tempFile, $bodyFile ) ) { // Handle error (e.g., log failure and abort caching) @@ -359,6 +417,12 @@ protected function commit( Request $request ) { } public function invalidateCache( Request $request ): void { + // Generate cache key if not already set + if ( ! isset( $request->cache_key ) ) { + [ $key, ] = $this->lookup( $request ); + $request->cache_key = $key; + } + $key = $request->cache_key; $bodyFile = $this->bodyPath( $key ); $metaFile = $this->metaPath( $key ); @@ -387,14 +451,44 @@ public static function directives( ?string $value ): array { return []; } $out = []; - foreach ( explode( ',', $value ) as $part ) { + + // Handle quoted values properly by not splitting on commas inside quotes + $parts = []; + $current = ''; + $in_quotes = false; + $quote_char = null; + + for ( $i = 0; $i < strlen( $value ); $i++ ) { + $char = $value[ $i ]; + + if ( ! $in_quotes && ( $char === '"' || $char === "'" ) ) { + $in_quotes = true; + $quote_char = $char; + $current .= $char; + } elseif ( $in_quotes && $char === $quote_char ) { + $in_quotes = false; + $quote_char = null; + $current .= $char; + } elseif ( ! $in_quotes && $char === ',' ) { + $parts[] = trim( $current ); + $current = ''; + } else { + $current .= $char; + } + } + + if ( $current !== '' ) { + $parts[] = trim( $current ); + } + + foreach ( $parts as $part ) { $part = trim( $part ); if ( $part === '' ) { continue; } if ( strpos( $part, '=' ) !== false ) { [ $k, $v ] = array_map( 'trim', explode( '=', $part, 2 ) ); - $out[ strtolower( $k ) ] = ctype_digit( $v ) ? (int) $v : strtolower( $v ); + $out[ strtolower( $k ) ] = ctype_digit( $v ) ? (int) $v : $v; } else { $out[ strtolower( $part ) ] = true; } @@ -405,10 +499,12 @@ public static function directives( ?string $value ): array { public static function response_is_cacheable( Response $r ): bool { $req = $r->request; - if ( $req->method !== 'GET' ) { + if ( $req->method !== 'GET' && $req->method !== 'HEAD' ) { return false; } - if ( $r->status_code !== 200 && $r->status_code !== 206 ) { + + // Allow caching of successful responses and redirects + if ( ! ( ( $r->status_code >= 200 && $r->status_code < 300 ) || ( $r->status_code >= 300 && $r->status_code < 400 ) ) ) { return false; } @@ -420,8 +516,13 @@ public static function response_is_cacheable( Response $r ): bool { return true; } - // heuristic: if Last-Modified present and older than 24 h cache for 10 % - return (bool) $r->get_header( 'last-modified' ); + // Cache responses with validation headers (ETag or Last-Modified) + if ( $r->get_header( 'etag' ) || $r->get_header( 'last-modified' ) ) { + return true; + } + + // Not cacheable by any rule + return false; } } diff --git a/components/HttpClient/Tests/CacheClientTest.php b/components/HttpClient/Tests/CacheClientTest.php new file mode 100644 index 00000000..65ec637f --- /dev/null +++ b/components/HttpClient/Tests/CacheClientTest.php @@ -0,0 +1,644 @@ +cacheDir = sys_get_temp_dir() . '/cache_client_test_' . uniqid(); + if ( ! is_dir( $this->cacheDir ) ) { + mkdir( $this->cacheDir, 0777, true ); + } + } + + protected function tearDown(): void { + parent::tearDown(); + $this->cleanupCacheDir(); + } + + private function cleanupCacheDir(): void { + if ( is_dir( $this->cacheDir ) ) { + $files = glob( $this->cacheDir . '/*' ); + foreach ( $files as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + rmdir( $this->cacheDir ); + } + } + + protected function withServer( callable $callback, $scenario = 'default', $host = '127.0.0.1', $port = 8950 ) { + $serverRoot = __DIR__ . '/test-server'; + $server = new Process( [ + 'php', + "$serverRoot/run.php", + $host, + $port, + $scenario, + ], $serverRoot ); + $server->start(); + try { + $attempts = 0; + while ( $server->isRunning() ) { + $output = $server->getIncrementalOutput(); + if ( strncmp( $output, 'Server started on http://', strlen( 'Server started on http://' ) ) === 0 ) { + break; + } + usleep( 40000 ); + if ( ++ $attempts > 20 ) { + $this->fail( 'Server did not start' ); + } + } + $callback( "http://{$host}:{$port}" ); + } finally { + $server->stop( 0 ); + } + } + + /** + * Helper to consume the entire response body for a request using the event loop. + */ + protected function request( CacheClient $client, Request $request ) { + $client->enqueue( $request ); + $body = ''; + + while ( $client->await_next_event() ) { + switch ( $client->get_event() ) { + case CacheClient::EVENT_HEADERS: + // Store the response when headers are received + $request->response = $client->get_response(); + break; + case CacheClient::EVENT_BODY: + $chunk = $client->get_response_body_chunk(); + if ( $chunk !== false ) { + $body .= $chunk; + } + break; + case CacheClient::EVENT_FINISH: + // Ensure response is set if not already + if ( ! $request->response ) { + $request->response = $client->get_response(); + } + return $body; + } + } + return $body; + } + + /** + * Test constructor creates cache directory + */ + public function test_constructor_creates_cache_dir() { + $tempDir = sys_get_temp_dir() . '/test_cache_' . uniqid(); + $upstream = new Client(); + $client = new CacheClient( $upstream, $tempDir ); + + $this->assertTrue( is_dir( $tempDir ) ); + + // Cleanup + rmdir( $tempDir ); + } + + /** + * Test constructor throws exception for invalid cache directory + */ + public function test_constructor_invalid_cache_dir() { + $upstream = new Client(); + + // Create a file first, then try to create a directory with the same name + $invalidDir = sys_get_temp_dir() . '/invalid_cache_dir_' . uniqid(); + file_put_contents( $invalidDir, 'test' ); + + $this->expectException( \RuntimeException::class ); + $this->expectExceptionMessage( 'path exists but is not a directory' ); + + try { + new CacheClient( $upstream, $invalidDir ); + } finally { + // Cleanup + if ( file_exists( $invalidDir ) ) { + unlink( $invalidDir ); + } + } + } + + /** + * Test basic caching with max-age + */ + public function test_basic_caching_max_age() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/max-age" ); + + // First request - should hit the server + $body1 = $this->request( $client, $request ); + $this->assertEquals( 'Cached for 1 hour', $body1 ); + $this->assertEquals( 200, $request->response->status_code ); + + // Second request - should be served from cache + $request2 = new Request( "$url/cache/max-age" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Cached for 1 hour', $body2 ); + $this->assertEquals( 200, $request2->response->status_code ); + + // Verify cache files exist + $this->assertGreaterThan( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + $this->assertGreaterThan( 0, count( glob( $this->cacheDir . '/*.body' ) ) ); + }, 'cache' ); + } + + /** + * Test caching with Expires header + */ + public function test_caching_expires_header() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/expires" ); + + // First request + $body1 = $this->request( $client, $request ); + $this->assertEquals( 'Expires in 1 hour', $body1 ); + + // Second request - should be cached + $request2 = new Request( "$url/cache/expires" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Expires in 1 hour', $body2 ); + }, 'cache' ); + } + + /** + * Test no-store directive prevents caching + */ + public function test_no_store_prevents_caching() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/no-store" ); + $body = $this->request( $client, $request ); + $this->assertEquals( 'Never stored', $body ); + + // Verify no cache files were created + $this->assertEquals( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + $this->assertEquals( 0, count( glob( $this->cacheDir . '/*.body' ) ) ); + }, 'cache' ); + } + + /** + * Test ETag validation + */ + public function test_etag_validation() { + $this->withServer( function ( $url ) { + $client = new CacheClient( new Client(), $this->cacheDir ); + + $request = new Request( "$url/cache/etag" ); + + // First request - should get full response + $body1 = $this->request( $client, $request ); + $this->assertEquals( 'ETag response', $body1 ); + $this->assertEquals( 200, $request->response->status_code ); + + // Second request - should get 304 and serve from cache + $request2 = new Request( "$url/cache/etag" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'ETag response', $body2 ); + $this->assertEquals( 304, $request2->response->status_code ); + }, 'cache' ); + } + + /** + * Test Last-Modified validation + */ + public function test_last_modified_validation() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/last-modified" ); + + // First request + $body1 = $this->request( $client, $request ); + $this->assertEquals( 'Last-Modified response', $body1 ); + $this->assertEquals( 200, $request->response->status_code ); + + // Second request - should get 304 and serve from cache + $request2 = new Request( "$url/cache/last-modified" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Last-Modified response', $body2 ); + $this->assertEquals( 304, $request2->response->status_code ); + }, 'cache' ); + } + + /** + * Test Vary header with Accept + */ + public function test_vary_header_accept() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + // Request with Accept: text/plain + $request1 = new Request( "$url/cache/vary-accept", [ 'headers' => [ 'Accept' => 'text/plain' ] ] ); + $body1 = $this->request( $client, $request1 ); + $this->assertEquals( 'Text response', $body1 ); + + // Request with Accept: application/json - should get different response + $request2 = new Request( "$url/cache/vary-accept", [ 'headers' => [ 'Accept' => 'application/json' ] ] ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( '{"message": "JSON response"}', $body2 ); + + // Same request as first - should be cached + $request3 = new Request( "$url/cache/vary-accept", [ 'headers' => [ 'Accept' => 'text/plain' ] ] ); + $body3 = $this->request( $client, $request3 ); + $this->assertEquals( 'Text response', $body3 ); + + // Verify multiple cache entries exist (different vary keys) + $this->assertGreaterThan( 1, count( glob( $this->cacheDir . '/*.json' ) ) ); + }, 'cache' ); + } + + /** + * Test Vary header with User-Agent + */ + public function test_vary_header_user_agent() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + // Desktop request + $request1 = new Request( "$url/cache/vary-user-agent", [ 'headers' => [ 'User-Agent' => 'Desktop Browser' ] ] ); + $body1 = $this->request( $client, $request1 ); + $this->assertEquals( 'Desktop response', $body1 ); + + // Mobile request + $request2 = new Request( "$url/cache/vary-user-agent", [ 'headers' => [ 'User-Agent' => 'Mobile Browser' ] ] ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Mobile response', $body2 ); + + // Same desktop request - should be cached + $request3 = new Request( "$url/cache/vary-user-agent", [ 'headers' => [ 'User-Agent' => 'Desktop Browser' ] ] ); + $body3 = $this->request( $client, $request3 ); + $this->assertEquals( 'Desktop response', $body3 ); + }, 'cache' ); + } + + /** + * Test 301 redirect caching + */ + public function test_301_redirect_caching() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/redirect-301" ); + + // First request - should get redirect response + $body1 = $this->request( $client, $request ); + $this->assertEquals( 'Permanent redirect', $body1 ); + $this->assertEquals( 301, $request->response->status_code ); + + // Second request - should be served from cache + $request2 = new Request( "$url/cache/redirect-301" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Permanent redirect', $body2 ); + $this->assertEquals( 301, $request2->response->status_code ); + + // Verify cache files exist + $this->assertGreaterThan( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + }, 'cache' ); + } + + /** + * Test heuristic caching with Last-Modified + */ + public function test_heuristic_caching() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/heuristic" ); + + // First request + $body1 = $this->request( $client, $request ); + $this->assertEquals( 'Heuristic caching', $body1 ); + + // Second request - should be cached based on heuristic + $request2 = new Request( "$url/cache/heuristic" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Heuristic caching', $body2 ); + }, 'cache' ); + } + + /** + * Test cache invalidation on POST request + */ + public function test_cache_invalidation_post() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + // First GET request - should be cached + $request1 = new Request( "$url/cache/post-invalidate" ); + $body1 = $this->request( $client, $request1 ); + $this->assertEquals( 'GET response - cacheable', $body1 ); + + // Verify cache files exist + $this->assertGreaterThan( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + + // POST request - should invalidate cache + $request2 = new Request( "$url/cache/post-invalidate", [ 'method' => 'POST' ] ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'POST response - cache invalidated', $body2 ); + + // Verify cache files were removed + $this->assertEquals( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + }, 'cache' ); + } + + /** + * Test only GET and HEAD requests are cached + */ + public function test_only_get_head_cached() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + // POST request - should not be cached + $request = new Request( "$url/cache/max-age", [ 'method' => 'POST' ] ); + $client->enqueue( $request ); + + while ( $client->await_next_event() ) { + if ( $client->get_event() === CacheClient::EVENT_FINISH ) { + break; + } + } + + // Verify no cache files were created + $this->assertEquals( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + }, 'cache' ); + } + + /** + * Test HEAD request caching + */ + public function test_head_request_caching() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + // HEAD request + $request = new Request( "$url/cache/max-age", [ 'method' => 'HEAD' ] ); + $client->enqueue( $request ); + + while ( $client->await_next_event() ) { + if ( $client->get_event() === CacheClient::EVENT_FINISH ) { + break; + } + } + + $this->assertEquals( 200, $request->response->status_code ); + + // Verify cache files exist + $this->assertGreaterThan( 0, count( glob( $this->cacheDir . '/*.json' ) ) ); + }, 'cache' ); + } + + /** + * Test event constants + */ + public function test_event_constants() { + $this->assertEquals( Client::EVENT_GOT_HEADERS, CacheClient::EVENT_HEADERS ); + $this->assertEquals( Client::EVENT_BODY_CHUNK_AVAILABLE, CacheClient::EVENT_BODY ); + $this->assertEquals( Client::EVENT_FINISHED, CacheClient::EVENT_FINISH ); + } + + /** + * Test cache key generation + */ + public function test_cache_key_generation() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request = new Request( "$url/cache/max-age" ); + $this->request( $client, $request ); + + // Verify cache key was set + $this->assertNotNull( $request->cache_key ); + $this->assertIsString( $request->cache_key ); + }, 'cache' ); + } + + /** + * Test response_is_cacheable static method + */ + public function test_response_is_cacheable() { + // Create mock request and response + $request = new Request( 'http://example.com/test' ); + $response = new \WordPress\HttpClient\Response( $request ); + $response->status_code = 200; + $response->headers = [ 'cache-control' => 'max-age=3600' ]; + + $this->assertTrue( CacheClient::response_is_cacheable( $response ) ); + + // Test non-GET request + $request->method = 'POST'; + $this->assertFalse( CacheClient::response_is_cacheable( $response ) ); + + // Test no-store + $request->method = 'GET'; + $response->headers = [ 'cache-control' => 'no-store' ]; + $this->assertFalse( CacheClient::response_is_cacheable( $response ) ); + + // Test non-200 status + $response->status_code = 404; + $response->headers = [ 'cache-control' => 'max-age=3600' ]; + $this->assertFalse( CacheClient::response_is_cacheable( $response ) ); + + // Test 206 status (partial content) + $response->status_code = 206; + $this->assertTrue( CacheClient::response_is_cacheable( $response ) ); + + // Test with Last-Modified (heuristic caching) + $response->status_code = 200; + $response->headers = [ 'last-modified' => 'Wed, 01 Jan 2020 00:00:00 GMT' ]; + $this->assertTrue( CacheClient::response_is_cacheable( $response ) ); + } + + /** + * Test directives parsing + */ + public function test_directives_parsing() { + $directives = CacheClient::directives( 'max-age=3600, no-cache, private' ); + $expected = [ + 'max-age' => 3600, + 'no-cache' => true, + 'private' => true, + ]; + $this->assertEquals( $expected, $directives ); + + // Test with quoted values + $directives = CacheClient::directives( 'max-age=0, no-cache="field1,field2"' ); + $expected = [ + 'max-age' => 0, + 'no-cache' => '"field1,field2"', + ]; + $this->assertEquals( $expected, $directives ); + + // Test null input + $this->assertEquals( [], CacheClient::directives( null ) ); + + // Test empty string + $this->assertEquals( [], CacheClient::directives( '' ) ); + } + + /** + * Test cache with multiple requests + */ + public function test_multiple_requests_caching() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $requests = [ + new Request( "$url/cache/max-age" ), + new Request( "$url/cache/expires" ), + ]; + + $client->enqueue( $requests ); + + $bodies = []; + while ( $client->await_next_event() ) { + switch ( $client->get_event() ) { + case CacheClient::EVENT_BODY: + $request = $client->get_request(); + if ( ! isset( $bodies[ $request->url ] ) ) { + $bodies[ $request->url ] = ''; + } + $bodies[ $request->url ] .= $client->get_response_body_chunk(); + break; + case CacheClient::EVENT_FINISH: + // Continue until all requests are done + break; + } + + // Check if all requests are done + $all_done = true; + foreach ( $requests as $req ) { + if ( $req->state !== \WordPress\HttpClient\Request::STATE_FINISHED ) { + $all_done = false; + break; + } + } + if ( $all_done ) { + break; + } + } + + $this->assertCount( 2, $bodies ); + $this->assertStringContainsString( 'Cached for 1 hour', $bodies[ "$url/cache/max-age" ] ); + $this->assertStringContainsString( 'Expires in 1 hour', $bodies[ "$url/cache/expires" ] ); + }, 'cache' ); + } + + /** + * Test cache miss and hit scenario + */ + public function test_cache_miss_and_hit() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request1 = new Request( "$url/cache/max-age" ); + + // First request - cache miss + $start_time = microtime( true ); + $body1 = $this->request( $client, $request1 ); + $first_duration = microtime( true ) - $start_time; + + $this->assertEquals( 'Cached for 1 hour', $body1 ); + + // Second request - cache hit (should be faster) + $request2 = new Request( "$url/cache/max-age" ); + $start_time = microtime( true ); + $body2 = $this->request( $client, $request2 ); + $second_duration = microtime( true ) - $start_time; + + $this->assertEquals( 'Cached for 1 hour', $body2 ); + + // Cache hit should be significantly faster (though this is not always reliable in tests) + // We'll just verify the content is the same + $this->assertEquals( $body1, $body2 ); + }, 'cache' ); + } + + public function test_cache_with_304_status() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + $request1 = new Request( "$url/cache/etag" ); + $body1 = $this->request( $client, $request1 ); + $this->assertEquals( 'ETag response', $body1 ); + $this->assertEquals( 200, $request1->response->status_code ); + + // Second request - should get 304 and serve from cache + $request2 = new Request( "$url/cache/etag" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'ETag response', $body2 ); + $this->assertEquals( 304, $request2->response->status_code ); + }, 'cache' ); + } + + public function test_cache_validation_headers() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + $client = new CacheClient( $upstream, $this->cacheDir ); + + // First request - should cache the response + $request1 = new Request( "$url/cache/last-modified" ); + $body1 = $this->request( $client, $request1 ); + $this->assertEquals( 'Last-Modified response', $body1 ); + $this->assertEquals( 200, $request1->response->status_code ); + + // Second request - should have validation headers set + $request2 = new Request( "$url/cache/last-modified" ); + $body2 = $this->request( $client, $request2 ); + $this->assertEquals( 'Last-Modified response', $body2 ); + + // Check that validation headers were added to the second request + $this->assertArrayHasKey( 'If-Modified-Since', $request2->headers ); + }, 'cache' ); + } + + public function test_server_304_response() { + $this->withServer( function ( $url ) { + $upstream = new Client(); + + // Test that the server can return 304 when we manually send the right headers + $request = new Request( "$url/cache/etag", [ 'headers' => [ 'If-None-Match' => '"test-etag-123"' ] ] ); + $client = new CacheClient( $upstream, $this->cacheDir ); + $body = $this->request( $client, $request ); + + $this->assertEquals( 304, $request->response->status_code ); + $this->assertEquals( '', $body ); // 304 responses have no body + }, 'cache' ); + } + + +} \ No newline at end of file diff --git a/components/HttpClient/Tests/test-server/run.php b/components/HttpClient/Tests/test-server/run.php index c8c9074a..a663a48e 100644 --- a/components/HttpClient/Tests/test-server/run.php +++ b/components/HttpClient/Tests/test-server/run.php @@ -296,6 +296,114 @@ $response->append_bytes( 'Not Found' ); } break; + case 'cache': + $type = basename( $path ); + if ( $type === 'max-age' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Cached for 1 hour' ); + } elseif ( $type === 'no-cache' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'no-cache' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Not cached' ); + } elseif ( $type === 'no-store' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'no-store' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Never stored' ); + } elseif ( $type === 'expires' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Expires', gmdate( 'D, d M Y H:i:s', time() + 3600 ) . ' GMT' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Expires in 1 hour' ); + } elseif ( $type === 'etag' ) { + $etag = '"test-etag-123"'; + // Try both case variations + $if_none_match = $request->get_header( 'if-none-match' ) ?: $request->get_header( 'If-None-Match' ); + + + + if ( $if_none_match === $etag ) { + $response->send_http_code( 304 ); + $response->send_header( 'ETag', $etag ); + // No body for 304 + } else { + $response->send_http_code( 200 ); + $response->send_header( 'ETag', $etag ); + $response->send_header( 'Cache-Control', 'must-revalidate' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'ETag response' ); + } + } elseif ( $type === 'last-modified' ) { + $last_modified = 'Wed, 01 Jan 2020 00:00:00 GMT'; + // Try both case variations + $if_modified_since = $request->get_header( 'if-modified-since' ) ?: $request->get_header( 'If-Modified-Since' ); + if ( $if_modified_since === $last_modified ) { + $response->send_http_code( 304 ); + $response->send_header( 'Last-Modified', $last_modified ); + // No body for 304 + } else { + $response->send_http_code( 200 ); + $response->send_header( 'Last-Modified', $last_modified ); + $response->send_header( 'Cache-Control', 'must-revalidate' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Last-Modified response' ); + } + } elseif ( $type === 'vary-accept' ) { + $accept = $request->get_header( 'accept' ); + $response->send_http_code( 200 ); + $response->send_header( 'Vary', 'Accept' ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + if ( $accept === 'application/json' ) { + $response->append_bytes( '{"message": "JSON response"}' ); + } else { + $response->append_bytes( 'Text response' ); + } + } elseif ( $type === 'vary-user-agent' ) { + $user_agent = $request->get_header( 'user-agent' ); + $response->send_http_code( 200 ); + $response->send_header( 'Vary', 'User-Agent' ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + if ( strpos( $user_agent, 'Mobile' ) !== false ) { + $response->append_bytes( 'Mobile response' ); + } else { + $response->append_bytes( 'Desktop response' ); + } + } elseif ( $type === 'redirect-301' ) { + $response->send_http_code( 301 ); + $response->send_header( 'Location', '/cache/redirect-target' ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->append_bytes( 'Permanent redirect' ); + } elseif ( $type === 'redirect-target' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Redirect target content' ); + } elseif ( $type === 'heuristic' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Last-Modified', 'Wed, 01 Jan 2020 00:00:00 GMT' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Heuristic caching' ); + } elseif ( $type === 'post-invalidate' ) { + if ( $request->method === 'POST' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'POST response - cache invalidated' ); + } else { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'GET response - cacheable' ); + } + } else { + $response->send_http_code( 404 ); + $response->append_bytes( 'Cache endpoint not found' ); + } + break; case 'edge-cases': $type = basename( $path ); if ( $type === 'no-body-204' ) { From b894121ee42f875db5e576bcabfe7b9de80cbd38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 30 May 2025 11:08:18 +0200 Subject: [PATCH 4/7] Support HTTP cache as a middleware --- components/HttpClient/Client.php | 13 +- .../HttpClient/Middleware/CacheMiddleware.php | 476 ++++++++++++++++++ .../HttpClient/Middleware/HttpMiddleware.php | 4 +- .../Middleware/RedirectionMiddleware.php | 4 +- .../Transport/TransportInterface.php | 2 - 5 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 components/HttpClient/Middleware/CacheMiddleware.php diff --git a/components/HttpClient/Client.php b/components/HttpClient/Client.php index a2a73b63..5ee7af08 100644 --- a/components/HttpClient/Client.php +++ b/components/HttpClient/Client.php @@ -4,6 +4,7 @@ use WordPress\DataLiberation\URL\WPURL; use WordPress\HttpClient\ByteStream\RequestReadStream; +use WordPress\HttpClient\Middleware\CacheMiddleware; use WordPress\HttpClient\Middleware\HttpMiddleware; use WordPress\HttpClient\Middleware\RedirectionMiddleware; use WordPress\HttpClient\Transport\CurlTransport; @@ -50,9 +51,17 @@ public function __construct( $options = array() ) { throw new HttpClientException( "Invalid transport: {$options['transport']}" ); } + $middleware = new HttpMiddleware( $this->state, array( 'transport' => $transport ) ); + if(isset($options['cache_dir'])) { + $middleware = new CacheMiddleware( $this->state, $middleware, [ + 'cache_dir' => $options['cache_dir'], + ] ); + } + $this->middleware = new RedirectionMiddleware( - new HttpMiddleware( array( 'state' => $this->state, 'transport' => $transport ) ), - array( 'client' => $this, 'state' => $this->state, 'max_redirects' => 5 ) + $this->state, + $middleware, + array( 'client' => $this, 'max_redirects' => 5 ) ); } diff --git a/components/HttpClient/Middleware/CacheMiddleware.php b/components/HttpClient/Middleware/CacheMiddleware.php new file mode 100644 index 00000000..86808cd6 --- /dev/null +++ b/components/HttpClient/Middleware/CacheMiddleware.php @@ -0,0 +1,476 @@ + */ + private array $replay = []; + + /** writers keyed by spl_object_hash(req) */ + private array $tempHandle = []; + private array $tempPath = []; + + public function __construct( $client_state, $next_middleware, $options = array() ) { + $this->next_middleware = $next_middleware; + $this->state = $client_state; + $this->dir = rtrim( $options['cache_dir'], '/' ); + + if ( ! is_dir( $this->dir ) ) { + throw new RuntimeException( "Cache dir {$this->dir} does not exist or is not a directory" ); + } + } + + public function enqueue( Request $request ) { + $meth = strtoupper( $request->method ); + if ( ! in_array( $meth, [ 'GET', 'HEAD' ], true ) ) { + $this->invalidateCache( $request ); + return $this->next_middleware->enqueue( $request ); + } + + [ $key, $meta ] = $this->lookup( $request ); + $request->cache_key = $key; + + if ( $meta && $this->fresh( $meta ) ) { + $this->startReplay( $request, $meta ); + return; + } + + if ( $meta ) { + $this->addValidators( $request, $meta ); + } + + return $this->next_middleware->enqueue( $request ); + } + + public function await_next_event( $requests_ids ): bool { + /* serve cached replay first */ + foreach ( $this->replay as $id => $context ) { + if ( $context['done'] ) { + fclose( $context['file'] ); + unset( $this->replay[ $id ] ); + continue; + } + $this->fromCache( $id ); + return true; + } + + /* drive next middleware */ + if ( ! $this->next_middleware->await_next_event( $requests_ids ) ) { + return false; + } + + return $this->handleNetwork(); + } + + /*============ CACHE REPLAY ============*/ + private function startReplay( Request $request, array $meta ): void { + $id = spl_object_hash( $request ); + $file_handle = fopen( $this->bodyPath( $request->cache_key ), 'rb' ); + $this->replay[ $id ] = [ + 'req' => $request, + 'meta' => $meta, + 'file' => $file_handle, + 'headerDone' => false, + 'done' => false, + ]; + } + + private function start304Replay( Request $request, array $meta ): void { + $id = spl_object_hash( $request ); + $file_handle = fopen( $this->bodyPath( $request->cache_key ), 'rb' ); + $this->replay[ $id ] = [ + 'req' => $request, + 'meta' => $meta, + 'file' => $file_handle, + 'headerDone' => true, // Skip header emission for 304 + 'done' => false, + 'is304' => true, // Mark as 304 replay + ]; + } + + private function fromCache( string $id ): void { + $context =& $this->replay[ $id ]; + $request = $context['req']; + + if ( ! $context['headerDone'] ) { + $resp = new Response( $request ); + $resp->status_code = $context['meta']['status']; + $resp->headers = $context['meta']['headers']; + + $request->response = $resp; + $this->state->event = Client::EVENT_GOT_HEADERS; + $this->state->request = $request; + + $context['headerDone'] = true; + return; + } + + $chunk = fread( $context['file'], 64 * 1024 ); + if ( $chunk !== '' && $chunk !== false ) { + $this->state->event = Client::EVENT_BODY_CHUNK_AVAILABLE; + $this->state->request = $request; + $this->state->response_body_chunk = $chunk; + return; + } + + $context['done'] = true; + $this->state->event = Client::EVENT_FINISHED; + $this->state->request = $request; + + // For 304 replays, we don't want to override the response + if ( ! isset( $context['is304'] ) || ! $context['is304'] ) { + $request->response = null; + } + } + + /*============ NETWORK HANDLING ============*/ + private function handleNetwork(): bool { + $event = $this->state->event; + $request = $this->state->request; + $response = $request->response; + + /* HEADERS */ + if ( $event === Client::EVENT_GOT_HEADERS ) { + if ( $response->status_code === 304 && isset( $request->cache_key ) ) { + [ , $meta ] = $this->lookup( $request, $request->cache_key ); + if ( $meta ) { + // For 304, start a special replay that serves cached body + $this->start304Replay( $request, $meta ); + return true; + } + } + if ( $this->cacheable( $response ) ) { + // Update cache key based on vary headers if present + $vary = $response->get_header( 'Vary' ); + if ( $vary ) { + $vary_keys = array_map( 'trim', explode( ',', $vary ) ); + $request->cache_key = $this->varyKey( $request, $vary_keys ); + } + + $tmp = $this->tempPath( $request->cache_key ); + + $this->tempPath[ spl_object_hash( $request ) ] = $tmp; + $this->tempHandle[ spl_object_hash( $request ) ] = fopen( $tmp, 'wb' ); + } + return true; + } + /* BODY */ + if ( $event === Client::EVENT_BODY_CHUNK_AVAILABLE ) { + $chunk = $this->state->response_body_chunk; + $hash = spl_object_hash( $request ); + if ( isset( $this->tempHandle[ $hash ] ) ) { + fwrite( $this->tempHandle[ $hash ], $chunk ); + } + return true; + } + /* FINISH */ + if ( $event === Client::EVENT_FINISHED ) { + $hash = spl_object_hash( $request ); + if ( isset( $this->tempHandle[ $hash ] ) ) { + fclose( $this->tempHandle[ $hash ] ); + $this->commit( $request, $response, $this->tempPath[ $hash ] ); + unset( $this->tempHandle[ $hash ], $this->tempPath[ $hash ] ); + } + return true; + } + + return true; + } + + /*============ CACHE UTILITIES ============*/ + private function metaPath( string $key ): string { + return "$this->dir/{$key}.json"; + } + + private function bodyPath( string $key ): string { + return "$this->dir/{$key}.body"; + } + + private function tempPath( string $key ): string { + return "$this->dir/{$key}.tmp"; + } + + private function varyKey( Request $request, ?array $vary_keys ): string { + $parts = [ $request->url ]; + if ( $vary_keys ) { + foreach ( $vary_keys as $header_name ) { + $header_value = $request->get_header( strtolower( $header_name ) ); + $parts[] = strtolower( $header_name ) . ':' . ( $header_value ?? '' ); + } + } + + return sha1( implode( '|', $parts ) ); + } + + /** @return array{string,array|null} */ + private function lookup( Request $request, ?string $forced = null ): array { + if ( $forced && is_file( $this->metaPath( $forced ) ) ) { + return [ $forced, json_decode( file_get_contents( $this->metaPath( $forced ) ), true ) ]; + } + $glob = glob( $this->dir . '/' . sha1( $request->url ) . '*.json' ); + foreach ( $glob as $meta_path ) { + $meta = json_decode( file_get_contents( $meta_path ), true ); + if ( basename( $meta_path, '.json' ) === $this->varyKey( $request, $meta['vary'] ?? [] ) ) { + return [ basename( $meta_path, '.json' ), $meta ]; + } + } + + return [ $this->varyKey( $request, null ), null ]; + } + + private function fresh( array $meta ): bool { + $now = time(); + + // Check for must-revalidate directive - if present, never consider fresh without explicit expiry + $cache_control = $meta['headers']['cache-control'] ?? ''; + $directives = self::directives( $cache_control ); + if ( isset( $directives['must-revalidate'] ) ) { + // With must-revalidate, only consider fresh if we have explicit expiry info + if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['max_age']) > $now; + } + if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + } + if ( isset( $meta['expires'] ) ) { + $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); + if ( $expires !== false ) { + return $expires > $now; + } + } + // With must-revalidate, don't use heuristic caching + return false; + } + + // If explicit expiry timestamp is set, use it + if ( isset( $meta['expires'] ) ) { + $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); + if ( $expires !== false ) { + return $expires > $now; + } + } + + // If explicit TTL (absolute timestamp) is set, use it + if ( isset( $meta['ttl'] ) ) { + if ( is_numeric( $meta['ttl'] ) ) { + return (int)$meta['ttl'] > $now; + } + } + + // If max_age is set, check if still valid + if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['max_age']) > $now; + } + + // If s-maxage is set, check if still valid + if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { + return ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + } + + // Heuristic: if Last-Modified is present, cache for 10% of its age at storage time + if ( isset( $meta['last_modified'] ) && isset( $meta['stored_at'] ) ) { + $lm = is_numeric( $meta['last_modified'] ) ? (int)$meta['last_modified'] : strtotime( $meta['last_modified'] ); + if ( $lm !== false ) { + $age = $meta['stored_at'] - $lm; + $heuristic_lifetime = (int) max( 0, $age / 10 ); + return ($meta['stored_at'] + $heuristic_lifetime) > $now; + } + } + + // Not fresh by any rule + return false; + } + + private function cacheable( Response $response ): bool { + return self::response_is_cacheable( $response ); + } + + private function addValidators( Request $request, array $meta ): void { + if ( ! empty( $meta['etag'] ) ) { + $request->headers['If-None-Match'] = $meta['etag']; + } + if ( ! empty( $meta['last_modified'] ) ) { + $request->headers['If-Modified-Since'] = $meta['last_modified']; + } + } + + protected function commit( Request $request, Response $response, string $tempFile ) { + $url = $request->url; + $meta = [ + 'url' => $url, + 'status' => $response->status_code, + 'headers' => $response->headers, + 'stored_at' => time(), + 'etag' => $response->get_header( 'ETag' ), + 'last_modified' => $response->get_header( 'Last-Modified' ), + ]; + + // Check for Vary header and store vary keys + $vary = $response->get_header( 'Vary' ); + if ( $vary ) { + $meta['vary'] = array_map( 'trim', explode( ',', $vary ) ); + } + + // Parse Cache-Control for max-age, if present + $cacheControl = $response->get_header( 'Cache-Control' ); + if ( $cacheControl ) { + $directives = self::directives( $cacheControl ); + if ( isset( $directives['max-age'] ) && is_int( $directives['max-age'] ) ) { + $meta['max_age'] = $directives['max-age']; + } + if ( isset( $directives['s-maxage'] ) && is_int( $directives['s-maxage'] ) ) { + $meta['s_maxage'] = $directives['s-maxage']; + } + } + + // Determine file paths + $key = $request->cache_key; + $bodyFile = $this->bodyPath( $key ); + $metaFile = $this->metaPath( $key ); + + // Atomically replace/rename the temp body file to final cache file + if ( ! rename( $tempFile, $bodyFile ) ) { + // Handle error (e.g., log failure and abort caching) + return; + } + + // Write metadata with exclusive lock + $fp = fopen( $metaFile, 'c' ); + if ( $fp ) { + flock( $fp, LOCK_EX ); + ftruncate( $fp, 0 ); + // Serialize or encode CacheEntry (e.g., JSON) + $metaData = json_encode( $meta ); + fwrite( $fp, $metaData ); + fflush( $fp ); + flock( $fp, LOCK_UN ); + fclose( $fp ); + } + } + + public function invalidateCache( Request $request ): void { + // Generate cache key if not already set + if ( ! isset( $request->cache_key ) ) { + [ $key, ] = $this->lookup( $request ); + $request->cache_key = $key; + } + + $key = $request->cache_key; + $bodyFile = $this->bodyPath( $key ); + $metaFile = $this->metaPath( $key ); + + // Optionally, acquire lock on meta file to prevent concurrent writes + if ( $fp = @fopen( $metaFile, 'c' ) ) { + flock( $fp, LOCK_EX ); + } + // Delete cache files if they exist + @unlink( $bodyFile ); + @unlink( $metaFile ); + // Also remove any temp files for this entry + foreach ( glob( $bodyFile . '.tmp*' ) as $tmp ) { + @unlink( $tmp ); + } + if ( isset( $fp ) && $fp ) { + flock( $fp, LOCK_UN ); + fclose( $fp ); + } + } + + + /** return ['no-store'=>true, 'max-age'=>60, …] */ + public static function directives( ?string $value ): array { + if ( $value === null ) { + return []; + } + $out = []; + + // Handle quoted values properly by not splitting on commas inside quotes + $parts = []; + $current = ''; + $in_quotes = false; + $quote_char = null; + + for ( $i = 0; $i < strlen( $value ); $i++ ) { + $char = $value[ $i ]; + + if ( ! $in_quotes && ( $char === '"' || $char === "'" ) ) { + $in_quotes = true; + $quote_char = $char; + $current .= $char; + } elseif ( $in_quotes && $char === $quote_char ) { + $in_quotes = false; + $quote_char = null; + $current .= $char; + } elseif ( ! $in_quotes && $char === ',' ) { + $parts[] = trim( $current ); + $current = ''; + } else { + $current .= $char; + } + } + + if ( $current !== '' ) { + $parts[] = trim( $current ); + } + + foreach ( $parts as $part ) { + $part = trim( $part ); + if ( $part === '' ) { + continue; + } + if ( strpos( $part, '=' ) !== false ) { + [ $k, $v ] = array_map( 'trim', explode( '=', $part, 2 ) ); + $out[ strtolower( $k ) ] = ctype_digit( $v ) ? (int) $v : $v; + } else { + $out[ strtolower( $part ) ] = true; + } + } + + return $out; + } + + public static function response_is_cacheable( Response $r ): bool { + $req = $r->request; + if ( $req->method !== 'GET' && $req->method !== 'HEAD' ) { + return false; + } + + // Allow caching of successful responses and redirects + if ( ! ( ( $r->status_code >= 200 && $r->status_code < 300 ) || ( $r->status_code >= 300 && $r->status_code < 400 ) ) ) { + return false; + } + + $d = self::directives( $r->get_header( 'cache-control' ) ); + if ( isset( $d['no-store'] ) ) { + return false; + } + if ( $r->get_header( 'expires' ) || isset( $d['max-age'] ) || isset( $d['s-maxage'] ) ) { + return true; + } + + // Cache responses with validation headers (ETag or Last-Modified) + if ( $r->get_header( 'etag' ) || $r->get_header( 'last-modified' ) ) { + return true; + } + + // Not cacheable by any rule + return false; + } +} \ No newline at end of file diff --git a/components/HttpClient/Middleware/HttpMiddleware.php b/components/HttpClient/Middleware/HttpMiddleware.php index ab42a41e..b86e13ee 100644 --- a/components/HttpClient/Middleware/HttpMiddleware.php +++ b/components/HttpClient/Middleware/HttpMiddleware.php @@ -27,8 +27,8 @@ class HttpMiddleware implements MiddlewareInterface { */ private $transport; - public function __construct( $options = array() ) { - $this->state = $options['state']; + public function __construct( $client_state, $options = array() ) { + $this->state = $client_state; $this->transport = $options['transport']; } diff --git a/components/HttpClient/Middleware/RedirectionMiddleware.php b/components/HttpClient/Middleware/RedirectionMiddleware.php index ba728c50..09dc746e 100644 --- a/components/HttpClient/Middleware/RedirectionMiddleware.php +++ b/components/HttpClient/Middleware/RedirectionMiddleware.php @@ -34,10 +34,10 @@ class RedirectionMiddleware implements MiddlewareInterface { */ private $state; - public function __construct( $next_middleware, $options = array() ) { + public function __construct( $client_state, $next_middleware, $options = array() ) { $this->next_middleware = $next_middleware; $this->max_redirects = $options['max_redirects'] ?? 5; - $this->state = $options['state']; + $this->state = $client_state; $this->client = $options['client']; } diff --git a/components/HttpClient/Transport/TransportInterface.php b/components/HttpClient/Transport/TransportInterface.php index 5e1fbc25..66713aad 100644 --- a/components/HttpClient/Transport/TransportInterface.php +++ b/components/HttpClient/Transport/TransportInterface.php @@ -2,8 +2,6 @@ namespace WordPress\HttpClient\Transport; -use WordPress\HttpClient\Request; - interface TransportInterface { public function event_loop_tick(): bool; From b3e9769f06eb028713df179ee2e16bdd08e496f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 30 May 2025 13:00:57 +0200 Subject: [PATCH 5/7] Unit tests and fixes for the HTTP cache middleware --- .../HttpClient/Middleware/CacheMiddleware.php | 125 ++-- .../Tests/CacheMiddlewareIntegrationTest.php | 335 +++++++++++ .../HttpClient/Tests/CacheMiddlewareTest.php | 549 ++++++++++++++++++ .../HttpClient/Tests/test-server/run.php | 84 +++ components/HttpServer/TcpServer.php | 14 +- 5 files changed, 1069 insertions(+), 38 deletions(-) create mode 100644 components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php create mode 100644 components/HttpClient/Tests/CacheMiddlewareTest.php diff --git a/components/HttpClient/Middleware/CacheMiddleware.php b/components/HttpClient/Middleware/CacheMiddleware.php index 86808cd6..31ce23ca 100644 --- a/components/HttpClient/Middleware/CacheMiddleware.php +++ b/components/HttpClient/Middleware/CacheMiddleware.php @@ -82,7 +82,7 @@ public function await_next_event( $requests_ids ): bool { /*============ CACHE REPLAY ============*/ private function startReplay( Request $request, array $meta ): void { $id = spl_object_hash( $request ); - $file_handle = fopen( $this->bodyPath( $request->cache_key ), 'rb' ); + $file_handle = fopen( $this->bodyPath( $request->cache_key, $request->url ), 'rb' ); $this->replay[ $id ] = [ 'req' => $request, 'meta' => $meta, @@ -94,12 +94,12 @@ private function startReplay( Request $request, array $meta ): void { private function start304Replay( Request $request, array $meta ): void { $id = spl_object_hash( $request ); - $file_handle = fopen( $this->bodyPath( $request->cache_key ), 'rb' ); + $file_handle = fopen( $this->bodyPath( $request->cache_key, $request->url ), 'rb' ); $this->replay[ $id ] = [ 'req' => $request, 'meta' => $meta, 'file' => $file_handle, - 'headerDone' => true, // Skip header emission for 304 + 'headerDone' => false, // Still emit headers for 304 replay 'done' => false, 'is304' => true, // Mark as 304 replay ]; @@ -111,7 +111,12 @@ private function fromCache( string $id ): void { if ( ! $context['headerDone'] ) { $resp = new Response( $request ); - $resp->status_code = $context['meta']['status']; + // For 304 replays, return 200 status with cached headers + if ( isset( $context['is304'] ) && $context['is304'] ) { + $resp->status_code = 200; // Convert 304 to 200 for the client + } else { + $resp->status_code = $context['meta']['status']; + } $resp->headers = $context['meta']['headers']; $request->response = $resp; @@ -195,11 +200,19 @@ private function handleNetwork(): bool { } /*============ CACHE UTILITIES ============*/ - private function metaPath( string $key ): string { + private function metaPath( string $key, ?string $url = null ): string { + if ( $url ) { + $url_hash = sha1( $url ); + return "$this->dir/{$url_hash}_{$key}.json"; + } return "$this->dir/{$key}.json"; } - private function bodyPath( string $key ): string { + private function bodyPath( string $key, ?string $url = null ): string { + if ( $url ) { + $url_hash = sha1( $url ); + return "$this->dir/{$url_hash}_{$key}.body"; + } return "$this->dir/{$key}.body"; } @@ -210,25 +223,37 @@ private function tempPath( string $key ): string { private function varyKey( Request $request, ?array $vary_keys ): string { $parts = [ $request->url ]; if ( $vary_keys ) { + // Build a lowercased map of headers for case-insensitive lookup + $header_map = []; + foreach ($request->headers as $k => $v) { + $header_map[strtolower($k)] = $v; + } foreach ( $vary_keys as $header_name ) { - $header_value = $request->get_header( strtolower( $header_name ) ); - $parts[] = strtolower( $header_name ) . ':' . ( $header_value ?? '' ); + $header_lc = strtolower( $header_name ); + $header_value = $header_map[$header_lc] ?? ''; + $parts[] = $header_lc . ':' . $header_value; } } - return sha1( implode( '|', $parts ) ); } /** @return array{string,array|null} */ - private function lookup( Request $request, ?string $forced = null ): array { - if ( $forced && is_file( $this->metaPath( $forced ) ) ) { - return [ $forced, json_decode( file_get_contents( $this->metaPath( $forced ) ), true ) ]; + public function lookup( Request $request, ?string $forced = null ): array { + if ( $forced && is_file( $this->metaPath( $forced, $request->url ) ) ) { + return [ $forced, json_decode( file_get_contents( $this->metaPath( $forced, $request->url ) ), true ) ]; } - $glob = glob( $this->dir . '/' . sha1( $request->url ) . '*.json' ); + + // Look for cache files that match this URL + $url_hash = sha1( $request->url ); + $glob = glob( $this->dir . '/' . $url_hash . '_*.json' ); foreach ( $glob as $meta_path ) { $meta = json_decode( file_get_contents( $meta_path ), true ); - if ( basename( $meta_path, '.json' ) === $this->varyKey( $request, $meta['vary'] ?? [] ) ) { - return [ basename( $meta_path, '.json' ), $meta ]; + $expected_key = $this->varyKey( $request, $meta['vary'] ?? [] ); + $actual_filename = basename( $meta_path, '.json' ); + $expected_filename = $url_hash . '_' . $expected_key; + + if ( $actual_filename === $expected_filename ) { + return [ $expected_key, $meta ]; } } @@ -244,15 +269,18 @@ private function fresh( array $meta ): bool { if ( isset( $directives['must-revalidate'] ) ) { // With must-revalidate, only consider fresh if we have explicit expiry info if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { - return ($meta['stored_at'] + (int)$meta['max_age']) > $now; + $fresh = ($meta['stored_at'] + (int)$meta['max_age']) > $now; + return $fresh; } if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { - return ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + $fresh = ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + return $fresh; } if ( isset( $meta['expires'] ) ) { $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); if ( $expires !== false ) { - return $expires > $now; + $fresh = $expires > $now; + return $fresh; } } // With must-revalidate, don't use heuristic caching @@ -263,25 +291,29 @@ private function fresh( array $meta ): bool { if ( isset( $meta['expires'] ) ) { $expires = is_numeric( $meta['expires'] ) ? (int)$meta['expires'] : strtotime( $meta['expires'] ); if ( $expires !== false ) { - return $expires > $now; + $fresh = $expires > $now; + return $fresh; } } // If explicit TTL (absolute timestamp) is set, use it if ( isset( $meta['ttl'] ) ) { if ( is_numeric( $meta['ttl'] ) ) { - return (int)$meta['ttl'] > $now; + $fresh = (int)$meta['ttl'] > $now; + return $fresh; } } - // If max_age is set, check if still valid - if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { - return ($meta['stored_at'] + (int)$meta['max_age']) > $now; + // If s-maxage is set, check if still valid (takes precedence over max-age) + if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { + $fresh = ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + return $fresh; } - // If s-maxage is set, check if still valid - if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { - return ($meta['stored_at'] + (int)$meta['s_maxage']) > $now; + // If max_age is set, check if still valid + if ( isset( $meta['max_age'] ) && isset( $meta['stored_at'] ) ) { + $fresh = ($meta['stored_at'] + (int)$meta['max_age']) > $now; + return $fresh; } // Heuristic: if Last-Modified is present, cache for 10% of its age at storage time @@ -290,7 +322,8 @@ private function fresh( array $meta ): bool { if ( $lm !== false ) { $age = $meta['stored_at'] - $lm; $heuristic_lifetime = (int) max( 0, $age / 10 ); - return ($meta['stored_at'] + $heuristic_lifetime) > $now; + $fresh = ($meta['stored_at'] + $heuristic_lifetime) > $now; + return $fresh; } } @@ -304,10 +337,10 @@ private function cacheable( Response $response ): bool { private function addValidators( Request $request, array $meta ): void { if ( ! empty( $meta['etag'] ) ) { - $request->headers['If-None-Match'] = $meta['etag']; + $request->headers['if-none-match'] = $meta['etag']; } if ( ! empty( $meta['last_modified'] ) ) { - $request->headers['If-Modified-Since'] = $meta['last_modified']; + $request->headers['if-modified-since'] = $meta['last_modified']; } } @@ -342,8 +375,8 @@ protected function commit( Request $request, Response $response, string $tempFil // Determine file paths $key = $request->cache_key; - $bodyFile = $this->bodyPath( $key ); - $metaFile = $this->metaPath( $key ); + $bodyFile = $this->bodyPath( $key, $url ); + $metaFile = $this->metaPath( $key, $url ); // Atomically replace/rename the temp body file to final cache file if ( ! rename( $tempFile, $bodyFile ) ) { @@ -373,8 +406,8 @@ public function invalidateCache( Request $request ): void { } $key = $request->cache_key; - $bodyFile = $this->bodyPath( $key ); - $metaFile = $this->metaPath( $key ); + $bodyFile = $this->bodyPath( $key, $request->url ); + $metaFile = $this->metaPath( $key, $request->url ); // Optionally, acquire lock on meta file to prevent concurrent writes if ( $fp = @fopen( $metaFile, 'c' ) ) { @@ -461,7 +494,30 @@ public static function response_is_cacheable( Response $r ): bool { if ( isset( $d['no-store'] ) ) { return false; } - if ( $r->get_header( 'expires' ) || isset( $d['max-age'] ) || isset( $d['s-maxage'] ) ) { + + // Check for explicit freshness indicators, but also validate they're not expired + if ( isset( $d['max-age'] ) ) { + // Don't cache responses with max-age=0 + if ( is_int( $d['max-age'] ) && $d['max-age'] <= 0 ) { + return false; + } + return true; + } + + if ( isset( $d['s-maxage'] ) ) { + // Don't cache responses with s-maxage=0 + if ( is_int( $d['s-maxage'] ) && $d['s-maxage'] <= 0 ) { + return false; + } + return true; + } + + if ( $r->get_header( 'expires' ) ) { + // Check if expires header indicates an already expired response + $expires = strtotime( $r->get_header( 'expires' ) ); + if ( $expires !== false && $expires <= time() ) { + return false; // Don't cache already expired responses + } return true; } @@ -470,7 +526,6 @@ public static function response_is_cacheable( Response $r ): bool { return true; } - // Not cacheable by any rule return false; } } \ No newline at end of file diff --git a/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php b/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php new file mode 100644 index 00000000..5081de70 --- /dev/null +++ b/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php @@ -0,0 +1,335 @@ + /dev/null 2>&1 &', + escapeshellarg( __DIR__ . '/test-server/run.php' ), + escapeshellarg( self::$server_host ), + self::$server_port + ); + exec( $cmd ); + + // Wait for server to start + $start_time = time(); + while ( time() - $start_time < 5 ) { + $connection = @fsockopen( self::$server_host, self::$server_port, $errno, $errstr, 1 ); + if ( $connection ) { + fclose( $connection ); + break; + } + usleep( 100000 ); // 100ms + } + } + + public static function tearDownAfterClass(): void { + // Kill server + exec( "pkill -f 'run.php.*cache'" ); + } + + protected function setUp(): void { + $this->cache_dir = sys_get_temp_dir() . '/http_cache_integration_test_' . uniqid(); + mkdir( $this->cache_dir, 0777, true ); + + // Client constructor automatically sets up CacheMiddleware when cache_dir is provided + $this->client = new Client( [ 'cache_dir' => $this->cache_dir ] ); + } + + protected function tearDown(): void { + $this->removeDirectory( $this->cache_dir ); + } + + private function removeDirectory( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + $files = array_diff( scandir( $dir ), [ '.', '..' ] ); + foreach ( $files as $file ) { + $path = $dir . '/' . $file; + is_dir( $path ) ? $this->removeDirectory( $path ) : unlink( $path ); + } + rmdir( $dir ); + } + + private function getServerUrl( string $endpoint ): string { + return sprintf( 'http://%s:%d/cache/%s', self::$server_host, self::$server_port, $endpoint ); + } + + private function makeRequest( string $url, string $method = 'GET', array $headers = [] ): array { + $request = new Request( $url, [ 'method' => $method ] ); + $request->headers = array_merge( $request->headers, $headers ); + + $this->client->enqueue( $request ); + + $response_data = [ + 'status_code' => null, + 'headers' => [], + 'body' => '', + ]; + + // Process events + while ( $this->client->await_next_event() ) { + $event = $this->client->get_event(); + $current_request = $this->client->get_request(); + + if ( $current_request->id !== $request->id ) { + continue; // Not our request + } + + if ( $event === Client::EVENT_GOT_HEADERS ) { + $response = $this->client->get_response(); + $response_data['status_code'] = $response->status_code; + $response_data['headers'] = $response->headers; + } elseif ( $event === Client::EVENT_BODY_CHUNK_AVAILABLE ) { + $chunk = $this->client->get_response_body_chunk(); + $response_data['body'] .= $chunk; + } elseif ( $event === Client::EVENT_FINISHED ) { + break; + } elseif ( $event === Client::EVENT_FAILED ) { + throw new \Exception( 'Request failed' ); + } + } + + return $response_data; + } + + private function resetCounter(): void { + $this->makeRequest( $this->getServerUrl( 'reset-counter' ) ); + } + + public function test_max_age_caching(): void { + $this->resetCounter(); + + // First request should hit server + $response1 = $this->makeRequest( $this->getServerUrl( 'counter' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Request count: 1', $response1['body'] ); + + // Second request should hit cache + $response2 = $this->makeRequest( $this->getServerUrl( 'counter' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Request count: 1', $response2['body'] ); // Same count = cache hit + + // Verify cache files exist + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertNotEmpty( $cache_files ); + } + + public function test_no_store_not_cached(): void { + // no-store responses should not be cached + $response1 = $this->makeRequest( $this->getServerUrl( 'no-store' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Never stored', $response1['body'] ); + + // Verify no cache files created + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + } + + public function test_etag_validation(): void { + // First request should cache the response + $response1 = $this->makeRequest( $this->getServerUrl( 'etag' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'ETag response', $response1['body'] ); + + // Second request should send If-None-Match and get cached content (middleware handles 304 internally) + $response2 = $this->makeRequest( $this->getServerUrl( 'etag' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'ETag response', $response2['body'] ); + } + + public function test_last_modified_validation(): void { + // First request should cache the response + $response1 = $this->makeRequest( $this->getServerUrl( 'last-modified' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Last-Modified response', $response1['body'] ); + + // Second request should send If-Modified-Since and get cached content + $response2 = $this->makeRequest( $this->getServerUrl( 'last-modified' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Last-Modified response', $response2['body'] ); + } + + public function test_vary_header_different_responses(): void { + // First request with JSON Accept header + $response1 = $this->makeRequest( + $this->getServerUrl( 'vary-accept' ), + 'GET', + [ 'Accept' => 'application/json' ] + ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertStringContainsString( 'JSON response', $response1['body'] ); + + // Second request with different Accept header should not hit cache + $response2 = $this->makeRequest( + $this->getServerUrl( 'vary-accept' ), + 'GET', + [ 'Accept' => 'text/html' ] + ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertStringContainsString( 'Text response', $response2['body'] ); + + // Third request with same Accept as first should hit cache + $response3 = $this->makeRequest( + $this->getServerUrl( 'vary-accept' ), + 'GET', + [ 'Accept' => 'application/json' ] + ); + $this->assertEquals( 200, $response3['status_code'] ); + $this->assertStringContainsString( 'JSON response', $response3['body'] ); + } + + public function test_large_body_caching(): void { + // Test caching of large response body (>64KB) + $response1 = $this->makeRequest( $this->getServerUrl( 'large-body' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $body1 = $response1['body']; + $this->assertGreaterThan( 64 * 1024, strlen( $body1 ) ); // Should be >64KB + + // Second request should hit cache + $response2 = $this->makeRequest( $this->getServerUrl( 'large-body' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $body2 = $response2['body']; + + // Bodies should be identical + $this->assertEquals( $body1, $body2 ); + $this->assertEquals( strlen( $body1 ), strlen( $body2 ) ); + } + + public function test_s_maxage_caching(): void { + // Test s-maxage directive + $response1 = $this->makeRequest( $this->getServerUrl( 's-maxage' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Shared cache for 2 hours, private cache for 1 hour', $response1['body'] ); + + // Should be cached due to s-maxage + $response2 = $this->makeRequest( $this->getServerUrl( 's-maxage' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Shared cache for 2 hours, private cache for 1 hour', $response2['body'] ); + + // Verify cache files exist + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertNotEmpty( $cache_files ); + } + + public function test_must_revalidate_behavior(): void { + // Test must-revalidate directive + $response1 = $this->makeRequest( $this->getServerUrl( 'must-revalidate' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Must revalidate when stale', $response1['body'] ); + + // Should be cached while fresh + $response2 = $this->makeRequest( $this->getServerUrl( 'must-revalidate' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Must revalidate when stale', $response2['body'] ); + } + + public function test_multiple_vary_headers(): void { + // Test response that varies on multiple headers + $response1 = $this->makeRequest( + $this->getServerUrl( 'vary-multiple' ), + 'GET', + [ 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip' ] + ); + $this->assertEquals( 200, $response1['status_code'] ); + + // Different Accept-Encoding should not hit cache + $response2 = $this->makeRequest( + $this->getServerUrl( 'vary-multiple' ), + 'GET', + [ 'Accept' => 'application/json', 'Accept-Encoding' => 'deflate' ] + ); + $this->assertEquals( 200, $response2['status_code'] ); + + // Different responses due to different Accept-Encoding + $this->assertNotEquals( $response1['body'], $response2['body'] ); + } + + public function test_post_invalidates_cache(): void { + $this->resetCounter(); + $url = $this->getServerUrl( 'post-invalidate' ); + + // First GET should cache + $response1 = $this->makeRequest( $url ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'GET response - cacheable', $response1['body'] ); + + // Second GET should hit cache + $response2 = $this->makeRequest( $url ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'GET response - cacheable', $response2['body'] ); + + // POST should invalidate cache + $response3 = $this->makeRequest( $url, 'POST' ); + $this->assertEquals( 200, $response3['status_code'] ); + $this->assertEquals( 'POST response - cache invalidated', $response3['body'] ); + + // Verify cache was invalidated + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + } + + public function test_expired_response(): void { + // Test already expired response + $response1 = $this->makeRequest( $this->getServerUrl( 'expired' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Already expired response', $response1['body'] ); + + // Should not be cached due to being already expired + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + } + + public function test_zero_max_age(): void { + // Test max-age=0 response + $response1 = $this->makeRequest( $this->getServerUrl( 'zero-max-age' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Zero max-age response', $response1['body'] ); + + // Should not be cached due to max-age=0 + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + } + + public function test_both_validators(): void { + // Test response with both ETag and Last-Modified + $response1 = $this->makeRequest( $this->getServerUrl( 'both-validators' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Response with both ETag and Last-Modified', $response1['body'] ); + + // Second request should use validation + $response2 = $this->makeRequest( $this->getServerUrl( 'both-validators' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Response with both ETag and Last-Modified', $response2['body'] ); + } + + public function test_heuristic_caching(): void { + // Test heuristic caching with only Last-Modified + $response1 = $this->makeRequest( $this->getServerUrl( 'no-explicit-cache' ) ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'No explicit cache headers, only Last-Modified for heuristic caching', $response1['body'] ); + + // Should be cached using heuristic rules + $response2 = $this->makeRequest( $this->getServerUrl( 'no-explicit-cache' ) ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'No explicit cache headers, only Last-Modified for heuristic caching', $response2['body'] ); + + // Verify cache files exist + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertNotEmpty( $cache_files ); + } +} \ No newline at end of file diff --git a/components/HttpClient/Tests/CacheMiddlewareTest.php b/components/HttpClient/Tests/CacheMiddlewareTest.php new file mode 100644 index 00000000..4e32dd1c --- /dev/null +++ b/components/HttpClient/Tests/CacheMiddlewareTest.php @@ -0,0 +1,549 @@ +cache_dir = sys_get_temp_dir() . '/http_cache_test_' . uniqid(); + mkdir( $this->cache_dir, 0777, true ); + + // Set up mocks + $this->state = new MockClientState(); + $this->next_middleware = new MockMiddleware(); + $this->cache_middleware = new CacheMiddleware( + $this->state, + $this->next_middleware, + [ 'cache_dir' => $this->cache_dir ] + ); + } + + protected function tearDown(): void { + // Clean up cache directory + $this->removeDirectory( $this->cache_dir ); + } + + private function removeDirectory( string $dir ): void { + if ( ! is_dir( $dir ) ) { + return; + } + $files = array_diff( scandir( $dir ), [ '.', '..' ] ); + foreach ( $files as $file ) { + $path = $dir . '/' . $file; + is_dir( $path ) ? $this->removeDirectory( $path ) : unlink( $path ); + } + rmdir( $dir ); + } + + public function test_cache_miss_forwards_to_next_middleware(): void { + $request = new Request( 'https://example.com/test' ); + + $this->cache_middleware->enqueue( $request ); + + $this->assertTrue( $this->next_middleware->was_called ); + $this->assertSame( $request, $this->next_middleware->last_request ); + } + + public function test_non_cacheable_methods_invalidate_cache(): void { + // First, create a cached entry + $get_request = new Request( 'https://example.com/test' ); + $this->createCachedResponse( $get_request, 'Cached content' ); + + // Now make a POST request to the same URL + $post_request = new Request( 'https://example.com/test', [ 'method' => 'POST' ] ); + + $this->cache_middleware->enqueue( $post_request ); + + $this->assertTrue( $this->next_middleware->was_called ); + + // Verify cache files were deleted + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + } + + public function test_cache_hit_serves_from_cache(): void { + $request = new Request( 'https://example.com/test' ); + $cached_content = 'This is cached content'; + $this->createCachedResponse( $request, $cached_content ); + + $this->cache_middleware->enqueue( $request ); + + // Should not call next middleware + $this->assertFalse( $this->next_middleware->was_called ); + + // Should start replay + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + // Verify headers event + $this->assertEquals( Client::EVENT_GOT_HEADERS, $this->state->event ); + $this->assertEquals( 200, $request->response->status_code ); + $content_type = $request->response->get_header( 'Content-Type' ); + $this->assertEquals( 'text/plain', $content_type ); + } + + public function test_cache_replay_body_chunks(): void { + $request = new Request( 'https://example.com/test' ); + $cached_content = str_repeat( 'Large content chunk. ', 1000 ); // ~20KB + $this->createCachedResponse( $request, $cached_content ); + + $this->cache_middleware->enqueue( $request ); + + // Headers event + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + $this->assertEquals( Client::EVENT_GOT_HEADERS, $this->state->event ); + + // Body chunks + $received_body = ''; + while ( $this->cache_middleware->await_next_event( [] ) ) { + if ( $this->state->event === Client::EVENT_BODY_CHUNK_AVAILABLE ) { + $received_body .= $this->state->response_body_chunk; + } elseif ( $this->state->event === Client::EVENT_FINISHED ) { + break; + } + } + + $this->assertEquals( $cached_content, $received_body ); + } + + public function test_large_response_chunking(): void { + $request = new Request( 'https://example.com/test' ); + // Create content larger than 64KB chunk size + $large_content = str_repeat( 'X', 100 * 1024 ); // 100KB + $this->createCachedResponse( $request, $large_content ); + + $this->cache_middleware->enqueue( $request ); + + // Skip headers + $this->cache_middleware->await_next_event( [] ); + + // Count chunks and verify size + $chunk_count = 0; + $total_size = 0; + while ( $this->cache_middleware->await_next_event( [] ) ) { + if ( $this->state->event === Client::EVENT_BODY_CHUNK_AVAILABLE ) { + $chunk_count++; + $chunk_size = strlen( $this->state->response_body_chunk ); + $total_size += $chunk_size; + + // Verify chunk size is reasonable (should be 64KB or less for final chunk) + $this->assertLessThanOrEqual( 64 * 1024, $chunk_size ); + } elseif ( $this->state->event === Client::EVENT_FINISHED ) { + break; + } + } + + $this->assertGreaterThan( 1, $chunk_count ); // Should have multiple chunks + $this->assertEquals( strlen( $large_content ), $total_size ); + } + + public function test_etag_validation(): void { + $request = new Request( 'https://example.com/test' ); + $etag = '"test-etag-123"'; + + // Create cached response with ETag + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ 'etag' => $etag, 'content-type' => 'text/plain' ], + 'stored_at' => time() - 7200, // 2 hours ago, expired + 'etag' => $etag, + ]; + $this->createCachedEntry( $request, 'Cached content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should add If-None-Match header + $this->assertTrue( $this->next_middleware->was_called ); + $this->assertEquals( $etag, $this->next_middleware->last_request->headers['if-none-match'] ); + } + + public function test_last_modified_validation(): void { + $request = new Request( 'https://example.com/test' ); + $last_modified = 'Wed, 01 Jan 2020 00:00:00 GMT'; + + // Create cached response with Last-Modified and explicit expiry + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ + 'last-modified' => $last_modified, + 'content-type' => 'text/plain', + 'cache-control' => 'max-age=3600' // Explicit expiry + ], + 'stored_at' => time() - 7200, // 2 hours ago, expired + 'last_modified' => $last_modified, + 'max_age' => 3600, // 1 hour max age (expired) + ]; + $this->createCachedEntry( $request, 'Cached content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should add If-Modified-Since header + $this->assertTrue( $this->next_middleware->was_called ); + $this->assertEquals( $last_modified, $this->next_middleware->last_request->headers['if-modified-since'] ); + } + + public function test_304_response_serves_cached_body(): void { + $request = new Request( 'https://example.com/test' ); + $cached_content = 'Original cached content'; + $this->createCachedResponse( $request, $cached_content ); + + // Enqueue the request first to set up cache_key and validators + $this->cache_middleware->enqueue( $request ); + + // Simulate 304 response from server + $this->state->event = Client::EVENT_GOT_HEADERS; + $this->state->request = $request; + $request->response = new Response( $request ); + $request->response->status_code = 304; + + // Process the 304 response + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + // Should start serving cached body + $received_body = ''; + while ( $this->cache_middleware->await_next_event( [] ) ) { + if ( $this->state->event === Client::EVENT_BODY_CHUNK_AVAILABLE ) { + $received_body .= $this->state->response_body_chunk; + } elseif ( $this->state->event === Client::EVENT_FINISHED ) { + break; + } + } + + $this->assertEquals( $cached_content, $received_body ); + } + + public function test_vary_header_different_cache_keys(): void { + $request1 = new Request( 'https://example.com/test' ); + $request1->headers['Accept'] = 'application/json'; + $request2 = new Request( 'https://example.com/test' ); + $request2->headers['Accept'] = 'text/html'; + + // First, simulate caching the first request + $response1 = new Response( $request1 ); + $response1->status_code = 200; + $response1->headers = [ + 'vary' => 'Accept', // Use lowercase key + 'content-type' => 'application/json', + 'cache-control' => 'max-age=3600' // Make it cacheable - use lowercase key + ]; + $response1->request = $request1; // Set the request property + + $this->cache_middleware->enqueue( $request1 ); + + // Set up the mock middleware to return true so handleNetwork gets called + $this->next_middleware->should_return_true_from_await = true; + $this->state->event = Client::EVENT_GOT_HEADERS; + $this->state->request = $request1; + $request1->response = $response1; + $this->cache_middleware->await_next_event( [] ); // Process headers - this updates cache_key with Vary + $cache_key1 = $request1->cache_key; // Get the updated cache key + $this->finishCachingRequest( $request1, 'JSON response' ); + + // Reset state for second request + $this->next_middleware->reset(); + $this->state = new MockClientState(); + + // Now test second request with different Accept header + $this->cache_middleware->enqueue( $request2 ); + // Simulate network response for second request with Vary header + $response2 = new Response( $request2 ); + $response2->status_code = 200; + $response2->headers = [ + 'vary' => 'Accept', + 'content-type' => 'text/html', + 'cache-control' => 'max-age=3600' // Make it cacheable - use lowercase key + ]; + $response2->request = $request2; // Set the request property + + // Set up the mock middleware to return true so handleNetwork gets called + $this->next_middleware->should_return_true_from_await = true; + $this->state->event = Client::EVENT_GOT_HEADERS; + $this->state->request = $request2; + $request2->response = $response2; + $this->cache_middleware->await_next_event( [] ); // Process headers - this updates cache_key with Vary + $cache_key2 = $request2->cache_key; // Get the updated cache key + + $this->assertNotEquals( $cache_key1 ?? '', $cache_key2 ?? '' ); + } + + public function test_max_age_freshness(): void { + $request = new Request( 'https://example.com/test' ); + + // Create fresh cached response (max-age: 3600, stored now) + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ 'Cache-Control' => 'max-age=3600', 'Content-Type' => 'text/plain' ], + 'stored_at' => time(), // Just stored + 'max_age' => 3600, + ]; + $this->createCachedEntry( $request, 'Fresh content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should serve from cache + $this->assertFalse( $this->next_middleware->was_called ); + } + + public function test_expired_max_age(): void { + $request = new Request( 'https://example.com/test' ); + + // Create expired cached response + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ 'Cache-Control' => 'max-age=3600', 'Content-Type' => 'text/plain' ], + 'stored_at' => time() - 7200, // 2 hours ago + 'max_age' => 3600, // 1 hour max age + ]; + $this->createCachedEntry( $request, 'Expired content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should not serve from cache + $this->assertTrue( $this->next_middleware->was_called ); + } + + public function test_s_maxage_takes_precedence(): void { + $request = new Request( 'https://example.com/test' ); + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ 'cache-control' => 's-maxage=7200, max-age=1800', 'content-type' => 'text/plain' ], + 'stored_at' => time() - 3600, // 1 hour ago + 'max_age' => 1800, // 30 minutes (would be expired) + 's_maxage' => 7200, // 2 hours (still fresh) + ]; + $this->createCachedEntry( $request, 'S-maxage content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should serve from cache (fresh due to s-maxage) + $this->assertFalse( $this->next_middleware->was_called ); + } + + public function test_must_revalidate_with_explicit_expiry(): void { + $request = new Request( 'https://example.com/test' ); + + // Fresh response with must-revalidate + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ 'Cache-Control' => 'max-age=3600, must-revalidate', 'Content-Type' => 'text/plain' ], + 'stored_at' => time() - 1800, // 30 minutes ago + 'max_age' => 3600, // 1 hour (still fresh) + ]; + $this->createCachedEntry( $request, 'Must revalidate content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should serve from cache when fresh + $this->assertFalse( $this->next_middleware->was_called ); + } + + public function test_must_revalidate_expired_no_heuristic(): void { + $request = new Request( 'https://example.com/test' ); + // Expired response with must-revalidate and no explicit expiry + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ + 'Cache-Control' => 'must-revalidate', + 'Last-Modified' => 'Wed, 01 Jan 2020 00:00:00 GMT', + 'Content-Type' => 'text/plain' + ], + 'stored_at' => time() - 86400, // 1 day ago + 'last_modified' => 'Wed, 01 Jan 2020 00:00:00 GMT', + 'max_age' => 0, // Explicitly expired + ]; + $this->createCachedEntry( $request, 'Must revalidate no heuristic', $meta ); + $this->cache_middleware->enqueue( $request ); + // Should not use heuristic caching with must-revalidate + $this->assertTrue( $this->next_middleware->was_called ); + } + + public function test_heuristic_caching(): void { + $request = new Request( 'https://example.com/test' ); + + // Response with only Last-Modified for heuristic caching + $last_modified_time = time() - 86400 * 10; // 10 days ago + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => [ + 'Last-Modified' => gmdate( 'D, d M Y H:i:s', $last_modified_time ) . ' GMT', + 'Content-Type' => 'text/plain' + ], + 'stored_at' => time() - 3600, // 1 hour ago + 'last_modified' => gmdate( 'D, d M Y H:i:s', $last_modified_time ) . ' GMT', + ]; + $this->createCachedEntry( $request, 'Heuristic cache content', $meta ); + + $this->cache_middleware->enqueue( $request ); + + // Should serve from cache using heuristic (10% of age = ~24 hours) + $this->assertFalse( $this->next_middleware->was_called ); + } + + public function test_network_response_caching(): void { + $request = new Request( 'https://example.com/test' ); + + // Set up cache key as would happen during enqueue + [ $key, ] = $this->cache_middleware->lookup( $request ); + $request->cache_key = $key; + + $response = new Response( $request ); + $response->status_code = 200; + $response->headers = [ 'cache-control' => 'max-age=3600', 'content-type' => 'text/plain' ]; + $response->request = $request; // Set the request property + + // Set up the mock middleware to return true so handleNetwork gets called + $this->next_middleware->should_return_true_from_await = true; + $this->state->event = Client::EVENT_GOT_HEADERS; + $this->state->request = $request; + $request->response = $response; + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + $content = 'Network response content'; + $this->state->event = Client::EVENT_BODY_CHUNK_AVAILABLE; + $this->state->response_body_chunk = $content; + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + $this->state->event = Client::EVENT_FINISHED; + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + $url_hash = sha1($request->url); + $cache_files = glob( $this->cache_dir . '/' . $url_hash . '_*.json' ); + $this->assertNotEmpty( $cache_files ); + + $body_files = glob( $this->cache_dir . '/' . $url_hash . '_*.body' ); + $this->assertNotEmpty( $body_files ); + + $cached_content = file_get_contents( $body_files[0] ); + $this->assertEquals( $content, $cached_content ); + } + + public function test_non_cacheable_response_not_stored(): void { + $request = new Request( 'https://example.com/test' ); + + // Set up cache key as would happen during enqueue + [ $key, ] = $this->cache_middleware->lookup( $request ); + $request->cache_key = $key; + + $response = new Response( $request ); + $response->status_code = 200; + $response->headers = [ 'cache-control' => 'no-store', 'content-type' => 'text/plain' ]; + $response->request = $request; // Set the request property + + // Set up the mock middleware to return true so handleNetwork gets called + $this->next_middleware->should_return_true_from_await = true; + $this->state->event = Client::EVENT_GOT_HEADERS; + $this->state->request = $request; + $request->response = $response; + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + // Simulate body chunk for non-cacheable response + $this->state->event = Client::EVENT_BODY_CHUNK_AVAILABLE; + $this->state->response_body_chunk = 'Non-cacheable content'; + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + // Simulate finish + $this->state->event = Client::EVENT_FINISHED; + $this->assertTrue( $this->cache_middleware->await_next_event( [] ) ); + + $url_hash = sha1($request->url); + // Check for both temp and cache files - should be empty since response is not cacheable + $temp_files = glob( $this->cache_dir . '/' . $url_hash . '_*.tmp' ); + $cache_files = glob( $this->cache_dir . '/' . $url_hash . '_*.json' ); + $body_files = glob( $this->cache_dir . '/' . $url_hash . '_*.body' ); + + $this->assertEmpty( $temp_files ); + $this->assertEmpty( $cache_files ); + $this->assertEmpty( $body_files ); + } + + private function createCachedResponse( Request $request, string $content, array $headers = [] ): void { + $default_headers = [ 'content-type' => 'text/plain' ]; + $headers = array_merge( $default_headers, $headers ); + + $meta = [ + 'url' => $request->url, + 'status' => 200, + 'headers' => $headers, + 'stored_at' => time(), + 'max_age' => 3600, // 1 hour + ]; + + $this->createCachedEntry( $request, $content, $meta ); + } + + private function createCachedEntry( Request $request, string $content, array $meta ): void { + [ $key, ] = $this->cache_middleware->lookup( $request ); + $request->cache_key = $key; + $url_hash = sha1($request->url); + $meta_file = $this->cache_dir . '/' . $url_hash . '_' . $key . '.json'; + $body_file = $this->cache_dir . '/' . $url_hash . '_' . $key . '.body'; + file_put_contents( $meta_file, json_encode( $meta ) ); + file_put_contents( $body_file, $content ); + } + + private function finishCachingRequest( Request $request, string $content ): void { + // Simulate body chunk + $this->state->event = Client::EVENT_BODY_CHUNK_AVAILABLE; + $this->state->response_body_chunk = $content; + $this->cache_middleware->await_next_event( [] ); + + // Simulate finish + $this->state->event = Client::EVENT_FINISHED; + $this->cache_middleware->await_next_event( [] ); + } +} + +class MockClientState { + public string $event = ''; + public ?Request $request = null; + public string $response_body_chunk = ''; +} + +class MockMiddleware { + public bool $was_called = false; + public ?Request $last_request = null; + public ?Response $mock_response = null; + public bool $should_return_304 = false; + public bool $should_return_true_from_await = false; + + public function enqueue( Request $request ) { + $this->was_called = true; + $this->last_request = $request; + + if ( $this->should_return_304 ) { + $request->response = new Response( $request ); + $request->response->status_code = 304; + } + } + + public function await_next_event( $requests_ids ): bool { + return $this->should_return_true_from_await; + } + + public function reset(): void { + $this->was_called = false; + $this->last_request = null; + $this->mock_response = null; + $this->should_return_304 = false; + $this->should_return_true_from_await = false; + } +} \ No newline at end of file diff --git a/components/HttpClient/Tests/test-server/run.php b/components/HttpClient/Tests/test-server/run.php index d59ff890..70344809 100644 --- a/components/HttpClient/Tests/test-server/run.php +++ b/components/HttpClient/Tests/test-server/run.php @@ -400,6 +400,90 @@ $response->send_header( 'Content-Type', 'text/plain' ); $response->append_bytes( 'GET response - cacheable' ); } + } elseif ( $type === 's-maxage' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 's-maxage=7200, max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Shared cache for 2 hours, private cache for 1 hour' ); + } elseif ( $type === 'must-revalidate' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=3600, must-revalidate' ); + $response->send_header( 'ETag', '"must-revalidate-123"' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Must revalidate when stale' ); + } elseif ( $type === 'large-body' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + // Create a response larger than 64KB to test chunking + $response->append_bytes( str_repeat( 'Large response body content. ', 5000 ) ); // ~150KB + } elseif ( $type === 'vary-multiple' ) { + $accept = $request->get_header( 'accept' ); + $encoding = $request->get_header( 'accept-encoding' ); + $response->send_http_code( 200 ); + $response->send_header( 'Vary', 'Accept, Accept-Encoding' ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( "Accept: {$accept}, Accept-Encoding: {$encoding}" ); + } elseif ( $type === 'private' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'private, max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Private cache only' ); + } elseif ( $type === 'both-validators' ) { + $etag = '"both-validators-123"'; + $last_modified = 'Wed, 01 Jan 2020 00:00:00 GMT'; + $if_none_match = $request->get_header( 'if-none-match' ) ?: $request->get_header( 'If-None-Match' ); + $if_modified_since = $request->get_header( 'if-modified-since' ) ?: $request->get_header( 'If-Modified-Since' ); + + if ( $if_none_match === $etag || $if_modified_since === $last_modified ) { + $response->send_http_code( 304 ); + $response->send_header( 'ETag', $etag ); + $response->send_header( 'Last-Modified', $last_modified ); + } else { + $response->send_http_code( 200 ); + $response->send_header( 'ETag', $etag ); + $response->send_header( 'Last-Modified', $last_modified ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Response with both ETag and Last-Modified' ); + } + } elseif ( $type === 'expired' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Expires', gmdate( 'D, d M Y H:i:s', time() - 3600 ) . ' GMT' ); // Expired 1 hour ago + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Already expired response' ); + } elseif ( $type === 'zero-max-age' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=0' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Zero max-age response' ); + } elseif ( $type === 'no-explicit-cache' ) { + $response->send_http_code( 200 ); + $response->send_header( 'Last-Modified', 'Wed, 01 Jan 2020 00:00:00 GMT' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'No explicit cache headers, only Last-Modified for heuristic caching' ); + } elseif ( $type === 'counter' ) { + // Simple counter to test cache hit/miss + $counter_file = sys_get_temp_dir() . '/http_cache_test_counter.txt'; + if ( ! file_exists( $counter_file ) ) { + file_put_contents( $counter_file, '0' ); + } + $count = (int) file_get_contents( $counter_file ); + $count++; + file_put_contents( $counter_file, (string) $count ); + + $response->send_http_code( 200 ); + $response->send_header( 'Cache-Control', 'max-age=3600' ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( "Request count: {$count}" ); + } elseif ( $type === 'reset-counter' ) { + // Reset counter for testing + $counter_file = sys_get_temp_dir() . '/http_cache_test_counter.txt'; + file_put_contents( $counter_file, '0' ); + $response->send_http_code( 200 ); + $response->send_header( 'Content-Type', 'text/plain' ); + $response->append_bytes( 'Counter reset' ); } else { $response->send_http_code( 404 ); $response->append_bytes( 'Cache endpoint not found' ); diff --git a/components/HttpServer/TcpServer.php b/components/HttpServer/TcpServer.php index d4d197e0..178fb3c2 100644 --- a/components/HttpServer/TcpServer.php +++ b/components/HttpServer/TcpServer.php @@ -55,6 +55,10 @@ public function serve( ?callable $on_accept = null ) { continue; } + // Initialize to null to avoid undefined variable errors + $socket_write_stream = null; + $response_writer = null; + try { $request = IncomingRequest::from_resource( $client ); if ( ! is_callable( $this->handler ) ) { @@ -72,7 +76,7 @@ public function serve( ?callable $on_accept = null ) { error_log( "Error: " . $e->getMessage() ); } finally { try { - if ( ! $response_writer->is_writing_closed() ) { + if ( $response_writer && ! $response_writer->is_writing_closed() ) { $response_writer->close_writing(); } } catch ( Exception $e ) { @@ -80,11 +84,15 @@ public function serve( ?callable $on_accept = null ) { } try { - $socket_write_stream->close_writing(); + if ( $socket_write_stream ) { + $socket_write_stream->close_writing(); + } } catch ( Exception $e ) { error_log( "Error closing socket write stream: " . $e->getMessage() ); } - echo "[" . date( 'Y-m-d H:i:s' ) . "] " . $response_writer->http_code . ' ' . $request->method . ' ' . $request->get_parsed_url()->pathname . "\n"; + if ( isset($response_writer, $request) && $response_writer ) { + echo "[" . date( 'Y-m-d H:i:s' ) . "] " . $response_writer->http_code . ' ' . $request->method . ' ' . $request->get_parsed_url()->pathname . "\n"; + } } } } From 8bf98d1240e5d992431ed21ba63a88b91d6eda43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 30 May 2025 14:08:45 +0200 Subject: [PATCH 6/7] Adjust unit tests --- .../Tests/CacheMiddlewareIntegrationTest.php | 441 +++++++++--------- .../HttpClient/Tests/CacheMiddlewareTest.php | 22 +- .../HttpClient/Tests/ClientTestBase.php | 46 +- .../HttpClient/Tests/WithServerTrait.php | 37 ++ 4 files changed, 265 insertions(+), 281 deletions(-) create mode 100644 components/HttpClient/Tests/WithServerTrait.php diff --git a/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php b/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php index 5081de70..18cba398 100644 --- a/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php +++ b/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php @@ -3,43 +3,16 @@ namespace WordPress\HttpClient\Tests; use PHPUnit\Framework\TestCase; +use WordPress\Filesystem\LocalFilesystem; use WordPress\HttpClient\Client; use WordPress\HttpClient\Request; class CacheMiddlewareIntegrationTest extends TestCase { - private static $server_process; - private static string $server_host = '127.0.0.1'; - private static int $server_port = 8951; - private string $cache_dir; - private Client $client; + use WithServerTrait; - public static function setUpBeforeClass(): void { - // Start test server - $cmd = sprintf( - 'php %s %s %d cache > /dev/null 2>&1 &', - escapeshellarg( __DIR__ . '/test-server/run.php' ), - escapeshellarg( self::$server_host ), - self::$server_port - ); - exec( $cmd ); - - // Wait for server to start - $start_time = time(); - while ( time() - $start_time < 5 ) { - $connection = @fsockopen( self::$server_host, self::$server_port, $errno, $errstr, 1 ); - if ( $connection ) { - fclose( $connection ); - break; - } - usleep( 100000 ); // 100ms - } - } - - public static function tearDownAfterClass(): void { - // Kill server - exec( "pkill -f 'run.php.*cache'" ); - } + private $cache_dir; + private $client; protected function setUp(): void { $this->cache_dir = sys_get_temp_dir() . '/http_cache_integration_test_' . uniqid(); @@ -54,19 +27,7 @@ protected function tearDown(): void { } private function removeDirectory( string $dir ): void { - if ( ! is_dir( $dir ) ) { - return; - } - $files = array_diff( scandir( $dir ), [ '.', '..' ] ); - foreach ( $files as $file ) { - $path = $dir . '/' . $file; - is_dir( $path ) ? $this->removeDirectory( $path ) : unlink( $path ); - } - rmdir( $dir ); - } - - private function getServerUrl( string $endpoint ): string { - return sprintf( 'http://%s:%d/cache/%s', self::$server_host, self::$server_port, $endpoint ); + LocalFilesystem::create($dir)->rmdir('/', ['recursive' => true]); } private function makeRequest( string $url, string $method = 'GET', array $headers = [] ): array { @@ -107,229 +68,257 @@ private function makeRequest( string $url, string $method = 'GET', array $header return $response_data; } - private function resetCounter(): void { - $this->makeRequest( $this->getServerUrl( 'reset-counter' ) ); + private function resetCounter( string $base_url ): void { + $this->makeRequest( $base_url . '/reset-counter' ); } public function test_max_age_caching(): void { - $this->resetCounter(); - - // First request should hit server - $response1 = $this->makeRequest( $this->getServerUrl( 'counter' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Request count: 1', $response1['body'] ); - - // Second request should hit cache - $response2 = $this->makeRequest( $this->getServerUrl( 'counter' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'Request count: 1', $response2['body'] ); // Same count = cache hit - - // Verify cache files exist - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertNotEmpty( $cache_files ); + $this->withServer( function ( $url ) { + $this->resetCounter( $url ); + + // First request should hit server + $response1 = $this->makeRequest( $url . '/counter' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Request count: 1', $response1['body'] ); + + // Second request should hit cache + $response2 = $this->makeRequest( $url . '/counter' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Request count: 1', $response2['body'] ); // Same count = cache hit + + // Verify cache files exist + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertNotEmpty( $cache_files ); + }, 'cache' ); } public function test_no_store_not_cached(): void { - // no-store responses should not be cached - $response1 = $this->makeRequest( $this->getServerUrl( 'no-store' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Never stored', $response1['body'] ); - - // Verify no cache files created - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertEmpty( $cache_files ); + $this->withServer( function ( $url ) { + // no-store responses should not be cached + $response1 = $this->makeRequest( $url . '/no-store' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Never stored', $response1['body'] ); + + // Verify no cache files created + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + }, 'cache' ); } public function test_etag_validation(): void { - // First request should cache the response - $response1 = $this->makeRequest( $this->getServerUrl( 'etag' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'ETag response', $response1['body'] ); - - // Second request should send If-None-Match and get cached content (middleware handles 304 internally) - $response2 = $this->makeRequest( $this->getServerUrl( 'etag' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'ETag response', $response2['body'] ); + $this->withServer( function ( $url ) { + // First request should cache the response + $response1 = $this->makeRequest( $url . '/etag' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'ETag response', $response1['body'] ); + + // Second request should send If-None-Match and get cached content (middleware handles 304 internally) + $response2 = $this->makeRequest( $url . '/etag' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'ETag response', $response2['body'] ); + }, 'cache' ); } public function test_last_modified_validation(): void { - // First request should cache the response - $response1 = $this->makeRequest( $this->getServerUrl( 'last-modified' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Last-Modified response', $response1['body'] ); - - // Second request should send If-Modified-Since and get cached content - $response2 = $this->makeRequest( $this->getServerUrl( 'last-modified' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'Last-Modified response', $response2['body'] ); + $this->withServer( function ( $url ) { + // First request should cache the response + $response1 = $this->makeRequest( $url . '/last-modified' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Last-Modified response', $response1['body'] ); + + // Second request should send If-Modified-Since and get cached content + $response2 = $this->makeRequest( $url . '/last-modified' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Last-Modified response', $response2['body'] ); + }, 'cache' ); } public function test_vary_header_different_responses(): void { - // First request with JSON Accept header - $response1 = $this->makeRequest( - $this->getServerUrl( 'vary-accept' ), - 'GET', - [ 'Accept' => 'application/json' ] - ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertStringContainsString( 'JSON response', $response1['body'] ); - - // Second request with different Accept header should not hit cache - $response2 = $this->makeRequest( - $this->getServerUrl( 'vary-accept' ), - 'GET', - [ 'Accept' => 'text/html' ] - ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertStringContainsString( 'Text response', $response2['body'] ); - - // Third request with same Accept as first should hit cache - $response3 = $this->makeRequest( - $this->getServerUrl( 'vary-accept' ), - 'GET', - [ 'Accept' => 'application/json' ] - ); - $this->assertEquals( 200, $response3['status_code'] ); - $this->assertStringContainsString( 'JSON response', $response3['body'] ); + $this->withServer( function ( $url ) { + // First request with JSON Accept header + $response1 = $this->makeRequest( + $url . '/vary-accept', + 'GET', + [ 'Accept' => 'application/json' ] + ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertStringContainsString( 'JSON response', $response1['body'] ); + + // Second request with different Accept header should not hit cache + $response2 = $this->makeRequest( + $url . '/vary-accept', + 'GET', + [ 'Accept' => 'text/html' ] + ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertStringContainsString( 'Text response', $response2['body'] ); + + // Third request with same Accept as first should hit cache + $response3 = $this->makeRequest( + $url . '/vary-accept', + 'GET', + [ 'Accept' => 'application/json' ] + ); + $this->assertEquals( 200, $response3['status_code'] ); + $this->assertStringContainsString( 'JSON response', $response3['body'] ); + }, 'cache' ); } public function test_large_body_caching(): void { - // Test caching of large response body (>64KB) - $response1 = $this->makeRequest( $this->getServerUrl( 'large-body' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $body1 = $response1['body']; - $this->assertGreaterThan( 64 * 1024, strlen( $body1 ) ); // Should be >64KB - - // Second request should hit cache - $response2 = $this->makeRequest( $this->getServerUrl( 'large-body' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $body2 = $response2['body']; - - // Bodies should be identical - $this->assertEquals( $body1, $body2 ); - $this->assertEquals( strlen( $body1 ), strlen( $body2 ) ); + $this->withServer( function ( $url ) { + // Test caching of large response body (>64KB) + $response1 = $this->makeRequest( $url . '/large-body' ); + $this->assertEquals( 200, $response1['status_code'] ); + $body1 = $response1['body']; + $this->assertGreaterThan( 64 * 1024, strlen( $body1 ) ); // Should be >64KB + + // Second request should hit cache + $response2 = $this->makeRequest( $url . '/large-body' ); + $this->assertEquals( 200, $response2['status_code'] ); + $body2 = $response2['body']; + + // Bodies should be identical + $this->assertEquals( $body1, $body2 ); + $this->assertEquals( strlen( $body1 ), strlen( $body2 ) ); + }, 'cache' ); } public function test_s_maxage_caching(): void { - // Test s-maxage directive - $response1 = $this->makeRequest( $this->getServerUrl( 's-maxage' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Shared cache for 2 hours, private cache for 1 hour', $response1['body'] ); - - // Should be cached due to s-maxage - $response2 = $this->makeRequest( $this->getServerUrl( 's-maxage' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'Shared cache for 2 hours, private cache for 1 hour', $response2['body'] ); - - // Verify cache files exist - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertNotEmpty( $cache_files ); + $this->withServer( function ( $url ) { + // Test s-maxage directive + $response1 = $this->makeRequest( $url . '/s-maxage' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Shared cache for 2 hours, private cache for 1 hour', $response1['body'] ); + + // Should be cached due to s-maxage + $response2 = $this->makeRequest( $url . '/s-maxage' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Shared cache for 2 hours, private cache for 1 hour', $response2['body'] ); + + // Verify cache files exist + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertNotEmpty( $cache_files ); + }, 'cache' ); } public function test_must_revalidate_behavior(): void { - // Test must-revalidate directive - $response1 = $this->makeRequest( $this->getServerUrl( 'must-revalidate' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Must revalidate when stale', $response1['body'] ); - - // Should be cached while fresh - $response2 = $this->makeRequest( $this->getServerUrl( 'must-revalidate' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'Must revalidate when stale', $response2['body'] ); + $this->withServer( function ( $url ) { + // Test must-revalidate directive + $response1 = $this->makeRequest( $url . '/must-revalidate' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Must revalidate when stale', $response1['body'] ); + + // Should be cached while fresh + $response2 = $this->makeRequest( $url . '/must-revalidate' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Must revalidate when stale', $response2['body'] ); + }, 'cache' ); } public function test_multiple_vary_headers(): void { - // Test response that varies on multiple headers - $response1 = $this->makeRequest( - $this->getServerUrl( 'vary-multiple' ), - 'GET', - [ 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip' ] - ); - $this->assertEquals( 200, $response1['status_code'] ); - - // Different Accept-Encoding should not hit cache - $response2 = $this->makeRequest( - $this->getServerUrl( 'vary-multiple' ), - 'GET', - [ 'Accept' => 'application/json', 'Accept-Encoding' => 'deflate' ] - ); - $this->assertEquals( 200, $response2['status_code'] ); - - // Different responses due to different Accept-Encoding - $this->assertNotEquals( $response1['body'], $response2['body'] ); + $this->withServer( function ( $url ) { + // Test response that varies on multiple headers + $response1 = $this->makeRequest( + $url . '/vary-multiple', + 'GET', + [ 'Accept' => 'application/json', 'Accept-Encoding' => 'gzip' ] + ); + $this->assertEquals( 200, $response1['status_code'] ); + + // Different Accept-Encoding should not hit cache + $response2 = $this->makeRequest( + $url . '/vary-multiple', + 'GET', + [ 'Accept' => 'application/json', 'Accept-Encoding' => 'deflate' ] + ); + $this->assertEquals( 200, $response2['status_code'] ); + + // Different responses due to different Accept-Encoding + $this->assertNotEquals( $response1['body'], $response2['body'] ); + }, 'cache' ); } public function test_post_invalidates_cache(): void { - $this->resetCounter(); - $url = $this->getServerUrl( 'post-invalidate' ); - - // First GET should cache - $response1 = $this->makeRequest( $url ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'GET response - cacheable', $response1['body'] ); - - // Second GET should hit cache - $response2 = $this->makeRequest( $url ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'GET response - cacheable', $response2['body'] ); - - // POST should invalidate cache - $response3 = $this->makeRequest( $url, 'POST' ); - $this->assertEquals( 200, $response3['status_code'] ); - $this->assertEquals( 'POST response - cache invalidated', $response3['body'] ); - - // Verify cache was invalidated - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertEmpty( $cache_files ); + $this->withServer( function ( $url ) { + $this->resetCounter( $url ); + $endpoint_url = $url . '/post-invalidate'; + + // First GET should cache + $response1 = $this->makeRequest( $endpoint_url ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'GET response - cacheable', $response1['body'] ); + + // Second GET should hit cache + $response2 = $this->makeRequest( $endpoint_url ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'GET response - cacheable', $response2['body'] ); + + // POST should invalidate cache + $response3 = $this->makeRequest( $endpoint_url, 'POST' ); + $this->assertEquals( 200, $response3['status_code'] ); + $this->assertEquals( 'POST response - cache invalidated', $response3['body'] ); + + // Verify cache was invalidated + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + }, 'cache' ); } public function test_expired_response(): void { - // Test already expired response - $response1 = $this->makeRequest( $this->getServerUrl( 'expired' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Already expired response', $response1['body'] ); - - // Should not be cached due to being already expired - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertEmpty( $cache_files ); + $this->withServer( function ( $url ) { + // Test already expired response + $response1 = $this->makeRequest( $url . '/expired' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Already expired response', $response1['body'] ); + + // Should not be cached due to being already expired + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + }, 'cache' ); } public function test_zero_max_age(): void { - // Test max-age=0 response - $response1 = $this->makeRequest( $this->getServerUrl( 'zero-max-age' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Zero max-age response', $response1['body'] ); - - // Should not be cached due to max-age=0 - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertEmpty( $cache_files ); + $this->withServer( function ( $url ) { + // Test max-age=0 response + $response1 = $this->makeRequest( $url . '/zero-max-age' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Zero max-age response', $response1['body'] ); + + // Should not be cached due to max-age=0 + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertEmpty( $cache_files ); + }, 'cache' ); } public function test_both_validators(): void { - // Test response with both ETag and Last-Modified - $response1 = $this->makeRequest( $this->getServerUrl( 'both-validators' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'Response with both ETag and Last-Modified', $response1['body'] ); - - // Second request should use validation - $response2 = $this->makeRequest( $this->getServerUrl( 'both-validators' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'Response with both ETag and Last-Modified', $response2['body'] ); + $this->withServer( function ( $url ) { + // Test response with both ETag and Last-Modified + $response1 = $this->makeRequest( $url . '/both-validators' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'Response with both ETag and Last-Modified', $response1['body'] ); + + // Second request should use validation + $response2 = $this->makeRequest( $url . '/both-validators' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'Response with both ETag and Last-Modified', $response2['body'] ); + }, 'cache' ); } public function test_heuristic_caching(): void { - // Test heuristic caching with only Last-Modified - $response1 = $this->makeRequest( $this->getServerUrl( 'no-explicit-cache' ) ); - $this->assertEquals( 200, $response1['status_code'] ); - $this->assertEquals( 'No explicit cache headers, only Last-Modified for heuristic caching', $response1['body'] ); - - // Should be cached using heuristic rules - $response2 = $this->makeRequest( $this->getServerUrl( 'no-explicit-cache' ) ); - $this->assertEquals( 200, $response2['status_code'] ); - $this->assertEquals( 'No explicit cache headers, only Last-Modified for heuristic caching', $response2['body'] ); - - // Verify cache files exist - $cache_files = glob( $this->cache_dir . '/*.json' ); - $this->assertNotEmpty( $cache_files ); + $this->withServer( function ( $url ) { + // Test heuristic caching with only Last-Modified + $response1 = $this->makeRequest( $url . '/no-explicit-cache' ); + $this->assertEquals( 200, $response1['status_code'] ); + $this->assertEquals( 'No explicit cache headers, only Last-Modified for heuristic caching', $response1['body'] ); + + // Should be cached using heuristic rules + $response2 = $this->makeRequest( $url . '/no-explicit-cache' ); + $this->assertEquals( 200, $response2['status_code'] ); + $this->assertEquals( 'No explicit cache headers, only Last-Modified for heuristic caching', $response2['body'] ); + + // Verify cache files exist + $cache_files = glob( $this->cache_dir . '/*.json' ); + $this->assertNotEmpty( $cache_files ); + }, 'cache' ); } } \ No newline at end of file diff --git a/components/HttpClient/Tests/CacheMiddlewareTest.php b/components/HttpClient/Tests/CacheMiddlewareTest.php index 4e32dd1c..6a5b5958 100644 --- a/components/HttpClient/Tests/CacheMiddlewareTest.php +++ b/components/HttpClient/Tests/CacheMiddlewareTest.php @@ -11,9 +11,9 @@ class CacheMiddlewareTest extends TestCase { private string $cache_dir; - private MockClientState $state; - private MockMiddleware $next_middleware; - private CacheMiddleware $cache_middleware; + private $state; + private $next_middleware; + private $cache_middleware; protected function setUp(): void { // Create temporary cache directory @@ -513,17 +513,17 @@ private function finishCachingRequest( Request $request, string $content ): void } class MockClientState { - public string $event = ''; - public ?Request $request = null; - public string $response_body_chunk = ''; + public $event = ''; + public $request = null; + public $response_body_chunk = ''; } class MockMiddleware { - public bool $was_called = false; - public ?Request $last_request = null; - public ?Response $mock_response = null; - public bool $should_return_304 = false; - public bool $should_return_true_from_await = false; + public $was_called = false; + public $last_request = null; + public $mock_response = null; + public $should_return_304 = false; + public $should_return_true_from_await = false; public function enqueue( Request $request ) { $this->was_called = true; diff --git a/components/HttpClient/Tests/ClientTestBase.php b/components/HttpClient/Tests/ClientTestBase.php index 3c7d119c..78a46769 100644 --- a/components/HttpClient/Tests/ClientTestBase.php +++ b/components/HttpClient/Tests/ClientTestBase.php @@ -53,6 +53,8 @@ public function length() : ?int { abstract class ClientTestBase extends TestCase { + use WithServerTrait; + /** * Create the client instance to be tested. * Must be implemented by concrete test classes. @@ -106,50 +108,6 @@ protected function withDroppingServer(callable $cb, int $port = 8971): void { finally { $p->stop(0); @unlink($tmp); } } - /** server that never answers – forces stream_select timeout */ - private function withSilentServer(callable $cb, int $port = 8972): void { - $tmp = tempnam(sys_get_temp_dir(), 'srv').'.php'; - file_put_contents($tmp, - <<start(); - for ($i = 0; $i < 20 && !@fsockopen('127.0.0.1', $port); $i++) usleep(50000); - try { $cb("http://127.0.0.1:$port"); } - finally { $p->stop(0); @unlink($tmp); } - } - - protected function withServer( callable $callback, $scenario = 'default', $host = '127.0.0.1', $port = 8950 ) { - $serverRoot = __DIR__ . '/test-server'; - $server = new Process( [ - 'php', - "$serverRoot/run.php", - $host, - $port, - $scenario, - ], $serverRoot ); - $server->start(); - try { - $attempts = 0; - while ( $server->isRunning() ) { - $output = $server->getIncrementalOutput(); - if ( strncmp( $output, 'Server started on http://', strlen( 'Server started on http://' ) ) === 0 ) { - break; - } - usleep( 40000 ); - if ( ++ $attempts > 20 ) { - $this->fail( 'Server did not start' ); - } - } - $callback( "http://{$host}:{$port}" ); - } finally { - $server->stop( 0 ); - } - } - /** * Helper to consume the entire response body for a request using the event loop. */ diff --git a/components/HttpClient/Tests/WithServerTrait.php b/components/HttpClient/Tests/WithServerTrait.php new file mode 100644 index 00000000..2212eb52 --- /dev/null +++ b/components/HttpClient/Tests/WithServerTrait.php @@ -0,0 +1,37 @@ +start(); + try { + $attempts = 0; + while ( $server->isRunning() ) { + $output = $server->getIncrementalOutput(); + if ( strncmp( $output, 'Server started on http://', strlen( 'Server started on http://' ) ) === 0 ) { + break; + } + usleep( 40000 ); + if ( ++ $attempts > 20 ) { + $this->fail( 'Server did not start' ); + } + } + $callback( "http://{$host}:{$port}" ); + } finally { + $server->stop( 0 ); + } + } + +} From 9915f47d1220ad942e0381907dc447d034165729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 30 May 2025 14:17:52 +0200 Subject: [PATCH 7/7] Adjust more tests --- components/HttpClient/Tests/CacheMiddlewareTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/HttpClient/Tests/CacheMiddlewareTest.php b/components/HttpClient/Tests/CacheMiddlewareTest.php index 6a5b5958..eb74ce2a 100644 --- a/components/HttpClient/Tests/CacheMiddlewareTest.php +++ b/components/HttpClient/Tests/CacheMiddlewareTest.php @@ -10,7 +10,7 @@ class CacheMiddlewareTest extends TestCase { - private string $cache_dir; + private $cache_dir; private $state; private $next_middleware; private $cache_middleware;