From ba0f055a56006b46fc644457ed0fce3091c1a668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 May 2025 10:59:16 +0200 Subject: [PATCH 1/9] Separate Client from Transports --- components/HttpClient/Client/Client.php | 337 +++--------------- components/HttpClient/Client/ClientState.php | 225 ++++++++++++ .../{CurlClient.php => CurlTransport.php} | 68 ++-- .../{SocketClient.php => SocketTransport.php} | 110 +++--- .../HttpClient/Client/TransportInterface.php | 11 + ...rlClientTest.php => CurlTransportTest.php} | 4 +- ...ClientTest.php => SocketTransportTest.php} | 4 +- 7 files changed, 397 insertions(+), 362 deletions(-) create mode 100644 components/HttpClient/Client/ClientState.php rename components/HttpClient/Client/{CurlClient.php => CurlTransport.php} (82%) rename components/HttpClient/Client/{SocketClient.php => SocketTransport.php} (80%) create mode 100644 components/HttpClient/Client/TransportInterface.php rename components/HttpClient/Tests/{CurlClientTest.php => CurlTransportTest.php} (98%) rename components/HttpClient/Tests/{SocketClientTest.php => SocketTransportTest.php} (97%) diff --git a/components/HttpClient/Client/Client.php b/components/HttpClient/Client/Client.php index a8d47133..b8ada352 100644 --- a/components/HttpClient/Client/Client.php +++ b/components/HttpClient/Client/Client.php @@ -9,95 +9,38 @@ use WordPress\HttpClient\HttpError; use WordPress\HttpClient\Request; -abstract class Client { +class Client { 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'; /** - * Microsecond is 1 millionth of a second. - * - * @var int - */ - const MICROSECONDS_TO_SECONDS = 1000000; - - /** - * 5/100th of a second - */ - const NONBLOCKING_TIMEOUT_MICROSECONDS = 0.05 * self::MICROSECONDS_TO_SECONDS; - - /** - * The maximum number of concurrent connections allowed. - * - * This is as a safeguard against: - * * Spreading our network bandwidth too thin and not making any real progress on any - * request. - * * Overwhelming the server with too many requests. - * - * @var int - */ - 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 + * @var ClientState */ - protected $max_redirects; - + 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 Request[] + * @var TransportInterface */ - protected $requests = []; + private $transport; - /** - * Network connection details managed privately by this Client. - * - * Each Request has a corresponding Connection object that contains - * the connection handle, response buffer, and other details. - * - * These are internal, will change without warning, and should not be - * exposed to the outside world. - * - * @var array - */ - protected $connections = []; - protected $events = []; - protected $event = null; - protected $request = null; - protected $response_body_chunk = null; - protected $request_timeout_ms = null; - - /** - * Creates a new HTTP client instance best suited to your platform. - * CurlClient is the default. If cURL is not available, it falls back to - * SocketClient. - */ - static public function create( $options = array() ) { - if ( ! extension_loaded( 'curl' ) ) { - return new SocketClient( $options ); + public function __construct( $options = array() ) { + $this->state = new ClientState( $options ); + if(empty($options['transport']) || $options['transport'] === 'auto') { + $options['transport'] = extension_loaded( 'curl' ) ? 'curl' : 'socket'; } - return new CurlClient( $options ); - } - - public function __construct( $options = array() ) { - $this->concurrency = $options['concurrency'] ?? 10; - $this->max_redirects = $options['max_redirects'] ?? 3; - $this->request_timeout_ms = $options['timeout_ms'] ?? 30000; + switch ( $options['transport'] ) { + case 'curl': + $this->transport = new CurlTransport( $this->state ); + break; + case 'socket': + $this->transport = new SocketTransport( $this->state ); + break; + default: + throw new HttpClientException( "Invalid transport: {$options['transport']}" ); + } } /** @@ -151,7 +94,7 @@ public function enqueue( $requests ) { if ( is_string( $request ) ) { $request = new Request( $request ); } - if ( array_key_exists( $request->id, $this->connections ) ) { + if ( array_key_exists( $request->id, $this->state->connections ) ) { throw new HttpClientException( "Request {$request->id} is already enqueued." ); } @@ -160,17 +103,17 @@ public function enqueue( $requests ) { } $request->state = Request::STATE_ENQUEUED; - $this->requests[] = apply_filters( 'wp_http_client_request', $request ); - $this->events[ $request->id ] = array(); - $this->connections[ $request->id ] = new Connection( $request ); + $this->state->requests[] = apply_filters( 'wp_http_client_request', $request ); + $this->state->events[ $request->id ] = array(); + $this->state->connections[ $request->id ] = new Connection( $request ); $parsed = WPURL::parse( $request->url ); if ( false === $parsed ) { - $this->set_error( $request, new HttpError( sprintf( 'Invalid URL: %s', $request->url ) ) ); + $this->state->set_request_error( $request, new HttpError( sprintf( 'Invalid URL: %s', $request->url ) ) ); continue; } if ( $parsed->protocol !== 'http:' && $parsed->protocol !== 'https:' ) { - $this->set_error( $request, + $this->state->set_request_error( $request, new HttpError( sprintf( 'Invalid URL – only HTTP and HTTPS URLs are supported: %s', $parsed->toString() ) ) ); continue; } @@ -188,7 +131,6 @@ public function enqueue( $requests ) { * * * `Client::EVENT_GOT_HEADERS` * * `Client::EVENT_BODY_CHUNK_AVAILABLE` - * * `Client::EVENT_REDIRECT` * * `Client::EVENT_FAILED` * * `Client::EVENT_FINISHED` * @@ -234,57 +176,52 @@ public function enqueue( $requests ) { */ public function await_next_event( $query = array() ) { $ordered_events = array( - self::EVENT_GOT_HEADERS, - self::EVENT_BODY_CHUNK_AVAILABLE, - self::EVENT_REDIRECT, - self::EVENT_FAILED, - self::EVENT_FINISHED, + Client::EVENT_GOT_HEADERS, + Client::EVENT_BODY_CHUNK_AVAILABLE, + Client::EVENT_FAILED, + Client::EVENT_FINISHED, ); - $this->event = null; - $this->request = null; - $this->response_body_chunk = null; + $this->state->event = null; + $this->state->request = null; + $this->state->response_body_chunk = null; $start_time = microtime( true ); $timeout_ms = isset( $query['timeout_ms'] ) ? $query['timeout_ms'] // Give the requests an opportunity to time out - : $this->request_timeout_ms * 1.1; + : $this->state->request_timeout_ms * 1.1; do { if ( empty( $query['requests'] ) ) { - $events = array_keys( $this->events ); + $events = array_keys( $this->state->events ); } else { $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; - } } } foreach ( $events as $request_id ) { foreach ( $ordered_events as $considered_event ) { - $needs_emitting = $this->events[ $request_id ][ $considered_event ] ?? false; + $needs_emitting = $this->state->events[ $request_id ][ $considered_event ] ?? false; if ( ! $needs_emitting ) { continue; } - $this->events[ $request_id ][ $considered_event ] = false; - $this->event = $considered_event; - $this->request = $this->get_request_by_id( $request_id ); - switch ( $this->event ) { - case self::EVENT_BODY_CHUNK_AVAILABLE: - $this->response_body_chunk = $this->consume_buffered_response_body( $request_id ); + $this->state->events[ $request_id ][ $considered_event ] = false; + $this->state->event = $considered_event; + $this->state->request = $this->state->get_request_by_id( $request_id ); + switch ( $this->state->event ) { + case Client::EVENT_BODY_CHUNK_AVAILABLE: + $this->state->response_body_chunk = $this->state->consume_buffered_response_body( $request_id ); break; - case self::EVENT_FAILED: - case self::EVENT_FINISHED: + case Client::EVENT_FAILED: + case Client::EVENT_FINISHED: // We don't need the response buffer anymore. It's // safe to clean up the connection object now. The // HTTP resource have been closed by now via the // close_connection() method. - unset( $this->connections[ $request_id ] ); + unset( $this->state->connections[ $request_id ] ); break; } @@ -300,43 +237,13 @@ public function await_next_event( $query = array() ) { if ( $timeout_ms && $time_elapsed_ms >= $timeout_ms ) { return false; } - } while ( $this->event_loop_tick() ); + } while ( $this->transport->event_loop_tick() ); return false; } - - /** - * Consumes $length bytes received in response to a given request. - * - * @return string - */ - protected function consume_buffered_response_body( $request_id ) { - $request = $this->get_request_by_id( $request_id ); - if ( null === $request ) { - return false; - } - $connection = $this->connections[ $request->id ]; - if ( - $request->state === Request::STATE_RECEIVING_BODY || - $request->state === Request::STATE_FINISHED - ) { - return $connection->consume_buffer(); - } - - $end_of_data = $request->state === Request::STATE_FINISHED && ( - ! is_resource( $this->connections[ $request->id ]->http_socket ) || - $this->connections[ $request->id ]->decoded_response_stream->reached_end_of_data() - ); - if ( $end_of_data ) { - return false; - } - - return ''; - } - public function has_pending_event( $request, $event_type ) { - return $this->events[ $request->id ][ $event_type ] ?? false; + return $this->state->has_pending_event( $request, $event_type ); } /** @@ -345,11 +252,11 @@ public function has_pending_event( $request, $event_type ) { * @return string|bool The next event, or false if no event is set. */ public function get_event() { - if ( null === $this->event ) { + if ( null === $this->state->event ) { return false; } - return $this->event; + return $this->state->event; } /** @@ -359,11 +266,11 @@ public function get_event() { * @return Request */ public function get_request() { - if ( null === $this->request ) { + if ( null === $this->state->request ) { return false; } - return $this->request; + return $this->state->request; } /** @@ -373,155 +280,19 @@ public function get_request() { * @return string|false */ public function get_response_body_chunk() { - if ( null === $this->response_body_chunk ) { + if ( null === $this->state->response_body_chunk ) { return false; } - return $this->response_body_chunk; + return $this->state->response_body_chunk; } - /** - * Asynchronously moves the enqueued Request objects through the - * various states of the HTTP request-response lifecycle. - * - * @return bool Whether any active requests were processed. - */ - abstract protected function event_loop_tick(); - public function get_active_requests( $states = null ) { - $processed_requests = $this->get_requests( - array( - Request::STATE_WILL_ENABLE_CRYPTO, - Request::STATE_WILL_SEND_HEADERS, - Request::STATE_WILL_SEND_BODY, - Request::STATE_SENT, - Request::STATE_RECEIVING_HEADERS, - Request::STATE_RECEIVING_BODY, - Request::STATE_RECEIVED, - ) - ); - $available_slots = $this->concurrency - count( $processed_requests ); - $enqueued_requests = $this->get_requests( Request::STATE_ENQUEUED ); - for ( $i = 0; $i < $available_slots; $i ++ ) { - if ( ! isset( $enqueued_requests[ $i ] ) ) { - break; - } - $processed_requests[] = $enqueued_requests[ $i ]; - } - if ( $states !== null ) { - $processed_requests = static::filter_requests_by_state( $processed_requests, $states ); - } - - return $processed_requests; - } - - protected function mark_finished( Request $request ) { - $request->state = Request::STATE_FINISHED; - $this->events[ $request->id ][ self::EVENT_FINISHED ] = true; - - $this->close_connection( $request ); + return $this->state->get_active_requests( $states ); } - protected function set_error( Request $request, $error ) { - $request->error = $error; - $request->state = Request::STATE_FAILED; - $this->events[ $request->id ][ self::EVENT_FAILED ] = true; - $this->close_connection( $request ); - } - - abstract protected function close_connection( Request $request ); - public function get_failed_requests() { - return $this->get_requests( Request::STATE_FAILED ); - } - - protected function get_requests( $states ) { - if ( ! is_array( $states ) ) { - $states = array( $states ); - } - - return static::filter_requests_by_state( $this->requests, $states ); - } - - static protected function filter_requests_by_state( array $requests, $states ) { - if ( ! is_array( $states ) ) { - $states = array( $states ); - } - $results = array(); - foreach ( $requests as $request ) { - if ( in_array( $request->state, $states ) ) { - $results[] = $request; - } - } - - return $results; - } - - protected function get_request_by_id( $request_id ) { - foreach ( $this->requests as $request ) { - if ( $request->id === $request_id ) { - return $request; - } - } - } - - /** - * @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; - 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, - ) - ) - ); - } - } - - protected function finalize_requests( $requests ) { - foreach ( $requests as $request ) { - $this->mark_finished( $request ); - } + return $this->state->get_failed_requests(); } } diff --git a/components/HttpClient/Client/ClientState.php b/components/HttpClient/Client/ClientState.php new file mode 100644 index 00000000..23107d0f --- /dev/null +++ b/components/HttpClient/Client/ClientState.php @@ -0,0 +1,225 @@ +concurrency = $options['concurrency'] ?? 10; + $this->max_redirects = $options['max_redirects'] ?? 3; + $this->request_timeout_ms = $options['timeout_ms'] ?? 30000; + } + + public function has_pending_event( $request, $event_type ) { + return $this->events[ $request->id ][ $event_type ] ?? false; + } + + /** + * 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() { + if ( null === $this->event ) { + return false; + } + + return $this->event; + } + + /** + * Returns the request associated with the last event found + * by await_next_event(). + * + * @return Request + */ + public function get_request() { + if ( null === $this->request ) { + return false; + } + + return $this->request; + } + + /** + * Returns the response body chunk associated with the EVENT_BODY_CHUNK_AVAILABLE + * event found by await_next_event(). + * + * @return string|false + */ + public function get_response_body_chunk() { + if ( null === $this->response_body_chunk ) { + return false; + } + + return $this->response_body_chunk; + } + + public function get_active_requests( $states = null ) { + $processed_requests = $this->get_requests( + array( + Request::STATE_WILL_ENABLE_CRYPTO, + Request::STATE_WILL_SEND_HEADERS, + Request::STATE_WILL_SEND_BODY, + Request::STATE_SENT, + Request::STATE_RECEIVING_HEADERS, + Request::STATE_RECEIVING_BODY, + Request::STATE_RECEIVED, + ) + ); + $available_slots = $this->concurrency - count( $processed_requests ); + $enqueued_requests = $this->get_requests( Request::STATE_ENQUEUED ); + for ( $i = 0; $i < $available_slots; $i ++ ) { + if ( ! isset( $enqueued_requests[ $i ] ) ) { + break; + } + $processed_requests[] = $enqueued_requests[ $i ]; + } + if ( $states !== null ) { + $processed_requests = static::filter_requests_by_state( $processed_requests, $states ); + } + + return $processed_requests; + } + + public function get_failed_requests() { + return $this->get_requests( Request::STATE_FAILED ); + } + + public function get_requests( $states ) { + if ( ! is_array( $states ) ) { + $states = array( $states ); + } + + return static::filter_requests_by_state( $this->requests, $states ); + } + + static public function filter_requests_by_state( array $requests, $states ) { + if ( ! is_array( $states ) ) { + $states = array( $states ); + } + $results = array(); + foreach ( $requests as $request ) { + if ( in_array( $request->state, $states ) ) { + $results[] = $request; + } + } + + return $results; + } + + public function get_request_by_id( $request_id ) { + foreach ( $this->requests as $request ) { + if ( $request->id === $request_id ) { + return $request; + } + } + } + + /** + * Consumes $length bytes received in response to a given request. + * + * @return string + */ + public function consume_buffered_response_body( $request_id ) { + $request = $this->get_request_by_id( $request_id ); + if ( null === $request ) { + return false; + } + $connection = $this->connections[ $request->id ]; + if ( + $request->state === Request::STATE_RECEIVING_BODY || + $request->state === Request::STATE_FINISHED + ) { + return $connection->consume_buffer(); + } + + $end_of_data = $request->state === Request::STATE_FINISHED && ( + ! is_resource( $this->connections[ $request->id ]->http_socket ) || + $this->connections[ $request->id ]->decoded_response_stream->reached_end_of_data() + ); + if ( $end_of_data ) { + return false; + } + + return ''; + } + + public function set_request_error( Request $request, $error ) { + $request->error = $error; + $request->state = Request::STATE_FAILED; + $this->events[ $request->id ][ Client::EVENT_FAILED ] = true; + } + + public function set_request_finished( Request $request ) { + $request->state = Request::STATE_FINISHED; + $this->events[ $request->id ][ Client::EVENT_FINISHED ] = true; + } + +} diff --git a/components/HttpClient/Client/CurlClient.php b/components/HttpClient/Client/CurlTransport.php similarity index 82% rename from components/HttpClient/Client/CurlClient.php rename to components/HttpClient/Client/CurlTransport.php index 410ff894..ec4bb8bc 100644 --- a/components/HttpClient/Client/CurlClient.php +++ b/components/HttpClient/Client/CurlTransport.php @@ -12,7 +12,13 @@ * * @extends Client */ -class CurlClient extends Client { +class CurlTransport implements TransportInterface { + + /** + * @var ClientState + */ + protected $state; + /** * @var \CurlMultiHandle cURL multi-handle managing parallel requests */ @@ -28,13 +34,13 @@ class CurlClient extends Client { * * @param array $options Optional config: 'concurrency', 'max_redirects', 'timeout_ms'. */ - public function __construct( $options = array() ) { - parent::__construct( $options ); + public function __construct( ClientState $state) { + $this->state = $state; $this->multi_handle = curl_multi_init(); curl_multi_setopt( $this->multi_handle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX ); - curl_multi_setopt( $this->multi_handle, CURLMOPT_MAX_TOTAL_CONNECTIONS, $this->concurrency ); - curl_multi_setopt( $this->multi_handle, CURLMOPT_MAX_HOST_CONNECTIONS, $this->concurrency ); + curl_multi_setopt( $this->multi_handle, CURLMOPT_MAX_TOTAL_CONNECTIONS, $this->state->concurrency ); + curl_multi_setopt( $this->multi_handle, CURLMOPT_MAX_HOST_CONNECTIONS, $this->state->concurrency ); } /** @@ -47,13 +53,13 @@ public function __destruct() { } } - protected function event_loop_tick() { - if ( count( $this->get_active_requests() ) === 0 ) { + public function event_loop_tick(): bool { + if ( count( $this->state->get_active_requests() ) === 0 ) { return false; } $this->open_nonblocking_curl_handles( - $this->get_active_requests( [ Request::STATE_ENQUEUED ] ) + $this->state->get_active_requests( [ Request::STATE_ENQUEUED ] ) ); if(count($this->handleMap) === 0) { @@ -61,15 +67,11 @@ protected function event_loop_tick() { } $this->poll_active_curl_requests(); - - $this->handle_redirects( - $this->get_active_requests( [ Request::STATE_RECEIVED ] ) - ); - $this->finalize_requests( - $this->get_active_requests( [ Request::STATE_RECEIVED ] ) - ); - + foreach ( $this->state->get_active_requests( [ Request::STATE_RECEIVED ] ) as $request ) { + $this->mark_finished( $request ); + } + return true; } @@ -87,7 +89,7 @@ private function open_nonblocking_curl_handles( $requests ) { $this->set_error( $request, new HttpError('Failed to add cURL handle to multi handle', $request) ); continue; } - $this->connections[ $request->id ]->http_socket = $ch; + $this->state->connections[ $request->id ]->http_socket = $ch; $this->handleMap[ (int) $ch ] = $request->id; } } @@ -106,7 +108,7 @@ private function poll_active_curl_requests() { if ( $id === null ) { throw new HttpClientException('Received completion event for an unknown request ' . ($ch ? (int) $ch : 'unknown')); } - $request = $this->get_request_by_id($id); + $request = $this->state->get_request_by_id($id); if ( $info['result'] !== CURLE_OK ) { $this->set_error($request, new HttpError(sprintf('cURL error %d: %s', $info['result'], curl_error( $ch )))); return; @@ -142,8 +144,9 @@ private function init_curl_handle( $request ) { // Basic curl settings for the request curl_setopt( $ch, CURLOPT_URL, $request->url ); curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false ); - curl_setopt( $ch, CURLOPT_MAXREDIRS, 0 ); //$this->max_redirects ); - curl_setopt( $ch, CURLOPT_TIMEOUT_MS, $this->request_timeout_ms ); + // Redirects are handled in the Client. + curl_setopt( $ch, CURLOPT_MAXREDIRS, 0 ); + curl_setopt( $ch, CURLOPT_TIMEOUT_MS, $this->state->request_timeout_ms ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, false ); // use callbacks for data curl_setopt( $ch, CURLOPT_HEADER, false ); // headers via callback curl_setopt($ch, CURLOPT_ENCODING, ''); @@ -198,7 +201,7 @@ private function handle_header_line( $ch, $header_line ) { if(null === $request) { throw new HttpClientException('Received header data for an unknown request ' . ($ch ? (int) $ch : 'unknown')); } - $connection = $this->connections[ $request->id ]; + $connection = $this->state->connections[ $request->id ]; if(strlen($connection->response_buffer) === 0) { $request->state = Request::STATE_RECEIVING_HEADERS; } @@ -216,7 +219,7 @@ private function handle_header_line( $ch, $header_line ) { $this->set_error( $request, new HttpError('Failed to parse headers', $request) ); return strlen( $header_line ); } - $this->events[$request->id][self::EVENT_GOT_HEADERS] = true; + $this->state->events[$request->id][Client::EVENT_GOT_HEADERS] = true; $request->state = Request::STATE_RECEIVING_BODY; return strlen( $header_line ); } @@ -237,23 +240,34 @@ private function handle_body_data( $ch, $data ) { if(null === $request) { throw new HttpClientException('Received body data for an unknown request ' . ($ch ? (int) $ch : 'unknown')); } - $this->connections[ $request->id ]->response_buffer .= $data; - $this->events[$request->id][self::EVENT_BODY_CHUNK_AVAILABLE] = true; + $this->state->connections[ $request->id ]->response_buffer .= $data; + $this->state->events[$request->id][Client::EVENT_BODY_CHUNK_AVAILABLE] = true; return strlen( $data ); } private function get_request_by_handle( $handle ) { $request_id = $this->handleMap[ (int) $handle ] ?? null; - return $this->get_request_by_id($request_id); + return $this->state->get_request_by_id($request_id); + } + + private function mark_finished( Request $request ) { + $this->state->set_request_finished( $request ); + $this->close_connection( $request ); } - protected function close_connection( Request $request ) { - $handle = $this->connections[ $request->id ]->http_socket; + private function set_error( Request $request, $error ) { + $this->state->set_request_error( $request, $error ); + $this->close_connection( $request ); + } + + private function close_connection( Request $request ) { + $handle = $this->state->connections[ $request->id ]->http_socket; if(null !== $handle) { curl_multi_remove_handle( $this->multi_handle, $handle ); curl_close( $handle ); } unset( $this->handleMap[ (int) $handle ] ); } + } diff --git a/components/HttpClient/Client/SocketClient.php b/components/HttpClient/Client/SocketTransport.php similarity index 80% rename from components/HttpClient/Client/SocketClient.php rename to components/HttpClient/Client/SocketTransport.php index ffa42860..ac203d44 100644 --- a/components/HttpClient/Client/SocketClient.php +++ b/components/HttpClient/Client/SocketTransport.php @@ -23,18 +23,26 @@ * * Streaming requests and responses * * GZip and Deflate transfer encoding */ -class SocketClient extends Client { +class SocketTransport implements TransportInterface { protected const STREAM_SELECT_READ = 1; protected const STREAM_SELECT_WRITE = 2; + /** + * @var ClientState + */ + protected $state; + + public function __construct( ClientState $state ) { + $this->state = $state; + } - protected function event_loop_tick() { - if ( count( $this->get_active_requests() ) === 0 ) { + public function event_loop_tick(): bool { + if ( count( $this->state->get_active_requests() ) === 0 ) { return false; } - foreach ( $this->get_active_requests([ + foreach ( $this->state->get_active_requests([ Request::STATE_WILL_ENABLE_CRYPTO, Request::STATE_WILL_SEND_HEADERS, Request::STATE_WILL_SEND_BODY, @@ -43,39 +51,35 @@ protected function event_loop_tick() { Request::STATE_RECEIVING_BODY, Request::STATE_RECEIVED, ]) as $request ) { - $time_elapsed_ms = $this->connections[ $request->id ]->time_elapsed_ms(); - if ( $time_elapsed_ms > $this->request_timeout_ms ) { + $time_elapsed_ms = $this->state->connections[ $request->id ]->time_elapsed_ms(); + if ( $time_elapsed_ms > $this->state->request_timeout_ms ) { $this->set_error( $request, new HttpError( sprintf( 'Request timed out after %s seconds.', $time_elapsed_ms ) ) ); } } $this->open_nonblocking_http_sockets( - $this->get_active_requests( Request::STATE_ENQUEUED ) + $this->state->get_active_requests( Request::STATE_ENQUEUED ) ); $this->enable_crypto( - $this->get_active_requests( Request::STATE_WILL_ENABLE_CRYPTO ) + $this->state->get_active_requests( Request::STATE_WILL_ENABLE_CRYPTO ) ); $this->send_request_headers( - $this->get_active_requests( Request::STATE_WILL_SEND_HEADERS ) + $this->state->get_active_requests( Request::STATE_WILL_SEND_HEADERS ) ); $this->send_request_body( - $this->get_active_requests( Request::STATE_WILL_SEND_BODY ) + $this->state->get_active_requests( Request::STATE_WILL_SEND_BODY ) ); $nb_headers_received = $this->receive_response_headers( - $this->get_active_requests( Request::STATE_RECEIVING_HEADERS ) - ); - - $this->handle_redirects( - $this->get_active_requests( Request::STATE_RECEIVED ) + $this->state->get_active_requests( Request::STATE_RECEIVING_HEADERS ) ); - $this->finalize_requests( - $this->get_active_requests( Request::STATE_RECEIVED ) - ); + foreach ( $this->state->get_active_requests( Request::STATE_RECEIVED ) as $request ) { + $this->mark_finished( $request ); + } /** * Allows the caller to consume the headers before we start polling @@ -98,7 +102,7 @@ protected function event_loop_tick() { } $this->receive_response_body( - $this->get_active_requests( Request::STATE_RECEIVING_BODY ) + $this->state->get_active_requests( Request::STATE_RECEIVING_BODY ) ); @@ -150,7 +154,7 @@ protected function open_nonblocking_http_sockets( $requests ) { 'tcp://' . $host . ':' . $port, $errno, $errstr, - $this->request_timeout_ms, + $this->state->request_timeout_ms, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT, $context ); @@ -165,8 +169,8 @@ protected function open_nonblocking_http_sockets( $requests ) { stream_set_blocking( $stream, false ); - $this->connections[ $request->id ]->http_socket = $stream; - $this->connections[ $request->id ]->started_at = microtime( true ); + $this->state->connections[ $request->id ]->http_socket = $stream; + $this->state->connections[ $request->id ]->started_at = microtime( true ); if ( $is_ssl ) { $request->state = Request::STATE_WILL_ENABLE_CRYPTO; } else { @@ -198,7 +202,7 @@ protected function decode_and_monitor_response_body_stream( Request $request ) { } $body_stream = FileReadStream::from_resource( - $this->connections[ $request->id ]->http_socket + $this->state->connections[ $request->id ]->http_socket ); $transformers = array(); @@ -242,9 +246,9 @@ protected function decode_and_monitor_response_body_stream( Request $request ) { */ protected function enable_crypto( array $requests ) { foreach ( $this->stream_select( $requests, static::STREAM_SELECT_WRITE ) as $request ) { - @stream_set_timeout( $this->connections[ $request->id ]->http_socket, 1 ); + @stream_set_timeout( $this->state->connections[ $request->id ]->http_socket, 1 ); $enabled_crypto = stream_socket_enable_crypto( - $this->connections[ $request->id ]->http_socket, + $this->state->connections[ $request->id ]->http_socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT ); @@ -272,7 +276,7 @@ protected function enable_crypto( array $requests ) { protected function send_request_headers( array $requests ) { foreach ( $this->stream_select( $requests, static::STREAM_SELECT_WRITE ) as $request ) { $header_bytes = static::prepare_request_headers( $request ); - if ( false === @fwrite( $this->connections[ $request->id ]->http_socket, $header_bytes ) ) { + if ( false === @fwrite( $this->state->connections[ $request->id ]->http_socket, $header_bytes ) ) { $last_error = error_get_last(); $last_error_message = is_array( $last_error ) ? $last_error['message'] : 'unknown'; $this->set_error( $request, @@ -319,7 +323,7 @@ protected function send_request_body( array $requests ) { } $chunk = $request->upload_body_stream->consume( $available_bytes ); - if ( ! @fwrite( $this->connections[ $request->id ]->http_socket, $chunk ) ) { + if ( ! @fwrite( $this->state->connections[ $request->id ]->http_socket, $chunk ) ) { $last_error = error_get_last(); $last_error_message = is_array( $last_error ) ? $last_error['message'] : 'unknown'; $this->set_error( $request, new HttpError( 'Failed to write request bytes: ' . $last_error_message ) ); @@ -340,28 +344,28 @@ protected function receive_response_headers( $requests ) { if ( ! $request->response ) { $request->response = new Response( $request ); } - $connection = $this->connections[ $request->id ]; + $connection = $this->state->connections[ $request->id ]; $response = $request->response; while ( true ) { // @TODO: Use a larger chunk size here and then scan for \r\n\r\n. // 1 seems slow and overly conservative. if ( - !$this->connections[ $request->id ]->http_socket || - !is_resource($this->connections[ $request->id ]->http_socket) || - @feof($this->connections[ $request->id ]->http_socket) + !$this->state->connections[ $request->id ]->http_socket || + !is_resource($this->state->connections[ $request->id ]->http_socket) || + @feof($this->state->connections[ $request->id ]->http_socket) ) { $this->set_error($request, new HttpError('Connection closed while reading response headers.')); break; } - $header_byte = fread( $this->connections[ $request->id ]->http_socket, 1 ); + $header_byte = fread( $this->state->connections[ $request->id ]->http_socket, 1 ); if ( false === $header_byte || '' === $header_byte ) { if ( - !$this->connections[ $request->id ]->http_socket || - !is_resource($this->connections[ $request->id ]->http_socket) || - @feof($this->connections[ $request->id ]->http_socket) + !$this->state->connections[ $request->id ]->http_socket || + !is_resource($this->state->connections[ $request->id ]->http_socket) || + @feof($this->state->connections[ $request->id ]->http_socket) ) { $this->set_error($request, new HttpError('Connection closed while reading response headers.')); break; @@ -391,7 +395,7 @@ protected function receive_response_headers( $requests ) { break; } - $this->events[ $request->id ][ self::EVENT_GOT_HEADERS ] = true; + $this->state->events[ $request->id ][ Client::EVENT_GOT_HEADERS ] = true; $nb_headers_received ++; if ( $response->total_bytes === 0 ) { @@ -400,7 +404,7 @@ protected function receive_response_headers( $requests ) { } $request->state = Request::STATE_RECEIVING_BODY; - $this->connections[ $request->id ]->decoded_response_stream = $this->decode_and_monitor_response_body_stream( $request ); + $this->state->connections[ $request->id ]->decoded_response_stream = $this->decode_and_monitor_response_body_stream( $request ); break; } } @@ -419,15 +423,15 @@ protected function receive_response_body( $requests ) { // * The last chunk in Transfer-Encoding: chunked is received // * The connection is closed foreach ( $this->stream_select( $requests, static::STREAM_SELECT_READ ) as $request ) { - $stream = $this->connections[ $request->id ]->decoded_response_stream; + $stream = $this->state->connections[ $request->id ]->decoded_response_stream; while ( true ) { $available_bytes = $stream->pull( 65536 ); if ( $available_bytes > 0 ) { $body_chunk = $stream->consume( $available_bytes ); $request->response->received_bytes += $available_bytes; - $this->connections[ $request->id ]->response_buffer .= $body_chunk; - $this->events[ $request->id ][ self::EVENT_BODY_CHUNK_AVAILABLE ] = true; + $this->state->connections[ $request->id ]->response_buffer .= $body_chunk; + $this->state->events[ $request->id ][ Client::EVENT_BODY_CHUNK_AVAILABLE ] = true; break; // Process one chunk per loop iteration } elseif ( $stream->reached_end_of_data() ) { $request->state = Request::STATE_RECEIVED; @@ -500,10 +504,10 @@ protected function stream_select( $requests, $mode ) { $write = array(); foreach ( $requests as $k => $request ) { if ( $mode & static::STREAM_SELECT_READ ) { - $read[ $k ] = $this->connections[ $request->id ]->http_socket; + $read[ $k ] = $this->state->connections[ $request->id ]->http_socket; } if ( $mode & static::STREAM_SELECT_WRITE ) { - $write[ $k ] = $this->connections[ $request->id ]->http_socket; + $write[ $k ] = $this->state->connections[ $request->id ]->http_socket; } } $except = null; @@ -512,7 +516,7 @@ protected function stream_select( $requests, $mode ) { } // phpcs:disable WordPress.PHP.NoSilencedErrors.Discouraged - $ready = @stream_select( $read, $write, $except, 0, static::NONBLOCKING_TIMEOUT_MICROSECONDS ); + $ready = @stream_select( $read, $write, $except, 0, ClientState::NONBLOCKING_TIMEOUT_MICROSECONDS ); if ( $ready === false ) { foreach ( $requests as $request ) { $this->set_error( $request, new HttpError( 'Error: ' . error_get_last()['message'] ) ); @@ -538,14 +542,24 @@ protected function stream_select( $requests, $mode ) { return $selected_requests; } - protected function close_connection( Request $request ) { - $socket = $this->connections[ $request->id ]->http_socket; + private function mark_finished( Request $request ) { + $this->state->set_request_finished( $request ); + $this->close_connection( $request ); + } + + private function set_error( Request $request, $error ) { + $this->state->set_request_error( $request, $error ); + $this->close_connection( $request ); + } + + private function close_connection( Request $request ) { + $socket = $this->state->connections[ $request->id ]->http_socket; if ( $socket && is_resource( $socket ) ) { // Close the TCP socket - if ( $this->connections[ $request->id ]->decoded_response_stream ) { - $stream = $this->connections[ $request->id ]->decoded_response_stream; + if ( $this->state->connections[ $request->id ]->decoded_response_stream ) { + $stream = $this->state->connections[ $request->id ]->decoded_response_stream; $stream->close_reading(); - $this->connections[ $request->id ]->decoded_response_stream = null; + $this->state->connections[ $request->id ]->decoded_response_stream = null; } else { @fclose( $socket ); } diff --git a/components/HttpClient/Client/TransportInterface.php b/components/HttpClient/Client/TransportInterface.php new file mode 100644 index 00000000..a07b9d9e --- /dev/null +++ b/components/HttpClient/Client/TransportInterface.php @@ -0,0 +1,11 @@ +withServer(function (string $base) { @@ -120,7 +120,7 @@ public function test_cutoff_head_request() { } protected function createClient( array $options = [] ): Client { - return new CurlClient( $options ); + return new Client( array_merge( $options, [ 'transport' => 'curl' ] ) ); } /** diff --git a/components/HttpClient/Tests/SocketClientTest.php b/components/HttpClient/Tests/SocketTransportTest.php similarity index 97% rename from components/HttpClient/Tests/SocketClientTest.php rename to components/HttpClient/Tests/SocketTransportTest.php index 74030fc8..60822fae 100644 --- a/components/HttpClient/Tests/SocketClientTest.php +++ b/components/HttpClient/Tests/SocketTransportTest.php @@ -115,7 +115,7 @@ public function test_corrupted_gzip() { } protected function createClient( array $options = [] ): Client { - return new SocketClient( $options ); + return new Client( array_merge( $options, [ 'transport' => 'socket' ] ) ); } /** @@ -126,7 +126,7 @@ public function test_errors( $scenario, $expectedErrorSubstring ) { if(!is_array($expectedErrorSubstring)) { $expectedErrorSubstring = [$expectedErrorSubstring]; } - $client = new SocketClient( [ 'timeout_ms' => 1000 ] ); // Increased timeout for timeout tests + $client = $this->createClient( [ 'timeout_ms' => 1000 ] ); // Increased timeout for timeout tests $request = new Request( "$url/error/$scenario" ); $client->enqueue( $request ); From 0d280cfda80a7f3cf21a43c38ef996424079872d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 May 2025 12:02:38 +0200 Subject: [PATCH 2/9] Internalize handling redirects in the Client class --- .../DataReference/DataReferenceResolver.php | 6 +- components/Blueprints/Runner.php | 6 +- components/Blueprints/Runtime.php | 8 +-- .../SiteResolver/NewSiteResolver.php | 4 +- .../Blueprints/Steps/SetSiteLanguageStep.php | 4 +- .../DataReferenceResolverTest.php | 6 +- .../Importer/AttachmentDownloader.php | 12 ++-- components/Git/GitRemote.php | 6 +- .../ByteStream/RequestReadStream.php | 27 ++++---- components/HttpClient/{Client => }/Client.php | 66 ++++++++++++++++--- .../HttpClient/{Client => }/ClientState.php | 8 +-- components/HttpClient/Crawler.php | 9 ++- ...tractClientTest.php => ClientTestBase.php} | 22 +++++-- .../HttpClient/Tests/CurlTransportTest.php | 11 ++-- .../Tests/RequestReadStreamTest.php | 5 +- .../HttpClient/Tests/SocketTransportTest.php | 9 ++- .../HttpClient/Tests/TestSocketClient.php | 52 --------------- .../{Client => Transport}/CurlTransport.php | 5 +- .../{Client => Transport}/SocketTransport.php | 7 +- .../TransportInterface.php | 4 +- .../examples/concurrent-downloads.php | 13 ++-- components/HttpClient/examples/http-proxy.php | 13 ++-- components/Zip/Tests/ZipFilesystemTest.php | 4 +- 23 files changed, 154 insertions(+), 153 deletions(-) rename components/HttpClient/{Client => }/Client.php (85%) rename components/HttpClient/{Client => }/ClientState.php (96%) rename components/HttpClient/Tests/{AbstractClientTest.php => ClientTestBase.php} (97%) delete mode 100644 components/HttpClient/Tests/TestSocketClient.php rename components/HttpClient/{Client => Transport}/CurlTransport.php (98%) rename components/HttpClient/{Client => Transport}/SocketTransport.php (99%) rename components/HttpClient/{Client => Transport}/TransportInterface.php (72%) diff --git a/components/Blueprints/DataReference/DataReferenceResolver.php b/components/Blueprints/DataReference/DataReferenceResolver.php index 23aa0df8..bb64908a 100644 --- a/components/Blueprints/DataReference/DataReferenceResolver.php +++ b/components/Blueprints/DataReference/DataReferenceResolver.php @@ -12,14 +12,14 @@ use WordPress\Git\GitFilesystem; use WordPress\Git\GitRepository; use WordPress\HttpClient\ByteStream\SeekableRequestReadStream; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use function WordPress\Filesystem\wp_join_unix_paths; use function WordPress\Filesystem\wp_unix_sys_get_temp_dir; class DataReferenceResolver { /** - * @var SocketClient + * @var Client */ private $client; /** @@ -47,7 +47,7 @@ class DataReferenceResolver { */ private $tmpRoot; - public function __construct( SocketClient $client, ?string $tmpRoot = null ) { + public function __construct( Client $client, ?string $tmpRoot = null ) { $this->client = $client; $this->tmpRoot = $tmpRoot ?: wp_unix_sys_get_temp_dir(); } diff --git a/components/Blueprints/Runner.php b/components/Blueprints/Runner.php index 84be4745..98943d02 100644 --- a/components/Blueprints/Runner.php +++ b/components/Blueprints/Runner.php @@ -51,7 +51,7 @@ use WordPress\Filesystem\InMemoryFilesystem; use WordPress\Filesystem\LocalFilesystem; use WordPress\HttpClient\ByteStream\RequestReadStream; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\Zip\ZipFilesystem; use function WordPress\Encoding\utf8_is_valid_byte_stream; @@ -65,7 +65,7 @@ class Runner { private $configuration; // TODO: Rename httpClient /** - * @var SocketClient + * @var Client */ private $client; /** @@ -113,7 +113,7 @@ public function __construct( RunnerConfiguration $configuration ) { $this->configuration = $configuration; $this->validateConfiguration( $configuration ); - $this->client = new SocketClient(); + $this->client = new Client(); $this->mainTracker = new Tracker(); // Set up progress logging diff --git a/components/Blueprints/Runtime.php b/components/Blueprints/Runtime.php index ded06716..a5cf875e 100644 --- a/components/Blueprints/Runtime.php +++ b/components/Blueprints/Runtime.php @@ -12,7 +12,7 @@ use WordPress\ByteStream\WriteStream\FileWriteStream; use WordPress\Filesystem\Filesystem; use WordPress\Filesystem\LocalFilesystem; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use function WordPress\Filesystem\pipe_stream; use function WordPress\Filesystem\wp_join_unix_paths; @@ -47,7 +47,7 @@ class Runtime { */ private $assets; /** - * @var SocketClient + * @var Client */ private $client; /** @@ -67,7 +67,7 @@ public function __construct( Filesystem $targetFs, RunnerConfiguration $configuration, DataReferenceResolver $assets, - SocketClient $client, + Client $client, array $blueprint, string $tempRoot, DataReference $wpCliReference @@ -81,7 +81,7 @@ public function __construct( $this->wpCliReference = $wpCliReference; } - public function getHttpClient(): SocketClient { + public function getHttpClient(): Client { return $this->client; } diff --git a/components/Blueprints/SiteResolver/NewSiteResolver.php b/components/Blueprints/SiteResolver/NewSiteResolver.php index f2300cd7..5dbf6c37 100644 --- a/components/Blueprints/SiteResolver/NewSiteResolver.php +++ b/components/Blueprints/SiteResolver/NewSiteResolver.php @@ -8,7 +8,7 @@ use WordPress\Blueprints\Progress\Tracker; use WordPress\Blueprints\Runtime; use WordPress\Blueprints\VersionStrings\VersionConstraint; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\Zip\ZipFilesystem; use function WordPress\Filesystem\copy_between_filesystems; @@ -143,7 +143,7 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon $progress->finish(); } - static private function resolveWordPressZipUrl( SocketClient $client, string $version_string ): string { + static private function resolveWordPressZipUrl( Client $client, string $version_string ): string { if ( $version_string === 'latest' ) { return 'https://wordpress.org/latest.zip'; } diff --git a/components/Blueprints/Steps/SetSiteLanguageStep.php b/components/Blueprints/Steps/SetSiteLanguageStep.php index cbac0455..99588a2e 100644 --- a/components/Blueprints/Steps/SetSiteLanguageStep.php +++ b/components/Blueprints/Steps/SetSiteLanguageStep.php @@ -5,7 +5,7 @@ use Exception; use WordPress\Blueprints\Progress\Tracker; use WordPress\Blueprints\Runtime; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\HttpClient\Request; use WordPress\Zip\ZipFilesystem; @@ -226,7 +226,7 @@ function(\$theme) { * * @return string|false */ - private function getWordPressTranslationUrl( Runtime $runtime, string $wpVersion, string $language, SocketClient $client ) { + private function getWordPressTranslationUrl( Runtime $runtime, string $wpVersion, string $language, Client $client ) { try { $api_url = "https://api.wordpress.org/translations/core/1.0/?version={$wpVersion}"; $translations_data = $client->fetch( $api_url )->json(); diff --git a/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php b/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php index 3d6d56a2..7c6ad42b 100644 --- a/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php +++ b/components/Blueprints/Tests/Unit/DataReference/DataReferenceResolverTest.php @@ -18,10 +18,10 @@ use WordPress\ByteStream\MemoryPipe; use WordPress\ByteStream\ReadStream\ByteReadStream; use WordPress\Filesystem\Filesystem; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; class DataReferenceResolverTest extends TestCase { - /** @var SocketClient&MockObject */ + /** @var Client&MockObject */ protected $client; protected $resolver; /** @var Filesystem&MockObject */ @@ -30,7 +30,7 @@ class DataReferenceResolverTest extends TestCase { protected function setUp(): void { // @TODO: Don't mock. Just test actual resolution. - $this->client = new SocketClient(); + $this->client = new Client(); $this->resolver = new DataReferenceResolver( $this->client ); $this->executionContext = $this->createMock( Filesystem::class ); $this->tracker = $this->createMock( Tracker::class ); diff --git a/components/DataLiberation/Importer/AttachmentDownloader.php b/components/DataLiberation/Importer/AttachmentDownloader.php index 883fa05a..917b8ce7 100644 --- a/components/DataLiberation/Importer/AttachmentDownloader.php +++ b/components/DataLiberation/Importer/AttachmentDownloader.php @@ -4,7 +4,7 @@ use Exception; use WordPress\Filesystem\Filesystem; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\HttpClient\Request; use function WordPress\Filesystem\wp_join_unix_paths; @@ -25,7 +25,7 @@ class AttachmentDownloader { private $progress = array(); public function __construct( $output_root, $options = array() ) { - $this->client = new SocketClient(); + $this->client = new Client(); $this->output_root = $output_root; $this->source_from_filesystem = $options['source_from_filesystem'] ?? null; } @@ -181,7 +181,7 @@ public function poll() { */ switch ( $event ) { - case SocketClient::EVENT_GOT_HEADERS: + case Client::EVENT_GOT_HEADERS: if ( ! $request->is_redirected() ) { if ( file_exists( $this->output_paths[ $original_request_id ] . '.partial' ) ) { unlink( $this->output_paths[ $original_request_id ] . '.partial' ); @@ -196,7 +196,7 @@ public function poll() { } } break; - case SocketClient::EVENT_BODY_CHUNK_AVAILABLE: + case Client::EVENT_BODY_CHUNK_AVAILABLE: $chunk = $this->client->get_response_body_chunk(); if ( ! fwrite( $this->fps[ $original_request_id ], $chunk ) ) { // @TODO: Don't echo the error message. Attach it to the import session instead for the user to review later on. @@ -205,10 +205,10 @@ public function poll() { } $this->progress[ $original_url ]['received'] += strlen( $chunk ); break; - case SocketClient::EVENT_FAILED: + case Client::EVENT_FAILED: $this->on_failure( $original_url, $original_request_id, $request->error ); break; - case SocketClient::EVENT_FINISHED: + case Client::EVENT_FINISHED: if ( ! $request->is_redirected() ) { // Only process if this was the last request in the chain. $is_success = ( diff --git a/components/Git/GitRemote.php b/components/Git/GitRemote.php index 780736e8..f8563a01 100644 --- a/components/Git/GitRemote.php +++ b/components/Git/GitRemote.php @@ -18,13 +18,13 @@ use WordPress\Git\Model\TreeEntry; use WordPress\Git\Protocol\GitProtocolEncoderPipe; use WordPress\Git\Protocol\Parser\GitProtocolDecoder; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\HttpClient\Request; class GitRemote { /** - * @var SocketClient + * @var Client */ private $http_client; /** @@ -36,7 +36,7 @@ class GitRemote { public function __construct( GitRepository $repository, $remote_name, $options = array() ) { $this->remote_name = $remote_name; $this->repository = $repository; - $this->http_client = $options['http_client'] ?? new SocketClient( + $this->http_client = $options['http_client'] ?? new Client( array( 'timeout_ms' => 300000, ) diff --git a/components/HttpClient/ByteStream/RequestReadStream.php b/components/HttpClient/ByteStream/RequestReadStream.php index 2f00752c..2cecc2bc 100644 --- a/components/HttpClient/ByteStream/RequestReadStream.php +++ b/components/HttpClient/ByteStream/RequestReadStream.php @@ -4,7 +4,8 @@ use WordPress\ByteStream\ByteStreamException; use WordPress\ByteStream\ReadStream\BaseByteReadStream; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Client; +use WordPress\HttpClient\Transport; use WordPress\HttpClient\HttpClientException; use WordPress\HttpClient\Request; use WordPress\HttpClient\Response; @@ -15,7 +16,7 @@ class RequestReadStream extends BaseByteReadStream { /** - * @var SocketClient + * @var Client */ private $client; /** @@ -43,7 +44,7 @@ public function __construct( $request, $options = array() ) { if ( is_string( $request ) ) { $request = new Request( $request ); } - $this->client = $options['client'] ?? new SocketClient(); + $this->client = $options['client'] ?? new Client(); $this->request = $request; if ( isset( $options['buffer_size'] ) ) { $this->buffer_size = $options['buffer_size']; @@ -87,13 +88,13 @@ protected function internal_pull( $max_bytes = 8096 ): string { return $this->pull_until_event( array( 'max_bytes' => $max_bytes, - 'event' => SocketClient::EVENT_BODY_CHUNK_AVAILABLE, + 'event' => Client::EVENT_BODY_CHUNK_AVAILABLE, ) ); } private function pull_until_event( $options = array() ) { - $stop_at_event = $options['event'] ?? SocketClient::EVENT_BODY_CHUNK_AVAILABLE; + $stop_at_event = $options['event'] ?? Client::EVENT_BODY_CHUNK_AVAILABLE; $this->ensure_is_enqueued(); while ( $this->client->await_next_event( @@ -113,7 +114,7 @@ private function pull_until_event( $options = array() ) { continue; } switch ( $this->client->get_event() ) { - case SocketClient::EVENT_GOT_HEADERS: + case Client::EVENT_GOT_HEADERS: $this->response = $response; $content_length = $response->get_header( 'Content-Length' ); if ( null !== $content_length ) { @@ -126,12 +127,12 @@ private function pull_until_event( $options = array() ) { */ $this->remote_file_length = (int) $content_length; } - if ( $stop_at_event === SocketClient::EVENT_GOT_HEADERS ) { + if ( $stop_at_event === Client::EVENT_GOT_HEADERS ) { return true; } break; - case SocketClient::EVENT_BODY_CHUNK_AVAILABLE: - if ( $stop_at_event === SocketClient::EVENT_BODY_CHUNK_AVAILABLE ) { + case Client::EVENT_BODY_CHUNK_AVAILABLE: + if ( $stop_at_event === Client::EVENT_BODY_CHUNK_AVAILABLE ) { $body_chunk = $this->client->get_response_body_chunk(); if ( $this->progress_tracker ) { @@ -144,7 +145,7 @@ private function pull_until_event( $options = array() ) { return $body_chunk; } break; - case SocketClient::EVENT_FINISHED: + case Client::EVENT_FINISHED: /** * If the server did not provide a Content-Length header, * backfill the file length with the number of downloaded @@ -155,7 +156,7 @@ private function pull_until_event( $options = array() ) { } return ''; - case SocketClient::EVENT_FAILED: + case Client::EVENT_FAILED: // TODO: Think through error handling. Errors are expected when working with // the network. Should we auto retry? Make it easy for the caller to retry? // Something else? @@ -174,7 +175,7 @@ public function await_response() { if ( ! $this->response ) { $this->pull_until_event( array( - 'event' => SocketClient::EVENT_GOT_HEADERS, + 'event' => Client::EVENT_GOT_HEADERS, ) ); } @@ -188,7 +189,7 @@ public function await_response() { protected function internal_reached_end_of_data(): bool { return ( Request::STATE_FINISHED === $this->request->latest_redirect()->state && - ! $this->client->has_pending_event( $this->request, SocketClient::EVENT_BODY_CHUNK_AVAILABLE ) && + ! $this->client->has_pending_event( $this->request, Client::EVENT_BODY_CHUNK_AVAILABLE ) && strlen( $this->buffer ) === $this->offset_in_current_buffer ); } diff --git a/components/HttpClient/Client/Client.php b/components/HttpClient/Client.php similarity index 85% rename from components/HttpClient/Client/Client.php rename to components/HttpClient/Client.php index b8ada352..6d5fcd9a 100644 --- a/components/HttpClient/Client/Client.php +++ b/components/HttpClient/Client.php @@ -1,13 +1,12 @@ state->event = $considered_event; $this->state->request = $this->state->get_request_by_id( $request_id ); switch ( $this->state->event ) { + case Client::EVENT_GOT_HEADERS: + $this->handle_redirect($this->state->request); + break; case Client::EVENT_BODY_CHUNK_AVAILABLE: $this->state->response_body_chunk = $this->state->consume_buffered_response_body( $request_id ); break; @@ -242,6 +244,56 @@ public function await_next_event( $query = array() ) { return false; } + /** + * @param array $requests An array of requests. + */ + protected function handle_redirect( $request ) { + $response = $request->response; + if ( ! $response ) { + return; + } + $code = $response->status_code; + if ( ! in_array($code, [301, 302, 303, 307, 308]) ) { + return; + } + + $location = $response->get_header( 'location' ); + if ( null === $location ) { + return; + } + + $redirects_so_far = 0; + $cause = $request; + while ( $cause->redirected_from ) { + ++ $redirects_so_far; + $cause = $cause->redirected_from; + } + + if ( $redirects_so_far >= $this->state->max_redirects ) { + $this->state->set_request_error( $request, new HttpError( 'Too many redirects' ) ); + return; + } + + $redirect_url = $location; + $parsed = WPURL::parse($redirect_url, $request->url); + if(false === $parsed) { + $this->state->set_request_error( $request, new HttpError( sprintf( 'Invalid redirect URL: %s', $redirect_url ) ) ); + return; + } + $redirect_url = $parsed->toString(); + + $this->enqueue( + new Request( + $redirect_url, + array( + // Redirects are always GET requests + 'method' => 'GET', + 'redirected_from' => $request, + ) + ) + ); + } + public function has_pending_event( $request, $event_type ) { return $this->state->has_pending_event( $request, $event_type ); } @@ -291,8 +343,4 @@ public function get_active_requests( $states = null ) { return $this->state->get_active_requests( $states ); } - public function get_failed_requests() { - return $this->state->get_failed_requests(); - } - } diff --git a/components/HttpClient/Client/ClientState.php b/components/HttpClient/ClientState.php similarity index 96% rename from components/HttpClient/Client/ClientState.php rename to components/HttpClient/ClientState.php index 23107d0f..9942daf0 100644 --- a/components/HttpClient/Client/ClientState.php +++ b/components/HttpClient/ClientState.php @@ -1,8 +1,6 @@ get_requests( Request::STATE_FAILED ); - } - public function get_requests( $states ) { if ( ! is_array( $states ) ) { $states = array( $states ); diff --git a/components/HttpClient/Crawler.php b/components/HttpClient/Crawler.php index ef282da8..e8f8546a 100644 --- a/components/HttpClient/Crawler.php +++ b/components/HttpClient/Crawler.php @@ -4,7 +4,6 @@ use WordPress\DataLiberation\BlockMarkup\BlockMarkupUrlProcessor; use WordPress\DataLiberation\URL\WPURL; -use WordPress\HttpClient\Client\SocketClient; use function WordPress\DataLiberation\URL\is_child_url_of; @@ -12,7 +11,7 @@ * A simple web crawler. */ class Crawler { - /** @var SocketClient */ + /** @var Client */ private $client; /** @var array */ @@ -37,7 +36,7 @@ class Crawler { * @param array $options Client options */ public function __construct( $base_url, array $options = array() ) { - $this->client = $options['client'] ?? new SocketClient(); + $this->client = $options['client'] ?? new Client(); $this->preprocess_url = $options['preprocess_url'] ?? null; $this->base_url = $base_url; $this->visited_urls[ $base_url ] = true; @@ -89,14 +88,14 @@ public function crawl_next() { $this->current_url = $current_request->url; switch ( $this->client->get_event() ) { - case SocketClient::EVENT_BODY_CHUNK_AVAILABLE: + case Client::EVENT_BODY_CHUNK_AVAILABLE: if ( ! isset( $this->responses[ $this->current_url ] ) ) { $this->responses[ $this->current_url ] = ''; } $this->responses[ $this->current_url ] .= $this->client->get_response_body_chunk(); break; - case SocketClient::EVENT_FINISHED: + case Client::EVENT_FINISHED: if ( ! isset( $this->responses[ $this->current_url ] ) ) { continue 2; } diff --git a/components/HttpClient/Tests/AbstractClientTest.php b/components/HttpClient/Tests/ClientTestBase.php similarity index 97% rename from components/HttpClient/Tests/AbstractClientTest.php rename to components/HttpClient/Tests/ClientTestBase.php index 6b86b5be..7fb0be4e 100644 --- a/components/HttpClient/Tests/AbstractClientTest.php +++ b/components/HttpClient/Tests/ClientTestBase.php @@ -4,7 +4,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; -use WordPress\HttpClient\Client\Client; +use WordPress\HttpClient\Client; use WordPress\HttpClient\HttpError; use WordPress\HttpClient\Request; @@ -51,7 +51,7 @@ public function length() : ?int { } } -abstract class AbstractClientTest extends TestCase { +abstract class ClientTestBase extends TestCase { /** * Create the client instance to be tested. @@ -447,9 +447,13 @@ public function test_redirect_loop() { $request = new Request( "$url/redirect/loop" ); $client->enqueue( $request ); + $requests = [ $request ]; $error_occurred = false; - while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + while ( $client->await_next_event( [ 'requests' => $requests ] ) ) { switch ( $client->get_event() ) { + case Client::EVENT_GOT_HEADERS: + $requests[] = $request->latest_redirect(); + break; case Client::EVENT_FAILED: $this->assertNotNull( $request->latest_redirect()->error ); $this->assertStringContainsString( 'Too many redirects', $request->latest_redirect()->error->message ); @@ -489,8 +493,12 @@ public function test_invalid_redirect_url() { $client->enqueue( $request ); $error_occurred = false; - while ( $client->await_next_event( [ 'requests' => [ $request ] ] ) ) { + $requests = [ $request ]; + while ( $client->await_next_event( [ 'requests' => $requests ] ) ) { switch ( $client->get_event() ) { + case Client::EVENT_GOT_HEADERS: + $requests[] = $request->latest_redirect(); + break; case Client::EVENT_FAILED: $this->assertNotNull( $request->latest_redirect()->error ); $this->assertStringContainsString( 'Invalid URL', $request->latest_redirect()->error->message ); @@ -597,12 +605,12 @@ protected function expectClientError(Request $req, ?float $timeout_ms = null, ar $currentMethod = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function']; if (isset($clientSpecificMappings[$currentMethod]['message'])) { $opts['message'] = array_merge( - (array) $opts['message'], + (array) $opts['message'], (array) $clientSpecificMappings[$currentMethod]['message'] ); } } - + $client = $this->createClient($opts); try { $body = $this->consume_entire_body($client, $req); @@ -624,4 +632,4 @@ public function assertStringContainsAny(string $haystack, $needles, ?string $mes } $this->fail($message ?? "None of the needles found in haystack: " . $haystack); } -} +} diff --git a/components/HttpClient/Tests/CurlTransportTest.php b/components/HttpClient/Tests/CurlTransportTest.php index 61323ac3..078c3fda 100644 --- a/components/HttpClient/Tests/CurlTransportTest.php +++ b/components/HttpClient/Tests/CurlTransportTest.php @@ -2,12 +2,11 @@ namespace WordPress\HttpClient\Tests; -use WordPress\HttpClient\Client\Client; -use WordPress\HttpClient\Client\CurlClient; +use WordPress\HttpClient\Client; use WordPress\HttpClient\HttpError; use WordPress\HttpClient\Request; -class CurlTransportTest extends AbstractClientTest { +class CurlTransportTest extends ClientTestBase { public function test_unsupported_encoding() { $this->withServer(function (string $base) { @@ -118,7 +117,7 @@ public function test_cutoff_head_request() { $this->assertEmpty( $body ); // Body should be empty for HEAD }, 'edge-cases' ); } - + protected function createClient( array $options = [] ): Client { return new Client( array_merge( $options, [ 'transport' => 'curl' ] ) ); } @@ -141,7 +140,7 @@ public function errorProvider() { 'Invalid Response' => [ 'invalid-response', 'cURL error 1: Received HTTP/0.9 when not allowed' ], 'Timeout' => [ 'timeout', 'cURL error' ], 'Timeout Read Body' => [ 'timeout-read-body', 'cURL error' ], - + // cURL ignores unsupported transfer encodings // 'Unsupported Transfer Encoding' => [ 'unsupported-encoding', 'Unsupported transfer encoding received from the server: unsupported' ], @@ -181,4 +180,4 @@ protected function getClientSpecificErrorMessages(): array { ], ]; } -} \ No newline at end of file +} diff --git a/components/HttpClient/Tests/RequestReadStreamTest.php b/components/HttpClient/Tests/RequestReadStreamTest.php index 47bd4681..2a866925 100644 --- a/components/HttpClient/Tests/RequestReadStreamTest.php +++ b/components/HttpClient/Tests/RequestReadStreamTest.php @@ -6,7 +6,8 @@ use Symfony\Component\Process\Process; use WordPress\ByteStream\ByteStreamException; use WordPress\HttpClient\ByteStream\RequestReadStream; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Client; +use WordPress\HttpClient\Transport; use WordPress\HttpClient\Request; use WordPress\HttpClient\Response; @@ -68,7 +69,7 @@ public function testConstructWithRequest() { public function testConstructWithCustomClient() { $this->withServer(function($url) { $test_url = $url . $this->fixture; - $client = new SocketClient(); + $client = new Client(); $stream = new RequestReadStream( $test_url, [ 'client' => $client ] ); $this->assertInstanceOf( RequestReadStream::class, $stream ); $response = $stream->await_response(); diff --git a/components/HttpClient/Tests/SocketTransportTest.php b/components/HttpClient/Tests/SocketTransportTest.php index 60822fae..ac11ed13 100644 --- a/components/HttpClient/Tests/SocketTransportTest.php +++ b/components/HttpClient/Tests/SocketTransportTest.php @@ -2,11 +2,10 @@ namespace WordPress\HttpClient\Tests; -use WordPress\HttpClient\Client\Client; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Client; use WordPress\HttpClient\Request; -class SocketClientTest extends AbstractClientTest { +class SocketTransportTest extends ClientTestBase { public function test_unsupported_encoding() { $this->withServer(function (string $base) { @@ -16,7 +15,7 @@ public function test_unsupported_encoding() { ]); }, 'encoding'); } - + /** * Test HEAD request. */ @@ -187,4 +186,4 @@ protected function getClientSpecificErrorMessages(): array { ], ]; } -} \ No newline at end of file +} diff --git a/components/HttpClient/Tests/TestSocketClient.php b/components/HttpClient/Tests/TestSocketClient.php deleted file mode 100644 index aa91da4d..00000000 --- a/components/HttpClient/Tests/TestSocketClient.php +++ /dev/null @@ -1,52 +0,0 @@ -concurrency; - } - - public function getMaxRedirects() { - return $this->max_redirects; - } - - public function getTimeout() { - return $this->request_timeout; - } - - public function getRequests() { - return $this->requests; - } - - public function simulateEvent( $event, $request ) { - $this->events[ $request->id ][ $event ] = true; - } - - public function simulateError( $request, $error ) { - $this->set_error( $request, $error ); - } - - public function simulateRedirect( $request, $url ) { - $request->response = new Response( $request ); - $request->response->status_code = 301; - $request->response->headers = array( - 'location' => $url, - ); - $this->handle_redirects( array( $request ) ); - } - - public function getRedirectCount( $request ) { - $count = 0; - while ( $request->redirected_to ) { - ++ $count; - $request = $request->redirected_to; - } - - return $count; - } -} diff --git a/components/HttpClient/Client/CurlTransport.php b/components/HttpClient/Transport/CurlTransport.php similarity index 98% rename from components/HttpClient/Client/CurlTransport.php rename to components/HttpClient/Transport/CurlTransport.php index ec4bb8bc..263ce772 100644 --- a/components/HttpClient/Client/CurlTransport.php +++ b/components/HttpClient/Transport/CurlTransport.php @@ -1,7 +1,10 @@ enqueue( $requests ); while ( $client->await_next_event() ) { $request = $client->get_request(); echo 'Request ' . $request->id . ': ' . $client->get_event() . ' '; switch ( $client->get_event() ) { - case SocketClient::EVENT_BODY_CHUNK_AVAILABLE: + case Client::EVENT_BODY_CHUNK_AVAILABLE: echo $request->response->received_bytes . '/' . $request->response->total_bytes . ' bytes received'; file_put_contents( 'downloads/' . $request->id, $client->get_response_body_chunk(), FILE_APPEND ); break; - case SocketClient::EVENT_REDIRECT: - case SocketClient::EVENT_GOT_HEADERS: - case SocketClient::EVENT_FINISHED: + case Client::EVENT_GOT_HEADERS: + case Client::EVENT_FINISHED: break; - case SocketClient::EVENT_FAILED: + case Client::EVENT_FAILED: echo '– ❌ Failed request to ' . $request->url . ' – ' . $request->error; break; } diff --git a/components/HttpClient/examples/http-proxy.php b/components/HttpClient/examples/http-proxy.php index 6ae5582b..659dde95 100644 --- a/components/HttpClient/examples/http-proxy.php +++ b/components/HttpClient/examples/http-proxy.php @@ -6,7 +6,7 @@ * in https://github.com/WordPress/wordpress-playground/pull/1546. */ -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\HttpClient\ClientEvent; use WordPress\HttpClient\Request; @@ -45,14 +45,14 @@ function get_target_url( $server_data = null ) { ), ); -$client = new SocketClient(); +$client = new Client(); $client->enqueue( $requests ); $headers_sent = false; while ( $client->await_next_event() ) { $request = $client->get_request(); switch ( $client->get_event() ) { - case SocketClient::EVENT_GOT_HEADERS: + case Client::EVENT_GOT_HEADERS: http_response_code( $request->response->status_code ); foreach ( $request->response->headers as $name => $value ) { if ( @@ -66,17 +66,16 @@ function get_target_url( $server_data = null ) { } $headers_sent = true; break; - case SocketClient::EVENT_BODY_CHUNK_AVAILABLE: + case Client::EVENT_BODY_CHUNK_AVAILABLE: echo $client->get_response_body_chunk(); break; - case SocketClient::EVENT_FAILED: + case Client::EVENT_FAILED: if ( ! $headers_sent ) { http_response_code( 500 ); echo 'Failed request to ' . $request->url . ' – ' . $request->error; } break; - case SocketClient::EVENT_REDIRECT: - case SocketClient::EVENT_FINISHED: + case Client::EVENT_FINISHED: break; } echo "\n"; diff --git a/components/Zip/Tests/ZipFilesystemTest.php b/components/Zip/Tests/ZipFilesystemTest.php index 8b3824e1..c60e902c 100644 --- a/components/Zip/Tests/ZipFilesystemTest.php +++ b/components/Zip/Tests/ZipFilesystemTest.php @@ -4,7 +4,7 @@ use Symfony\Component\Process\Process; use WordPress\ByteStream\ReadStream\FileReadStream; use WordPress\HttpClient\ByteStream\SeekableRequestReadStream; -use WordPress\HttpClient\Client\SocketClient; +use WordPress\HttpClient\Transport; use WordPress\Zip\ZipFilesystem; use function WordPress\Filesystem\wp_join_unix_paths; @@ -63,7 +63,7 @@ public function testReadRemoteZip( $chunked ) { $zip = ZipFilesystem::create( new SeekableRequestReadStream( "$url/childrens-literature.zip?chunked=$chunked", - [ 'client' => new SocketClient() ] + [ 'client' => new Client() ] ) ); $this->assertEquals( From 7892527ba96b305f20617c6269a598f0e6aca070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 May 2025 12:05:50 +0200 Subject: [PATCH 3/9] Follow redirects in RequestReadStream --- .../ByteStream/RequestReadStream.php | 2 +- .../Tests/RequestReadStreamTest.php | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/components/HttpClient/ByteStream/RequestReadStream.php b/components/HttpClient/ByteStream/RequestReadStream.php index 2cecc2bc..52b2d4f7 100644 --- a/components/HttpClient/ByteStream/RequestReadStream.php +++ b/components/HttpClient/ByteStream/RequestReadStream.php @@ -99,7 +99,7 @@ private function pull_until_event( $options = array() ) { while ( $this->client->await_next_event( array( - 'requests' => array( $this->request ), + 'requests' => array( $this->request->latest_redirect() ), ) ) ) { $request = $this->client->get_request(); diff --git a/components/HttpClient/Tests/RequestReadStreamTest.php b/components/HttpClient/Tests/RequestReadStreamTest.php index 2a866925..f5d0be4d 100644 --- a/components/HttpClient/Tests/RequestReadStreamTest.php +++ b/components/HttpClient/Tests/RequestReadStreamTest.php @@ -140,6 +140,27 @@ public function testReadingContent() { }); } + public function testRedirects() { + $this->withServer(function($url) { + $test_url = $url . '/redirect/relative-path-redirect'; + $stream = new RequestReadStream( $test_url ); + $response = $stream->await_response(); + + // Should follow redirects and get the final response + $this->assertInstanceOf( Response::class, $response ); + $this->assertEquals( 200, $response->status_code ); + + // Should be able to read the final content + $content = $stream->consume_all(); + $this->assertStringContainsString( 'Arrived at /redirect/new-path/resource.html.', $content ); + + // Check that the request was redirected + $request = $stream->get_request(); + $this->assertNotNull( $request->redirected_to ); + $this->assertStringContainsString( '/redirect/new-path/resource.html', $request->redirected_to->url ); + }, 'redirect'); + } + public function testTell() { $this->withServer(function($url) { $test_url = $url . $this->fixture; From a587670aaa5b82b9f0d651709de97c6cee2913b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 26 May 2025 12:21:07 +0200 Subject: [PATCH 4/9] Adjust tests --- components/Zip/Tests/ZipFilesystemTest.php | 2 +- phpunit.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/Zip/Tests/ZipFilesystemTest.php b/components/Zip/Tests/ZipFilesystemTest.php index c60e902c..8afeb322 100644 --- a/components/Zip/Tests/ZipFilesystemTest.php +++ b/components/Zip/Tests/ZipFilesystemTest.php @@ -4,7 +4,7 @@ use Symfony\Component\Process\Process; use WordPress\ByteStream\ReadStream\FileReadStream; use WordPress\HttpClient\ByteStream\SeekableRequestReadStream; -use WordPress\HttpClient\Transport; +use WordPress\HttpClient\Client; use WordPress\Zip\ZipFilesystem; use function WordPress\Filesystem\wp_join_unix_paths; diff --git a/phpunit.xml b/phpunit.xml index 54932d67..3edf44dd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ + stopOnFailure="false">