├── .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 | 
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 |
--------------------------------------------------------------------------------