diff --git a/components/HttpClient/Client.php b/components/HttpClient/Client.php index 19129b64..5ee7af08 100644 --- a/components/HttpClient/Client.php +++ b/components/HttpClient/Client.php @@ -4,11 +4,11 @@ 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; use WordPress\HttpClient\Transport\SocketTransport; -use WordPress\HttpClient\Transport\TransportInterface; class Client { @@ -18,14 +18,22 @@ class Client { const EVENT_FINISHED = 'EVENT_FINISHED'; /** - * @var ClientState - */ - private $state; - /** + * All the HTTP requests ever enqueued with this Client. + * + * Each Request may have a different state, and this Client will manage them + * asynchronously, moving them through the various states as the network + * operations progress. + * + * @since Next Release * @var MiddlewareInterface */ private $middleware; + /** + * @var ClientState + */ + private $state; + public function __construct( $options = array() ) { $this->state = new ClientState( $options ); if(empty($options['transport']) || $options['transport'] === 'auto') { @@ -43,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 ) ); } @@ -54,10 +70,11 @@ 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 ], @@ -70,10 +87,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 ) { @@ -89,11 +107,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[]|Request|string|string[] $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 ) { @@ -161,7 +179,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, * ] ); @@ -173,11 +191,11 @@ 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 = [] ) { $requests_ids = array(); if(empty($query['requests'])) { $requests_ids = array_keys( $this->state->events ); @@ -189,7 +207,7 @@ public function await_next_event( $query = array() ) { return $this->middleware->await_next_event( $requests_ids ); } - public function has_pending_event( $request, $event_type ) { + public function has_pending_event( Request $request, string $event_type ) { return $this->state->has_pending_event( $request, $event_type ); } @@ -220,6 +238,10 @@ public function get_request() { return $this->state->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/Middleware/CacheMiddleware.php b/components/HttpClient/Middleware/CacheMiddleware.php new file mode 100644 index 00000000..31ce23ca --- /dev/null +++ b/components/HttpClient/Middleware/CacheMiddleware.php @@ -0,0 +1,531 @@ + */ + 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, $request->url ), '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, $request->url ), 'rb' ); + $this->replay[ $id ] = [ + 'req' => $request, + 'meta' => $meta, + 'file' => $file_handle, + 'headerDone' => false, // Still emit headers for 304 replay + '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 ); + // 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; + $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 $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 $url = null ): string { + if ( $url ) { + $url_hash = sha1( $url ); + return "$this->dir/{$url_hash}_{$key}.body"; + } + 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 ) { + // 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_lc = strtolower( $header_name ); + $header_value = $header_map[$header_lc] ?? ''; + $parts[] = $header_lc . ':' . $header_value; + } + } + return sha1( implode( '|', $parts ) ); + } + + /** @return array{string,array|null} */ + 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 ) ]; + } + + // 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 ); + $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 ]; + } + } + + 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'] ) ) { + $fresh = ($meta['stored_at'] + (int)$meta['max_age']) > $now; + return $fresh; + } + if ( isset( $meta['s_maxage'] ) && isset( $meta['stored_at'] ) ) { + $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 ) { + $fresh = $expires > $now; + return $fresh; + } + } + // 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 ) { + $fresh = $expires > $now; + return $fresh; + } + } + + // If explicit TTL (absolute timestamp) is set, use it + if ( isset( $meta['ttl'] ) ) { + if ( is_numeric( $meta['ttl'] ) ) { + $fresh = (int)$meta['ttl'] > $now; + return $fresh; + } + } + + // 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 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 + 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 ); + $fresh = ($meta['stored_at'] + $heuristic_lifetime) > $now; + return $fresh; + } + } + + // 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, $url ); + $metaFile = $this->metaPath( $key, $url ); + + // 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, $request->url ); + $metaFile = $this->metaPath( $key, $request->url ); + + // 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; + } + + // 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; + } + + // Cache responses with validation headers (ETag or Last-Modified) + if ( $r->get_header( 'etag' ) || $r->get_header( 'last-modified' ) ) { + return true; + } + + 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/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 */ diff --git a/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php b/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php new file mode 100644 index 00000000..18cba398 --- /dev/null +++ b/components/HttpClient/Tests/CacheMiddlewareIntegrationTest.php @@ -0,0 +1,324 @@ +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 { + LocalFilesystem::create($dir)->rmdir('/', ['recursive' => true]); + } + + 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( string $base_url ): void { + $this->makeRequest( $base_url . '/reset-counter' ); + } + + public function test_max_age_caching(): void { + $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 { + $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 { + $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 { + $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 { + $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 { + $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 { + $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 { + $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 { + $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->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 { + $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 { + $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 { + $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 { + $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 new file mode 100644 index 00000000..eb74ce2a --- /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 $event = ''; + public $request = null; + public $response_body_chunk = ''; +} + +class MockMiddleware { + 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; + $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/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 ); + } + } + +} diff --git a/components/HttpClient/Tests/test-server/run.php b/components/HttpClient/Tests/test-server/run.php index fdc1a950..70344809 100644 --- a/components/HttpClient/Tests/test-server/run.php +++ b/components/HttpClient/Tests/test-server/run.php @@ -297,6 +297,198 @@ $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' ); + } + } 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' ); + } + break; case 'edge-cases': $type = basename( $path ); if ( $type === 'no-body-204' ) { 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; 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"; + } } } }