├── .github └── workflows │ └── ci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── psalm.xml └── src ├── ApplicationInterceptor.php ├── BufferedContent.php ├── Connection ├── Connection.php ├── ConnectionFactory.php ├── ConnectionLimitingPool.php ├── ConnectionPool.php ├── DefaultConnectionFactory.php ├── Http1Connection.php ├── Http2Connection.php ├── HttpStream.php ├── InterceptedStream.php ├── Internal │ ├── Http1Parser.php │ ├── Http2ConnectionProcessor.php │ ├── Http2Stream.php │ └── RequestNormalizer.php ├── Stream.php ├── StreamLimitingPool.php ├── UnlimitedConnectionPool.php └── UpgradedSocket.php ├── DelegateHttpClient.php ├── EventListener.php ├── EventListener └── LogHttpArchive.php ├── Form.php ├── HttpClient.php ├── HttpClientBuilder.php ├── HttpContent.php ├── HttpException.php ├── InterceptedHttpClient.php ├── Interceptor ├── AddRequestHeader.php ├── AddResponseHeader.php ├── DecompressResponse.php ├── FollowRedirects.php ├── ForbidUriUserInfo.php ├── MatchOrigin.php ├── ModifyRequest.php ├── ModifyResponse.php ├── RemoveRequestHeader.php ├── RemoveResponseHeader.php ├── ResolveBaseUri.php ├── RetryRequests.php ├── SetRequestHeader.php ├── SetRequestHeaderIfUnset.php ├── SetRequestTimeout.php ├── SetResponseHeader.php ├── SetResponseHeaderIfUnset.php └── TooManyRedirectsException.php ├── Internal ├── EventInvoker.php ├── FormField.php ├── HarAttributes.php ├── Phase.php ├── ResponseBodyStream.php ├── SizeLimitingReadableStream.php └── functions.php ├── InvalidRequestException.php ├── MissingAttributeError.php ├── NetworkInterceptor.php ├── ParseException.php ├── PooledHttpClient.php ├── Request.php ├── Response.php ├── SocketException.php ├── StreamedContent.php ├── TimeoutException.php ├── TlsException.php ├── Trailers.php └── functions.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | strategy: 10 | matrix: 11 | include: 12 | - operating-system: 'ubuntu-latest' 13 | php-version: '8.1' 14 | 15 | - operating-system: 'ubuntu-latest' 16 | php-version: '8.2' 17 | 18 | - operating-system: 'ubuntu-latest' 19 | php-version: '8.3' 20 | 21 | - operating-system: 'windows-latest' 22 | php-version: '8.3' 23 | job-description: 'on Windows' 24 | 25 | - operating-system: 'macos-latest' 26 | php-version: '8.3' 27 | job-description: 'on macOS' 28 | 29 | name: PHP ${{ matrix.php-version }} ${{ matrix.job-description }} 30 | 31 | runs-on: ${{ matrix.operating-system }} 32 | 33 | steps: 34 | - name: Set git to use LF 35 | run: | 36 | git config --global core.autocrlf false 37 | git config --global core.eol lf 38 | 39 | - name: Checkout code 40 | uses: actions/checkout@v3 41 | 42 | - name: Setup PHP 43 | uses: shivammathur/setup-php@v2 44 | with: 45 | php-version: ${{ matrix.php-version }} 46 | 47 | - name: Get Composer cache directory 48 | id: composer-cache 49 | run: echo "dir=$(composer config cache-dir)" >> $GITHUB_OUTPUT 50 | shell: bash 51 | 52 | - name: Cache dependencies 53 | uses: actions/cache@v3 54 | with: 55 | path: ${{ steps.composer-cache.outputs.dir }} 56 | key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-${{ matrix.composer-flags }} 57 | restore-keys: | 58 | composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}- 59 | composer-${{ runner.os }}-${{ matrix.php-version }}- 60 | composer-${{ runner.os }}- 61 | composer- 62 | 63 | - name: Install dependencies 64 | uses: nick-invision/retry@v2 65 | with: 66 | timeout_minutes: 5 67 | max_attempts: 5 68 | retry_wait_seconds: 30 69 | command: | 70 | composer update --optimize-autoloader --no-interaction --no-progress ${{ matrix.composer-flags }} 71 | composer info -D 72 | 73 | - name: Run tests 74 | run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 75 | 76 | - name: Run static analysis 77 | run: vendor/bin/psalm.phar 78 | 79 | - name: Run style fixer 80 | env: 81 | PHP_CS_FIXER_IGNORE_ENV: 1 82 | run: vendor/bin/php-cs-fixer --diff --dry-run -v fix 83 | 84 | - name: Install composer-require-checker 85 | run: php -r 'file_put_contents("composer-require-checker.phar", file_get_contents("https://github.com/maglnet/ComposerRequireChecker/releases/download/3.7.0/composer-require-checker.phar"));' 86 | if: runner.os != 'Windows' && matrix.composer-require-checker-version != 'none' 87 | 88 | - name: Run composer-require-checker 89 | run: php composer-require-checker.phar check composer.json --config-file $PWD/composer-require-check.json 90 | if: runner.os != 'Windows' && matrix.composer-require-checker-version != 'none' 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 4.6.2 4 | 5 | - Fixed `setBodySizeLimit(0)` with HTTP/2 protocol (#297) 6 | 7 | ### 4.6.1 8 | 9 | - Fixed `te` request header fields being sent via HTTP/2 instead of being stripped (unless the value is `trailers`) 10 | 11 | ## 4.6.0 12 | 13 | - Add support for `amphp/file` v2 (#295) 14 | - Fix some parameter names not aligning with parent classes / interfaces. 15 | 16 | ### 4.5.5 17 | 18 | - Fixed ALPN setting if unsupported (#283) 19 | 20 | ### 4.5.4 21 | 22 | - Avoid increasing HTTP/2 window size if too many bytes are buffered locally, avoiding exploding buffers if the consumer is slow. 23 | - Fix inactivity timeout on HTTP/2 with slow consumers. 24 | Slowly reading the response shouldn't result in inactivity timeouts if the server is responsive. 25 | - Check for HTTP/1 connection closing while idle (#279) 26 | 27 | ### 4.5.3 28 | 29 | - Account for server window changes when discarding data frames 30 | If streams are cancelled, this might result in hanging connections, because the client thinks the server window is still large enough and doesn't increase it. 31 | - Fixed potential state synchronization errors with async event listeners 32 | - Write stream window increments asynchronously, avoiding increments for already closed streams 33 | - Improved exception messages 34 | 35 | ### 4.5.2 36 | 37 | - Fixed `ConnectionLimitingPool` closing non-idle connections (#278) 38 | 39 | ### 4.5.1 40 | 41 | - Retry idempotent requests on `Http2ConnectionException` 42 | - Fix graceful HTTP/2 connection shutdown 43 | - Improve behavior if HTTP/2 connections become unresponsive 44 | 45 | ### 4.5.0 46 | 47 | - Added support for resolving protocol relative URLs (#275) 48 | - Added `FormBody::addFileFromString()` 49 | 50 | ### 4.4.1 51 | 52 | - Reject pushes with invalid stream ID 53 | - Fix potential double stream release, which might result in int → float overflows and thus type errors 54 | 55 | ### 4.4.0 56 | 57 | This version fixes a security weakness that might leak sensitive request headers from the initial request to the redirected host on cross-domain redirects, which were not removed correctly. `Message::setHeaders` does _not_ replace the entire set of headers, but only operates on the headers matching the given array keys, see fa79253. 58 | 59 | - Support direct HTTP/2 connections without TLS (#271) 60 | - Security: Remove headers on cross-domain redirects 61 | 62 | ### 4.3.1 63 | 64 | - Relax `"conflict"` rule with `amphp/file` to allow `dev-master` installations with Composer v1.x (#267, composer/composer#8856) 65 | - Error if request URI provides a relative path instead of sending an invalid request (#269) 66 | 67 | ### 4.3.0 68 | 69 | - **Added inactivity timeout** (#263) 70 | This provides a separate timeout while waiting for the response or streaming the body. If no data is received for the response within the given number of milliseconds, the request fails similarly to the transfer timeout. 71 | - **Close idle connections if there are too many** 72 | Requesting URLs from many hosts without reusing connections will otherwise result in resource exhaustion due to too many open files. 73 | - Improved types for static analysis 74 | 75 | ### 4.2.2 76 | 77 | - Fixed transfer timeout enforcement for HTTP/2 (#262) 78 | 79 | ### 4.2.1 80 | 81 | - Fixed HTTP/2 on 32 bit platforms 82 | - Fixed potentially stalled requests in ConnectionLimitingPool (#256) 83 | 84 | ### 4.2.0 85 | 86 | - Add improved ConnectionLimitingPool 87 | The new ConnectionLimitingPool limits connections instead of streams. In addition, it has improved connection handling, racing between new connections and existing connections becoming available once the limit has been reached. The older LimitedConnectionPool has been renamed to StreamLimitingPool with a class alias for backward compatibility. 88 | - Don't set ALPN if only HTTP/1.1 is enabled, which allows connections to certain misbehaving servers (#255) 89 | 90 | ### 4.1.0 91 | 92 | - Fix possible double resolution of promises (#244) 93 | - Fix assertion error on invalid HTTP/2 frame (#236) 94 | - Fix HTTP/2 connection reuse if too many concurrent streams for one connection are in use (#246) 95 | - Allow skipping default `accept`, `accept-encoding` and `user-agent` headers (#238) 96 | - Keep original header case for HTTP/1 requests (#250) 97 | - Allow access to informational responses (1XX) (#239) 98 | - Await `startReceiveResponse` event listeners on HTTP/2 before resolving the response promise (#254) 99 | - Delay `startReceiveResponse` event until the final response is started to be received, instead of calling it for the first byte or multiple times for HTTP/2 (#254) 100 | - Use common HTTP/2 parser from `amphp/http` 101 | 102 | ## 4.0.0 103 | 104 | Initial release of `amphp/http-client`, the successor of `amphp/artax`. 105 | This is a major rewrite to support interceptors and HTTP/2. 106 | 107 | **Major Changes** 108 | 109 | - Support for HTTP/2 (including push) 110 | - Support for interceptors to customize behavior 111 | - Switch to a mutable `Request` / `Response` API, because streams are never immutable 112 | - Compatibility with `amphp/socket@^1` 113 | - Compatibility with `amphp/file@^1` 114 | - Compatibility with `league/uri@^6` 115 | 116 | ## 3.x - 1.x 117 | 118 | Please refer to `CHANGELOD.md` in [`amphp/artax`](https://github.com/amphp/artax). 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Daniel Lowrey, Levi Morrison 4 | Copyright (c) 2014-2022 amphp 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a 7 | copy of this software and associated documentation files (the "Software"), 8 | to deal in the Software without restriction, including without limitation 9 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 10 | and/or sell copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 22 | IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # amphp/http-client 2 | 3 | AMPHP is a collection of event-driven libraries for PHP designed with fibers and concurrency in mind. 4 | This package provides an asynchronous HTTP client for PHP based on [Revolt](https://revolt.run/). 5 | Its API simplifies standards-compliant HTTP resource traversal and RESTful web service consumption without obscuring the underlying protocol. 6 | The library manually implements HTTP over TCP sockets; as such it has no dependency on `ext/curl`. 7 | 8 | ## Features 9 | 10 | - Supports HTTP/1 and HTTP/2 11 | - [Requests concurrently by default](examples/concurrency/1-concurrent-fetch.php) 12 | - [Pools persistent connections (keep-alive @ HTTP/1.1, multiplexing @ HTTP/2)](examples/pooling/1-connection-count.php) 13 | - [Transparently follows redirects](#redirects) 14 | - [Decodes compressed entity bodies (gzip, deflate)](examples/basic/7-gzip.php) 15 | - [Exposes headers and message data](examples/basic/1-get-request.php) 16 | - [Streams entity bodies for memory management with large transfers](examples/streaming/1-large-response.php) 17 | - [Supports all standard and custom HTTP method verbs](#request-method) 18 | - [Simplifies HTTP form submissions](examples/basic/4-forms.php) 19 | - [Implements secure-by-default TLS (`https://`)](examples/basic/1-get-request.php) 20 | - [Supports cookies and sessions](#cookies) 21 | - [Functions seamlessly behind HTTP proxies](#proxies) 22 | 23 | ## Installation 24 | 25 | This package can be installed as a [Composer](https://getcomposer.org/) dependency. 26 | 27 | ```bash 28 | composer require amphp/http-client 29 | ``` 30 | 31 | Additionally, you might want to install the `nghttp2` library to take advantage of FFI to speed up and reduce the memory usage. 32 | 33 | ## Usage 34 | 35 | The main interaction point with this library is the `HttpClient` class. 36 | `HttpClient` instances can be built using `HttpClientBuilder` without knowing about the existing implementations. 37 | 38 | `HttpClientBuilder` allows to register two kinds of [interceptors](#interceptors), which allows customizing the `HttpClient` behavior in a composable fashion. 39 | 40 | In its simplest form, the HTTP client takes a request with a URL as string and interprets that as a `GET` request to that resource without any custom headers. 41 | Standard headers like `Accept`, `Connection` or `Host` will automatically be added if not present. 42 | 43 | ```php 44 | use Amp\Http\Client\HttpClientBuilder; 45 | 46 | $client = HttpClientBuilder::buildDefault(); 47 | 48 | $response = $client->request(new Request("https://httpbin.org/get")); 49 | 50 | var_dump($response->getStatus()); 51 | var_dump($response->getHeaders()); 52 | var_dump($response->getBody()->buffer()); 53 | ``` 54 | 55 | ### Request 56 | 57 | The `HttpClient` requires a `Request` being passed as first argument to `request()`. 58 | The `Request` class can be used to specify further specifics of the request such as setting headers or changing the request method. 59 | 60 | > **Note** 61 | > `Request` objects are mutable (instead of immutable as in `amphp/artax` / PSR-7). 62 | > 63 | > Cloning `Request` objects will result in a deep clone, but doing so is usually only required if requests are retried or cloned for sub-requests. 64 | 65 | #### Request URI 66 | 67 | The constructor requires an absolute request URI. `Request::setUri(string $uri)` allows changing the request URI. 68 | 69 | ```php 70 | $request = new Request("https://httpbin.org/post", "POST"); 71 | $request->setBody("foobar"); 72 | $request->setUri("https://google.com/"); 73 | ``` 74 | 75 | `Request::getUri()` exposes the request URI of the given `Request` object. 76 | 77 | #### Request Method 78 | 79 | The constructor accepts an optional request method, it defaults to `GET`. `Request::setMethod(string $method)` allows changing the request method. 80 | 81 | ```php 82 | $request = new Request("https://httpbin.org/post", "POST"); 83 | $request->setBody("foobar"); 84 | $request->setMethod("PUT"); 85 | ``` 86 | 87 | `Request::getMethod()` exposes the request method of the given `Request` object. 88 | 89 | #### Request Headers 90 | 91 | `Request::setHeader(string $field, string $value)` allows changing the request headers. It will remove any previous values for that field. `Request::addHeader(string $field, string $value)` allows adding an additional header line without removing existing lines. 92 | 93 | `Request::setHeaders(array $headers)` allows adding multiple headers at once with the array keys being the field names and the values being the header values. The header values can also be arrays of strings to set multiple header lines. 94 | 95 | `Request::hasHeader(string $field)` checks whether at least one header line with the given name exists. 96 | 97 | `Request::getHeader(string $field)` returns the first header line with the given name or `null` if no such header exists. 98 | 99 | `Request::getHeaderArray(string $field)` returns an array of header lines with the given name. An empty array is returned if no header with the given name exists. 100 | 101 | `Request::getHeaders()` returns an associative array with the keys being the header names and the values being arrays of header lines. 102 | 103 | ```php 104 | $request = new Request("https://httpbin.org/post", "POST"); 105 | $request->setHeader("X-Foobar", "Hello World"); 106 | $request->setBody("foobar"); 107 | ``` 108 | 109 | #### Request Bodies 110 | 111 | `Request::setBody($body)` allows changing the request body. Accepted types are `string`, `null`, and `HttpContent`. `string` and `null` are automatically converted to an instance of `HttpContent`. 112 | 113 | > **Note** 114 | > `HttpContent` is basically a factory for request bodies. We cannot simply accept streams here, because a request body might have to be sent again on a redirect / retry. 115 | 116 | ```php 117 | $request = new Request("https://httpbin.org/post", "POST"); 118 | $request->setBody("foobar"); 119 | ``` 120 | 121 | `Request::getBody()` exposes the request body of the given `Request` object and will always return a `HttpContent`. 122 | 123 | ### Response 124 | 125 | `HttpClient::request()` returns a `Response` as soon as the response headers are successfully received. 126 | 127 | > **Note** 128 | > `Response` objects are mutable (instead of immutable as in Artax v3 / PSR-7) 129 | 130 | #### Response Status 131 | 132 | You can retrieve the response's HTTP status using `getStatus()`. It returns the status as an integer. The optional (and possibly empty) reason associated with the status can be retrieved using `getReason()`. 133 | 134 | ```php 135 | $response = $client->request($request); 136 | 137 | var_dump($response->getStatus(), $response->getReason()); 138 | ``` 139 | 140 | #### Response Protocol Version 141 | 142 | You can retrieve the response's HTTP protocol version using `getProtocolVersion()`. 143 | 144 | ```php 145 | $response = $client->request($request); 146 | 147 | var_dump($response->getProtocolVersion()); 148 | ``` 149 | 150 | #### Response Headers 151 | 152 | Response headers can be accessed by a set of methods. 153 | 154 | * `hasHeader(string)` returns whether a given header is present. 155 | * `getHeader(string)` returns the first header with the given name or `null` if no such header is present. 156 | * `getHeaderArray(string)` returns all headers with the given name, possibly an empty array. 157 | * `getHeaders()` returns all headers as an associative array, see below. 158 | 159 | **`getHeaders()` Format** 160 | 161 | ```php 162 | [ 163 | "header-1" => [ 164 | "value-1", 165 | "value-2", 166 | ], 167 | "header-2" => [ 168 | "value-1", 169 | ], 170 | ] 171 | ``` 172 | 173 | #### Response Body 174 | 175 | `getBody()` returns a [`Payload`](https://v3.amphp.org/byte-stream#payload), which allows simple buffering and streaming access. 176 | 177 | > **Warning** 178 | > `$chunk = $response->getBody()->read();` reads only a single chunk from the body while `$contents = $response->getBody()->buffer()` buffers the complete body. 179 | > Please refer to the [`Payload` documentation](https://v3.amphp.org/byte-stream#payload) for more information. 180 | 181 | #### Request, Original Request and Previous Response 182 | 183 | `getRequest()` allows access to the request corresponding to the response. This might not be the original request in case of redirects. `getOriginalRequest()` returns the original request sent by the client. This might not be the same request that was passed to `Client::request()`, because the client might normalize headers or assign cookies. `getPreviousResponse` allows access to previous responses in case of redirects, but the response bodies of these responses won't be available, as they're discarded. If you need access to these, you need to disable auto redirects and implement them yourself. 184 | 185 | ### Interceptors 186 | 187 | Interceptors allow customizing the `HttpClient` behavior in a composable fashion. 188 | Use cases range from adding / removing headers from a request / response and recording timing information to more advanced use cases like a fully compliant [HTTP cache](https://github.com/amphp/http-client-cache) that intercepts requests and serves them from the cache if possible. 189 | 190 | ```php 191 | use Amp\Http\Client\Client; 192 | use Amp\Http\Client\HttpClientBuilder; 193 | use Amp\Http\Client\Interceptor\SetRequestHeader; 194 | use Amp\Http\Client\Interceptor\SetResponseHeader; 195 | use Amp\Http\Client\Request; 196 | 197 | $client = (new HttpClientBuilder) 198 | ->intercept(new SetRequestHeader('x-foo', 'bar')) 199 | ->intercept(new SetResponseHeader('x-tea', 'now')) 200 | ->build(); 201 | 202 | $response = $client->request(new Request("https://httpbin.org/get")); 203 | $body = $response->getBody()->buffer(); 204 | ``` 205 | 206 | There are two kinds of interceptors with separate interfaces named `ApplicationInterceptor` and `NetworkInterceptor`. 207 | 208 | #### Choosing the right interceptor 209 | 210 | Most interceptors should be implemented as `ApplicationInterceptor`. 211 | However, there's sometimes the need to have access to the underlying connection properties. 212 | In such a case, a `NetworkInterceptor` can be implemented to access the used IPs and TLS settings. 213 | 214 | Another use-case for implementing a `NetworkInterceptor` is an interceptor, that should only ever run if the request is sent over the network instead of served from a cache or similar. 215 | However, that should usually be solved with the configuration order of the application interceptors. 216 | 217 | The big disadvantage of network interceptors is that they have to be rather quick and can't take too long, because they're only invoked after the connection has been created and the client will run into a timeout if there's no activity within a reasonable time. 218 | 219 | #### List of Interceptors 220 | 221 | - `AddRequestHeader` 222 | - `AddResponseHeader` 223 | - `ConditionalInterceptor` 224 | - `DecompressResponse` 225 | - `FollowRedirects` 226 | - `ForbidUriUserInfo` 227 | - `IfOrigin` 228 | - `ModifyRequest` 229 | - `ModifyResponse` 230 | - `RemoveRequestHeader` 231 | - `RemoveResponseHeader` 232 | - `RetryRequests` 233 | - `SetRequestHeader` 234 | - `SetRequestHeaderIfUnset` 235 | - `SetResponseHeader` 236 | - `SetResponseHeaderIfUnset` 237 | - `SetRequestTimeout` 238 | - [`CookieHandler`](https://github.com/amphp/http-client-cookies) 239 | - [`PrivateCache`](https://github.com/amphp/http-client-cache) 240 | 241 | ### Redirects 242 | 243 | If you use `HttpClientBuilder`, the resulting `HttpClient` will automatically follow up to ten redirects by default. 244 | Automatic following can be customized or disabled (using a limit of `0`) using `HttpClientBuilder::followRedirects()`. 245 | 246 | #### Redirect Policy 247 | 248 | The `FollowRedirects` interceptor will only follow redirects with a `GET` method. 249 | If another request method is used and a `307` or `308` response is received, the response will be returned as is, so another interceptor or the application can take care of it. 250 | Cross-origin redirects will be attempted without any headers set, so any application headers will be discarded. 251 | If `HttpClientBuilder` is used to configure the client, the `FollowRedirects` interceptor is the outermost interceptor, so any headers set by interceptors will still be present in the response. 252 | It is therefore recommended to set headers via interceptors instead of directly in the request. 253 | 254 | #### Examining the Redirect Chain 255 | 256 | All previous responses can be accessed from the resulting `Response` via `Response::getPreviousResponse()`. 257 | However, the response body is discarded on redirects, so it can no longer be consumed. 258 | If you want to consume redirect response bodies, you need to implement your own interceptor. 259 | 260 | ### Cookies 261 | 262 | See [`amphp/http-client-cookies`](https://github.com/amphp/http-client-cookies). 263 | 264 | ### Logging 265 | 266 | The `LogHttpArchive` event listener allows logging all requests / responses including detailed timing information to an [HTTP archive (HAR)](https://en.wikipedia.org/wiki/HAR_%28file_format%29). 267 | 268 | These log files can then be imported into the browsers developer tools or online tools like [HTTP Archive Viewer](http://www.softwareishard.com/har/viewer/) or [Google's HAR Analyzer](https://toolbox.googleapps.com/apps/har_analyzer/). 269 | 270 | > **Warning** 271 | > Be careful if your log files might contain sensitive information in URLs or headers if you submit these files to third parties like the linked services above. 272 | 273 | ```php 274 | use Amp\Http\Client\HttpClientBuilder; 275 | use Amp\Http\Client\EventListener\LogHttpArchive; 276 | 277 | $httpClient = (new HttpClientBuilder) 278 | ->listen(new LogHttpArchive('/tmp/http-client.har')) 279 | ->build(); 280 | 281 | $httpClient->request(...); 282 | ``` 283 | 284 | ![HAR Viewer Screenshot](https://user-images.githubusercontent.com/2743004/196048526-bf496986-ea5b-4a30-9b3f-4fa51a9c5bb1.png) 285 | 286 | ### Proxies 287 | 288 | See [`amphp/http-tunnel`](https://github.com/amphp/http-tunnel). 289 | 290 | ## Versioning 291 | 292 | `amphp/http-client` follows the [semver](http://semver.org/) semantic versioning specification like all other `amphp` packages. 293 | 294 | Everything in an `Internal` namespace or marked as `@internal` is not public API and therefore not covered by BC guarantees. 295 | 296 | ## Security 297 | 298 | If you discover any security related issues, please email [`me@kelunik.com`](mailto:me@kelunik.com) instead of using the issue tracker. 299 | 300 | ## License 301 | 302 | The MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. 303 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/http-client", 3 | "homepage": "https://amphp.org/http-client", 4 | "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.", 5 | "keywords": [ 6 | "http", 7 | "rest", 8 | "client", 9 | "concurrent", 10 | "async", 11 | "non-blocking" 12 | ], 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Daniel Lowrey", 17 | "email": "rdlowrey@gmail.com" 18 | }, 19 | { 20 | "name": "Niklas Keller", 21 | "email": "me@kelunik.com" 22 | }, 23 | { 24 | "name": "Aaron Piotrowski", 25 | "email": "aaron@trowski.com" 26 | } 27 | ], 28 | "require": { 29 | "php": ">=8.1", 30 | "revolt/event-loop": "^1", 31 | "amphp/amp": "^3", 32 | "amphp/byte-stream": "^2", 33 | "amphp/hpack": "^3", 34 | "amphp/http": "^2", 35 | "amphp/pipeline": "^1", 36 | "amphp/socket": "^2", 37 | "amphp/sync": "^2", 38 | "league/uri": "^7", 39 | "league/uri-components": "^7", 40 | "league/uri-interfaces": "^7.1", 41 | "psr/http-message": "^1 | ^2" 42 | }, 43 | "require-dev": { 44 | "ext-json": "*", 45 | "amphp/file": "^3 | ^4", 46 | "amphp/phpunit-util": "^3", 47 | "amphp/php-cs-fixer-config": "^2", 48 | "phpunit/phpunit": "^9", 49 | "amphp/http-server": "^3", 50 | "kelunik/link-header-rfc5988": "^1", 51 | "psalm/phar": "~5.23", 52 | "laminas/laminas-diactoros": "^2.3" 53 | }, 54 | "suggest": { 55 | "ext-zlib": "Allows using compression for response bodies.", 56 | "ext-json": "Required for logging HTTP archives", 57 | "amphp/file": "Required for file request bodies and HTTP archive logging" 58 | }, 59 | "autoload": { 60 | "psr-4": { 61 | "Amp\\Http\\Client\\": "src" 62 | }, 63 | "files": [ 64 | "src/functions.php", 65 | "src/Internal/functions.php" 66 | ] 67 | }, 68 | "autoload-dev": { 69 | "psr-4": { 70 | "Amp\\Http\\Client\\": "test" 71 | } 72 | }, 73 | "conflict": { 74 | "amphp/file": "<3 | >=5" 75 | }, 76 | "scripts": { 77 | "check": [ 78 | "@cs", 79 | "@test" 80 | ], 81 | "cs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -v --diff --dry-run", 82 | "cs-fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -v --diff", 83 | "test": "@php -dzend.assertions=1 -dassert.exception=1 ./vendor/bin/phpunit --coverage-text" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/ApplicationInterceptor.php: -------------------------------------------------------------------------------- 1 | request(...)` returns. 18 | * 19 | * An interceptor might also short-circuit and not delegate to the `$httpClient` at all. 20 | * 21 | * Any retry or follow-up request must use a new request instance instead of reusing `$request` to ensure a properly 22 | * working interceptor chain, e.g. the {@see DecompressResponse} interceptor only decodes a response if the 23 | * `accept-encoding` header isn't set manually. If the request is reused, the first attempt will set the header 24 | * and the second attempt will see the header and won't decode the response, because it thinks another interceptor 25 | * or the application itself will care about the decoding. 26 | */ 27 | public function request( 28 | Request $request, 29 | Cancellation $cancellation, 30 | DelegateHttpClient $httpClient 31 | ): Response; 32 | } 33 | -------------------------------------------------------------------------------- /src/BufferedContent.php: -------------------------------------------------------------------------------- 1 | getMessage(), 30 | previous: $exception, 31 | ); 32 | } 33 | } 34 | 35 | public static function fromFile( 36 | string $path, 37 | ?string $contentType = null, 38 | ): self { 39 | if (!\class_exists(Filesystem::class)) { 40 | throw new \Error("File request bodies require amphp/file to be installed"); 41 | } 42 | 43 | try { 44 | return self::fromString(read($path), $contentType); 45 | } catch (FilesystemException $filesystemException) { 46 | throw new HttpException('Failed to open file: ' . $path, 0, $filesystemException); 47 | } 48 | } 49 | 50 | private function __construct( 51 | private readonly string $content, 52 | private readonly ?string $contentType, 53 | ) { 54 | } 55 | 56 | public function getContent(): ReadableStream 57 | { 58 | return new ReadableBuffer($this->content); 59 | } 60 | 61 | public function getContentLength(): int 62 | { 63 | return \strlen($this->content); 64 | } 65 | 66 | public function getContentType(): ?string 67 | { 68 | return $this->contentType; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Connection/Connection.php: -------------------------------------------------------------------------------- 1 | getUri(); 32 | $scheme = $uri->getScheme(); 33 | 34 | $isHttps = $scheme === 'https'; 35 | $defaultPort = $isHttps ? 443 : 80; 36 | 37 | $host = $uri->getHost(); 38 | $port = $uri->getPort() ?? $defaultPort; 39 | 40 | $authority = $host . ':' . $port; 41 | 42 | return $scheme . '://' . $authority; 43 | } 44 | 45 | private ConnectionFactory $connectionFactory; 46 | 47 | /** @var array>> */ 48 | private array $connections = []; 49 | 50 | /** @var array Idle connections, indexed by connection object ID. */ 51 | private array $idleConnections = []; 52 | 53 | /** @var array Map of connection object IDs to request counts. */ 54 | private array $activeRequestCounts = []; 55 | 56 | /** @var array> */ 57 | private array $waiting = []; 58 | 59 | /** @var array Map of URIs to flags to wait for potential HTTP/2 connection. */ 60 | private array $waitForPriorConnection = []; 61 | 62 | private int $totalConnectionAttempts = 0; 63 | 64 | private int $totalStreamRequests = 0; 65 | 66 | private int $openConnectionCount = 0; 67 | 68 | private function __construct(private readonly int $connectionLimit, ?ConnectionFactory $connectionFactory = null) 69 | { 70 | if ($connectionLimit < 1) { 71 | throw new \Error('The connection limit must be greater than 0'); 72 | } 73 | 74 | $this->connectionFactory = $connectionFactory ?? new DefaultConnectionFactory(); 75 | } 76 | 77 | public function __clone() 78 | { 79 | $this->connections = []; 80 | $this->totalConnectionAttempts = 0; 81 | $this->totalStreamRequests = 0; 82 | $this->openConnectionCount = 0; 83 | } 84 | 85 | public function getTotalConnectionAttempts(): int 86 | { 87 | return $this->totalConnectionAttempts; 88 | } 89 | 90 | public function getTotalStreamRequests(): int 91 | { 92 | return $this->totalStreamRequests; 93 | } 94 | 95 | public function getOpenConnectionCount(): int 96 | { 97 | return $this->openConnectionCount; 98 | } 99 | 100 | public function getStream(Request $request, Cancellation $cancellation): Stream 101 | { 102 | $this->totalStreamRequests++; 103 | 104 | $uri = self::formatUri($request); 105 | 106 | [$connection, $stream] = $this->getStreamFor($uri, $request, $cancellation); 107 | 108 | $connectionId = \spl_object_id($connection); 109 | $this->activeRequestCounts[$connectionId] = ($this->activeRequestCounts[$connectionId] ?? 0) + 1; 110 | unset($this->idleConnections[$connectionId]); 111 | 112 | $poolRef = \WeakReference::create($this); 113 | $releaseCallback = static function () use ($poolRef, $connection, $uri): void { 114 | $pool = $poolRef->get(); 115 | if ($pool) { 116 | $pool->onReadyConnection($connection, $uri); 117 | } elseif ($connection->isIdle()) { 118 | $connection->close(); 119 | } 120 | }; 121 | 122 | return HttpStream::fromStream( 123 | $stream, 124 | function (Request $request, Cancellation $cancellation) use ( 125 | $releaseCallback, 126 | $connection, 127 | $stream, 128 | $uri 129 | ): Response { 130 | try { 131 | $response = $stream->request($request, $cancellation); 132 | } catch (\Throwable $e) { 133 | $this->onReadyConnection($connection, $uri); 134 | throw $e; 135 | } 136 | 137 | $response->getTrailers()->finally($releaseCallback)->ignore(); 138 | 139 | return $response; 140 | }, 141 | $releaseCallback, 142 | ); 143 | } 144 | 145 | /** 146 | * @return array{Connection, Stream} 147 | */ 148 | private function getStreamFor(string $uri, Request $request, Cancellation $cancellation): array 149 | { 150 | $isHttps = $request->getUri()->getScheme() === 'https'; 151 | 152 | $connections = $this->connections[$uri] ?? []; 153 | 154 | do { 155 | foreach ($connections as $connectionFuture) { 156 | \assert($connectionFuture instanceof Future); 157 | 158 | try { 159 | if ($isHttps && ($this->waitForPriorConnection[$uri] ?? true)) { 160 | // Wait for first successful connection if using a secure connection (maybe we can use HTTP/2). 161 | $connection = $connectionFuture->await(); 162 | } elseif ($connectionFuture->isComplete()) { 163 | $connection = $connectionFuture->await(); 164 | } else { 165 | continue; 166 | } 167 | } catch (\Exception $exception) { 168 | continue; // Ignore cancellations and errors of other requests. 169 | } 170 | 171 | \assert($connection instanceof Connection); 172 | 173 | $stream = $this->getStreamFromConnection($connection, $request); 174 | 175 | if ($stream === null) { 176 | if (!$this->isAdditionalConnectionAllowed($uri) && $this->isConnectionIdle($connection)) { 177 | $connection->close(); 178 | break; 179 | } 180 | 181 | continue; // No stream available for the given request. 182 | } 183 | 184 | return [$connection, $stream]; 185 | } 186 | 187 | $deferred = new DeferredFuture; 188 | $futureFromDeferred = $deferred->getFuture(); 189 | 190 | $this->waiting[$uri][\spl_object_id($deferred)] = $deferred; 191 | 192 | if ($this->isAdditionalConnectionAllowed($uri)) { 193 | break; 194 | } 195 | 196 | $connection = $futureFromDeferred->await(); 197 | 198 | \assert($connection instanceof Connection); 199 | 200 | $stream = $this->getStreamFromConnection($connection, $request); 201 | 202 | if ($stream === null) { 203 | continue; // Wait for a different connection to become available. 204 | } 205 | 206 | return [$connection, $stream]; 207 | } while (true); 208 | 209 | $this->totalConnectionAttempts++; 210 | 211 | $connectionFuture = async($this->connectionFactory->create(...), $request, $cancellation); 212 | 213 | $futureId = \spl_object_id($connectionFuture); 214 | $this->connections[$uri] ??= new \ArrayObject(); 215 | $this->connections[$uri][$futureId] = $connectionFuture; 216 | 217 | EventLoop::queue(function () use ( 218 | $connectionFuture, 219 | $uri, 220 | $futureId, 221 | $isHttps 222 | ): void { 223 | try { 224 | /** @var Connection $connection */ 225 | $connection = $connectionFuture->await(); 226 | } catch (\Throwable) { 227 | $this->dropConnection($uri, null, $futureId); 228 | return; 229 | } 230 | 231 | $connectionId = \spl_object_id($connection); 232 | $this->openConnectionCount++; 233 | 234 | if ($isHttps) { 235 | $this->waitForPriorConnection[$uri] = \in_array('2', $connection->getProtocolVersions(), true); 236 | } 237 | 238 | $poolRef = \WeakReference::create($this); 239 | $connection->onClose(static function () use ($poolRef, $uri, $connectionId, $futureId): void { 240 | $pool = $poolRef->get(); 241 | if ($pool) { 242 | $pool->openConnectionCount--; 243 | $pool->dropConnection($uri, $connectionId, $futureId); 244 | } 245 | }); 246 | }); 247 | 248 | try { 249 | // Await both new connection future and deferred to reuse an existing connection. 250 | $connection = Future\awaitFirst([$connectionFuture, $futureFromDeferred]); 251 | } catch (CompositeException $exception) { 252 | [$exception] = $exception->getReasons(); // The first reason is why the connection failed. 253 | throw $exception; 254 | } 255 | 256 | $this->removeWaiting($uri, \spl_object_id($deferred)); // DeferredFuture no longer needed for this request. 257 | 258 | \assert($connection instanceof Connection); 259 | 260 | $stream = $this->getStreamFromConnection($connection, $request); 261 | 262 | if ($stream === null) { 263 | // Potentially reused connection did not have an available stream for the given request. 264 | $connection = $connectionFuture->await(); // Wait for new connection request instead. 265 | 266 | $stream = $this->getStreamFromConnection($connection, $request); 267 | 268 | if ($stream === null) { 269 | // Other requests used the new connection first, so we need to go around again. 270 | return $this->getStreamFor($uri, $request, $cancellation); 271 | } 272 | } 273 | 274 | return [$connection, $stream]; 275 | } 276 | 277 | private function getStreamFromConnection(Connection $connection, Request $request): ?Stream 278 | { 279 | if ($connection->isClosed()) { 280 | return null; // Connection closed during iteration over available connections. 281 | } 282 | 283 | if (!\array_intersect($request->getProtocolVersions(), $connection->getProtocolVersions())) { 284 | return null; // Connection does not support any of the requested protocol versions. 285 | } 286 | 287 | return $connection->getStream($request); 288 | } 289 | 290 | private function isAdditionalConnectionAllowed(string $uri): bool 291 | { 292 | return \count($this->connections[$uri] ?? []) < $this->connectionLimit; 293 | } 294 | 295 | private function onReadyConnection(Connection $connection, string $uri): void 296 | { 297 | $connectionId = \spl_object_id($connection); 298 | if (isset($this->activeRequestCounts[$connectionId])) { 299 | $this->activeRequestCounts[$connectionId]--; 300 | 301 | if ($this->activeRequestCounts[$connectionId] === 0) { 302 | while (\count($this->idleConnections) > 64) { // not customizable for now 303 | $idleConnection = \reset($this->idleConnections); 304 | $key = \key($this->idleConnections); 305 | unset($this->idleConnections[$key]); 306 | $idleConnection->close(); 307 | } 308 | 309 | $this->idleConnections[$connectionId] = $connection; 310 | } 311 | } 312 | 313 | if (empty($this->waiting[$uri])) { 314 | return; 315 | } 316 | 317 | /** @var DeferredFuture $deferred */ 318 | $deferred = \reset($this->waiting[$uri]); 319 | $this->removeWaiting($uri, \spl_object_id($deferred)); 320 | $deferred->complete($connection); 321 | } 322 | 323 | private function isConnectionIdle(Connection $connection): bool 324 | { 325 | $connectionId = \spl_object_id($connection); 326 | 327 | \assert( 328 | !isset($this->activeRequestCounts[$connectionId]) 329 | || $this->activeRequestCounts[$connectionId] >= 0 330 | ); 331 | 332 | return ($this->activeRequestCounts[$connectionId] ?? 0) === 0; 333 | } 334 | 335 | private function removeWaiting(string $uri, int $deferredId): void 336 | { 337 | unset($this->waiting[$uri][$deferredId]); 338 | if (empty($this->waiting[$uri])) { 339 | unset($this->waiting[$uri]); 340 | } 341 | } 342 | 343 | private function dropConnection(string $uri, ?int $connectionId, int $futureId): void 344 | { 345 | unset($this->connections[$uri][$futureId]); 346 | if ($connectionId !== null) { 347 | unset($this->activeRequestCounts[$connectionId], $this->idleConnections[$connectionId]); 348 | } 349 | 350 | if (\count($this->connections[$uri]) === 0) { 351 | unset($this->connections[$uri], $this->waitForPriorConnection[$uri]); 352 | } 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/Connection/ConnectionPool.php: -------------------------------------------------------------------------------- 1 | connectContext = $connectContext ?? new ConnectContext(); 29 | } 30 | 31 | public function create(Request $request, Cancellation $cancellation): Connection 32 | { 33 | $connectStart = now(); 34 | 35 | $connector = $this->connector ?? Socket\socketConnector(); 36 | $connectContext = $this->connectContext; 37 | 38 | $uri = $request->getUri(); 39 | $scheme = $uri->getScheme(); 40 | 41 | if (!\in_array($scheme, ['http', 'https'], true)) { 42 | throw new InvalidRequestException($request, 'Invalid scheme provided in the request URI: ' . $uri); 43 | } 44 | 45 | $isHttps = $scheme === 'https'; 46 | $defaultPort = $isHttps ? 443 : 80; 47 | 48 | $host = $uri->getHost(); 49 | $port = $uri->getPort() ?? $defaultPort; 50 | 51 | if ($host === '') { 52 | throw new InvalidRequestException($request, 'A host must be provided in the request URI: ' . $uri); 53 | } 54 | 55 | $authority = $host . ':' . $port; 56 | $protocolVersions = $request->getProtocolVersions(); 57 | 58 | $isConnect = $request->getMethod() === 'CONNECT'; 59 | 60 | if ($isHttps) { 61 | $protocols = []; 62 | 63 | if (!$isConnect && \in_array('2', $protocolVersions, true)) { 64 | $protocols[] = 'h2'; 65 | } 66 | 67 | if (\in_array('1.1', $protocolVersions, true) || \in_array('1.0', $protocolVersions, true)) { 68 | $protocols[] = 'http/1.1'; 69 | } 70 | 71 | if (!$protocols) { 72 | throw new InvalidRequestException( 73 | $request, 74 | \sprintf( 75 | "None of the requested protocol versions (%s) are supported by %s (HTTP/2 is only supported on HTTPS)", 76 | \implode(', ', $protocolVersions), 77 | self::class 78 | ) 79 | ); 80 | } 81 | 82 | $tlsContext = ($connectContext->getTlsContext() ?? new ClientTlsContext('')) 83 | ->withApplicationLayerProtocols($protocols) 84 | ->withPeerCapturing(); 85 | 86 | if ($protocols === ['http/1.1']) { 87 | // If we only have HTTP/1.1 available, don't set application layer protocols. 88 | // There are misbehaving sites like n11.com, see https://github.com/amphp/http-client/issues/255 89 | $tlsContext = $tlsContext->withApplicationLayerProtocols([]); 90 | } 91 | 92 | if ($tlsContext->getPeerName() === '') { 93 | $tlsContext = $tlsContext->withPeerName($host); 94 | } 95 | 96 | $connectContext = $connectContext->withTlsContext($tlsContext); 97 | } 98 | 99 | try { 100 | $socket = $connector->connect( 101 | 'tcp://' . $authority, 102 | $connectContext->withConnectTimeout($request->getTcpConnectTimeout()), 103 | $cancellation 104 | ); 105 | } catch (Socket\ConnectException $connectException) { 106 | throw new SocketException(\sprintf("Connection to '%s' failed", $authority), 0, $connectException); 107 | } catch (CancelledException) { 108 | // In case of a user cancellation request, throw the expected exception 109 | $cancellation->throwIfRequested(); 110 | 111 | // Otherwise we ran into a timeout of our TimeoutCancellation 112 | throw new TimeoutException(\sprintf("Connection to '%s' timed out, took longer than " . $request->getTcpConnectTimeout() . ' s', $authority)); 113 | } 114 | 115 | $tlsHandshakeDuration = null; 116 | 117 | if ($isHttps) { 118 | $tlsHandshakeStart = now(); 119 | 120 | try { 121 | $tlsState = $socket->getTlsState(); 122 | 123 | // Error if anything enabled TLS on a new connection before we can do it 124 | if ($tlsState !== Socket\TlsState::Disabled) { 125 | $socket->close(); 126 | 127 | throw new TlsException('Failed to setup TLS connection, connection was in an unexpected TLS state (' . $tlsState->name . ')'); 128 | } 129 | 130 | $socket->setupTls(new CompositeCancellation( 131 | $cancellation, 132 | new TimeoutCancellation($request->getTlsHandshakeTimeout()) 133 | )); 134 | } catch (StreamException $streamException) { 135 | $socket->close(); 136 | 137 | $errorMessage = $streamException->getMessage(); 138 | \preg_match('/error:[0-9a-f]*:[^:]*:[^:]*:(.+)$/i', $errorMessage, $matches); 139 | $errorMessage = \trim($matches[1] ?? \explode('():', $errorMessage, 2)[1] ?? $errorMessage); 140 | 141 | throw new TlsException(\sprintf( 142 | "Connection to '%s' @ '%s' closed during TLS handshake: %s", 143 | $authority, 144 | $socket->getRemoteAddress()->toString(), 145 | $errorMessage, 146 | ), 0, $streamException); 147 | } catch (CancelledException) { 148 | $socket->close(); 149 | 150 | // In case of a user cancellation request, throw the expected exception 151 | $cancellation->throwIfRequested(); 152 | 153 | // Otherwise we ran into a timeout of our TimeoutCancellation 154 | throw new TimeoutException(\sprintf( 155 | "TLS handshake with '%s' @ '%s' timed out, took longer than " . $request->getTlsHandshakeTimeout() . ' s', 156 | $authority, 157 | $socket->getRemoteAddress()->toString() 158 | )); 159 | } 160 | 161 | $tlsInfo = $socket->getTlsInfo(); 162 | if ($tlsInfo === null) { 163 | $socket->close(); 164 | 165 | throw new TlsException(\sprintf( 166 | "Socket closed after TLS handshake with '%s' @ '%s'", 167 | $authority, 168 | $socket->getRemoteAddress()->toString() 169 | )); 170 | } 171 | 172 | $tlsHandshakeDuration = now() - $tlsHandshakeStart; 173 | $connectDuration = now() - $connectStart; 174 | 175 | if ($tlsInfo->getApplicationLayerProtocol() === 'h2') { 176 | $http2Connection = new Http2Connection($socket, $connectDuration, $tlsHandshakeDuration); 177 | $http2Connection->initialize($cancellation); 178 | 179 | return $http2Connection; 180 | } 181 | } 182 | 183 | $connectDuration = now() - $connectStart; 184 | 185 | // Treat the presence of only HTTP/2 as prior knowledge, see https://http2.github.io/http2-spec/#known-http 186 | if ($request->getProtocolVersions() === ['2']) { 187 | $http2Connection = new Http2Connection($socket, $connectDuration, $tlsHandshakeDuration); 188 | $http2Connection->initialize($cancellation); 189 | 190 | return $http2Connection; 191 | } 192 | 193 | if (!\array_intersect($request->getProtocolVersions(), ['1.0', '1.1'])) { 194 | $socket->close(); 195 | 196 | throw new InvalidRequestException($request, \sprintf( 197 | "None of the requested protocol versions (%s) are supported by '%s' @ '%s'", 198 | \implode(', ', $protocolVersions), 199 | $authority, 200 | $socket->getRemoteAddress()->toString() 201 | )); 202 | } 203 | 204 | return new Http1Connection($socket, $connectDuration, $tlsHandshakeDuration); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Connection/Http2Connection.php: -------------------------------------------------------------------------------- 1 | processor = new Http2ConnectionProcessor($socket); 36 | } 37 | 38 | public function isIdle(): bool 39 | { 40 | return $this->processor->isIdle(); 41 | } 42 | 43 | public function getProtocolVersions(): array 44 | { 45 | return self::PROTOCOL_VERSIONS; 46 | } 47 | 48 | public function initialize(?Cancellation $cancellation = null): void 49 | { 50 | $this->processor->initialize($cancellation ?? new TimeoutCancellation(5)); 51 | } 52 | 53 | public function getStream(Request $request): ?Stream 54 | { 55 | if (!$this->processor->isInitialized()) { 56 | throw new \Error('The ' . __CLASS__ . '::initialize() invocation must be complete before using the connection'); 57 | } 58 | 59 | if ($this->processor->isClosed() || $this->processor->getRemainingStreams() <= 0) { 60 | return null; 61 | } 62 | 63 | $this->processor->reserveStream(); 64 | 65 | events()->connectionAcquired($request, $this, ++$this->streamCounter); 66 | 67 | return HttpStream::fromConnection($this, $this->request(...), $this->processor->unreserveStream(...)); 68 | } 69 | 70 | public function onClose(\Closure $onClose): void 71 | { 72 | $this->processor->onClose($onClose); 73 | } 74 | 75 | public function close(): void 76 | { 77 | $this->processor->close(); 78 | } 79 | 80 | public function isClosed(): bool 81 | { 82 | return $this->processor->isClosed(); 83 | } 84 | 85 | public function getLocalAddress(): SocketAddress 86 | { 87 | return $this->socket->getLocalAddress(); 88 | } 89 | 90 | public function getRemoteAddress(): SocketAddress 91 | { 92 | return $this->socket->getRemoteAddress(); 93 | } 94 | 95 | public function getTlsInfo(): ?TlsInfo 96 | { 97 | return $this->socket->getTlsInfo(); 98 | } 99 | 100 | private function request(Request $request, Cancellation $cancellation, Stream $stream): Response 101 | { 102 | $this->requestCount++; 103 | 104 | return $this->processor->request($request, $cancellation, $stream); 105 | } 106 | 107 | public function getTlsHandshakeDuration(): ?float 108 | { 109 | return $this->tlsHandshakeDuration; 110 | } 111 | 112 | public function getConnectDuration(): float 113 | { 114 | return $this->connectDuration; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Connection/HttpStream.php: -------------------------------------------------------------------------------- 1 | getLocalAddress(), 35 | $connection->getRemoteAddress(), 36 | $connection->getTlsInfo(), 37 | $RequestCallbackType, 38 | $ReleaseCallbackType, 39 | ); 40 | } 41 | 42 | /** 43 | * @param RequestCallbackType $RequestCallbackType 44 | * @param ReleaseCallbackType $ReleaseCallbackType 45 | */ 46 | public static function fromStream(Stream $stream, callable $RequestCallbackType, callable $ReleaseCallbackType): self 47 | { 48 | return new self( 49 | $stream->getLocalAddress(), 50 | $stream->getRemoteAddress(), 51 | $stream->getTlsInfo(), 52 | $RequestCallbackType, 53 | $ReleaseCallbackType, 54 | ); 55 | } 56 | 57 | /** @var callable */ 58 | private $RequestCallbackType; 59 | 60 | /** @var callable|null */ 61 | private $ReleaseCallbackType; 62 | 63 | /** 64 | * @param RequestCallbackType $RequestCallbackType 65 | * @param ReleaseCallbackType $ReleaseCallbackType 66 | */ 67 | private function __construct( 68 | private readonly SocketAddress $localAddress, 69 | private readonly SocketAddress $remoteAddress, 70 | private readonly ?TlsInfo $tlsInfo, 71 | callable $RequestCallbackType, 72 | callable $ReleaseCallbackType, 73 | ) { 74 | $this->RequestCallbackType = $RequestCallbackType; 75 | $this->ReleaseCallbackType = $ReleaseCallbackType; 76 | } 77 | 78 | public function __destruct() 79 | { 80 | if ($this->ReleaseCallbackType !== null) { 81 | ($this->ReleaseCallbackType)(); 82 | } 83 | } 84 | 85 | /** 86 | * @throws HttpException 87 | */ 88 | public function request(Request $request, Cancellation $cancellation): Response 89 | { 90 | if ($this->ReleaseCallbackType === null) { 91 | throw new \Error('A stream may only be used for a single request'); 92 | } 93 | 94 | $this->ReleaseCallbackType = null; 95 | 96 | return processRequest($request, [], fn (): Response => ($this->RequestCallbackType)($request, $cancellation, $this)); 97 | } 98 | 99 | public function getLocalAddress(): SocketAddress 100 | { 101 | return $this->localAddress; 102 | } 103 | 104 | public function getRemoteAddress(): SocketAddress 105 | { 106 | return $this->remoteAddress; 107 | } 108 | 109 | public function getTlsInfo(): ?TlsInfo 110 | { 111 | return $this->tlsInfo; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Connection/InterceptedStream.php: -------------------------------------------------------------------------------- 1 | interceptor = $interceptor; 29 | } 30 | 31 | /** 32 | * @throws HttpException 33 | */ 34 | public function request(Request $request, Cancellation $cancellation): Response 35 | { 36 | return processRequest($request, [], function () use ($request, $cancellation): Response { 37 | $interceptor = $this->interceptor; 38 | $this->interceptor = null; 39 | 40 | if (!$interceptor) { 41 | throw new \Error(__METHOD__ . ' may only be invoked once per instance. ' 42 | . 'If you need to implement retries or otherwise issue multiple requests, register an ApplicationInterceptor to do so.'); 43 | } 44 | 45 | /** @psalm-suppress RedundantPropertyInitializationCheck */ 46 | self::$requestInterceptors ??= new \WeakMap(); 47 | $requestInterceptors = self::$requestInterceptors[$request] ?? []; 48 | $requestInterceptors[] = $interceptor; 49 | self::$requestInterceptors[$request] = $requestInterceptors; 50 | 51 | events()->networkInterceptorStart($request, $interceptor); 52 | 53 | $response = $interceptor->requestViaNetwork($request, $cancellation, $this->stream); 54 | 55 | events()->networkInterceptorEnd($request, $interceptor, $response); 56 | 57 | return $response; 58 | }); 59 | } 60 | 61 | public function getLocalAddress(): SocketAddress 62 | { 63 | return $this->stream->getLocalAddress(); 64 | } 65 | 66 | public function getRemoteAddress(): SocketAddress 67 | { 68 | return $this->stream->getRemoteAddress(); 69 | } 70 | 71 | public function getTlsInfo(): ?TlsInfo 72 | { 73 | return $this->stream->getTlsInfo(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Connection/Internal/Http1Parser.php: -------------------------------------------------------------------------------- 1 | \d+\.\d+)[\x20\x09]+ 33 | (?P[1-9]\d\d)[\x20\x09]* 34 | (?P[^\x01-\x08\x10-\x19]*) 35 | $#ix"; 36 | 37 | public const AWAITING_HEADERS = 0; 38 | public const BODY_IDENTITY = 1; 39 | public const BODY_IDENTITY_EOF = 2; 40 | public const BODY_CHUNKS = 3; 41 | public const TRAILERS_START = 4; 42 | public const TRAILERS = 5; 43 | 44 | /** @var \WeakReference|null */ 45 | private ?\WeakReference $responseRef = null; 46 | 47 | private int $state = self::AWAITING_HEADERS; 48 | 49 | private bool $headersStarted = false; 50 | private bool $bodyStarted = false; 51 | 52 | private string $buffer = ''; 53 | 54 | private ?int $remainingBodyBytes = null; 55 | 56 | private int $bodyBytesConsumed = 0; 57 | 58 | private bool $chunkedEncoding = false; 59 | 60 | private ?int $chunkLengthRemaining = null; 61 | 62 | private bool $complete = false; 63 | 64 | private readonly int $maxHeaderBytes; 65 | 66 | private readonly int $maxBodyBytes; 67 | 68 | /** 69 | * @param \Closure(string):Future $bodyDataCallback 70 | * @param \Closure(HeaderMapType):void $trailersCallback 71 | */ 72 | public function __construct( 73 | private readonly Request $request, 74 | private readonly Stream $stream, 75 | private readonly \Closure $bodyDataCallback, 76 | private readonly Cancellation $bodyCancellation, 77 | private readonly \Closure $trailersCallback, 78 | ) { 79 | $this->maxHeaderBytes = $request->getHeaderSizeLimit(); 80 | $this->maxBodyBytes = $request->getBodySizeLimit(); 81 | } 82 | 83 | public function getBuffer(): string 84 | { 85 | return $this->buffer; 86 | } 87 | 88 | public function getState(): int 89 | { 90 | return $this->state; 91 | } 92 | 93 | public function buffer(string $data): void 94 | { 95 | $this->buffer .= $data; 96 | } 97 | 98 | /** 99 | * @throws ParseException 100 | */ 101 | public function parse(?string $data = null): ?Response 102 | { 103 | if ($data !== null && $data !== '') { 104 | $this->buffer .= $data; 105 | } 106 | 107 | if ($this->buffer === '') { 108 | return null; 109 | } 110 | 111 | if ($this->complete) { 112 | throw new ParseException('Can\'t continue parsing, response is already complete', HttpStatus::BAD_REQUEST); 113 | } 114 | 115 | if (!$this->bodyStarted && \in_array($this->state, [self::BODY_CHUNKS, self::BODY_IDENTITY, self::BODY_IDENTITY_EOF], true)) { 116 | $this->bodyStarted = true; 117 | $response = $this->responseRef?->get(); 118 | if ($response) { 119 | events()->responseBodyStart($this->request, $this->stream, $response); 120 | $response = null; 121 | } 122 | } 123 | 124 | switch ($this->state) { 125 | case self::AWAITING_HEADERS: 126 | goto headers; 127 | case self::BODY_IDENTITY: 128 | goto body_identity; 129 | case self::BODY_IDENTITY_EOF: 130 | goto body_identity_eof; 131 | case self::BODY_CHUNKS: 132 | goto body_chunks; 133 | case self::TRAILERS_START: 134 | goto trailers_start; 135 | case self::TRAILERS: 136 | goto trailers; 137 | } 138 | 139 | headers: 140 | { 141 | $startLineAndHeaders = $this->shiftHeadersFromBuffer(); 142 | if ($startLineAndHeaders === null) { 143 | return null; 144 | } 145 | 146 | $startLineEndPos = \strpos($startLineAndHeaders, "\r\n"); 147 | 148 | \assert($startLineEndPos !== false); 149 | 150 | $startLine = \substr($startLineAndHeaders, 0, $startLineEndPos); 151 | $rawHeaders = \substr($startLineAndHeaders, $startLineEndPos + 2); 152 | 153 | if (\preg_match(self::STATUS_LINE_PATTERN, $startLine, $match)) { 154 | $protocol = $match['protocol']; 155 | $statusCode = (int) $match['status']; 156 | $statusReason = \trim($match['reason']); 157 | } else { 158 | throw new ParseException('Invalid status line: ' . $startLine, HttpStatus::BAD_REQUEST); 159 | } 160 | 161 | if (!\in_array($protocol, ['1.0', '1.1'], true)) { 162 | throw new ParseException('Invalid protocol version: ' . $protocol, HttpStatus::BAD_REQUEST); 163 | } 164 | 165 | if ($rawHeaders !== '') { 166 | $headers = $this->parseRawHeaders($rawHeaders); 167 | } else { 168 | $headers = []; 169 | } 170 | 171 | $requestMethod = $this->request->getMethod(); 172 | $skipBody = $statusCode < HttpStatus::OK 173 | || $statusCode === HttpStatus::NOT_MODIFIED 174 | || $statusCode === HttpStatus::NO_CONTENT 175 | || $requestMethod === 'HEAD' 176 | || $requestMethod === 'CONNECT'; 177 | 178 | if ($skipBody) { 179 | $this->complete = true; 180 | } elseif ($this->chunkedEncoding) { 181 | $this->state = self::BODY_CHUNKS; 182 | } elseif ($this->remainingBodyBytes === null) { 183 | $this->state = self::BODY_IDENTITY_EOF; 184 | } elseif ($this->remainingBodyBytes > 0) { 185 | $this->state = self::BODY_IDENTITY; 186 | } else { 187 | $this->complete = true; 188 | } 189 | 190 | $response = new Response($protocol, $statusCode, $statusReason, [], new ReadableBuffer, $this->request); 191 | foreach ($headers as [$key, $value]) { 192 | $response->addHeader($key, $value); 193 | } 194 | 195 | $this->responseRef = \WeakReference::create($response); 196 | 197 | events()->responseHeaderEnd($this->request, $this->stream, $response); 198 | 199 | return $response; 200 | } 201 | 202 | body_identity: 203 | { 204 | if ($data !== null && $data !== '') { 205 | $response = $this->responseRef?->get(); 206 | if ($response) { 207 | events()->responseBodyProgress($this->request, $this->stream, $response); 208 | $response = null; 209 | } 210 | } 211 | 212 | $bufferDataSize = \strlen($this->buffer); 213 | 214 | if ($bufferDataSize <= $this->remainingBodyBytes) { 215 | $chunk = $this->buffer; 216 | $this->buffer = ''; 217 | $this->remainingBodyBytes -= $bufferDataSize; 218 | $this->addToBody($chunk); 219 | 220 | if ($this->remainingBodyBytes === 0) { 221 | $this->complete = true; 222 | } 223 | 224 | return null; 225 | } 226 | 227 | $bodyData = \substr($this->buffer, 0, $this->remainingBodyBytes); 228 | $this->addToBody($bodyData); 229 | $this->buffer = \substr($this->buffer, $this->remainingBodyBytes); 230 | $this->remainingBodyBytes = 0; 231 | 232 | goto complete; 233 | } 234 | 235 | body_identity_eof: 236 | { 237 | if ($data !== null && $data !== '') { 238 | $response = $this->responseRef?->get(); 239 | if ($response) { 240 | events()->responseBodyProgress($this->request, $this->stream, $response); 241 | $response = null; 242 | } 243 | } 244 | 245 | $this->addToBody($this->buffer); 246 | $this->buffer = ''; 247 | return null; 248 | } 249 | 250 | body_chunks: 251 | { 252 | if ($data !== null && $data !== '') { 253 | $response = $this->responseRef?->get(); 254 | if ($response) { 255 | events()->responseBodyProgress($this->request, $this->stream, $response); 256 | $response = null; 257 | } 258 | } 259 | 260 | if ($this->parseChunkedBody()) { 261 | $this->state = self::TRAILERS_START; 262 | goto trailers_start; 263 | } 264 | 265 | return null; 266 | } 267 | 268 | trailers_start: 269 | { 270 | $firstTwoBytes = \substr($this->buffer, 0, 2); 271 | 272 | if ($firstTwoBytes === "" || $firstTwoBytes === "\r") { 273 | return null; 274 | } 275 | 276 | if ($firstTwoBytes === "\r\n") { 277 | $this->buffer = \substr($this->buffer, 2); 278 | goto complete; 279 | } 280 | 281 | $this->state = self::TRAILERS; 282 | goto trailers; 283 | } 284 | 285 | trailers: 286 | { 287 | $trailers = $this->shiftHeadersFromBuffer(); 288 | if ($trailers === null) { 289 | return null; 290 | } 291 | 292 | $this->parseTrailers($trailers); 293 | goto complete; 294 | } 295 | 296 | complete: 297 | { 298 | $response = $this->responseRef?->get(); 299 | if ($response) { 300 | events()->responseBodyEnd($this->request, $this->stream, $response); 301 | $response = null; 302 | } 303 | 304 | $this->complete = true; 305 | 306 | return null; 307 | } 308 | } 309 | 310 | public function isComplete(): bool 311 | { 312 | return $this->complete; 313 | } 314 | 315 | /** 316 | * @throws ParseException 317 | */ 318 | private function shiftHeadersFromBuffer(): ?string 319 | { 320 | $this->buffer = \ltrim($this->buffer, "\r\n"); 321 | 322 | if (!$this->headersStarted && $this->buffer !== '') { 323 | $this->headersStarted = true; 324 | events()->responseHeaderStart($this->request, $this->stream); 325 | } 326 | 327 | if ($headersSize = \strpos($this->buffer, "\r\n\r\n")) { 328 | $headers = \substr($this->buffer, 0, $headersSize + 2); 329 | $this->buffer = \substr($this->buffer, $headersSize + 4); 330 | } else { 331 | $headersSize = \strlen($this->buffer); 332 | $headers = null; 333 | } 334 | 335 | if ($this->maxHeaderBytes > 0 && $headersSize > $this->maxHeaderBytes) { 336 | throw new ParseException( 337 | "Configured header size exceeded: {$headersSize} bytes received, while the configured " . 338 | "limit is {$this->maxHeaderBytes} bytes", 339 | HttpStatus::REQUEST_HEADER_FIELDS_TOO_LARGE, 340 | ); 341 | } 342 | 343 | return $headers; 344 | } 345 | 346 | /** 347 | * @throws ParseException 348 | */ 349 | private function parseRawHeaders(string $rawHeaders): array 350 | { 351 | // Legacy support for folded headers 352 | if (\strpos($rawHeaders, "\r\n\x20") || \strpos($rawHeaders, "\r\n\t")) { 353 | $rawHeaders = \preg_replace("/\r\n[\x20\t]++/", ' ', $rawHeaders); 354 | } 355 | 356 | try { 357 | $headers = Rfc7230::parseHeaderPairs($rawHeaders); 358 | $headerMap = mapHeaderPairs($headers); 359 | } catch (InvalidHeaderException $e) { 360 | throw new ParseException('Invalid headers', HttpStatus::BAD_REQUEST, $e); 361 | } 362 | 363 | if (isset($headerMap['transfer-encoding'])) { 364 | $transferEncodings = \explode(',', \strtolower(\implode(',', $headerMap['transfer-encoding']))); 365 | $transferEncodings = \array_map('trim', $transferEncodings); 366 | $this->chunkedEncoding = \in_array('chunked', $transferEncodings, true); 367 | } elseif (!empty($headerMap['content-length'])) { 368 | if (\count($headerMap['content-length']) > 1) { 369 | throw new ParseException('Can\'t determine body length, because multiple content-length ' . 370 | 'headers present in the response', HttpStatus::BAD_REQUEST); 371 | } 372 | 373 | $contentLength = $headerMap['content-length'][0]; 374 | 375 | if (!\preg_match('/^(0|[1-9][0-9]*)$/', $contentLength)) { 376 | throw new ParseException( 377 | 'Can\'t determine body length, because the content-length header value is invalid', 378 | HttpStatus::BAD_REQUEST, 379 | ); 380 | } 381 | 382 | $this->remainingBodyBytes = (int) $contentLength; 383 | } 384 | 385 | return $headers; 386 | } 387 | 388 | /** 389 | * Decodes a chunked response body. 390 | * 391 | * @return bool Returns {@code true} if the body is complete, otherwise {@code false}. 392 | * 393 | * @throws ParseException 394 | */ 395 | private function parseChunkedBody(): bool 396 | { 397 | if ($this->chunkLengthRemaining !== null) { 398 | goto decode_chunk; 399 | } 400 | 401 | determine_chunk_size: 402 | { 403 | if (false === ($lineEndPos = \strpos($this->buffer, "\r\n"))) { 404 | return false; 405 | } 406 | 407 | if ($lineEndPos === 0) { 408 | throw new ParseException('Invalid line; hexadecimal chunk size expected', HttpStatus::BAD_REQUEST); 409 | } 410 | 411 | $line = \substr($this->buffer, 0, $lineEndPos); 412 | $hex = \strtolower(\trim(\ltrim($line, '0'))) ?: '0'; 413 | $dec = \hexdec($hex); 414 | 415 | if ($hex !== \dechex($dec)) { 416 | throw new ParseException('Invalid hexadecimal chunk size', HttpStatus::BAD_REQUEST); 417 | } 418 | 419 | $this->chunkLengthRemaining = $dec; 420 | $this->buffer = \substr($this->buffer, $lineEndPos + 2); 421 | 422 | if ($this->chunkLengthRemaining === 0) { 423 | return true; 424 | } 425 | } 426 | 427 | decode_chunk: 428 | { 429 | $bufferLength = \strlen($this->buffer); 430 | 431 | // These first two (extreme) edge cases prevent errors where the packet boundary ends after 432 | // the \r and before the \n at the end of a chunk. 433 | if ($bufferLength === $this->chunkLengthRemaining || $bufferLength === $this->chunkLengthRemaining + 1) { 434 | return false; 435 | } 436 | 437 | if ($bufferLength >= $this->chunkLengthRemaining + 2) { 438 | $chunk = \substr($this->buffer, 0, $this->chunkLengthRemaining); 439 | $this->buffer = \substr($this->buffer, $this->chunkLengthRemaining + 2); 440 | $this->chunkLengthRemaining = null; 441 | $this->addToBody($chunk); 442 | 443 | goto determine_chunk_size; 444 | } 445 | 446 | /** @noinspection SuspiciousAssignmentsInspection */ 447 | $chunk = $this->buffer; 448 | $this->buffer = ''; 449 | $this->chunkLengthRemaining -= $bufferLength; 450 | $this->addToBody($chunk); 451 | 452 | return false; 453 | } 454 | } 455 | 456 | /** 457 | * @throws ParseException 458 | */ 459 | private function parseTrailers(string $trailers): void 460 | { 461 | try { 462 | $trailers = Rfc7230::parseHeaders($trailers); 463 | } catch (InvalidHeaderException $e) { 464 | throw new ParseException('Invalid trailers', HttpStatus::BAD_REQUEST, $e); 465 | } 466 | 467 | ($this->trailersCallback)($trailers); 468 | } 469 | 470 | /** 471 | * @throws ParseException 472 | */ 473 | private function addToBody(string $data): void 474 | { 475 | $length = \strlen($data); 476 | if (!$length) { 477 | return; 478 | } 479 | 480 | $this->bodyBytesConsumed += $length; 481 | 482 | if ($this->maxBodyBytes > 0 && $this->bodyBytesConsumed > $this->maxBodyBytes) { 483 | throw new ParseException( 484 | "Configured body size exceeded: {$this->bodyBytesConsumed} bytes received," . 485 | " while the configured limit is {$this->maxBodyBytes} bytes", 486 | HttpStatus::PAYLOAD_TOO_LARGE, 487 | ); 488 | } 489 | 490 | ($this->bodyDataCallback)($data)->await($this->bodyCancellation); 491 | } 492 | } 493 | -------------------------------------------------------------------------------- /src/Connection/Internal/Http2Stream.php: -------------------------------------------------------------------------------- 1 | pendingResponse = new DeferredFuture(); 79 | $this->requestHeaderCompletion = new DeferredFuture(); 80 | $this->requestBodyCompletion = new DeferredFuture(); 81 | $this->body = new Queue(); 82 | 83 | $this->deferredCancellation = new DeferredCancellation(); 84 | $this->cancellation = new CompositeCancellation($cancellation, $this->deferredCancellation->getCancellation()); 85 | 86 | // Trailers future may never be exposed to the user if the request fails, so ignore. 87 | $this->trailers = new DeferredFuture(); 88 | $this->trailers->getFuture()->ignore(); 89 | } 90 | 91 | public function cancel(): void 92 | { 93 | $this->deferredCancellation->cancel(); 94 | } 95 | 96 | public function __destruct() 97 | { 98 | if ($this->transferWatcher !== null) { 99 | EventLoop::cancel($this->transferWatcher); 100 | } 101 | 102 | if ($this->inactivityWatcher !== null) { 103 | EventLoop::cancel($this->inactivityWatcher); 104 | } 105 | 106 | $this->deferredCancellation->cancel(); 107 | 108 | // Setting these to null due to PHP's random destruct order on shutdown to avoid errors from double completion. 109 | $this->pendingResponse = null; 110 | $this->body = null; 111 | $this->trailers = null; 112 | } 113 | 114 | public function disableInactivityWatcher(): void 115 | { 116 | if ($this->inactivityWatcher === null) { 117 | return; 118 | } 119 | 120 | EventLoop::disable($this->inactivityWatcher); 121 | } 122 | 123 | public function enableInactivityWatcher(): void 124 | { 125 | if ($this->inactivityWatcher === null) { 126 | return; 127 | } 128 | 129 | $watcher = $this->inactivityWatcher; 130 | 131 | EventLoop::disable($watcher); 132 | EventLoop::enable($watcher); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Connection/Internal/RequestNormalizer.php: -------------------------------------------------------------------------------- 1 | hasHeader('host')) { 17 | // Though servers are supposed to be able to handle standard port names on the end of the 18 | // Host header some fail to do this correctly. Thankfully PSR-7 recommends to strip the port 19 | // if it is the standard port for the given scheme. 20 | $request->setHeader('host', $request->getUri()->withUserInfo('')->getAuthority()); 21 | } 22 | 23 | self::normalizeRequestBodyHeaders($request); 24 | 25 | // Always normalize this as last item, because we need to strip sensitive headers 26 | self::normalizeTraceRequest($request); 27 | 28 | return $request; 29 | } 30 | 31 | /** 32 | * @throws HttpException 33 | */ 34 | private static function normalizeRequestBodyHeaders(Request $request): void 35 | { 36 | $body = $request->getBody(); 37 | 38 | $contentType = $body->getContentType(); 39 | if ($contentType !== null) { 40 | $previousContentType = $request->getHeaderArray('content-type'); 41 | if ($previousContentType !== [] && $previousContentType !== [$contentType]) { 42 | throw new HttpException('Conflicting content type headers in request and request body: ' . \implode(', ', $previousContentType) . ' / ' . $contentType); 43 | } 44 | 45 | $request->setHeader('content-type', $contentType); 46 | } 47 | 48 | if ($request->hasHeader("transfer-encoding")) { 49 | $request->removeHeader("content-length"); 50 | 51 | return; 52 | } 53 | 54 | $contentLength = $body->getContentLength(); 55 | if ($contentLength === 0 && \in_array($request->getMethod(), ["CONNECT", "GET", "HEAD", "OPTIONS", "CONNECT", "TRACE"], true)) { 56 | $request->removeHeader('content-length'); 57 | $request->removeHeader('transfer-encoding'); 58 | } elseif ($contentLength !== null) { 59 | $request->setHeader('content-length', (string) $contentLength); 60 | $request->removeHeader('transfer-encoding'); 61 | } else { 62 | $request->removeHeader('content-length'); 63 | $request->setHeader("transfer-encoding", "chunked"); 64 | } 65 | } 66 | 67 | private static function normalizeTraceRequest(Request $request): void 68 | { 69 | if ($request->getMethod() !== 'TRACE') { 70 | return; 71 | } 72 | 73 | // https://tools.ietf.org/html/rfc7231#section-4.3.8 74 | $request->setBody(''); 75 | 76 | // Remove all body and sensitive headers 77 | $request->replaceHeaders([ 78 | "transfer-encoding" => [], 79 | "content-length" => [], 80 | "authorization" => [], 81 | "proxy-authorization" => [], 82 | "cookie" => [], 83 | ]); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Connection/Stream.php: -------------------------------------------------------------------------------- 1 | getUri()->getHost(); 22 | }); 23 | } 24 | 25 | public static function byStaticKey( 26 | ConnectionPool $delegate, 27 | KeyedSemaphore $semaphore, 28 | string $key = '' 29 | ): self { 30 | return new self($delegate, $semaphore, static function () use ($key) { 31 | return $key; 32 | }); 33 | } 34 | 35 | public static function byCustomKey( 36 | ConnectionPool $delegate, 37 | KeyedSemaphore $semaphore, 38 | callable $requestToKeyMapper 39 | ): self { 40 | return new self($delegate, $semaphore, $requestToKeyMapper); 41 | } 42 | 43 | private ConnectionPool $delegate; 44 | 45 | private KeyedSemaphore $semaphore; 46 | 47 | /** @var callable */ 48 | private $requestToKeyMapper; 49 | 50 | private function __construct(ConnectionPool $delegate, KeyedSemaphore $semaphore, callable $requestToKeyMapper) 51 | { 52 | $this->delegate = $delegate; 53 | $this->semaphore = $semaphore; 54 | $this->requestToKeyMapper = $requestToKeyMapper; 55 | } 56 | 57 | public function getStream(Request $request, Cancellation $cancellation): Stream 58 | { 59 | $lock = $this->semaphore->acquire(($this->requestToKeyMapper)($request)); 60 | 61 | $stream = $this->delegate->getStream($request, $cancellation); 62 | 63 | return HttpStream::fromStream( 64 | $stream, 65 | static function (Request $request, Cancellation $cancellation) use ( 66 | $stream, 67 | $lock 68 | ): Response { 69 | try { 70 | $response = $stream->request($request, $cancellation); 71 | } catch (\Throwable $e) { 72 | $lock->release(); 73 | throw $e; 74 | } 75 | 76 | // await response being completely received 77 | async(static function () use ($response, $lock): void { 78 | try { 79 | $response->getTrailers()->await(); 80 | } finally { 81 | $lock->release(); 82 | } 83 | })->ignore(); 84 | 85 | return $response; 86 | }, 87 | static function () use ($lock): void { 88 | $lock->release(); 89 | } 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Connection/UnlimitedConnectionPool.php: -------------------------------------------------------------------------------- 1 | pool = ConnectionLimitingPool::byAuthority(\PHP_INT_MAX, $connectionFactory); 18 | } 19 | 20 | public function __clone() 21 | { 22 | $this->pool = clone $this->pool; 23 | } 24 | 25 | public function getTotalConnectionAttempts(): int 26 | { 27 | return $this->pool->getTotalConnectionAttempts(); 28 | } 29 | 30 | public function getTotalStreamRequests(): int 31 | { 32 | return $this->pool->getTotalStreamRequests(); 33 | } 34 | 35 | public function getOpenConnectionCount(): int 36 | { 37 | return $this->pool->getOpenConnectionCount(); 38 | } 39 | 40 | public function getStream(Request $request, Cancellation $cancellation): Stream 41 | { 42 | return $this->pool->getStream($request, $cancellation); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Connection/UpgradedSocket.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class UpgradedSocket implements Socket, ResourceStream, \IteratorAggregate 19 | { 20 | use ForbidCloning; 21 | use ForbidSerialization; 22 | use ReadableStreamIteratorAggregate; 23 | 24 | private ?string $buffer; 25 | 26 | /** 27 | * @param string $buffer Remaining buffer previously read from the socket. 28 | */ 29 | public function __construct(private readonly Socket $socket, string $buffer) 30 | { 31 | $this->buffer = $buffer !== '' ? $buffer : null; 32 | } 33 | 34 | public function read(?Cancellation $cancellation = null, ?int $limit = null): ?string 35 | { 36 | if ($this->buffer !== null) { 37 | if ($limit !== null && $limit < \strlen($this->buffer)) { 38 | $buffer = \substr($this->buffer, 0, $limit); 39 | $this->buffer = \substr($this->buffer, $limit); 40 | 41 | return $buffer; 42 | } 43 | 44 | $buffer = $this->buffer; 45 | $this->buffer = null; 46 | 47 | return $buffer; 48 | } 49 | 50 | return $this->socket->read($cancellation); 51 | } 52 | 53 | public function close(): void 54 | { 55 | $this->socket->close(); 56 | } 57 | 58 | public function __destruct() 59 | { 60 | $this->close(); 61 | } 62 | 63 | public function write(string $bytes): void 64 | { 65 | $this->socket->write($bytes); 66 | } 67 | 68 | public function end(): void 69 | { 70 | $this->socket->end(); 71 | } 72 | 73 | public function reference(): void 74 | { 75 | if ($this->socket instanceof ResourceStream) { 76 | $this->socket->reference(); 77 | } 78 | } 79 | 80 | public function unreference(): void 81 | { 82 | if ($this->socket instanceof ResourceStream) { 83 | $this->socket->unreference(); 84 | } 85 | } 86 | 87 | public function isClosed(): bool 88 | { 89 | return $this->socket->isClosed(); 90 | } 91 | 92 | public function onClose(\Closure $onClose): void 93 | { 94 | $this->socket->onClose($onClose); 95 | } 96 | 97 | public function getLocalAddress(): SocketAddress 98 | { 99 | return $this->socket->getLocalAddress(); 100 | } 101 | 102 | public function getRemoteAddress(): SocketAddress 103 | { 104 | return $this->socket->getRemoteAddress(); 105 | } 106 | 107 | public function setupTls(?Cancellation $cancellation = null): void 108 | { 109 | $this->socket->setupTls($cancellation); 110 | } 111 | 112 | public function shutdownTls(?Cancellation $cancellation = null): void 113 | { 114 | $this->socket->shutdownTls(); 115 | } 116 | 117 | public function isTlsConfigurationAvailable(): bool 118 | { 119 | return $this->socket->isTlsConfigurationAvailable(); 120 | } 121 | 122 | public function getTlsState(): TlsState 123 | { 124 | return $this->socket->getTlsState(); 125 | } 126 | 127 | public function getTlsInfo(): ?TlsInfo 128 | { 129 | return $this->socket->getTlsInfo(); 130 | } 131 | 132 | public function isReadable(): bool 133 | { 134 | return $this->socket->isReadable(); 135 | } 136 | 137 | public function isWritable(): bool 138 | { 139 | return $this->socket->isWritable(); 140 | } 141 | 142 | public function getResource() 143 | { 144 | return $this->socket instanceof ResourceStream 145 | ? $this->socket->getResource() 146 | : null; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/DelegateHttpClient.php: -------------------------------------------------------------------------------- 1 | hasAttribute($attributeName)) { 38 | return -1; 39 | } 40 | 41 | return $request->getAttribute($attributeName) ?? -1; 42 | } 43 | 44 | /** 45 | * @param non-empty-string $start 46 | * @param non-empty-string ...$ends 47 | */ 48 | private static function getTime(Request $request, string $start, string ...$ends): float 49 | { 50 | if (!$request->hasAttribute($start)) { 51 | return -1; 52 | } 53 | 54 | foreach ($ends as $end) { 55 | if ($request->hasAttribute($end)) { 56 | return $request->getAttribute($end) - $request->getAttribute($start); 57 | } 58 | } 59 | 60 | return -1; 61 | } 62 | 63 | private static function formatHeaders(HttpMessage $message): array 64 | { 65 | $headers = []; 66 | 67 | foreach ($message->getHeaders() as $field => $values) { 68 | foreach ($values as $value) { 69 | $headers[] = [ 70 | 'name' => $field, 71 | 'value' => $value, 72 | ]; 73 | } 74 | } 75 | 76 | return $headers; 77 | } 78 | 79 | private static function formatEntry(Response $response): array 80 | { 81 | $request = $response->getRequest(); 82 | 83 | $includeConnectTime = $request->hasAttribute(HarAttributes::INCLUDE_CONNECT_TIME) 84 | ? $request->getAttribute(HarAttributes::INCLUDE_CONNECT_TIME) 85 | : false; 86 | 87 | $connectDuration = $includeConnectTime ? self::getDuration( 88 | $request, 89 | HarAttributes::TIME_CONNECT 90 | ) : -1; 91 | 92 | $tlsHandshakeDuration = $includeConnectTime ? self::getDuration( 93 | $request, 94 | HarAttributes::TIME_SSL 95 | ) : -1; 96 | 97 | $sendDuration = self::getTime( 98 | $request, 99 | HarAttributes::TIME_SEND, 100 | HarAttributes::TIME_WAIT 101 | ); 102 | 103 | $waitDuration = self::getTime( 104 | $request, 105 | HarAttributes::TIME_WAIT, 106 | HarAttributes::TIME_RECEIVE 107 | ); 108 | 109 | $receiveDuration = self::getTime( 110 | $request, 111 | HarAttributes::TIME_RECEIVE, 112 | HarAttributes::TIME_COMPLETE 113 | ); 114 | 115 | $blockedDuration = self::getTime( 116 | $request, 117 | HarAttributes::TIME_START, 118 | HarAttributes::TIME_COMPLETE 119 | ) - ($includeConnectTime ? $connectDuration : 0) - $sendDuration - $receiveDuration; 120 | 121 | $data = [ 122 | 'startedDateTime' => $request->getAttribute(HarAttributes::STARTED_DATE_TIME)->format(\DateTimeInterface::RFC3339_EXTENDED), 123 | 'time' => self::getTime($request, HarAttributes::TIME_START, HarAttributes::TIME_COMPLETE), 124 | 'request' => [ 125 | 'method' => $request->getMethod(), 126 | 'url' => (string) $request->getUri()->withUserInfo(''), 127 | 'httpVersion' => 'http/' . $request->getProtocolVersions()[0], 128 | 'headers' => self::formatHeaders($request), 129 | 'queryString' => [], 130 | 'cookies' => [], 131 | 'headersSize' => -1, 132 | 'bodySize' => -1, 133 | ], 134 | 'response' => [ 135 | 'status' => $response->getStatus(), 136 | 'statusText' => $response->getReason(), 137 | 'httpVersion' => 'http/' . $response->getProtocolVersion(), 138 | 'headers' => self::formatHeaders($response), 139 | 'cookies' => [], 140 | 'redirectURL' => $response->getHeader('location') ?? '', 141 | 'headersSize' => -1, 142 | 'bodySize' => -1, 143 | 'content' => [ 144 | 'size' => (int) ($response->getHeader('content-length') ?? '-1'), 145 | 'mimeType' => $response->getHeader('content-type') ?? '', 146 | ], 147 | ], 148 | 'cache' => [], 149 | 'timings' => [ 150 | 'blocked' => $blockedDuration, 151 | 'dns' => -1, 152 | 'connect' => $connectDuration, 153 | 'ssl' => $tlsHandshakeDuration, 154 | 'send' => $sendDuration, 155 | 'wait' => $waitDuration, 156 | 'receive' => $receiveDuration, 157 | ], 158 | ]; 159 | 160 | if ($request->hasAttribute(HarAttributes::SERVER_IP_ADDRESS)) { 161 | $data['serverIPAddress'] = $request->getAttribute(HarAttributes::SERVER_IP_ADDRESS); 162 | } 163 | 164 | return $data; 165 | } 166 | 167 | private LocalMutex $fileMutex; 168 | 169 | private Filesystem $filesystem; 170 | 171 | private ?File $fileHandle = null; 172 | 173 | private string $filePath; 174 | 175 | private ?\Throwable $error = null; 176 | 177 | public function __construct(string $filePath, ?Filesystem $filesystem = null) 178 | { 179 | if (!\class_exists(Filesystem::class)) { 180 | throw new \Error("File request bodies require amphp/file to be installed"); 181 | } 182 | 183 | $this->filePath = $filePath; 184 | $this->fileMutex = new LocalMutex; 185 | $this->filesystem = $filesystem ?? filesystem(); 186 | } 187 | 188 | public function reset(): void 189 | { 190 | $this->rotate($this->filePath); 191 | } 192 | 193 | public function rotate(string $filePath): void 194 | { 195 | $lock = $this->fileMutex->acquire(); 196 | 197 | // Will automatically reopen and reset the file 198 | $this->fileHandle = null; 199 | $this->filePath = $filePath; 200 | $this->error = null; 201 | 202 | $lock->release(); 203 | } 204 | 205 | private function writeLog(Response $response): void 206 | { 207 | try { 208 | $response->getTrailers()->await(); 209 | } catch (\Throwable) { 210 | // ignore, still log the remaining response times 211 | } 212 | 213 | try { 214 | $lock = $this->fileMutex->acquire(); 215 | 216 | $firstEntry = $this->fileHandle === null; 217 | 218 | if ($firstEntry) { 219 | $this->fileHandle = $fileHandle = openFile($this->filePath, 'w'); 220 | 221 | $header = '{"log":{"version":"1.2","creator":{"name":"amphp/http-client","version":"4.x"},"pages":[],"entries":['; 222 | 223 | $fileHandle->write($header); 224 | } else { 225 | $fileHandle = $this->fileHandle; 226 | 227 | \assert($fileHandle !== null); 228 | 229 | $fileHandle->seek(-3, Whence::Current); 230 | } 231 | 232 | $json = \json_encode(self::formatEntry($response)); 233 | 234 | $fileHandle->write(($firstEntry ? '' : ',') . $json . ']}}'); 235 | 236 | $lock->release(); 237 | } catch (HttpException $e) { 238 | $this->error = $e; 239 | } catch (\Throwable $e) { 240 | $this->error = new HttpException('Writing HTTP archive log failed', 0, $e); 241 | } 242 | } 243 | 244 | public function requestStart(Request $request): void 245 | { 246 | if (!$request->hasAttribute(HarAttributes::STARTED_DATE_TIME)) { 247 | $request->setAttribute(HarAttributes::STARTED_DATE_TIME, new \DateTimeImmutable); 248 | } 249 | 250 | $this->addTiming(HarAttributes::TIME_START, $request); 251 | } 252 | 253 | public function connectionAcquired(Request $request, Connection $connection, int $streamCount): void 254 | { 255 | $request->setAttribute(HarAttributes::INCLUDE_CONNECT_TIME, $streamCount === 1); 256 | $request->setAttribute(HarAttributes::TIME_CONNECT, (int) ($connection->getConnectDuration() * 1000)); 257 | 258 | $tlsHandshakeDuration = $connection->getTlsHandshakeDuration(); 259 | if ($tlsHandshakeDuration !== null) { 260 | $request->setAttribute(HarAttributes::TIME_SSL, (int) ($tlsHandshakeDuration * 1000)); 261 | } else { 262 | $request->setAttribute(HarAttributes::TIME_SSL, -1); 263 | } 264 | } 265 | 266 | public function requestHeaderStart(Request $request, Stream $stream): void 267 | { 268 | $address = $stream->getRemoteAddress(); 269 | $host = match (true) { 270 | $address instanceof InternetAddress => $address->getAddress(), 271 | default => $address->toString(), 272 | }; 273 | if (\strrpos($host, ':')) { 274 | $host = '[' . $host . ']'; 275 | } 276 | 277 | $request->setAttribute(HarAttributes::SERVER_IP_ADDRESS, $host); 278 | $this->addTiming(HarAttributes::TIME_SEND, $request); 279 | } 280 | 281 | public function requestBodyEnd(Request $request, Stream $stream): void 282 | { 283 | $this->addTiming(HarAttributes::TIME_WAIT, $request); 284 | } 285 | 286 | public function responseHeaderStart(Request $request, Stream $stream): void 287 | { 288 | $this->addTiming(HarAttributes::TIME_RECEIVE, $request); 289 | } 290 | 291 | public function requestEnd(Request $request, Response $response): void 292 | { 293 | $this->addTiming(HarAttributes::TIME_COMPLETE, $request); 294 | 295 | EventLoop::queue(fn () => $this->writeLog($response)); 296 | } 297 | 298 | /** 299 | * @param non-empty-string $key 300 | */ 301 | private function addTiming(string $key, Request $request): void 302 | { 303 | if (!$request->hasAttribute($key)) { 304 | $request->setAttribute($key, now()); 305 | } 306 | } 307 | 308 | public function requestFailed(Request $request, \Throwable $exception): void 309 | { 310 | // TODO: Log error to archive 311 | } 312 | 313 | public function requestHeaderEnd(Request $request, Stream $stream): void 314 | { 315 | // nothing to do 316 | } 317 | 318 | public function requestBodyStart(Request $request, Stream $stream): void 319 | { 320 | // nothing to do 321 | } 322 | 323 | public function requestBodyProgress(Request $request, Stream $stream): void 324 | { 325 | // nothing to do 326 | } 327 | 328 | public function responseHeaderEnd(Request $request, Stream $stream, Response $response): void 329 | { 330 | // nothing to do 331 | } 332 | 333 | public function responseBodyStart(Request $request, Stream $stream, Response $response): void 334 | { 335 | // nothing to do 336 | } 337 | 338 | public function responseBodyProgress(Request $request, Stream $stream, Response $response): void 339 | { 340 | // nothing to do 341 | } 342 | 343 | public function responseBodyEnd(Request $request, Stream $stream, Response $response): void 344 | { 345 | // nothing to do 346 | } 347 | 348 | public function applicationInterceptorStart(Request $request, ApplicationInterceptor $interceptor): void 349 | { 350 | // nothing to do 351 | } 352 | 353 | public function applicationInterceptorEnd(Request $request, ApplicationInterceptor $interceptor, Response $response): void 354 | { 355 | // nothing to do 356 | } 357 | 358 | public function networkInterceptorStart(Request $request, NetworkInterceptor $interceptor): void 359 | { 360 | // nothing to do 361 | } 362 | 363 | public function networkInterceptorEnd(Request $request, NetworkInterceptor $interceptor, Response $response): void 364 | { 365 | // nothing to do 366 | } 367 | 368 | public function push(Request $request): void 369 | { 370 | // nothing to do 371 | } 372 | 373 | public function requestRejected(Request $request): void 374 | { 375 | // nothing to do 376 | } 377 | } 378 | -------------------------------------------------------------------------------- /src/Form.php: -------------------------------------------------------------------------------- 1 | boundary = $boundary ?? \bin2hex(\random_bytes(16)); 38 | } catch (\Exception $exception) { 39 | throw new HttpException('Failed to obtain random boundary', 0, $exception); 40 | } 41 | } 42 | 43 | public function addField(string $name, string $content, ?string $contentType = null): void 44 | { 45 | if ($this->used) { 46 | throw new \Error('Form body is already used and can no longer be modified'); 47 | } 48 | 49 | $this->fields[] = new FormField($name, BufferedContent::fromString($content, $contentType)); 50 | } 51 | 52 | /** 53 | * Adds each member of the array as an entry for the given key name. Array keys are persevered. 54 | * 55 | * @param array $fields 56 | */ 57 | public function addNestedFields(string $name, array $fields): void 58 | { 59 | foreach ($this->flattenArray($fields) as $key => $value) { 60 | $this->addField($name . $key, $value); 61 | } 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | private function flattenArray(array $fields): array 68 | { 69 | $result = []; 70 | foreach ($fields as $outerKey => $value) { 71 | $key = "[{$outerKey}]"; 72 | if (!\is_array($value)) { 73 | $result[$key] = (string) $value; 74 | continue; 75 | } 76 | 77 | foreach ($this->flattenArray($value) as $innerKey => $flattened) { 78 | $result[$key . $innerKey] = $flattened; 79 | } 80 | } 81 | 82 | return $result; 83 | } 84 | 85 | public function addStream(string $name, HttpContent $content, ?string $filename = null): void 86 | { 87 | if ($this->used) { 88 | throw new \Error('Form body is already used and can no longer be modified'); 89 | } 90 | 91 | $this->fields[] = new FormField($name, $content, $filename); 92 | $this->isMultipart = true; 93 | } 94 | 95 | /** 96 | * @param string $path Local file path. Filename will be provided to the server. 97 | * @throws HttpException 98 | */ 99 | public function addFile(string $name, string $path, ?string $contentType = null): void 100 | { 101 | $this->addStream($name, StreamedContent::fromFile($path, $contentType), \basename($path)); 102 | } 103 | 104 | public function getContent(): ReadableStream 105 | { 106 | $this->used = true; 107 | 108 | if ($this->content === null) { 109 | if ($this->isMultipart) { 110 | $this->content = $this->generateMultipartStream($this->getMultipartParts()); 111 | } else { 112 | $this->content = new ReadableBuffer($this->generateFormEncodedBody()); 113 | } 114 | } 115 | 116 | try { 117 | return $this->content; 118 | } finally { 119 | $this->content = null; 120 | } 121 | } 122 | 123 | public function getContentType(): string 124 | { 125 | return $this->isMultipart 126 | ? "multipart/form-data; boundary={$this->boundary}" 127 | : 'application/x-www-form-urlencoded'; 128 | } 129 | 130 | /** 131 | * @throws HttpException 132 | */ 133 | public function getContentLength(): ?int 134 | { 135 | if ($this->contentLength !== null) { 136 | return $this->contentLength; 137 | } 138 | 139 | if ($this->isMultipart) { 140 | $fields = $this->getMultipartParts(); 141 | $length = 0; 142 | 143 | foreach ($fields as $field) { 144 | if (\is_string($field)) { 145 | $length += \strlen($field); 146 | } else { 147 | $contentLength = $field->getContentLength(); 148 | if ($contentLength === null) { 149 | return null; 150 | } 151 | 152 | $length += $contentLength; 153 | } 154 | } 155 | 156 | return $this->contentLength = $length; 157 | } 158 | 159 | $body = $this->generateFormEncodedBody(); 160 | $this->content = new ReadableBuffer($body); 161 | 162 | return $this->contentLength = \strlen($body); 163 | } 164 | 165 | /** 166 | * @throws HttpException 167 | */ 168 | private function getMultipartParts(): array 169 | { 170 | try { 171 | $parts = []; 172 | 173 | foreach ($this->fields as $field) { 174 | $parts[] = "--{$this->boundary}\r\n" . Rfc7230::formatHeaderPairs($field->getHeaderPairs()) . "\r\n"; 175 | $parts[] = $field; 176 | $parts[] = "\r\n"; 177 | } 178 | 179 | $parts[] = "--{$this->boundary}--\r\n"; 180 | 181 | return $parts; 182 | } catch (InvalidHeaderException|HttpException $e) { 183 | throw new HttpException('Failed to build request body', 0, $e); 184 | } 185 | } 186 | 187 | /** 188 | * @throws HttpException 189 | */ 190 | private function generateFormEncodedBody(): string 191 | { 192 | $pairs = []; 193 | foreach ($this->fields as $field) { 194 | try { 195 | $pairs[] = [$field->getName(), buffer($field->getContent())]; 196 | } catch (BufferException|HttpException $e) { 197 | throw new HttpException('Failed to build request body', 0, $e); 198 | } 199 | } 200 | 201 | /** @psalm-suppress InvalidArgument */ 202 | return QueryString::build($pairs, '&', \PHP_QUERY_RFC1738) ?? ''; 203 | } 204 | 205 | /** 206 | * @param (FormField|string)[] $parts 207 | * @throws HttpException 208 | */ 209 | private function generateMultipartStream(array $parts): ReadableStream 210 | { 211 | $streams = []; 212 | foreach ($parts as $part) { 213 | if (\is_string($part)) { 214 | $streams[] = new ReadableBuffer($part); 215 | } else { 216 | $streams[] = $part->getContent(); 217 | } 218 | } 219 | 220 | return new ReadableStreamChain(...$streams); 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/HttpClient.php: -------------------------------------------------------------------------------- 1 | eventListeners, 33 | fn () => $this->httpClient->request($request, $cancellation ?? new NullCancellation()), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/HttpClientBuilder.php: -------------------------------------------------------------------------------- 1 | build(); 26 | } 27 | 28 | private ?ForbidUriUserInfo $forbidUriUserInfo; 29 | 30 | private ?RetryRequests $retryInterceptor; 31 | 32 | private ?FollowRedirects $followRedirectsInterceptor; 33 | 34 | private ?SetRequestHeaderIfUnset $defaultUserAgentInterceptor; 35 | 36 | private ?SetRequestHeaderIfUnset $defaultAcceptInterceptor; 37 | 38 | private ?DecompressResponse $defaultCompressionHandler; 39 | 40 | /** @var ApplicationInterceptor[] */ 41 | private array $applicationInterceptors = []; 42 | 43 | /** @var NetworkInterceptor[] */ 44 | private array $networkInterceptors = []; 45 | 46 | /** @var EventListener[] */ 47 | private array $eventListeners = []; 48 | 49 | private ConnectionPool $pool; 50 | 51 | public function __construct() 52 | { 53 | $this->pool = new UnlimitedConnectionPool; 54 | $this->forbidUriUserInfo = new ForbidUriUserInfo; 55 | $this->followRedirectsInterceptor = new FollowRedirects(10); 56 | $this->retryInterceptor = new RetryRequests(2); 57 | $this->defaultAcceptInterceptor = new SetRequestHeaderIfUnset('accept', '*/*'); 58 | $this->defaultUserAgentInterceptor = new SetRequestHeaderIfUnset('user-agent', 'amphp/http-client/5.x'); 59 | $this->defaultCompressionHandler = new DecompressResponse; 60 | } 61 | 62 | public function build(): HttpClient 63 | { 64 | $client = new PooledHttpClient($this->pool); 65 | 66 | foreach ($this->networkInterceptors as $interceptor) { 67 | $client = $client->intercept($interceptor); 68 | } 69 | 70 | if ($this->defaultAcceptInterceptor) { 71 | $client = $client->intercept($this->defaultAcceptInterceptor); 72 | } 73 | 74 | if ($this->defaultUserAgentInterceptor) { 75 | $client = $client->intercept($this->defaultUserAgentInterceptor); 76 | } 77 | 78 | if ($this->defaultCompressionHandler) { 79 | $client = $client->intercept($this->defaultCompressionHandler); 80 | } 81 | 82 | foreach ($this->eventListeners as $eventListener) { 83 | $client = $client->listen($eventListener); 84 | } 85 | 86 | $applicationInterceptors = $this->applicationInterceptors; 87 | 88 | if ($this->followRedirectsInterceptor) { 89 | \array_unshift($applicationInterceptors, $this->followRedirectsInterceptor); 90 | } 91 | 92 | if ($this->forbidUriUserInfo) { 93 | \array_unshift($applicationInterceptors, $this->forbidUriUserInfo); 94 | } 95 | 96 | if ($this->retryInterceptor) { 97 | $applicationInterceptors[] = $this->retryInterceptor; 98 | } 99 | 100 | foreach (\array_reverse($applicationInterceptors) as $applicationInterceptor) { 101 | $client = new InterceptedHttpClient($client, $applicationInterceptor, $this->eventListeners); 102 | } 103 | 104 | return new HttpClient($client, $this->eventListeners); 105 | } 106 | 107 | /** 108 | * @param ConnectionPool $pool Connection pool to use. 109 | */ 110 | public function usingPool(ConnectionPool $pool): self 111 | { 112 | $builder = clone $this; 113 | $builder->pool = $pool; 114 | 115 | return $builder; 116 | } 117 | 118 | /** 119 | * @param ApplicationInterceptor $interceptor This interceptor gets added to the interceptor queue, so interceptors 120 | * are executed in the order given to this method. 121 | */ 122 | public function intercept(ApplicationInterceptor $interceptor): self 123 | { 124 | if ($this->followRedirectsInterceptor !== null && $interceptor instanceof FollowRedirects) { 125 | throw new \Error('Disable automatic redirect following or use HttpClientBuilder::followRedirects() to customize redirects'); 126 | } 127 | 128 | if ($this->retryInterceptor !== null && $interceptor instanceof RetryRequests) { 129 | throw new \Error('Disable automatic retries or use HttpClientBuilder::retry() to customize retries'); 130 | } 131 | 132 | $builder = clone $this; 133 | $builder->applicationInterceptors[] = $interceptor; 134 | 135 | return $builder; 136 | } 137 | 138 | /** 139 | * @param NetworkInterceptor $interceptor This interceptor gets added to the interceptor queue, so interceptors 140 | * are executed in the order given to this method. 141 | */ 142 | public function interceptNetwork(NetworkInterceptor $interceptor): self 143 | { 144 | $builder = clone $this; 145 | $builder->networkInterceptors[] = $interceptor; 146 | 147 | return $builder; 148 | } 149 | 150 | /** 151 | * @param EventListener $eventListener This event listener gets added to the request automatically. 152 | */ 153 | public function listen(EventListener $eventListener): self 154 | { 155 | $builder = clone $this; 156 | $builder->eventListeners[] = $eventListener; 157 | 158 | return $builder; 159 | } 160 | 161 | /** 162 | * @param int $retryLimit Maximum number of times a request may be retried. Only certain requests will be retried 163 | * automatically (GET, HEAD, PUT, and DELETE requests are automatically retried, or any 164 | * request that was indicated as unprocessed by the connection). 165 | */ 166 | public function retry(int $retryLimit): self 167 | { 168 | $builder = clone $this; 169 | 170 | if ($retryLimit <= 0) { 171 | $builder->retryInterceptor = null; 172 | } else { 173 | $builder->retryInterceptor = new RetryRequests($retryLimit); 174 | } 175 | 176 | return $builder; 177 | } 178 | 179 | /** 180 | * @param int $limit Maximum number of redirects to follow. The client will automatically request the URI supplied 181 | * by a redirect response (3xx status codes) and returns that response instead. 182 | */ 183 | public function followRedirects(int $limit = 10): self 184 | { 185 | $builder = clone $this; 186 | 187 | if ($limit <= 0) { 188 | $builder->followRedirectsInterceptor = null; 189 | } else { 190 | $builder->followRedirectsInterceptor = new FollowRedirects($limit); 191 | } 192 | 193 | return $builder; 194 | } 195 | 196 | /** 197 | * Removes the default restriction of user:password in request URIs. 198 | */ 199 | public function allowDeprecatedUriUserInfo(): self 200 | { 201 | $builder = clone $this; 202 | $builder->forbidUriUserInfo = null; 203 | 204 | return $builder; 205 | } 206 | 207 | /** 208 | * Doesn't automatically set an 'accept' header. 209 | */ 210 | public function skipDefaultAcceptHeader(): self 211 | { 212 | $builder = clone $this; 213 | $builder->defaultAcceptInterceptor = null; 214 | 215 | return $builder; 216 | } 217 | 218 | /** 219 | * Doesn't automatically set a 'user-agent' header. 220 | */ 221 | public function skipDefaultUserAgent(): self 222 | { 223 | $builder = clone $this; 224 | $builder->defaultUserAgentInterceptor = null; 225 | 226 | return $builder; 227 | } 228 | 229 | /** 230 | * Doesn't automatically set an 'accept-encoding' header and decompress the response. 231 | */ 232 | public function skipAutomaticCompression(): self 233 | { 234 | $builder = clone $this; 235 | $builder->defaultCompressionHandler = null; 236 | 237 | return $builder; 238 | } 239 | 240 | final protected function __clone() 241 | { 242 | // clone is automatically denied to all external calls 243 | // final protected instead of private to also force denial for all children 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/HttpContent.php: -------------------------------------------------------------------------------- 1 | eventListeners, function () use ($request, $cancellation) { 29 | /** @psalm-suppress RedundantPropertyInitializationCheck */ 30 | self::$requestInterceptors ??= new \WeakMap(); 31 | 32 | $requestInterceptors = self::$requestInterceptors[$request] ?? []; 33 | $requestInterceptors[] = $this->interceptor; 34 | self::$requestInterceptors[$request] = $requestInterceptors; 35 | 36 | events()->applicationInterceptorStart($request, $this->interceptor); 37 | 38 | $response = $this->interceptor->request($request, $cancellation, $this->httpClient); 39 | 40 | events()->applicationInterceptorEnd($request, $this->interceptor, $response); 41 | 42 | return $response; 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Interceptor/AddRequestHeader.php: -------------------------------------------------------------------------------- 1 | addHeader($headerName, $headerValues); 16 | 17 | return $request; 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Interceptor/AddResponseHeader.php: -------------------------------------------------------------------------------- 1 | addHeader($headerName, $headerValues); 16 | 17 | return $response; 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Interceptor/DecompressResponse.php: -------------------------------------------------------------------------------- 1 | hasZlib = \extension_loaded('zlib'); 25 | } 26 | 27 | public function requestViaNetwork( 28 | Request $request, 29 | Cancellation $cancellation, 30 | Stream $stream 31 | ): Response { 32 | // If a header is manually set, we won't interfere 33 | if ($request->hasHeader('accept-encoding')) { 34 | return $stream->request($request, $cancellation); 35 | } 36 | 37 | $this->addAcceptEncodingHeader($request); 38 | 39 | $request->interceptPush(function (Request $request, Response $response): Response { 40 | return $this->decompressResponse($response); 41 | }); 42 | 43 | return $this->decompressResponse($stream->request($request, $cancellation)); 44 | } 45 | 46 | private function addAcceptEncodingHeader(Request $request): void 47 | { 48 | if ($this->hasZlib) { 49 | $request->setHeader('Accept-Encoding', 'gzip, deflate, identity'); 50 | } 51 | } 52 | 53 | private function decompressResponse(Response $response): Response 54 | { 55 | if (($encoding = $this->determineCompressionEncoding($response))) { 56 | $stream = new DecompressingReadableStream($response->getBody(), $encoding); 57 | 58 | $sizeLimit = $response->getRequest()->getBodySizeLimit(); 59 | if ($sizeLimit > 0) { 60 | $stream = new SizeLimitingReadableStream($stream, $sizeLimit); 61 | } 62 | 63 | $response->setBody($stream); 64 | $response->removeHeader('content-encoding'); 65 | } 66 | 67 | return $response; 68 | } 69 | 70 | private function determineCompressionEncoding(Response $response): int 71 | { 72 | if (!$this->hasZlib) { 73 | return 0; 74 | } 75 | 76 | if (!$response->hasHeader("content-encoding")) { 77 | return 0; 78 | } 79 | 80 | $contentEncoding = $response->getHeader("content-encoding"); 81 | 82 | \assert($contentEncoding !== null); 83 | 84 | $contentEncodingHeader = \trim($contentEncoding); 85 | 86 | if (\strcasecmp($contentEncodingHeader, 'gzip') === 0) { 87 | return \ZLIB_ENCODING_GZIP; 88 | } 89 | 90 | if (\strcasecmp($contentEncodingHeader, 'deflate') === 0) { 91 | return \ZLIB_ENCODING_DEFLATE; 92 | } 93 | 94 | return 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Interceptor/FollowRedirects.php: -------------------------------------------------------------------------------- 1 | getScheme() !== '' || $locationUri->getHost() !== '') { 34 | $resultUri = $locationUri->withPath(self::removeDotSegments($locationUri->getPath())); 35 | 36 | if ($locationUri->getScheme() === '') { 37 | $resultUri = $resultUri->withScheme($baseUri->getScheme()); 38 | } 39 | 40 | return $resultUri; 41 | } 42 | 43 | $baseUri = $baseUri->withQuery($locationUri->getQuery()); 44 | $baseUri = $baseUri->withFragment($locationUri->getFragment()); 45 | 46 | if ($locationUri->getPath() !== '' && \substr($locationUri->getPath(), 0, 1) === "/") { 47 | $baseUri = $baseUri->withPath(self::removeDotSegments($locationUri->getPath())); 48 | } else { 49 | $baseUri = $baseUri->withPath(self::mergePaths($baseUri->getPath(), $locationUri->getPath())); 50 | } 51 | 52 | return $baseUri; 53 | } 54 | 55 | /** 56 | * @link http://www.apps.ietf.org/rfc/rfc3986.html#sec-5.2.4 57 | */ 58 | private static function removeDotSegments(string $input): string 59 | { 60 | $output = ''; 61 | $patternA = ',^(\.\.?/),'; 62 | $patternB1 = ',^(/\./),'; 63 | $patternB2 = ',^(/\.)$,'; 64 | $patternC = ',^(/\.\./|/\.\.),'; 65 | // $patternD = ',^(\.\.?)$,'; 66 | $patternE = ',(/*[^/]*),'; 67 | 68 | while ($input !== '') { 69 | if (\preg_match($patternA, $input)) { 70 | $input = \preg_replace($patternA, '', $input); 71 | } elseif (\preg_match($patternB1, $input, $match) || \preg_match($patternB2, $input, $match)) { 72 | $input = \preg_replace(",^" . $match[1] . ",", '/', $input); 73 | } elseif (\preg_match($patternC, $input, $match)) { 74 | $input = \preg_replace(',^' . \preg_quote($match[1], ',') . ',', '/', $input); 75 | $output = \preg_replace(',/([^/]+)$,', '', $output); 76 | } elseif ($input === '.' || $input === '..') { // pattern D 77 | $input = ''; 78 | } elseif (\preg_match($patternE, $input, $match)) { 79 | $initialSegment = $match[1]; 80 | $input = \preg_replace(',^' . \preg_quote($initialSegment, ',') . ',', '', $input, 1); 81 | $output .= $initialSegment; 82 | } 83 | } 84 | 85 | return $output; 86 | } 87 | 88 | /** 89 | * @link http://tools.ietf.org/html/rfc3986#section-5.2.3 90 | */ 91 | private static function mergePaths(string $basePath, string $pathToMerge): string 92 | { 93 | if ($pathToMerge === '') { 94 | return self::removeDotSegments($basePath); 95 | } 96 | 97 | if ($basePath === '') { 98 | return self::removeDotSegments('/' . $pathToMerge); 99 | } 100 | 101 | $parts = \explode('/', $basePath); 102 | \array_pop($parts); 103 | $parts[] = $pathToMerge; 104 | 105 | return self::removeDotSegments(\implode('/', $parts)); 106 | } 107 | 108 | private readonly int $maxRedirects; 109 | 110 | private readonly bool $autoReferrer; 111 | 112 | public function __construct(int $limit, bool $autoReferrer = true) 113 | { 114 | if ($limit < 1) { 115 | throw new \Error("Invalid redirection limit: " . $limit); 116 | } 117 | 118 | $this->maxRedirects = $limit; 119 | $this->autoReferrer = $autoReferrer; 120 | } 121 | 122 | public function request( 123 | Request $request, 124 | Cancellation $cancellation, 125 | DelegateHttpClient $httpClient 126 | ): Response { 127 | // Don't follow redirects on pushes, just store the redirect in cache (if an interceptor is configured) 128 | 129 | $clonedRequest = $this->cloneRequest($request); 130 | 131 | $response = $httpClient->request($request, $cancellation); 132 | 133 | return $this->followRedirects($clonedRequest, $response, $httpClient, $cancellation); 134 | } 135 | 136 | private function followRedirects( 137 | Request $clonedRequest, 138 | Response $response, 139 | DelegateHttpClient $client, 140 | Cancellation $cancellation 141 | ): Response { 142 | $maxRedirects = $this->maxRedirects; 143 | $requestNr = 2; 144 | 145 | do { 146 | $request = $this->updateRequestForRedirect($clonedRequest, $response); 147 | if ($request === null) { 148 | return $response; 149 | } 150 | 151 | $clonedRequest = $this->cloneRequest($request); 152 | 153 | $redirectResponse = $client->request($request, $cancellation); 154 | $redirectResponse->setPreviousResponse($response); 155 | 156 | $response = $redirectResponse; 157 | } while (++$requestNr <= $maxRedirects + 1); 158 | 159 | if ($this->getRedirectUri($response) !== null) { 160 | throw new TooManyRedirectsException($response); 161 | } 162 | 163 | return $response; 164 | } 165 | 166 | private function cloneRequest(Request $originalRequest): Request 167 | { 168 | $request = clone $originalRequest; 169 | $request->setMethod('GET'); 170 | $request->removeHeader('transfer-encoding'); 171 | $request->removeHeader('content-length'); 172 | $request->removeHeader('content-type'); 173 | 174 | return $request; 175 | } 176 | 177 | private function updateRequestForRedirect(Request $request, Response $response): ?Request 178 | { 179 | $redirectUri = $this->getRedirectUri($response); 180 | if ($redirectUri === null) { 181 | return null; 182 | } 183 | 184 | $originalUri = $response->getRequest()->getUri(); 185 | $isSameHost = $redirectUri->getAuthority() === $originalUri->getAuthority(); 186 | 187 | $request->setUri($redirectUri); 188 | 189 | if (!$isSameHost) { 190 | // Avoid copying headers for security reasons, any interceptor headers will be added again, 191 | // but application headers will be discarded. 192 | $request->setHeaders([]); 193 | } 194 | 195 | if ($this->autoReferrer) { 196 | $this->assignRedirectRefererHeader($request, $originalUri, $redirectUri); 197 | } 198 | 199 | $this->discardResponseBody($response); 200 | 201 | return $request; 202 | } 203 | 204 | /** 205 | * Clients must not add a Referer header when leaving an unencrypted resource and redirecting to an encrypted 206 | * resource. 207 | * 208 | * @link http://www.w3.org/Protocols/rfc2616/rfc2616-sec15.html#sec15.1.3 209 | */ 210 | private function assignRedirectRefererHeader( 211 | Request $request, 212 | PsrUri $referrerUri, 213 | PsrUri $followUri 214 | ): void { 215 | $referrerIsEncrypted = $referrerUri->getScheme() === 'https'; 216 | $destinationIsEncrypted = $followUri->getScheme() === 'https'; 217 | 218 | if (!$referrerIsEncrypted || $destinationIsEncrypted) { 219 | $request->setHeader('Referer', (string) $referrerUri->withUserInfo('')->withFragment('')); 220 | } else { 221 | $request->removeHeader('Referer'); 222 | } 223 | } 224 | 225 | private function getRedirectUri(Response $response): ?PsrUri 226 | { 227 | if (\count($response->getHeaderArray('location')) !== 1) { 228 | return null; 229 | } 230 | 231 | $status = $response->getStatus(); 232 | $request = $response->getRequest(); 233 | $method = $request->getMethod(); 234 | 235 | if ($method !== 'GET' && \in_array($status, [307, 308], true)) { 236 | return null; 237 | } 238 | 239 | // We don't automatically follow: 240 | // - 300 (Multiple Choices) 241 | // - 304 (Not Modified) 242 | // - 305 (Use Proxy) 243 | if (!\in_array($status, [301, 302, 303, 307, 308], true)) { 244 | return null; 245 | } 246 | 247 | try { 248 | $header = $response->getHeader('location'); 249 | \assert($header !== null); // see check above 250 | 251 | $locationUri = Uri\Http::new($header); 252 | } catch (\Exception $e) { 253 | return null; 254 | } 255 | 256 | return self::resolve($request->getUri(), $locationUri); 257 | } 258 | 259 | private function discardResponseBody(Response $response): void 260 | { 261 | // Discard response body of redirect responses 262 | $body = $response->getBody(); 263 | 264 | try { 265 | /** @noinspection PhpStatementHasEmptyBodyInspection */ 266 | /** @noinspection LoopWhichDoesNotLoopInspection */ 267 | /** @noinspection MissingOrEmptyGroupStatementInspection */ 268 | while (null !== $body->read()) { 269 | // discard 270 | } 271 | } catch (HttpException|StreamException $e) { 272 | // ignore streaming errors on previous responses 273 | } finally { 274 | unset($body); 275 | } 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/Interceptor/ForbidUriUserInfo.php: -------------------------------------------------------------------------------- 1 | getUri()->getUserInfo() !== '') { 14 | throw new InvalidRequestException( 15 | $request, 16 | 'The user information (username:password) component of URIs has been deprecated ' 17 | . '(see https://tools.ietf.org/html/rfc3986#section-3.2.1 and https://tools.ietf.org/html/rfc7230#section-2.7.1); ' 18 | . 'Instead, set an "Authorization" header containing "Basic " . \\base64_encode("username:password"). ' 19 | . 'If you used HttpClientBuilder, you can use HttpClientBuilder::allowDeprecatedUriUserInfo() to disable this protection. ' 20 | . 'Doing so is strongly discouraged and you need to be aware of any interceptor using UriInterface::__toString(), which might expose the password in headers or logs.' 21 | ); 22 | } 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Interceptor/MatchOrigin.php: -------------------------------------------------------------------------------- 1 | $interceptor) { 33 | if (!$interceptor instanceof ApplicationInterceptor) { 34 | $type = \get_debug_type($interceptor); 35 | throw new HttpException('Origin map must be a map from origin to ApplicationInterceptor, got ' . $type); 36 | } 37 | 38 | $validatedMap[$this->checkOrigin($origin)] = $interceptor; 39 | } 40 | 41 | $this->originMap = $validatedMap; 42 | } 43 | 44 | public function request( 45 | Request $request, 46 | Cancellation $cancellation, 47 | DelegateHttpClient $httpClient 48 | ): Response { 49 | $interceptor = $this->originMap[$this->normalizeOrigin($request->getUri())] ?? $this->default; 50 | 51 | if (!$interceptor) { 52 | return $httpClient->request($request, $cancellation); 53 | } 54 | 55 | return $interceptor->request($request, $cancellation, $httpClient); 56 | } 57 | 58 | /** 59 | * @throws HttpException 60 | */ 61 | private function checkOrigin(string $origin): string 62 | { 63 | try { 64 | $originUri = Http::new($origin); 65 | } catch (\Exception) { 66 | throw new HttpException("Invalid origin provided: parsing failed: " . $origin); 67 | } 68 | 69 | if (!\in_array($originUri->getScheme(), ['http', 'https'], true)) { 70 | throw new HttpException('Invalid origin with unsupported scheme: ' . $origin); 71 | } 72 | 73 | if ($originUri->getHost() === '') { 74 | throw new HttpException('Invalid origin without host: ' . $origin); 75 | } 76 | 77 | if ($originUri->getUserInfo() !== '') { 78 | throw new HttpException('Invalid origin with user info, which must not be present: ' . $origin); 79 | } 80 | 81 | if (!\in_array($originUri->getPath(), ['', '/'], true)) { 82 | throw new HttpException('Invalid origin with path, which must not be present: ' . $origin); 83 | } 84 | 85 | if ($originUri->getQuery() !== '') { 86 | throw new HttpException('Invalid origin with query, which must not be present: ' . $origin); 87 | } 88 | 89 | if ($originUri->getFragment() !== '') { 90 | throw new HttpException('Invalid origin with fragment, which must not be present: ' . $origin); 91 | } 92 | 93 | return $this->normalizeOrigin($originUri); 94 | } 95 | 96 | private function normalizeOrigin(UriInterface $uri): string 97 | { 98 | $defaultPort = $uri->getScheme() === 'https' ? 443 : 80; 99 | 100 | return $uri->getScheme() . '://' . $uri->getHost() . ':' . ($uri->getPort() ?? $defaultPort); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Interceptor/ModifyRequest.php: -------------------------------------------------------------------------------- 1 | mapper)($request); 33 | 34 | \assert($mappedRequest instanceof Request || $mappedRequest === null); 35 | 36 | return $stream->request($mappedRequest ?? $request, $cancellation); 37 | } 38 | 39 | public function request( 40 | Request $request, 41 | Cancellation $cancellation, 42 | DelegateHttpClient $httpClient 43 | ): Response { 44 | $mappedRequest = ($this->mapper)($request); 45 | 46 | \assert($mappedRequest instanceof Request || $mappedRequest === null); 47 | 48 | return $httpClient->request($mappedRequest ?? $request, $cancellation); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Interceptor/ModifyResponse.php: -------------------------------------------------------------------------------- 1 | request($request, $cancellation); 33 | $mappedResponse = ($this->mapper)($response); 34 | 35 | \assert($mappedResponse instanceof Response || $mappedResponse === null); 36 | 37 | return $mappedResponse ?? $response; 38 | } 39 | 40 | public function request( 41 | Request $request, 42 | Cancellation $cancellation, 43 | DelegateHttpClient $httpClient 44 | ): Response { 45 | $request->interceptPush(fn (Request $request, Response $response) => ($this->mapper)($response)); 46 | 47 | $response = $httpClient->request($request, $cancellation); 48 | $mappedResponse = ($this->mapper)($response); 49 | 50 | \assert($mappedResponse instanceof Response || $mappedResponse === null); 51 | 52 | return $mappedResponse ?? $response; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Interceptor/RemoveRequestHeader.php: -------------------------------------------------------------------------------- 1 | removeHeader($headerName); 13 | 14 | return $request; 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Interceptor/RemoveResponseHeader.php: -------------------------------------------------------------------------------- 1 | removeHeader($headerName); 13 | 14 | return $response; 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Interceptor/ResolveBaseUri.php: -------------------------------------------------------------------------------- 1 | $request->setUri(Http::fromBaseUri($request->getUri(), $baseUri)) 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Interceptor/RetryRequests.php: -------------------------------------------------------------------------------- 1 | request($request, $cancellation); 35 | } catch (HttpException $exception) { 36 | if ($request->isIdempotent() || $request->isUnprocessed()) { 37 | // Request was deemed retryable by connection, so carry on. 38 | $request = $clonedRequest; 39 | continue; 40 | } 41 | 42 | throw $exception; 43 | } 44 | } while ($attempt++ <= $this->retryLimit); 45 | 46 | throw $exception; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Interceptor/SetRequestHeader.php: -------------------------------------------------------------------------------- 1 | setHeader($headerName, $headerValues); 18 | 19 | return $request; 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Interceptor/SetRequestHeaderIfUnset.php: -------------------------------------------------------------------------------- 1 | hasHeader($headerName)) { 18 | $request->setHeader($headerName, $headerValues); 19 | } 20 | 21 | return $request; 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Interceptor/SetRequestTimeout.php: -------------------------------------------------------------------------------- 1 | setTcpConnectTimeout($tcpConnectTimeout); 22 | $request->setTlsHandshakeTimeout($tlsHandshakeTimeout); 23 | $request->setTransferTimeout($transferTimeout); 24 | 25 | if (null !== $inactivityTimeout) { 26 | $request->setInactivityTimeout($inactivityTimeout); 27 | } 28 | 29 | return $request; 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Interceptor/SetResponseHeader.php: -------------------------------------------------------------------------------- 1 | setHeader($headerName, $headerValues); 18 | 19 | return $response; 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Interceptor/SetResponseHeaderIfUnset.php: -------------------------------------------------------------------------------- 1 | hasHeader($headerName)) { 18 | $response->setHeader($headerName, $headerValues); 19 | } 20 | 21 | return $response; 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Interceptor/TooManyRedirectsException.php: -------------------------------------------------------------------------------- 1 | response = $response; 17 | } 18 | 19 | public function getResponse(): Response 20 | { 21 | return $this->response; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Internal/EventInvoker.php: -------------------------------------------------------------------------------- 1 | requestPhase[$request] ?? Phase::Unprocessed; 27 | } 28 | 29 | public static function isRejected(Request $request): bool 30 | { 31 | return self::get()->requestRejected[$request] ?? false; 32 | } 33 | 34 | private \WeakMap $requestPhase; 35 | private \WeakMap $requestRejected; 36 | 37 | public function __construct() 38 | { 39 | $this->requestPhase = new \WeakMap(); 40 | $this->requestRejected = new \WeakMap(); 41 | } 42 | 43 | private function invoke(Request $request, \Closure $closure): void 44 | { 45 | foreach ($request->getEventListeners() as $eventListener) { 46 | $closure($eventListener, $request); 47 | } 48 | } 49 | 50 | public function requestStart(Request $request): void 51 | { 52 | if (self::isRejected($request)) { 53 | throw new \Error('Request has been rejected by the server. Use a new request for retries.'); 54 | } 55 | 56 | $previousPhase = self::getPhase($request); 57 | if ($previousPhase !== Phase::Unprocessed) { 58 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to Blocked'); 59 | } 60 | 61 | $this->requestPhase[$request] = Phase::Blocked; 62 | 63 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestStart($request)); 64 | } 65 | 66 | public function requestFailed(Request $request, \Throwable $exception): void 67 | { 68 | $previousPhase = self::getPhase($request); 69 | if (\in_array($previousPhase, [Phase::Complete, Phase::Failed], true)) { 70 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to Failed'); 71 | } 72 | 73 | $this->requestPhase[$request] = Phase::Failed; 74 | 75 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestFailed($request, $exception)); 76 | } 77 | 78 | public function requestEnd(Request $request, Response $response): void 79 | { 80 | $previousPhase = self::getPhase($request); 81 | if (!\in_array($previousPhase, [Phase::Blocked, Phase::ResponseHeaders, Phase::ResponseBody], true)) { 82 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to Complete'); 83 | } 84 | 85 | $this->requestPhase[$request] = Phase::Complete; 86 | 87 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestEnd($request, $response)); 88 | } 89 | 90 | public function requestRejected(Request $request): void 91 | { 92 | $this->requestRejected[$request] = true; 93 | 94 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestRejected($request)); 95 | } 96 | 97 | public function connectionAcquired(Request $request, Connection $connection, int $streamCount): void 98 | { 99 | $previousPhase = self::getPhase($request); 100 | if ($previousPhase !== Phase::Blocked) { 101 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to Connected'); 102 | } 103 | 104 | $this->requestPhase[$request] = Phase::Connected; 105 | 106 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->connectionAcquired($request, $connection, $streamCount)); 107 | } 108 | 109 | public function push(Request $request): void 110 | { 111 | $previousPhase = self::getPhase($request); 112 | if ($previousPhase !== Phase::Blocked) { 113 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to Push'); 114 | } 115 | 116 | $this->requestPhase[$request] = Phase::Push; 117 | 118 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->push($request)); 119 | } 120 | 121 | public function requestHeaderStart(Request $request, Stream $stream): void 122 | { 123 | if (self::isRejected($request)) { 124 | throw new \Error('Request has been rejected by the server. Use a new request for retries.'); 125 | } 126 | 127 | $previousPhase = self::getPhase($request); 128 | if ($previousPhase !== Phase::Connected && $previousPhase !== Phase::Push) { 129 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to RequestHeaders'); 130 | } 131 | 132 | $this->requestPhase[$request] = Phase::RequestHeaders; 133 | 134 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestHeaderStart($request, $stream)); 135 | } 136 | 137 | public function requestHeaderEnd(Request $request, Stream $stream): void 138 | { 139 | $previousPhase = self::getPhase($request); 140 | if ($previousPhase !== Phase::RequestHeaders) { 141 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 142 | } 143 | 144 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestHeaderEnd($request, $stream)); 145 | } 146 | 147 | public function requestBodyStart(Request $request, Stream $stream): void 148 | { 149 | $previousPhase = self::getPhase($request); 150 | if ($previousPhase !== Phase::RequestHeaders) { 151 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to RequestBody'); 152 | } 153 | 154 | $this->requestPhase[$request] = Phase::RequestBody; 155 | 156 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestBodyStart($request, $stream)); 157 | } 158 | 159 | public function requestBodyProgress(Request $request, Stream $stream): void 160 | { 161 | $previousPhase = self::getPhase($request); 162 | if (!\in_array($previousPhase, [ 163 | Phase::RequestBody, 164 | Phase::ServerProcessing, 165 | Phase::ResponseHeaders, 166 | Phase::ResponseBody, 167 | ], true)) { 168 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 169 | } 170 | 171 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestBodyProgress($request, $stream)); 172 | } 173 | 174 | public function requestBodyEnd(Request $request, Stream $stream): void 175 | { 176 | $previousPhase = self::getPhase($request); 177 | if (!\in_array($previousPhase, [ 178 | Phase::RequestBody, 179 | Phase::ServerProcessing, 180 | Phase::ResponseHeaders, 181 | Phase::ResponseBody, 182 | ], true)) { 183 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to ServerProcessing'); 184 | } 185 | 186 | if ($previousPhase === Phase::RequestBody) { 187 | $this->requestPhase[$request] = Phase::ServerProcessing; 188 | } 189 | 190 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->requestBodyEnd($request, $stream)); 191 | } 192 | 193 | public function responseHeaderStart(Request $request, Stream $stream): void 194 | { 195 | $previousPhase = self::getPhase($request); 196 | if (!\in_array($previousPhase, [ 197 | Phase::RequestHeaders, 198 | Phase::RequestBody, 199 | Phase::ServerProcessing, 200 | Phase::ResponseHeaders, 201 | ], true)) { 202 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to ResponseHeaders'); 203 | } 204 | 205 | $this->requestPhase[$request] = Phase::ResponseHeaders; 206 | 207 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->responseHeaderStart($request, $stream)); 208 | } 209 | 210 | public function responseHeaderEnd(Request $request, Stream $stream, Response $response): void 211 | { 212 | $previousPhase = self::getPhase($request); 213 | if ($previousPhase !== Phase::ResponseHeaders) { 214 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 215 | } 216 | 217 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->responseHeaderEnd($request, $stream, $response)); 218 | } 219 | 220 | public function responseBodyStart(Request $request, Stream $stream, Response $response): void 221 | { 222 | $previousPhase = self::getPhase($request); 223 | if ($previousPhase !== Phase::ResponseHeaders) { 224 | throw new \Error('Invalid request phase transition from ' . $previousPhase->name . ' to ResponseBody'); 225 | } 226 | 227 | $this->requestPhase[$request] = Phase::ResponseBody; 228 | 229 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->responseBodyStart($request, $stream, $response)); 230 | } 231 | 232 | public function responseBodyProgress(Request $request, Stream $stream, Response $response): void 233 | { 234 | $previousPhase = self::getPhase($request); 235 | if ($previousPhase !== Phase::ResponseBody) { 236 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 237 | } 238 | 239 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->responseBodyProgress($request, $stream, $response)); 240 | } 241 | 242 | public function responseBodyEnd(Request $request, Stream $stream, Response $response): void 243 | { 244 | $previousPhase = self::getPhase($request); 245 | if ($previousPhase !== Phase::ResponseBody) { 246 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 247 | } 248 | 249 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->responseBodyEnd($request, $stream, $response)); 250 | } 251 | 252 | public function applicationInterceptorStart(Request $request, ApplicationInterceptor $interceptor): void 253 | { 254 | $previousPhase = self::getPhase($request); 255 | if ($previousPhase === Phase::Unprocessed) { 256 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 257 | } 258 | 259 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->applicationInterceptorStart($request, $interceptor)); 260 | } 261 | 262 | public function applicationInterceptorEnd(Request $request, ApplicationInterceptor $interceptor, Response $response): void 263 | { 264 | $previousPhase = self::getPhase($request); 265 | if ($previousPhase === Phase::Unprocessed) { 266 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 267 | } 268 | 269 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->applicationInterceptorEnd($request, $interceptor, $response)); 270 | } 271 | 272 | public function networkInterceptorStart(Request $request, NetworkInterceptor $interceptor): void 273 | { 274 | $previousPhase = self::getPhase($request); 275 | if ($previousPhase === Phase::Unprocessed) { 276 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 277 | } 278 | 279 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->networkInterceptorStart($request, $interceptor)); 280 | } 281 | 282 | public function networkInterceptorEnd(Request $request, NetworkInterceptor $interceptor, Response $response): void 283 | { 284 | $previousPhase = self::getPhase($request); 285 | if ($previousPhase === Phase::Unprocessed) { 286 | throw new \Error('Invalid request phase: ' . $previousPhase->name); 287 | } 288 | 289 | $this->invoke($request, fn (EventListener $eventListener) => $eventListener->networkInterceptorEnd($request, $interceptor, $response)); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/Internal/FormField.php: -------------------------------------------------------------------------------- 1 | getContentType(); 19 | 20 | if ($this->filename === null) { 21 | $this->replaceHeaders([ 22 | 'Content-Disposition' => "form-data; name=\"{$name}\"", 23 | 'Content-Type' => $contentType === '' || $contentType === null ? [] : $contentType, 24 | ]); 25 | } else { 26 | $this->replaceHeaders([ 27 | 'Content-Disposition' => "form-data; name=\"{$name}\"; filename=\"{$filename}\"", 28 | 'Content-Type' => $contentType === '' || $contentType === null ? [] : $contentType, 29 | 'Content-Transfer-Encoding' => 'binary', 30 | ]); 31 | } 32 | } 33 | 34 | public function getName(): string 35 | { 36 | return $this->name; 37 | } 38 | 39 | /** 40 | * @throws HttpException 41 | */ 42 | public function getContent(): ReadableStream 43 | { 44 | return $this->content->getContent(); 45 | } 46 | 47 | /** 48 | * @throws HttpException 49 | */ 50 | public function getContentLength(): ?int 51 | { 52 | return $this->content->getContentLength(); 53 | } 54 | 55 | public function getContentType(): ?string 56 | { 57 | return $this->content->getContentType(); 58 | } 59 | 60 | public function getFilename(): ?string 61 | { 62 | return $this->filename; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Internal/HarAttributes.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class ResponseBodyStream implements ReadableStream, \IteratorAggregate 17 | { 18 | use ForbidSerialization; 19 | use ForbidCloning; 20 | use ReadableStreamIteratorAggregate; 21 | 22 | private bool $successfulEnd = false; 23 | 24 | public function __construct( 25 | private readonly ReadableStream $body, 26 | private readonly DeferredCancellation $bodyCancellation 27 | ) { 28 | } 29 | 30 | public function read(?Cancellation $cancellation = null): ?string 31 | { 32 | $chunk = $this->body->read($cancellation); 33 | 34 | if ($chunk === null) { 35 | $this->successfulEnd = true; 36 | } 37 | 38 | return $chunk; 39 | } 40 | 41 | public function isReadable(): bool 42 | { 43 | return $this->body->isReadable(); 44 | } 45 | 46 | public function __destruct() 47 | { 48 | if (!$this->successfulEnd) { 49 | $this->bodyCancellation->cancel(); 50 | } 51 | } 52 | 53 | public function close(): void 54 | { 55 | $this->body->close(); 56 | } 57 | 58 | public function isClosed(): bool 59 | { 60 | return $this->body->isClosed(); 61 | } 62 | 63 | public function onClose(\Closure $onClose): void 64 | { 65 | $this->body->onClose($onClose); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Internal/SizeLimitingReadableStream.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class SizeLimitingReadableStream implements ReadableStream, \IteratorAggregate 17 | { 18 | use ForbidSerialization; 19 | use ForbidCloning; 20 | use ReadableStreamIteratorAggregate; 21 | 22 | private int $bytesRead = 0; 23 | 24 | private ?\Throwable $exception = null; 25 | 26 | public function __construct( 27 | private readonly ReadableStream $source, 28 | private readonly int $sizeLimit 29 | ) { 30 | } 31 | 32 | public function read(?Cancellation $cancellation = null): ?string 33 | { 34 | if ($this->exception) { 35 | throw $this->exception; 36 | } 37 | 38 | $chunk = $this->source->read($cancellation); 39 | 40 | if ($chunk !== null) { 41 | $this->bytesRead += \strlen($chunk); 42 | if ($this->bytesRead > $this->sizeLimit) { 43 | $this->source->close(); 44 | 45 | throw $this->exception = new StreamException( 46 | "Configured body size exceeded: {$this->bytesRead} bytes received, while the configured limit is {$this->sizeLimit} bytes", 47 | ); 48 | } 49 | } 50 | 51 | return $chunk; 52 | } 53 | 54 | public function isReadable(): bool 55 | { 56 | return $this->source->isReadable(); 57 | } 58 | 59 | public function close(): void 60 | { 61 | $this->source->close(); 62 | } 63 | 64 | public function isClosed(): bool 65 | { 66 | return $this->source->isClosed(); 67 | } 68 | 69 | public function onClose(\Closure $onClose): void 70 | { 71 | $this->source->onClose($onClose); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Internal/functions.php: -------------------------------------------------------------------------------- 1 | getUri()->getPath(); 16 | $query = $request->getUri()->getQuery(); 17 | 18 | if ($path === '') { 19 | return '/' . ($query !== '' ? '?' . $query : ''); 20 | } 21 | 22 | if ($path[0] !== '/') { 23 | throw new InvalidRequestException( 24 | $request, 25 | 'Relative path (' . $path . ') is not allowed in the request URI' 26 | ); 27 | } 28 | 29 | return $path . ($query !== '' ? '?' . $query : ''); 30 | } 31 | -------------------------------------------------------------------------------- /src/InvalidRequestException.php: -------------------------------------------------------------------------------- 1 | request = $request; 14 | } 15 | 16 | public function getRequest(): Request 17 | { 18 | return $this->request; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/MissingAttributeError.php: -------------------------------------------------------------------------------- 1 | request(...)` returned. 17 | * 18 | * A NetworkInterceptor SHOULD NOT short-circuit and SHOULD delegate to the `$stream` passed as third argument 19 | * exactly once. The only exception to this is throwing an exception, e.g. because the TLS settings used are 20 | * unacceptable. If you need short circuits, use an {@see ApplicationInterceptor} instead. 21 | */ 22 | public function requestViaNetwork(Request $request, Cancellation $cancellation, Stream $stream): Response; 23 | } 24 | -------------------------------------------------------------------------------- /src/ParseException.php: -------------------------------------------------------------------------------- 1 | connectionPool = $connectionPool ?? new UnlimitedConnectionPool; 25 | } 26 | 27 | public function request(Request $request, Cancellation $cancellation): Response 28 | { 29 | return processRequest($request, $this->eventListeners, function () use ($request, $cancellation) { 30 | $stream = $this->connectionPool->getStream($request, $cancellation); 31 | 32 | foreach (\array_reverse($this->networkInterceptors) as $interceptor) { 33 | $stream = new InterceptedStream($stream, $interceptor); 34 | } 35 | 36 | return $stream->request($request, $cancellation); 37 | }); 38 | } 39 | 40 | /** 41 | * Adds a network interceptor. 42 | * 43 | * Network interceptors are only invoked if the request requires network access, i.e. there's no short-circuit by 44 | * an application interceptor, e.g. a cache. 45 | * 46 | * Whether the given network interceptor will be respected for currently running requests is undefined. 47 | * 48 | * Any new requests have to take the new interceptor into account. 49 | */ 50 | public function intercept(NetworkInterceptor $networkInterceptor): self 51 | { 52 | $clone = clone $this; 53 | $clone->networkInterceptors[] = $networkInterceptor; 54 | 55 | return $clone; 56 | } 57 | 58 | /** 59 | * Adds an event listener. 60 | * 61 | * Returns a new PooledHttpClient instance with the listener attached. 62 | */ 63 | public function listen(EventListener $eventListener): self 64 | { 65 | $clone = clone $this; 66 | $clone->eventListeners[] = $eventListener; 67 | 68 | return $clone; 69 | } 70 | 71 | final protected function __clone() 72 | { 73 | // clone is automatically denied to all external calls 74 | // final protected instead of private to also force denial for all children 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | */ 33 | private Future $trailers; 34 | 35 | private ?Response $previousResponse; 36 | 37 | /** 38 | * @param ProtocolVersion $protocolVersion 39 | * @param HeaderParamArrayType $headers 40 | * @param Future|null $trailers 41 | * 42 | * @throws \Amp\Http\InvalidHeaderException 43 | */ 44 | public function __construct( 45 | string $protocolVersion, 46 | int $status, 47 | ?string $reason, 48 | array $headers, 49 | ReadableStream|string|null $body, 50 | Request $request, 51 | ?Future $trailers = null, 52 | ?Response $previousResponse = null 53 | ) { 54 | parent::__construct($status, $reason); 55 | 56 | $this->setProtocolVersion($protocolVersion); 57 | $this->setHeaders($headers); 58 | $this->setBody($body); 59 | $this->request = $request; 60 | /** @noinspection PhpUnhandledExceptionInspection */ 61 | $this->trailers = $trailers ?? Future::complete(new Trailers([])); 62 | $this->previousResponse = $previousResponse; 63 | } 64 | 65 | /** 66 | * Retrieve the HTTP protocol version used for the request. 67 | * 68 | * @return ProtocolVersion 69 | */ 70 | public function getProtocolVersion(): string 71 | { 72 | return $this->protocolVersion; 73 | } 74 | 75 | /** 76 | * @param ProtocolVersion $protocolVersion 77 | */ 78 | public function setProtocolVersion(string $protocolVersion): void 79 | { 80 | if (!\in_array($protocolVersion, ["1.0", "1.1", "2"], true)) { 81 | throw new \Error( 82 | "Invalid HTTP protocol version: " . $protocolVersion 83 | ); 84 | } 85 | 86 | $this->protocolVersion = $protocolVersion; 87 | } 88 | 89 | public function setStatus(int $status, ?string $reason = null): void 90 | { 91 | parent::setStatus($status, $reason); 92 | } 93 | 94 | /** 95 | * Retrieve the Request instance that resulted in this Response instance. 96 | */ 97 | public function getRequest(): Request 98 | { 99 | return $this->request; 100 | } 101 | 102 | public function setRequest(Request $request): void 103 | { 104 | $this->request = $request; 105 | } 106 | 107 | /** 108 | * Retrieve the original Request instance associated with this Response instance. 109 | * 110 | * A given Response may be the result of one or more redirects. This method is a shortcut to 111 | * access information from the original Request that led to this response. 112 | */ 113 | public function getOriginalRequest(): Request 114 | { 115 | if (empty($this->previousResponse)) { 116 | return $this->request; 117 | } 118 | 119 | return $this->previousResponse->getOriginalRequest(); 120 | } 121 | 122 | /** 123 | * Retrieve the original Response instance associated with this Response instance. 124 | * 125 | * A given Response may be the result of one or more redirects. This method is a shortcut to 126 | * access information from the original Response that led to this response. 127 | */ 128 | public function getOriginalResponse(): Response 129 | { 130 | if (empty($this->previousResponse)) { 131 | return $this; 132 | } 133 | 134 | return $this->previousResponse->getOriginalResponse(); 135 | } 136 | 137 | /** 138 | * If this Response is the result of a redirect traverse up the redirect history. 139 | */ 140 | public function getPreviousResponse(): ?Response 141 | { 142 | return $this->previousResponse; 143 | } 144 | 145 | public function setPreviousResponse(?Response $previousResponse): void 146 | { 147 | $this->previousResponse = $previousResponse; 148 | } 149 | 150 | /** 151 | * Assign a value for the specified header field by replacing any existing values for that field. 152 | * 153 | * @param non-empty-string $name Header name. 154 | * @param HeaderParamValueType $value Header value. 155 | */ 156 | public function setHeader(string $name, array|string $value): void 157 | { 158 | if (($name[0] ?? ":") === ":") { 159 | throw new \Error("Header name cannot be empty or start with a colon (:)"); 160 | } 161 | 162 | parent::setHeader($name, $value); 163 | } 164 | 165 | /** 166 | * Assign a value for the specified header field by adding an additional header line. 167 | * 168 | * @param non-empty-string $name Header name. 169 | * @param HeaderParamValueType $value Header value. 170 | */ 171 | public function addHeader(string $name, array|string $value): void 172 | { 173 | if (($name[0] ?? ":") === ":") { 174 | throw new \Error("Header name cannot be empty or start with a colon (:)"); 175 | } 176 | 177 | parent::addHeader($name, $value); 178 | } 179 | 180 | public function setHeaders(array $headers): void 181 | { 182 | /** @noinspection PhpUnhandledExceptionInspection */ 183 | parent::setHeaders($headers); 184 | } 185 | 186 | public function replaceHeaders(array $headers): void 187 | { 188 | /** @noinspection PhpUnhandledExceptionInspection */ 189 | parent::replaceHeaders($headers); 190 | } 191 | 192 | /** 193 | * Remove the specified header field from the message. 194 | * 195 | * @param string $name Header name. 196 | */ 197 | public function removeHeader(string $name): void 198 | { 199 | parent::removeHeader($name); 200 | } 201 | 202 | /** 203 | * Retrieve the response body. 204 | */ 205 | public function getBody(): Payload 206 | { 207 | return $this->body; 208 | } 209 | 210 | public function setBody(ReadableStream|string|null $body): void 211 | { 212 | $this->body = match (true) { 213 | $body instanceof Payload => $body, 214 | $body instanceof ReadableStream, \is_string($body) => new Payload($body), 215 | $body === null => new Payload(''), 216 | }; 217 | } 218 | 219 | /** 220 | * @return Future 221 | */ 222 | public function getTrailers(): Future 223 | { 224 | return $this->trailers; 225 | } 226 | 227 | /** 228 | * @param Future $future 229 | */ 230 | public function setTrailers(Future $future): void 231 | { 232 | $this->trailers = $future; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/SocketException.php: -------------------------------------------------------------------------------- 1 | content = $content; 44 | } 45 | 46 | public function getContent(): ReadableStream 47 | { 48 | if ($this->content === null) { 49 | throw new HttpException('The content has already been streamed and can\'t be streamed again'); 50 | } 51 | 52 | try { 53 | return $this->content; 54 | } finally { 55 | $this->content = null; 56 | } 57 | } 58 | 59 | public function getContentLength(): ?int 60 | { 61 | return $this->contentLength; 62 | } 63 | 64 | public function getContentType(): ?string 65 | { 66 | return $this->contentType; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/TimeoutException.php: -------------------------------------------------------------------------------- 1 | true, 16 | "content-encoding" => true, 17 | "content-length" => true, 18 | "content-range" => true, 19 | "content-type" => true, 20 | "cookie" => true, 21 | "expect" => true, 22 | "host" => true, 23 | "pragma" => true, 24 | "proxy-authenticate" => true, 25 | "proxy-authorization" => true, 26 | "range" => true, 27 | "te" => true, 28 | "trailer" => true, 29 | "transfer-encoding" => true, 30 | "www-authenticate" => true, 31 | ]; 32 | 33 | /** 34 | * @param string[]|string[][] $headers 35 | * 36 | * @throws InvalidHeaderException Thrown if a disallowed field is in the header values. 37 | */ 38 | public function __construct(array $headers) 39 | { 40 | if (!empty($headers)) { 41 | $this->setHeaders($headers); 42 | } 43 | 44 | if (\array_intersect_key($this->getHeaders(), self::DISALLOWED_TRAILERS)) { 45 | throw new InvalidHeaderException('Disallowed field in trailers'); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | $eventListeners 15 | * @param \Closure(Request):Response $requestHandler 16 | */ 17 | function processRequest(Request $request, array $eventListeners, \Closure $requestHandler): Response 18 | { 19 | if (EventInvoker::getPhase($request) !== Phase::Unprocessed) { 20 | return $requestHandler($request); 21 | } 22 | 23 | foreach ($eventListeners as $eventListener) { 24 | $request->addEventListener($eventListener); 25 | } 26 | 27 | events()->requestStart($request); 28 | 29 | try { 30 | $response = $requestHandler($request); 31 | } catch (\Throwable $exception) { 32 | events()->requestFailed($request, $exception); 33 | 34 | throw $exception; 35 | } 36 | 37 | $trailers = $response->getTrailers(); 38 | 39 | $responseRef = \WeakReference::create($response); 40 | $trailers->map(function () use ($request, $responseRef): void { 41 | $response = $responseRef->get(); 42 | if ($response) { 43 | events()->requestEnd($request, $response); 44 | } 45 | })->ignore(); 46 | 47 | $trailers->catch(fn (\Throwable $exception) => events()->requestFailed($request, $exception))->ignore(); 48 | 49 | return $response; 50 | } 51 | --------------------------------------------------------------------------------