class-chunkeddecoderreadstream.php
6 days ago
class-chunkedencoderbytetransformer.php
6 days ago
class-requestreadstream.php
6 days ago
class-seekablerequestreadstream.php
6 days ago
class-requestreadstream.php
224 lines
| 1 | <?php |
| 2 | |
| 3 | namespace WordPress\HttpClient\ByteStream; |
| 4 | |
| 5 | use WordPress\ByteStream\ByteStreamException; |
| 6 | use WordPress\ByteStream\ReadStream\BaseByteReadStream; |
| 7 | use WordPress\HttpClient\Client; |
| 8 | use WordPress\HttpClient\HttpClientException; |
| 9 | use WordPress\HttpClient\Request; |
| 10 | use WordPress\HttpClient\Response; |
| 11 | |
| 12 | /** |
| 13 | * Streams bytes from a remote file. |
| 14 | */ |
| 15 | class RequestReadStream extends BaseByteReadStream { |
| 16 | |
| 17 | /** |
| 18 | * @var Client |
| 19 | */ |
| 20 | private $client; |
| 21 | /** |
| 22 | * @var Request |
| 23 | */ |
| 24 | private $request; |
| 25 | /** |
| 26 | * @var Response |
| 27 | */ |
| 28 | private $response; |
| 29 | /** |
| 30 | * @var bool |
| 31 | */ |
| 32 | private $is_enqueued = false; |
| 33 | /** |
| 34 | * @var int |
| 35 | */ |
| 36 | private $remote_file_length; |
| 37 | /** |
| 38 | * @var Tracker |
| 39 | */ |
| 40 | private $progress_tracker; |
| 41 | |
| 42 | public function __construct( $request, $options = array() ) { |
| 43 | if ( is_string( $request ) ) { |
| 44 | $request = new Request( $request ); |
| 45 | } |
| 46 | $this->client = $options['client'] ?? new Client(); |
| 47 | $this->request = $request; |
| 48 | if ( isset( $options['max_lookbehind_bytes'] ) ) { |
| 49 | $this->max_lookbehind_bytes = $options['max_lookbehind_bytes']; |
| 50 | } |
| 51 | if ( isset( $options['progress_tracker'] ) ) { |
| 52 | $this->progress_tracker = $options['progress_tracker']; |
| 53 | } |
| 54 | if ( isset( $options['eagerly_enqueue'] ) ) { |
| 55 | $this->ensure_is_enqueued(); |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | public function get_request(): Request { |
| 60 | return $this->request; |
| 61 | } |
| 62 | |
| 63 | public function json() { |
| 64 | return json_decode( $this->consume_all(), true ); |
| 65 | } |
| 66 | |
| 67 | private function ensure_is_enqueued() { |
| 68 | if ( ! $this->is_enqueued ) { |
| 69 | $this->client->enqueue( $this->request ); |
| 70 | $this->is_enqueued = true; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | protected function seek_outside_of_buffer( int $target_offset ): void { |
| 75 | if ( $target_offset > $this->tell() ) { |
| 76 | $pulled = $this->pull_exactly( $target_offset - $this->tell() ); |
| 77 | $this->consume( $pulled ); |
| 78 | } else { |
| 79 | throw new ByteStreamException( |
| 80 | 'RequestReadStream cannot seek() backwards to offset ' . $target_offset . ' outside of the in-memory data buffer. ' . |
| 81 | 'You can either increase the buffer size or implement a custom SeekableRequestReadStream.' |
| 82 | ); |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | protected function internal_pull( $max_bytes = 8096 ): string { |
| 87 | return $this->pull_until_event( |
| 88 | array( |
| 89 | 'max_bytes' => $max_bytes, |
| 90 | 'event' => Client::EVENT_BODY_CHUNK_AVAILABLE, |
| 91 | ) |
| 92 | ); |
| 93 | } |
| 94 | |
| 95 | private function pull_until_event( $options = array() ) { |
| 96 | $stop_at_event = $options['event'] ?? Client::EVENT_BODY_CHUNK_AVAILABLE; |
| 97 | $this->ensure_is_enqueued(); |
| 98 | |
| 99 | while ( $this->client->await_next_event( |
| 100 | array( |
| 101 | 'requests' => array( $this->request->latest_redirect() ), |
| 102 | ) |
| 103 | ) ) { |
| 104 | $request = $this->client->get_request(); |
| 105 | if ( $request->error ) { |
| 106 | throw new HttpClientException( sprintf( 'HTTP request failed: %s. Method=%s, URL=%s', $request->error->message, $request->method, $request->url ) ); |
| 107 | } |
| 108 | $response = $request->response; |
| 109 | if ( ! $response ) { |
| 110 | continue; |
| 111 | } |
| 112 | if ( $request->redirected_to ) { |
| 113 | continue; |
| 114 | } |
| 115 | switch ( $this->client->get_event() ) { |
| 116 | case Client::EVENT_GOT_HEADERS: |
| 117 | $this->response = $response; |
| 118 | $content_length = $response->get_header( 'Content-Length' ); |
| 119 | if ( null !== $content_length ) { |
| 120 | /** |
| 121 | * Best-effort attempt to guess the content-length of the response. |
| 122 | * |
| 123 | * Web servers often respond with a combination of Content-Length |
| 124 | * and Content-Encoding. For example, a 16kb text file may be compressed |
| 125 | * to 4kb with gzip and served with a Content-Encoding of `gzip` and a |
| 126 | * Content-Length of 4KB. |
| 127 | * |
| 128 | * If we just use that value, we'd truncate a 16KB body stream with at a |
| 129 | * Content-Length of 4KB. |
| 130 | * |
| 131 | * To correct that behavior, we're discarding the Content-Length header when |
| 132 | * it's used alongside a compressed response stream. |
| 133 | */ |
| 134 | if ( ! $response->get_header( 'Content-Encoding' ) ) { |
| 135 | /** |
| 136 | * Set the content-length based on the header, but make sure it stays null |
| 137 | * when the Content-Length header is not set. |
| 138 | * |
| 139 | * Important: Don't set the content-length to 0 if the header is missing! This |
| 140 | * would tell the streaming machinery there's no body to consume. |
| 141 | */ |
| 142 | $this->remote_file_length = (int) $content_length; |
| 143 | } |
| 144 | } |
| 145 | if ( Client::EVENT_GOT_HEADERS === $stop_at_event ) { |
| 146 | return true; |
| 147 | } |
| 148 | break; |
| 149 | case Client::EVENT_BODY_CHUNK_AVAILABLE: |
| 150 | if ( Client::EVENT_BODY_CHUNK_AVAILABLE === $stop_at_event ) { |
| 151 | $body_chunk = $this->client->get_response_body_chunk(); |
| 152 | |
| 153 | if ( $this->progress_tracker ) { |
| 154 | $bytes_downloaded = $this->bytes_already_forgotten + strlen( $this->buffer ) + strlen( $body_chunk ); |
| 155 | // Arbitrarily assume 15MB if no length is provided. |
| 156 | $length = $this->remote_file_length ? $this->remote_file_length : 15 * 1024 * 1024; |
| 157 | $this->progress_tracker->set( $bytes_downloaded / $length * 100 ); |
| 158 | } |
| 159 | |
| 160 | return $body_chunk; |
| 161 | } |
| 162 | break; |
| 163 | case Client::EVENT_FINISHED: |
| 164 | /** |
| 165 | * If the server did not provide a Content-Length header, |
| 166 | * backfill the file length with the number of downloaded |
| 167 | * bytes. |
| 168 | */ |
| 169 | if ( null === $this->remote_file_length ) { |
| 170 | $this->remote_file_length = $this->bytes_already_forgotten + strlen( $this->buffer ); |
| 171 | } |
| 172 | |
| 173 | return ''; |
| 174 | case Client::EVENT_FAILED: |
| 175 | // TODO: Think through error handling. Errors are expected when working with |
| 176 | // the network. Should we auto retry? Make it easy for the caller to retry? |
| 177 | // Something else? |
| 178 | throw new ByteStreamException( 'HTTP request failed: ' . $this->client->get_request()->error ); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | return ''; |
| 183 | } |
| 184 | |
| 185 | public function length(): ?int { |
| 186 | return $this->remote_file_length; |
| 187 | } |
| 188 | |
| 189 | public function await_response() { |
| 190 | if ( ! $this->response ) { |
| 191 | $this->pull_until_event( |
| 192 | array( |
| 193 | 'event' => Client::EVENT_GOT_HEADERS, |
| 194 | ) |
| 195 | ); |
| 196 | } |
| 197 | if ( ! $this->response ) { |
| 198 | throw new ByteStreamException( 'HTTP request failed – never received a response' ); |
| 199 | } |
| 200 | |
| 201 | return $this->response; |
| 202 | } |
| 203 | |
| 204 | protected function internal_reached_end_of_data(): bool { |
| 205 | return ( |
| 206 | Request::STATE_FINISHED === $this->request->latest_redirect()->state && |
| 207 | ! $this->client->has_pending_event( $this->request, Client::EVENT_BODY_CHUNK_AVAILABLE ) && |
| 208 | ! $this->client->has_pending_event( $this->request, Client::EVENT_FINISHED ) && |
| 209 | strlen( $this->buffer ) === $this->offset_in_current_buffer |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | protected function internal_close_reading(): void { |
| 214 | $latest_redirect = $this->request->latest_redirect(); |
| 215 | if ( |
| 216 | $latest_redirect && |
| 217 | Request::STATE_FINISHED !== $latest_redirect->state && |
| 218 | Request::STATE_FAILED !== $latest_redirect->state |
| 219 | ) { |
| 220 | throw new ByteStreamException( 'Cancelling the request is not implemented yet' ); |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 |