This commit is contained in:
Javier Casares 2026-08-18 07:00:18 +00:00
commit 77d0cea926
995 changed files with 28142 additions and 8603 deletions

View file

@ -3,6 +3,462 @@
Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version.
## 8.0.2 - 2026-08-05
### Changed
- Adjusted `guzzlehttp/promises` version constraint to `^3.0.1`
### Fixed
- Fail a cURL multi handler wait with an attributable error when the transfer is no longer tracked
- Fix `StreamHandler` resolving numeric IPv4 hosts differently from cURL handlers on macOS and Windows
- Fix `StreamHandler` TLS peer names and proxy authorities for numeric IPv4 hosts on all platforms
- Settle a cURL multi handler transfer displaced by a request reusing its native handle ID
## 8.0.1 - 2026-07-26
### Security
- Reject request URI hosts and `Host` header values that are not printable ASCII (GHSA-v5mv-p594-2x33)
- Reject URI hosts with a percent escape or that are not valid RFC 3986 hosts (GHSA-v5mv-p594-2x33)
- Reject numeric-looking URI hosts with trailing dots, read as IPv4 addresses (GHSA-v5mv-p594-2x33)
- Treat percent-escaped cookie domains as exact-match-only (GHSA-f7vp-7xgx-4w4r)
- Regenerate a derived `Host` header after client URI rewrites (GHSA-v5mv-p594-2x33)
## 8.0.0 - 2026-07-20
### Added
- Add `SensitiveParameter` metadata to credential-bearing parameters so PHP 8.2+ redacts their values in exception traces
- Add HTTP/3 request support to the built-in cURL handlers when PHP 8.4+ and libcurl provide HTTP/3 support
- Add `Multiplexing::NONE` support as a client, cURL multi handler, and conditional request option
- Add generic and structured PHPDoc annotations to client request/config option, async promise, handler, middleware, pool, and mock handler APIs
- Add `ConnectTimeoutException` for connect-phase timeouts, extending `ConnectException`
- Add `NetworkException` for no-response network failures
- Add `NetworkTimeoutException` for no-response transport timeouts
- Add `ResponseTransferException`, with `ResponseTimeoutException` for response-transfer timeouts
- Add PSR-17 `request_factory`, `response_factory`, `stream_factory`, and `uri_factory` request options
- Add explicit `close()` lifecycle methods to the built-in cURL handlers and concrete cURL factory
- Add `HandlerClosedException` for pending transfers rejected by `CurlMultiHandler::close()`
- Add persistent transport sharing modes (`TransportSharing::PERSISTENT_PREFER` and `TransportSharing::PERSISTENT_REQUIRE`)
- Add `ProxyOptions` for proxy option resolution
- Add `ResponseException` for request failures with responses
- Add auth middleware for built-in Basic and Digest authentication
### Changed
- Canonicalize IPv6 hosts in Digest challenge cache keys
- Canonicalize IPv6 hosts in cookie domains, host-only identities, and domain matching
- Restrict cookie domain suffix matching to valid non-literal, nonnumeric host names
- Reject Secure cookies and insecure overlays received over insecure connections
- Enforce the `__Secure-` and `__Host-` prefix requirements on response cookies
- Hardened `FileCookieJar` and `SessionCookieJar` persistence against unsafe unserialization
- Reject native PHP serialization of runtime objects
- Restrict persisted `FileCookieJar` cookie files to owner-only permissions
- Require persisted cookie data to use JSON lists and construct all records before changing the jar
- Normalize persistent cookie JSON failures as `RuntimeException`
- Moved the internal `Utils` time, timeout, IDN, and environment helpers to dedicated internal classes
- Adjusted `guzzlehttp/promises` version constraint to `^3.0`
- Adjusted `guzzlehttp/psr7` version constraint to `^3.0`
- Quote multipart `Content-Type` boundary parameters when required
- Added parameter and return types to `SetCookie` methods
- Added native property types to supported public cURL handler state properties
- Added a `string` return type to `SetCookie::__toString()`
- Validate proxy and no-proxy option types strictly across handlers
- Match leading-dot no-proxy entries against the bare domain and split string no-proxy lists on whitespace
- Validate `force_ip_resolve`, protocols, and `delay` ranges at the client boundary
- Treat a matching proxy `no` entry as final even without a scheme-specific proxy entry
- Validate proxy URLs in the built-in handlers and reject malformed or unsupported ones up front
- Default a port-less proxy to 1080 in the stream handler, matching libcurl
- Downgrade HTTP/3 requests to HTTP/2 or HTTP/1.1 when the proxy is resolved from environment variables
- Throw `RequestException`, not `InvalidArgumentException`, for an unavailable proxy or TLS feature
- Resolve proxy environment variables in the stream handler, consistent with the cURL handlers
- Honor `no_proxy`/`NO_PROXY` from the environment in the stream handler, including `*` to disable proxying
- Reject an environment-resolved `https://` or SOCKS proxy in the stream handler, matching its `proxy` option behavior
- Require cURL proxy header separation for first-class `Proxy-Authorization` on every route
- Reject every first-class `Proxy-Authorization` field, including empty, on stream proxies
- Reject raw `CURLOPT_PROXYHEADER` without proxy header separation support
- Pass the request as the second argument to `on_headers` callbacks
- Pass the `Pool` iterable key as a trailing argument to per-request observer callbacks
- Declare strict types across remaining source files
- Reject request option values that do not match their documented types
- Reject invalid `idn_conversion`, `retries`, and built-in handler `on_stats` option values before use
- Reject non-finite floats in the `query` and `form_params` options
- Reject non-string scalar values in the `body` option
- Apply automatic `Expect: 100-Continue` injection to HTTP/1.1 requests only
- Reject invalid `SetCookie` constructor field types instead of coercing them
- Validate and normalize request framing across the built-in cURL and stream handlers
- Reject raw cURL request options outside the built-in cURL handlers' allow-list
- Reject non-string raw cURL header-list entries before applying them
- Reject proxy tunnels that require fresh connections when persistent transport sharing requires reuse
- Reject PHP stream context options outside the built-in stream handler allow-list
- Reject selected request options ignored by incompatible built-in handlers
- Treat only `null` as an omitted path or name when clearing cookies
- Validate malformed `auth` request option arrays
- Reject colons in built-in Basic usernames and ASCII control characters in Basic credentials
- Move built-in Basic and Digest authentication handling to the default auth middleware
- Reject unchallenged Digest probes for body-bearing requests instead of replaying the request unauthenticated
- Reject malformed Digest challenge parameter lists that libcurl's Digest parser may have tolerated
- Reuse Digest challenges to authorize subsequent body-less requests preemptively
- Advance the Digest nonce count when a stale challenge repeats the same nonce during the initial handshake
- Remove first-class NTLM authentication from the `auth` request option
- Stop forwarding the generic `auth` request option when following cross-origin redirects
- Limit the `Referer` header to the origin on cross-origin redirects
- Follow only redirect status codes 301, 302, 303, 307, and 308
- Reject invalid `HandlerStack::remove()` arguments
- Require `Pool` request collections to be iterable
- Raised the built-in cURL handler floor to libcurl 7.34.0 with SSL support
- Store response cookies without a `Domain` attribute as host-only cookies
- Prefer cookie `Max-Age` over `Expires` when both attributes are present
- Match cookie names case-sensitively in `CookieJar::getCookieByName()`
- Ignore float-like or exponent `Max-Age` cookie values instead of truncating them
- Tighten invalid response handling and avoid exposing response-derived cURL stats
- Reject malformed response protocol versions and reason phrases
- Escape controls and malformed UTF-8 when copying raw values into exception messages
- Reject malformed or conflicting response `Content-Length` and combinations with `Transfer-Encoding`
- Expose raw stream-handler `Transfer-Encoding` metadata and coalesced framing in `progress` on newer PHP
- Wrap malformed redirect `Location` values in `BadResponseException`
- Default HTTPS requests sent by the built-in cURL and stream handlers to TLS 1.2 or newer
- Apply the stream handler `crypto_method` option through the SSL context so it consistently controls the minimum TLS version
- Validate built-in handler timeout options before applying them
- Require a request when constructing `TransferException` and its subclasses
- Classify empty, malformed, or handler-unsupported request protocol versions as request exceptions
- Classify additional cURL transport failures without a response as `NetworkException`
- Classify stream connect failures as `ConnectException`, with connect timeouts as `ConnectTimeoutException`
- Classify stream transport failures without a response as `NetworkException`, with timeouts as `NetworkTimeoutException`
- Classify generic response-aware request failures as `ResponseException`
- Classify response-aware transfer failures as `ResponseTransferException`
- The stream handler returns an empty body and releases the connection at the end of the headers for HEAD and CONNECT-2xx exchanges and 1xx, 204, and 304 responses
- The stream handler no longer writes to the `sink` option or reads trailing bytes for responses that cannot carry a body
- Reject short buffered stream-handler bodies against `Content-Length`, including decoded gzip/deflate
- Normalize duplicate `Content-Length` casings and preserve encoded values on decoded responses
- Reject unrepresentable byte counts and response sizes requiring integer bounds as `ResponseException`
- Ignore cURL informational responses other than `101 Switching Protocols` before the final response
- Treat response sink rewind failures as `ResponseException` and skip non-seekable sink rewinds
- Classify redirect request-body rewind failures as `ResponseException`
- Ignore stream source close failures after a complete response body transfer
- Throw `GuzzleHttp\Exception\InvalidArgumentException` for invalid built-in handler options
- Classify built-in cURL handle, `sink`, and HTTP/3 setup failures as `RequestException`
- Throw `ConnectTimeoutException` for connect timeouts
- Throw `NetworkTimeoutException` for cURL no-response timeout errors
- Throw `ResponseTimeoutException` for response-aware transfer timeouts
- Enforce the `timeout` option as a total transfer deadline in the stream handler when it buffers the response
- Reject stream handler responses whose header block arrives after the `timeout` deadline
- Stop consulting the `default_socket_timeout` ini setting in the stream handler
- Treat stream handler `read_timeout` as an idle timeout for every request stage, defaulting to 60 seconds
- Default the cURL connect timeout to 60 seconds, with `connect_timeout` set to `0` disabling it
- Stop the stream handler from injecting `User-Agent` and `From` header values from the `user_agent` and `from` ini settings
- Classify request-body stream size detection, read, stringification, and rewind failures as `RequestException` or `ResponseException` by phase
- Classify cURL response sink write failures, including timeouts, as `ResponseException` or `RequestException` by phase
- Treat request method names case-sensitively in built-in handler and redirect method-specific behavior
- Treat PHP resources passed as `sink` as caller-owned in the built-in cURL and stream handlers
- Use the configured PSR-17 URI factory when parsing redirect `Location` headers
- Allow built-in cURL handler `progress` callbacks to abort transfers with truthy return values
- Normalize built-in handler `progress` callback arguments to integer byte counts
- Reject built-in cURL `progress` throwables with `ResponseException` when a response exists, otherwise `RequestException`
- Release built-in cURL easy handles before invoking `on_stats`
- Prefer `CURLOPT_XFERINFOFUNCTION` for built-in cURL progress callbacks when available
- Made `MessageFormatter` final and required `Middleware::log()` formatters to implement `MessageFormatterInterface`
- Made `CurlFactory`, `CurlHandler`, `CurlMultiHandler`, `MockHandler`, and `StreamHandler` final
- Made static utility classes non-instantiable and declared `GuzzleHttp\Handler\Proxy` final
- Pass the request to `on_trailers` callbacks, reject non-callable `on_trailers` values, and wrap `on_trailers` callback exceptions in `ResponseException`
- Wait for in-progress HTTP/2-capable connections by default (`multiplex` defaults to `Multiplexing::WAIT`)
- Require libcurl 7.65.2 or newer for HTTP/2 requests so multiplex waiting is never silently unavailable
- Require libcurl 7.54.0 for HTTPS proxies and requests tunneled through HTTP proxies
- Suppress proxy CONNECT response headers for tunneled requests
- Point rejections of the raw `CURLOPT_PIPEWAIT` cURL option at the `multiplex` request option
- Reject raw `CURLMOPT_PIPELINING` in favour of the `multiplex` cURL multi handler option
- Reject required multiplexing when the final `CURLOPT_HTTPAUTH` mask permits NTLM
- Reject cURL multi options that the runtime libcurl cannot apply
- Reject unknown handler constructor options
- Reject invalid `select_timeout` cURL multi handler option values
- Reject raw cURL multi connection cap options in favour of the named options
- Parse `Set-Cookie` strings with RFC 6265 whitespace trimming
- Ignore valueless `Set-Cookie` attributes that require a value when parsing
- Trim only the trailing CRLF from the stream handler header block
- Fail streamed uploads immediately when the body cannot be resent for an auth challenge
### Removed
- Dropped support for PHP 7.2 and 7.3
- Removed `Client::__call()`; use the typed HTTP verb methods or `request()`/`requestAsync()`
- Removed `ClientInterface::getConfig()`; the concrete `Client::getConfig()` remains available
- Removed support for the `GUZZLE_CURL_SELECT_TIMEOUT` environment variable; use `CurlMultiHandler`'s `select_timeout` option
- Removed support for the `handler` request option; configure the handler on the client
- Removed direct access to `CurlMultiHandler::$_mh`; pass `CURLMOPT_*` values through constructor `options` instead
- Removed `RedirectMiddleware::$defaultSettings`; use `RedirectMiddleware::DEFAULT_SETTINGS`
- Removed the deprecated `RetryMiddleware::exponentialDelay()` method
- Removed the deprecated `RequestException::wrapException()` method
- Removed the deprecated `Utils::describeType()` method
- Removed `Utils::jsonDecode()` and `Utils::jsonEncode()` in favor of native JSON functions
- Removed deprecated `GuzzleHttp` namespace functions in favor of native or class equivalents
- Removed `Utils::defaultCaBundle()`; rely on the system trust store or pass a bundle path via the `verify` option
- Removed `HandlerStack::__toString()`
- Removed `RequestException::getHandlerContext()` and `ConnectException::getHandlerContext()`
- Removed response access from `RequestException`; use `ResponseException`
- Removed `Utils::isHostInNoProxy()`; use `ProxyOptions` helpers for Guzzle 8 no-proxy matching
- Removed `Utils::isUriInNoProxy()`; use `ProxyOptions::isUriInNoProxy()`
- Removed `Handler\Proxy::wrapTlsFallback()`; the default handler stack selects the cURL or stream handler by TLS support automatically
## 7.15.1 - 2026-07-18
### Security
- Preserve host-only cookie scope and require explicit persistence markers (GHSA-wm3w-8rrp-j577)
- Bound response cookie admission and generated `Cookie` headers (GHSA-f283-ghqc-fg79)
- Exclude URI fragments from `Referer` headers generated for redirects (GHSA-h95v-h523-3mw8)
## 7.15.0 - 2026-07-17
### Added
- Added `Multiplexing::NONE` support as a client, cURL multi handler, and conditional request option
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.13`
- Use locale-independent ASCII folding for all case normalization and comparison
- Bound cURL upload reads to the declared `Content-Length`
- Sanitize the cURL error text exposed through exception handler context
- Fail closed when a named cURL multi connection cap cannot be applied
- Reject the request-level `CURLOPT_SHARE` cURL option when named connection caps are configured
- Strengthen old-libcurl SOCKS isolation for raw `CURLOPT_PRE_PROXY` and opaque share handles
- Isolate HTTP proxy tunnels from opaque shared connection caches
- Trigger runtime deprecations for previously deprecated functionality in 7.1.0
### Deprecated
- Deprecated `Utils::jsonDecode()` and `Utils::jsonEncode()` in favor of native JSON functions
- Deprecated passing `CURLMOPT_PIPELINING` in the cURL multi handler `options` array
- Deprecated passing `CURLOPT_PROXYHEADER` without cURL proxy header separation support
### Fixed
- Defer cURL requests created from multi callbacks until native execution unwinds
- Fail synchronous waits from native cURL callbacks promptly instead of self-deadlocking
- Guard cURL multi handle removal against progress callbacks re-entering the handler
- Scope promise waits on the cURL multi handler to the awaited transfer
- Strip `Content-Length` and `Transfer-Encoding` when redirects discard the request body
- Stop re-applying the `delay` request option to followed redirects
## 7.14.2 - 2026-07-14
### Security
- Prevent first-class and proxy URL credentials from reaching origins (GHSA-94pj-82f3-465w)
## 7.14.1 - 2026-07-13
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.5`
### Fixed
- Fail closed when a proxy tunnel isolation cURL option cannot be applied
- Normalize Stringable proxy credential values before computing connection-reuse section signatures
- Restore conservative credential redaction for unparseable proxies with multiple `@` separators
- Redact request URI credentials from the stream handler connection error message
- Reject enabled response streaming (`stream => true`) on cap-configured stream handlers
- Distinguish CurlMultiHandler and StreamHandler outcomes in connection-cap custom-handler guidance
- Reject raw cURL options that conflict with explicit multiplexing guarantees
- Stop explicit multiplexing conflict checks faulting on non-array cURL multi `options` values
- Reject required multiplexing when the final `CURLOPT_HTTPAUTH` mask permits NTLM
- Require an integer `CURLMOPT_PIPELINING` when combined with explicit multiplexing
- Check the required multiplexing cleartext proxy rule against the final cURL configuration
- Bound cURL multi handler blocking selects by the earliest pending request delay
- Stop synchronous cURL multi handler waits blocking on other transfers once the target has settled
- Stop cURL multi completion processing double-settling promises canceled from completion callbacks
- Run ready promise queue tasks before sleeping for delayed cURL multi requests
- Avoid integer overflow in cURL multi delay timing on 32-bit platforms
- Roll back failed cURL multi handle attachment instead of leaving requests pending
- Release the cURL easy handle when the `on_stats` callback throws
- Normalize response trailer field names to lowercase with values in wire order
- Retain response trailers only when an `on_trailers` callback is configured
- Validate the `on_trailers` callback before starting a cURL transfer
- Reject the `on_trailers` request option on the stream handler, which cannot observe trailers
- Match cookies, proxy schemes, auth types, and header names with locale-independent ASCII folding
- Reject proxy option values that Guzzle cannot classify identically to ext-curl
## 7.14.0 - 2026-07-08
### Added
- Added the `on_trailers` request option to expose parsed HTTP response trailers
- Added the `multiplex` request option with `Multiplexing::*` modes to control or require HTTP/2 multiplexing
- Added rejection of explicit `multiplex` requests when `CURLMOPT_PIPELINING` disables multiplexing
- Added the `max_host_connections` and `max_total_connections` client and cURL multi handler options
### Changed
- Redirects that discard the request body no longer require it to be rewindable
- Synchronous cURL multi handler requests no longer wait for other queued transfers
- Section SOCKS proxy connections by credentials on libcurl before 7.69.0
- Reject request-level `CURLOPT_SHARE` when combined with authenticated SOCKS proxy configuration
- Redact proxy userinfo containing raw control bytes in cURL errors
- Check linked curl/libcurl NTLM support before applying NTLM auth
- Clarify that NTLM is deprecated by both Guzzle and curl/libcurl
- Remove deprecation for the raw cURL `CURLOPT_CERTINFO` option
- Warn when a cURL multi option cannot be applied
### Deprecated
- Deprecate the raw `CURLOPT_PIPEWAIT` cURL option in favour of the `multiplex` request option
- Deprecate unknown handler constructor options
- Deprecate invalid `select_timeout` cURL multi handler option values
- Deprecate raw cURL multi connection cap options in favour of the named options
## 7.13.3 - 2026-07-08
### Changed
- Adjusted `guzzlehttp/promises` version constraint to `^2.5.1`
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.4`
- Pass explicit trim characters ahead of the PHP 8.6 trim default change
### Fixed
- Stop matching cookie domains against hosts with a trailing newline
- Reject HTTP status codes and certificate type extensions with a trailing newline
- Treat PCRE engine failures as invalid cookie names during cookie validation
- Report PCRE engine failures when formatting log messages
- Report PCRE engine failures when splitting `no_proxy` values
## 7.13.2 - 2026-07-05
### Fixed
- Stop the cURL multi handler busy-waiting on request delays shorter than one second
- Stop cURL HEAD requests with request bodies hanging on responses that declare a content length
- The cURL handler no longer transmits request bodies on HEAD requests
- Preserve response headers when a response includes HTTP trailers
- Harden cURL response header block detection when HTTP trailers are received
- Corrected the PSR-7 class names in the Pool iterator exception
- Redirect body rewind failures no longer leak a bare `RuntimeException`
## 7.13.1 - 2026-06-29
### Fixed
- Allow middleware to rewrite partial URIs before transports validate them
## 7.13.0 - 2026-06-29
### Added
- Added the `crypto_method_max` request option to cap the maximum TLS protocol version
- Added HTTP QUERY redirect support, preserving method and body on 301 and 302
### Changed
- Section proxy tunnel connection reuse by credential so distinct credentials never share a tunnel
- Isolate concurrent foreign cURL proxy tunnels added while another owner's tunnel is active
- Route credentialed HTTP(S) proxy Proxy-Authorization headers through cURL proxy header handling
- Reject request-level `CURLOPT_SHARE` when combined with authenticated HTTP/HTTPS proxy tunnel configuration
- Remove deprecation for raw cURL `CURLOPT_PREREQFUNCTION` callbacks when defined by PHP cURL
- Route TLS 1.2 `crypto_method` requests to the stream handler when cURL cannot select TLS 1.2
- Reject final request URIs missing a scheme or host before transfer
### Deprecated
- Deprecate invalid protocols, force_ip_resolve, delay, cookies, and allow_redirects values
## 7.12.3 - 2026-06-23
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.3`
### Security
- Treat IP and numeric cookie domains as exact-match-only (GHSA-g446-98w2-8p5w)
## 7.12.2 - 2026-06-23
### Fixed
- Clamp out-of-range `Max-Age` so a very large value no longer overflows to an already-expired timestamp
- Use strict comparison in `CookieJar` conflict resolution so distinct numeric-string names don't overwrite
- Store a cookie whose `Domain` has a trailing dot on the origin host instead of silently discarding it
- Fix `StreamHandler` hard-failing on bracketed IPv6 literal hosts when `force_ip_resolve` is set
- Use strict cookie `Path` comparison so `CookieJar::clear()` with a numeric path keeps a distinct-path cookie
- Fixed cookie handling for falsey `Domain`, `Max-Age`, path, and name values
- Fixed `decode_content` handling for falsey string values
- Fixed deprecated request option values reaching built-in handlers before normalization
## 7.12.1 - 2026-06-18
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12.1`
### Fixed
- Reject proxy URLs with a malformed scheme in the cURL handlers instead of letting libcurl mishandle them
### Security
- Reject HTTPS proxies when the installed libcurl lacks HTTPS-proxy support (GHSA-wpwq-4j6v-78m3)
- Reject dot-only cookie `Domain` attributes as match-all (GHSA-cwxw-98qj-8qjx)
## 7.12.0 - 2026-06-16
### Added
- Added `RequestOptions` constants for `curl`, `retries`, and `stream_context`
### Changed
- Adjusted `guzzlehttp/psr7` version constraint to `^2.12`
- Constrain cURL transport sharing to safe libcurl DNS and SSL session support
- Resolve proxy environment variables in the cURL handlers; libcurl no longer reads the environment itself
- Ignore proxy environment variables when the `proxy` request option makes a decision
- Disable proxy environment variables on Windows SAPIs other than CLI (httpoxy hardening)
- Redact proxy credentials from cURL handler error messages, following `Psr7\Utils::redactUserInfo()`
- Normalize no-proxy domain and IP literal matching across the cURL and stream handlers
### Deprecated
- Deprecated the request-level `handler` option, which will be ignored in 8.0
- Deprecated raw cURL request options outside the built-in cURL handlers' allow-list
- Deprecated the `CURLOPT_PROXYTYPE` cURL request option; set the proxy type via a scheme-prefixed proxy URL
- Deprecated PHP stream context options outside the built-in stream handler allow-list
- Deprecated passing `ntlm` as a built-in `auth` type
- Deprecated `Utils::describeType()`
- Deprecated non-finite floats in the `query` and `form_params` options; 8.0 rejects them
- Deprecated non-string scalar values in the `body` option; 8.0 rejects them
### Fixed
- Fix cURL TLS and HTTP/2 capability detection using libcurl feature checks
- Fix proxy `no` list matches being re-proxied through environment-configured proxies by libcurl
- Fix `no` list and `NO_PROXY` matching to support IP CIDR ranges, matching libcurl
- Fix the stream handler not applying scheme-less proxies and their credentials
## 7.11.2 - 2026-06-12
### Fixed
- Fixed non-finite float values emitting coercion warnings on PHP 8.5
## 7.11.1 - 2026-06-07
### Fixed

View file

@ -1,10 +1,6 @@
![Guzzle](.github/logo.png?raw=true)
# Guzzle, PHP HTTP client
[![Latest Version](https://img.shields.io/github/release/guzzle/guzzle.svg?style=flat-square)](https://github.com/guzzle/guzzle/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/guzzle/guzzle/ci.yml?label=ci%20build&style=flat-square)](https://github.com/guzzle/guzzle/actions?query=workflow%3ACI)
[![Total Downloads](https://img.shields.io/packagist/dt/guzzlehttp/guzzle.svg?style=flat-square)](https://packagist.org/packages/guzzlehttp/guzzle)
# Guzzle, PHP HTTP Client
Guzzle is a PHP HTTP client that makes it easy to send HTTP requests and
trivial to integrate with web services.
@ -15,40 +11,13 @@ trivial to integrate with web services.
- Can send both synchronous and asynchronous requests using the same interface.
- Uses PSR-7 interfaces for requests, responses, and streams. This allows you
to utilize other PSR-7 compatible libraries with Guzzle.
- Supports PSR-18 allowing interoperability between other PSR-18 HTTP Clients.
- Supports PSR-18, allowing interoperability with other PSR-18 HTTP clients.
- Abstracts away the underlying HTTP transport, allowing you to write
environment and transport agnostic code; i.e., no hard dependency on cURL,
PHP streams, sockets, or non-blocking event loops.
- Middleware system allows you to augment and compose client behavior.
```php
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', 'https://api.github.com/repos/guzzle/guzzle');
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('content-type'); // 'application/json; charset=utf8'
echo $response->getBody(); // '{"id": 1420053, "name": "guzzle", ...}'
// Send an asynchronous request.
$request = new \GuzzleHttp\Psr7\Request('GET', 'http://httpbin.org');
$promise = $client->sendAsync($request)->then(function ($response) {
echo 'I completed! ' . $response->getBody();
});
$promise->wait();
```
## Help and docs
We use GitHub issues only to discuss bugs and new features. For support please refer to:
- [Documentation](docs/index.md)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/guzzle)
- [#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on [PHP-HTTP Slack](https://slack.httplug.io/)
- [Gitter](https://gitter.im/guzzle/guzzle)
## Installing Guzzle
## Installation
The recommended way to install Guzzle is through
[Composer](https://getcomposer.org/).
@ -57,38 +26,71 @@ The recommended way to install Guzzle is through
composer require guzzlehttp/guzzle
```
## Version Guidance
| Version | Status | Packagist | Namespace | Repo | Docs | PSR-7 | PHP Version |
|---------|---------------------|---------------------|--------------|---------------------|---------------------|-------|--------------|
| 3.x | EOL (2016-10-31) | `guzzle/guzzle` | `Guzzle` | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No | >=5.3.3,<7.0 |
| 4.x | EOL (2016-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A | No | >=5.4,<7.0 |
| 5.x | EOL (2019-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No | >=5.4,<7.4 |
| 6.x | EOL (2023-10-31) | `guzzlehttp/guzzle` | `GuzzleHttp` | [v6][guzzle-6-repo] | [v6][guzzle-6-docs] | Yes | >=5.5,<8.0 |
| 7.x | Latest | `guzzlehttp/guzzle` | `GuzzleHttp` | [v7][guzzle-7-repo] | [v7][guzzle-7-docs] | Yes | >=7.2.5,<8.6 |
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 8.0 | Latest | >=7.4,<8.6 |
| 7.15 | Maintenance | >=7.2.5,<8.6 |
| 6.5 | End of Life | >=5.5,<8.0 |
[guzzle-3-repo]: https://github.com/guzzle/guzzle3
[guzzle-4-repo]: https://github.com/guzzle/guzzle/tree/4.x
[guzzle-5-repo]: https://github.com/guzzle/guzzle/tree/5.3
[guzzle-6-repo]: https://github.com/guzzle/guzzle/tree/6.5
[guzzle-7-repo]: https://github.com/guzzle/guzzle/tree/7.11
[guzzle-3-docs]: https://github.com/guzzle/guzzle3/tree/master/docs
[guzzle-5-docs]: https://github.com/guzzle/guzzle/tree/5.3/docs
[guzzle-6-docs]: https://github.com/guzzle/guzzle/tree/6.5/docs
[guzzle-7-docs]: https://github.com/guzzle/guzzle/blob/7.11/docs/index.md
## Quick Start
```php
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', 'https://api.example.com/users/123');
echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('content-type'); // 'application/json'
echo $response->getBody(); // '{"id": 123, "name": "Ada"}'
```
For more examples, see the [Quick Start](docs/quick-start.md).
## Documentation
- [Quick Start](docs/quick-start.md)
- [Overview](docs/overview.md)
- [Request Options](docs/request-options.md)
- [Uploading Data](docs/uploading-data.md)
- [Cookies](docs/cookies.md)
- [Exceptions](docs/exceptions.md)
- [Guzzle and PSR-7](docs/guzzle-and-psr-7.md)
- [Handlers](docs/handlers.md)
- [Middleware](docs/middleware.md)
- [Testing Guzzle Clients](docs/testing-guzzle-clients.md)
- [FAQ](docs/faq.md)
- [Package Ecosystem](docs/package-ecosystem.md)
- [Upgrade Guide](UPGRADING.md)
- [Changelog](CHANGELOG.md)
We use GitHub issues only to discuss bugs and new features. For support, use
[Stack Overflow](https://stackoverflow.com/questions/tagged/guzzle), the
[#guzzle](https://app.slack.com/client/T0D2S9JCT/CE6UAAKL4) channel on
[PHP-HTTP Slack](https://slack.httplug.io/), or
[Gitter](https://gitter.im/guzzle/guzzle).
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/guzzle/security/policy) for more information.
If you discover a security vulnerability within this package, please send an
email to security@tidelift.com. All security vulnerabilities will be promptly
addressed. Please do not disclose security-related issues publicly until a fix
has been announced. Please see
[Security Policy](https://github.com/guzzle/guzzle/security/policy) for more
information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
Guzzle is made available under the MIT License (MIT). Please see
[License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-guzzle?utm_source=packagist-guzzlehttp-guzzle&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
The maintainers of Guzzle and thousands of other packages are working with
Tidelift to deliver commercial support and maintenance for the open source
dependencies you use to build your applications. Save time, reduce risk, and
improve code health, while paying the maintainers of the exact dependencies you
use.
[Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-guzzle?utm_source=packagist-guzzlehttp-guzzle&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +0,0 @@
{
"name": "guzzle",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View file

@ -0,0 +1,515 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Auth;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* @internal
*/
final class DigestAuth
{
/**
* @var array<string, array{hash: string, sess: bool, rank: int, header: string}>
*/
private const ALGORITHMS = [
'MD5' => ['hash' => 'md5', 'sess' => false, 'rank' => 10, 'header' => 'MD5'],
'MD5-SESS' => ['hash' => 'md5', 'sess' => true, 'rank' => 11, 'header' => 'MD5-sess'],
'SHA-256' => ['hash' => 'sha256', 'sess' => false, 'rank' => 20, 'header' => 'SHA-256'],
'SHA-256-SESS' => ['hash' => 'sha256', 'sess' => true, 'rank' => 21, 'header' => 'SHA-256-sess'],
'SHA-512-256' => ['hash' => 'sha512/256', 'sess' => false, 'rank' => 30, 'header' => 'SHA-512-256'],
'SHA-512-256-SESS' => ['hash' => 'sha512/256', 'sess' => true, 'rank' => 31, 'header' => 'SHA-512-256-sess'],
];
/**
* @var array<string, true>
*/
private const DIGEST_CHALLENGE_PARAMETER_NAMES = [
'realm' => true,
'domain' => true,
'nonce' => true,
'opaque' => true,
'stale' => true,
'algorithm' => true,
'qop' => true,
'charset' => true,
'userhash' => true,
];
private function __construct()
{
}
public static function selectChallenge(
#[\SensitiveParameter]
ResponseInterface $response
): ?DigestChallenge {
$selected = null;
foreach ($response->getHeader('WWW-Authenticate') as $header) {
foreach (self::parseAuthenticateHeader($header) as $challenge) {
if ($challenge['scheme'] !== 'digest' || $challenge['invalid']) {
continue;
}
$digest = self::createChallenge($challenge['params']);
if ($digest === null) {
continue;
}
if ($selected === null || $digest->algorithm['rank'] > $selected->algorithm['rank']) {
$selected = $digest;
}
}
}
return $selected;
}
public static function authorizationHeader(
#[\SensitiveParameter]
RequestInterface $request,
DigestChallenge $challenge,
string $username,
#[\SensitiveParameter]
string $password,
string $cnonce,
string $nc = '00000001'
): ?string {
if ($challenge->algorithm['sess'] && $challenge->qop === null) {
return null;
}
$uri = $request->getRequestTarget();
if ($uri === '') {
$uri = '/';
}
foreach ([$username, $challenge->realm, $challenge->nonce, $uri, $cnonce] as $value) {
if (!self::isHeaderSafe($value)) {
return null;
}
}
if ($challenge->opaque !== null && !self::isHeaderSafe($challenge->opaque)) {
return null;
}
if ($challenge->qop !== null && !self::isNonceCount($nc)) {
return null;
}
$hash = $challenge->algorithm['hash'];
$ha1 = \hash($hash, $username.':'.$challenge->realm.':'.$password);
if ($challenge->algorithm['sess']) {
$ha1 = \hash($hash, $ha1.':'.$challenge->nonce.':'.$cnonce);
}
$ha2 = \hash($hash, $request->getMethod().':'.$uri);
$response = $challenge->qop === null
? \hash($hash, $ha1.':'.$challenge->nonce.':'.$ha2)
: \hash($hash, $ha1.':'.$challenge->nonce.':'.$nc.':'.$cnonce.':'.$challenge->qop.':'.$ha2);
if ($challenge->userhash) {
$headerUsername = \hash($hash, $username.':'.$challenge->realm);
if ($headerUsername === false) {
return null;
}
} else {
$headerUsername = $username;
}
$parts = [
'username='.self::quote($headerUsername),
'realm='.self::quote($challenge->realm),
'nonce='.self::quote($challenge->nonce),
'uri='.self::quote($uri),
'response='.self::quote($response),
'algorithm='.$challenge->algorithm['header'],
];
if ($challenge->opaque !== null) {
$parts[] = 'opaque='.self::quote($challenge->opaque);
}
if ($challenge->qop !== null) {
$parts[] = 'qop='.$challenge->qop;
$parts[] = 'nc='.$nc;
$parts[] = 'cnonce='.self::quote($cnonce);
}
if ($challenge->userhash) {
$parts[] = 'userhash=true';
}
return 'Digest '.\implode(', ', $parts);
}
/**
* @return list<array{scheme: string, params: array<string, string>, invalid: bool}>
*/
public static function parseAuthenticateHeader(string $header): array
{
$length = \strlen($header);
$offset = 0;
$challenges = [];
while (true) {
self::skipSeparators($header, $offset, $length);
if ($offset >= $length) {
break;
}
$scheme = self::readToken($header, $offset, $length);
if ($scheme === null) {
break;
}
self::skipWhitespace($header, $offset, $length);
if (self::skipToken68Challenge($header, $offset, $length)) {
$challenges[] = [
'scheme' => Psr7\Utils::asciiToLower($scheme),
'params' => [],
'invalid' => false,
];
continue;
}
$params = [];
$invalid = false;
while ($offset < $length) {
self::skipWhitespace($header, $offset, $length);
if ($offset < $length && $header[$offset] === ',') {
if (self::commaStartsNextChallenge($header, $offset + 1, $length)) {
if (Psr7\Utils::caselessEquals($scheme, 'Digest')
&& self::commaStartsKnownDigestParameterWithoutValue($header, $offset + 1, $length)
) {
$invalid = true;
break;
}
++$offset;
break;
}
++$offset;
continue;
}
$name = self::readToken($header, $offset, $length);
if ($name === null) {
if ($offset < $length) {
$invalid = true;
}
break;
}
self::skipWhitespace($header, $offset, $length);
if ($offset >= $length || $header[$offset] !== '=') {
$invalid = true;
break;
}
++$offset;
self::skipWhitespace($header, $offset, $length);
$value = self::readValue($header, $offset, $length);
if ($value === null) {
$invalid = true;
break;
}
$lowerName = Psr7\Utils::asciiToLower($name);
if (\array_key_exists($lowerName, $params)) {
$invalid = true;
}
$params[$lowerName] = $value;
self::skipWhitespace($header, $offset, $length);
if ($offset >= $length) {
break;
}
if ($header[$offset] !== ',') {
$invalid = true;
break;
}
}
$challenges[] = [
'scheme' => Psr7\Utils::asciiToLower($scheme),
'params' => $params,
'invalid' => $invalid,
];
}
return $challenges;
}
/**
* @param array<string, string> $params
*/
private static function createChallenge(array $params): ?DigestChallenge
{
if (!isset($params['nonce']) || $params['nonce'] === '') {
return null;
}
if (isset($params['charset']) && !Psr7\Utils::caselessEquals($params['charset'], 'UTF-8')) {
return null;
}
$algorithm = self::algorithm($params['algorithm'] ?? null);
if ($algorithm === null) {
return null;
}
$qop = self::selectQop($params['qop'] ?? null);
if ($qop === false) {
return null;
}
if ($algorithm['sess'] && $qop === null) {
return null;
}
$challenge = new DigestChallenge();
$challenge->algorithm = $algorithm;
$challenge->realm = $params['realm'] ?? '';
$challenge->nonce = $params['nonce'];
$challenge->opaque = $params['opaque'] ?? null;
if (isset($params['domain'])) {
$domainAreas = \preg_split('/[ \t]+/', $params['domain']);
if ($domainAreas === false) {
throw new \RuntimeException('Unable to split the Digest domain list: '.\preg_last_error_msg());
}
$challenge->domain = \array_values(\array_filter($domainAreas));
} else {
$challenge->domain = [];
}
$challenge->qop = $qop;
$challenge->stale = isset($params['stale']) && Psr7\Utils::caselessEquals($params['stale'], 'true');
$challenge->userhash = isset($params['userhash']) && Psr7\Utils::caselessEquals($params['userhash'], 'true');
return $challenge;
}
/**
* @return array{name: string, hash: string, sess: bool, rank: int, header: string}|null
*/
private static function algorithm(?string $algorithm): ?array
{
$name = Psr7\Utils::asciiToUpper($algorithm ?? 'MD5');
if (!isset(self::ALGORITHMS[$name])) {
return null;
}
$mapped = self::ALGORITHMS[$name];
if (!\in_array($mapped['hash'], \hash_algos(), true)) {
return null;
}
return ['name' => $name] + $mapped;
}
/**
* @return string|false|null
*/
private static function selectQop(?string $qop)
{
if ($qop === null) {
return null;
}
$tokens = \array_map(
static function (string $token): string {
return Psr7\Utils::asciiToLower(\trim($token, " \t"));
},
\explode(',', $qop)
);
return \in_array('auth', $tokens, true) ? 'auth' : false;
}
private static function quote(string $value): string
{
return '"'.\strtr($value, [
'\\' => '\\\\',
'"' => '\\"',
]).'"';
}
private static function isHeaderSafe(string $value): bool
{
return \preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*$/D', $value) === 1;
}
private static function isNonceCount(string $nc): bool
{
// Nonce counts start at one.
return $nc !== '00000000' && \preg_match('/^[0-9a-f]{8}$/D', $nc) === 1;
}
private static function commaStartsNextChallenge(string $header, int $offset, int $length): bool
{
self::skipWhitespace($header, $offset, $length);
$nextOffset = $offset;
$token = self::readToken($header, $nextOffset, $length);
if ($token === null) {
return false;
}
self::skipWhitespace($header, $nextOffset, $length);
return $nextOffset >= $length || $header[$nextOffset] !== '=';
}
private static function commaStartsKnownDigestParameterWithoutValue(string $header, int $offset, int $length): bool
{
self::skipWhitespace($header, $offset, $length);
$name = self::readToken($header, $offset, $length);
if ($name === null || !isset(self::DIGEST_CHALLENGE_PARAMETER_NAMES[Psr7\Utils::asciiToLower($name)])) {
return false;
}
self::skipWhitespace($header, $offset, $length);
return $offset >= $length || $header[$offset] !== '=';
}
private static function skipToken68Challenge(string $header, int &$offset, int $length): bool
{
$cursor = $offset;
$hasValue = false;
while ($cursor < $length && self::isToken68Char($header[$cursor])) {
$hasValue = true;
++$cursor;
}
while ($cursor < $length && $header[$cursor] === '=') {
++$cursor;
}
if (!$hasValue) {
return false;
}
$after = $cursor;
self::skipWhitespace($header, $after, $length);
if ($after < $length && $header[$after] !== ',') {
return false;
}
$offset = $after;
return true;
}
private static function skipSeparators(string $header, int &$offset, int $length): void
{
while ($offset < $length) {
$char = $header[$offset];
if ($char !== ',' && !self::isWhitespace($char)) {
return;
}
++$offset;
}
}
private static function skipWhitespace(string $header, int &$offset, int $length): void
{
while ($offset < $length && self::isWhitespace($header[$offset])) {
++$offset;
}
}
private static function isWhitespace(string $char): bool
{
return $char === ' ' || $char === "\t";
}
private static function readToken(string $header, int &$offset, int $length): ?string
{
$start = $offset;
while ($offset < $length && self::isTokenChar($header[$offset])) {
++$offset;
}
if ($offset === $start) {
return null;
}
$token = \substr($header, $start, $offset - $start);
return $token === false ? null : $token;
}
private static function isTokenChar(string $char): bool
{
return \strspn($char, "!#$%&'*+-.^_`|~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") === 1;
}
private static function isToken68Char(string $char): bool
{
return \strspn($char, '-._~+/0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz') === 1;
}
private static function readValue(string $header, int &$offset, int $length): ?string
{
if ($offset >= $length) {
return null;
}
if ($header[$offset] === '"') {
return self::readQuotedString($header, $offset, $length);
}
return self::readToken($header, $offset, $length);
}
private static function readQuotedString(string $header, int &$offset, int $length): ?string
{
++$offset;
$value = '';
$escaped = false;
while ($offset < $length) {
$char = $header[$offset++];
if ($escaped) {
$value .= $char;
$escaped = false;
continue;
}
if ($char === '\\') {
$escaped = true;
continue;
}
if ($char === '"') {
return $value;
}
$value .= $char;
}
return null;
}
}

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Auth;
/**
* @internal
*/
final class DigestChallenge
{
/** @var array{name: string, hash: string, sess: bool, rank: int, header: string} */
public array $algorithm;
public string $realm = '';
public string $nonce;
public ?string $opaque = null;
/** @var list<string> */
public array $domain = [];
public ?string $qop = null;
public bool $stale = false;
public bool $userhash = false;
}

View file

@ -0,0 +1,843 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Auth\DigestAuth;
use GuzzleHttp\Auth\DigestChallenge;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\LazyOpenStream;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* Applies built-in Basic authentication and handles Digest authentication
* challenges.
*/
final class AuthMiddleware
{
use NonSerializableTrait;
private const DIGEST_MAX_RETRIES = 2;
private const CHALLENGE_CACHE_LIMIT = 32;
/**
* Request option carrying nonce counts already spent during a Digest
* handshake, so stale retries advance instead of repeating a count.
*/
private const DIGEST_NONCE_COUNTS_OPTION = '__guzzle_digest_nonce_counts';
/**
* Headers that describe the request payload. A probe with an empty body
* must not carry them.
*
* @var list<string>
*/
private const DIGEST_PROBE_PAYLOAD_HEADERS = [
'Transfer-Encoding',
'Expect',
'Trailer',
'Content-Range',
'Content-Encoding',
'Content-MD5',
'Digest',
'Content-Digest',
'Repr-Digest',
];
/**
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @var callable(): string
*/
private $cnonceGenerator;
private bool $reuseChallenges;
private ?string $credentialHashSecret = null;
/**
* @var array<string, array{challenge: DigestChallenge, credentials: string, nextNc: int}>
*/
private array $digestChallenges = [];
/**
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler
* @param (callable(): string)|null $cnonceGenerator
*/
public function __construct(callable $nextHandler, ?callable $cnonceGenerator = null, bool $reuseChallenges = true)
{
$this->nextHandler = $nextHandler;
$this->cnonceGenerator = $cnonceGenerator ?? static function (): string {
return \bin2hex(\random_bytes(16));
};
$this->reuseChallenges = $reuseChallenges;
}
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$auth = $options['auth'] ?? null;
if ($auth === null || $auth === false || $auth === [] || \is_string($auth)) {
return ($this->nextHandler)($request, $options);
}
$normalizedAuth = self::normalizeAuth($auth);
if ($normalizedAuth === null) {
return ($this->nextHandler)($request, $options);
}
[$username, $password, $type] = $normalizedAuth;
if ($type === 'basic') {
return $this->sendBasic($request, $options, $username, $password);
}
return $this->sendDigest($request, $options, $username, $password);
}
/**
* @param array<array-key, mixed> $auth
*
* @return array{0: string, 1: string, 2: 'basic'|'digest'}|null
*/
private static function normalizeAuth(
#[\SensitiveParameter]
array $auth
): ?array {
$type = 'basic';
if (\array_key_exists(2, $auth) && $auth[2] !== null) {
if (!\is_string($auth[2])) {
throw new InvalidArgumentException('auth type must be a string');
}
$type = Psr7\Utils::asciiToLower($auth[2]);
}
if (!\in_array($type, ['basic', 'digest'], true)) {
return null;
}
if (!\array_key_exists(0, $auth) || !\array_key_exists(1, $auth)) {
throw new InvalidArgumentException('auth must contain username and password strings');
}
if (!\is_string($auth[0]) || !\is_string($auth[1])) {
throw new InvalidArgumentException('auth must contain username and password strings');
}
return [$auth[0], $auth[1], $type];
}
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function sendBasic(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
string $username,
#[\SensitiveParameter]
string $password
): PromiseInterface {
if (\strpos($username, ':') !== false) {
throw new InvalidArgumentException('Basic authentication username must not contain a colon');
}
if (\preg_match('/[\x00-\x1F\x7F]/', $username.$password) !== 0) {
throw new InvalidArgumentException('Basic authentication credentials must not contain ASCII control characters');
}
unset($options['auth']);
return ($this->nextHandler)(
$request->withHeader('Authorization', 'Basic '.\base64_encode($username.':'.$password)),
$options
);
}
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function sendDigest(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
string $username,
#[\SensitiveParameter]
string $password
): PromiseInterface {
$preemptive = $this->reuseChallenges
? $this->preemptiveDigestRequest($request, $username, $password)
: null;
if ($preemptive !== null) {
$preemptiveOptions = self::withTemporarySink($options);
unset($preemptiveOptions['auth']);
return ($this->nextHandler)($preemptive, $preemptiveOptions)->then(
function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $options, $preemptiveOptions, $username, $password) {
// Clear on 4xx/5xx other than 401 so non-conformant stale-nonce
// errors cannot poison the cache indefinitely.
$status = $response->getStatusCode();
if ($status >= 400 && $status !== 401) {
$this->clearDigestChallenge($request);
}
return $this->handleDigestResponse($request, $options, $preemptiveOptions, $response, $username, $password, false);
},
function (
#[\SensitiveParameter]
$reason
) use ($request, $preemptiveOptions) {
return $this->handleDigestRejection($request, $preemptiveOptions, $reason, true);
}
);
}
$probeOptions = self::withTemporarySink($options);
unset($probeOptions['auth']);
[$probeRequest, $bodyWithheld] = self::probeRequest($request, $options);
if ($bodyWithheld) {
// The probe has no payload, so prepare_body must not add Expect
// if a custom empty stream has unknown size.
$probeOptions[RequestOptions::EXPECT] = false;
}
return ($this->nextHandler)($probeRequest, $probeOptions)->then(
function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $options, $probeOptions, $username, $password, $bodyWithheld) {
return $this->handleDigestResponse($request, $options, $probeOptions, $response, $username, $password, $bodyWithheld);
},
function (
#[\SensitiveParameter]
$reason
) use ($request, $probeOptions) {
return $this->handleDigestRejection($request, $probeOptions, $reason, false);
}
);
}
private function preemptiveDigestRequest(
#[\SensitiveParameter]
RequestInterface $request,
string $username,
#[\SensitiveParameter]
string $password
): ?RequestInterface {
try {
if ($request->getBody()->getSize() !== 0) {
return null;
}
} catch (\Exception $e) {
return null;
}
$key = self::digestCacheKey($request);
if ($key === null) {
return null;
}
$entry = $this->digestChallenges[$key] ?? null;
if ($entry === null
|| $entry['credentials'] !== $this->digestCredentialKey($username, $password)
|| !self::challengeCoversRequest($entry['challenge'], $request)
) {
return null;
}
$authorization = DigestAuth::authorizationHeader(
$request,
$entry['challenge'],
$username,
$password,
($this->cnonceGenerator)(),
\sprintf('%08x', $entry['nextNc'])
);
if ($authorization === null) {
return null;
}
++$this->digestChallenges[$key]['nextNc'];
return $request->withHeader('Authorization', $authorization);
}
/**
* Builds the unauthenticated probe request for a Digest handshake.
*
* @return array{0: RequestInterface, 1: bool}
*/
private static function probeRequest(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): array {
$probe = $request->withoutHeader('Authorization');
try {
$size = $probe->getBody()->getSize();
} catch (\Exception $e) {
$size = null;
}
if ($size === 0) {
return [$probe, false];
}
$streamFactory = self::requireStreamFactory(
$options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory()
);
$probe = $probe->withBody($streamFactory->createStream(''))
->withHeader('Content-Length', '0');
foreach (self::DIGEST_PROBE_PAYLOAD_HEADERS as $header) {
$probe = $probe->withoutHeader($header);
}
return [$probe, true];
}
/**
* @return ResponseInterface|PromiseInterface<ResponseInterface, mixed>
*/
private function handleDigestResponse(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
array $probeOptions,
#[\SensitiveParameter]
ResponseInterface $response,
string $username,
#[\SensitiveParameter]
string $password,
bool $bodyWithheld
) {
$status = $response->getStatusCode();
$previousEntry = null;
if ($this->reuseChallenges && $status === 401) {
$key = self::digestCacheKey($request);
$previousEntry = $key !== null ? ($this->digestChallenges[$key] ?? null) : null;
$this->clearDigestChallenge($request);
}
if ($status !== 401) {
if ($this->reuseChallenges && $response->hasHeader('Authentication-Info')) {
$this->clearDigestChallenge($request);
}
$response = self::restoreOriginalSink($request, $response, $probeOptions);
if ($bodyWithheld && $status !== 407 && !self::isRedirectLikeResponse($response)) {
throw new ResponseException(
'Digest authentication failed because the server did not issue a challenge; the request was probed without its body',
$request,
$response
);
}
return $response;
}
$challenge = DigestAuth::selectChallenge($response);
if ($challenge === null) {
return self::restoreOriginalSink($request, $response, $probeOptions);
}
$retries = $options['__guzzle_digest_retries'] ?? 0;
if (!\is_int($retries)) {
$retries = 0;
}
if (($retries > 0 && !$challenge->stale) || $retries >= self::DIGEST_MAX_RETRIES) {
return self::restoreOriginalSink($request, $response, $probeOptions);
}
try {
self::rewindBodyForRetry($request, $bodyWithheld);
} catch (\Exception $e) {
$response = self::restoreOriginalSink($request, $response, $probeOptions);
throw new ResponseException(
'Digest authentication failed because the request body could not be rewound',
$request,
$response,
$e
);
}
$nonceCounts = self::digestNonceCountsFromOptions($options);
if ($previousEntry !== null
&& $previousEntry['credentials'] === $this->digestCredentialKey($username, $password)
) {
self::rememberDigestNonceCount($nonceCounts, $previousEntry['challenge'], $previousEntry['nextNc']);
}
$nc = $challenge->qop === null ? 1 : ($nonceCounts[self::digestNonceCountKey($challenge)] ?? 1);
$authorization = DigestAuth::authorizationHeader(
$request,
$challenge,
$username,
$password,
($this->cnonceGenerator)(),
\sprintf('%08x', $nc)
);
if ($authorization === null) {
return self::restoreOriginalSink($request, $response, $probeOptions);
}
$response->getBody()->close();
self::rememberDigestNonceCount($nonceCounts, $challenge, $nc + 1);
$retryOptions = $options;
$retryOptions['__guzzle_digest_retries'] = $retries + 1;
$retryOptions[self::DIGEST_NONCE_COUNTS_OPTION] = $nonceCounts;
$downstreamOptions = $retryOptions;
// The nonce-count map only carries handshake state into the retry
// recursion; downstream handlers must not observe it.
unset($downstreamOptions[self::DIGEST_NONCE_COUNTS_OPTION]);
// The caller's delay applies once, before the first Digest leg, not
// before each handshake retry.
unset($downstreamOptions[RequestOptions::DELAY]);
$downstreamOptions = self::withTemporarySink($downstreamOptions);
unset($downstreamOptions['auth']);
$retryRequest = $request->withHeader('Authorization', $authorization);
return ($this->nextHandler)($retryRequest, $downstreamOptions)->then(
function (
#[\SensitiveParameter]
ResponseInterface $retryResponse
) use ($retryRequest, $retryOptions, $downstreamOptions, $username, $password, $challenge, $nc) {
if ($this->reuseChallenges && self::isCacheableDigestSuccess($retryResponse)) {
$this->storeDigestChallenge($retryRequest, $challenge, $username, $password, $nc + 1);
}
return $this->handleDigestResponse($retryRequest, $retryOptions, $downstreamOptions, $retryResponse, $username, $password, false);
},
function (
#[\SensitiveParameter]
$reason
) use ($retryRequest, $downstreamOptions) {
return $this->handleDigestRejection($retryRequest, $downstreamOptions, $reason, false);
}
);
}
private static function isCacheableDigestSuccess(ResponseInterface $response): bool
{
$status = $response->getStatusCode();
return $status >= 200 && $status < 400 && !$response->hasHeader('Authentication-Info');
}
private function storeDigestChallenge(
#[\SensitiveParameter]
RequestInterface $request,
DigestChallenge $challenge,
string $username,
#[\SensitiveParameter]
string $password,
int $nextNc
): void {
$key = self::digestCacheKey($request);
if ($key === null || $challenge->qop === null) {
return;
}
unset($this->digestChallenges[$key]);
if (\count($this->digestChallenges) >= self::CHALLENGE_CACHE_LIMIT) {
\array_shift($this->digestChallenges);
}
$this->digestChallenges[$key] = [
'challenge' => $challenge,
'credentials' => $this->digestCredentialKey($username, $password),
'nextNc' => $nextNc,
];
}
private function clearDigestChallenge(RequestInterface $request): void
{
$key = self::digestCacheKey($request);
if ($key !== null) {
unset($this->digestChallenges[$key]);
}
}
/**
* @param array<array-key, mixed> $options
*
* @return array<string, int>
*/
private static function digestNonceCountsFromOptions(array $options): array
{
$counts = $options[self::DIGEST_NONCE_COUNTS_OPTION] ?? [];
if (!\is_array($counts)) {
return [];
}
$normalized = [];
foreach ($counts as $key => $nextNc) {
if (\is_string($key) && \is_int($nextNc) && $nextNc > 0 && $nextNc < \PHP_INT_MAX) {
$normalized[$key] = $nextNc;
}
}
return $normalized;
}
private static function digestNonceCountKey(DigestChallenge $challenge): string
{
return \implode("\0", [
$challenge->realm,
$challenge->nonce,
$challenge->qop ?? '',
$challenge->algorithm['header'],
]);
}
/**
* @param array<string, int> $counts
*/
private static function rememberDigestNonceCount(array &$counts, DigestChallenge $challenge, int $nextNc): void
{
if ($challenge->qop === null) {
return;
}
$key = self::digestNonceCountKey($challenge);
$counts[$key] = \max($counts[$key] ?? 1, $nextNc);
}
/**
* @param mixed $reason
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function handleDigestRejection(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
$reason,
bool $preemptive
): PromiseInterface {
if ($this->reuseChallenges && $reason instanceof ResponseException) {
$response = $reason->getResponse();
if ($response->getStatusCode() === 401
|| $response->hasHeader('Authentication-Info')
|| ($preemptive && $response->getStatusCode() >= 400)
) {
$this->clearDigestChallenge($request);
}
}
return self::restoreOriginalSinkOnRejection($options, $reason);
}
private static function digestCacheKey(RequestInterface $request): ?string
{
$uri = $request->getUri();
$scheme = Psr7\Utils::asciiToLower($uri->getScheme());
$host = HostIdentity::canonicalHost($uri->getHost());
if (($scheme !== 'http' && $scheme !== 'https') || $host === '') {
return null;
}
$port = $uri->getPort() ?? ($scheme === 'https' ? 443 : 80);
return $scheme.'://'.$host.':'.$port.'|'.HostIdentity::canonicalHostHeader($request->getHeaderLine('Host'));
}
private function digestCredentialKey(
string $username,
#[\SensitiveParameter]
string $password
): string {
if ($this->credentialHashSecret === null) {
$this->credentialHashSecret = \random_bytes(32);
}
return \hash_hmac('sha256', $username."\0".$password, $this->credentialHashSecret);
}
private static function challengeCoversRequest(DigestChallenge $challenge, RequestInterface $request): bool
{
if ($challenge->domain === []) {
return true;
}
$target = $request->getRequestTarget();
foreach ($challenge->domain as $space) {
$prefix = self::protectionSpacePath($space, $request);
if ($prefix !== null && \str_starts_with($target, $prefix)) {
return true;
}
}
return false;
}
private static function protectionSpacePath(string $space, RequestInterface $request): ?string
{
if ($space === '') {
return null;
}
if ($space[0] === '/') {
return $space;
}
try {
$uri = new Psr7\Uri($space);
} catch (\InvalidArgumentException $e) {
return null;
}
if (Psr7\UriComparator::isCrossOrigin($uri, self::effectiveRequestUri($request))) {
return null;
}
$path = $uri->getPath();
$prefix = $path === '' ? '/' : $path;
if ($uri->getQuery() !== '') {
$prefix .= '?'.$uri->getQuery();
}
return $prefix;
}
private static function effectiveRequestUri(RequestInterface $request): UriInterface
{
$uri = $request->getUri();
$host = $request->getHeaderLine('Host');
if ($host === '' || Psr7\Utils::caselessEquals($host, $uri->getHost())) {
return $uri;
}
try {
$authority = new Psr7\Uri('//'.$host);
if ($authority->getHost() === '') {
return $uri;
}
return $uri->withHost($authority->getHost())->withPort($authority->getPort());
} catch (\InvalidArgumentException $e) {
return $uri;
}
}
private static function isRedirectLikeResponse(ResponseInterface $response): bool
{
return $response->hasHeader('Location')
&& \in_array($response->getStatusCode(), [301, 302, 303, 307, 308], true);
}
private static function rewindBodyForRetry(
#[\SensitiveParameter]
RequestInterface $request,
bool $bodyWithheld
): void {
$body = $request->getBody();
if (!$bodyWithheld) {
try {
if ($body->getSize() === 0) {
return;
}
} catch (\Exception $e) {
// Fall through to the standard rewind path.
}
Psr7\Message::rewindBody($request);
return;
}
if ($body->isSeekable()) {
$body->rewind();
}
}
private static function withTemporarySink(
#[\SensitiveParameter]
array $options
): array {
// The 'stream' option is deliberately not checked here: the cURL
// handlers do not support streaming and write each leg to the sink,
// so a configured sink always needs challenge-body protection.
if (!isset($options[RequestOptions::SINK])) {
return $options;
}
$streamFactory = self::requireStreamFactory(
$options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory()
);
$options['__guzzle_auth_original_sink'] = $options[RequestOptions::SINK];
$options[RequestOptions::SINK] = $streamFactory->createStreamFromResource(
Psr7\Utils::tryFopen('php://temp', 'w+')
);
return $options;
}
private static function restoreOriginalSink(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
array $options
): ResponseInterface {
if (!\array_key_exists('__guzzle_auth_original_sink', $options)) {
return $response;
}
try {
$source = $response->getBody();
if ($source->isSeekable()) {
$source->rewind();
}
$target = self::streamForOriginalSink($options['__guzzle_auth_original_sink']);
Psr7\Utils::copyToStream($source, $target);
if ($target->isSeekable()) {
$target->rewind();
}
return $response->withBody($target);
} catch (\Exception $e) {
throw new ResponseException(
$e->getMessage() !== '' ? $e->getMessage() : 'Failed to write the response body',
$request,
$response,
$e
);
}
}
/**
* @param mixed $reason
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
private static function restoreOriginalSinkOnRejection(
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
$reason
): PromiseInterface {
if (!$reason instanceof ResponseException || !\array_key_exists('__guzzle_auth_original_sink', $options)) {
/** @var PromiseInterface<ResponseInterface, mixed> */
return P\Create::rejectionFor($reason);
}
try {
$response = self::restoreOriginalSink($reason->getRequest(), $reason->getResponse(), $options);
/** @var PromiseInterface<ResponseInterface, mixed> */
return P\Create::rejectionFor($reason->withResponse($response));
} catch (\Throwable $e) {
/** @var PromiseInterface<ResponseInterface, mixed> */
return P\Create::rejectionFor($e);
}
}
/**
* @param mixed $sink
*/
private static function streamForOriginalSink($sink): StreamInterface
{
if (\is_string($sink)) {
return new LazyOpenStream($sink, 'w+');
}
if (\is_resource($sink)) {
return self::streamForResourceSink(Psr7\Utils::streamFor($sink));
}
if (!$sink instanceof StreamInterface) {
throw new InvalidArgumentException(\sprintf(
'sink must be a resource, string, or %s',
StreamInterface::class
));
}
return Psr7\Utils::streamFor($sink);
}
/**
* Decorates a caller-owned sink stream so that closing the response body
* detaches Guzzle's wrapper without closing the original PHP resource.
*/
private static function streamForResourceSink(StreamInterface $stream): StreamInterface
{
return Psr7\FnStream::decorate($stream, [
'close' => static function () use ($stream): void {
$stream->detach();
},
]);
}
/**
* @param mixed $factory
*/
private static function requireStreamFactory($factory): StreamFactoryInterface
{
if (!$factory instanceof StreamFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::STREAM_FACTORY,
StreamFactoryInterface::class
));
}
return $factory;
}
}

View file

@ -1,15 +1,14 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\MessageInterface;
final class BodySummarizer implements BodySummarizerInterface
{
/**
* @var int|null
*/
private $truncateAt;
private ?int $truncateAt;
public function __construct(?int $truncateAt = null)
{
@ -21,8 +20,10 @@ final class BodySummarizer implements BodySummarizerInterface
*/
public function summarize(MessageInterface $message): ?string
{
return $this->truncateAt === null
? Psr7\Message::bodySummary($message)
: Psr7\Message::bodySummary($message, $this->truncateAt);
try {
return Psr7\Message::bodySummary($message, $this->truncateAt);
} catch (\Exception $e) {
return null;
}
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\MessageInterface;

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,19 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
@ -16,14 +24,86 @@ interface ClientInterface
/**
* The Guzzle major version.
*/
public const MAJOR_VERSION = 7;
public const MAJOR_VERSION = 8;
/**
* Send an HTTP request.
*
* @param RequestInterface $request Request to send
* @param array $options Request options to apply to the given
* request and to the transfer.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply to the given request and to the transfer.
*
* @throws GuzzleException
*/
@ -33,8 +113,82 @@ interface ClientInterface
* Asynchronously send an HTTP request.
*
* @param RequestInterface $request Request to send
* @param array $options Request options to apply to the given
* request and to the transfer.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply to the given request and to the transfer.
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface;
@ -45,9 +199,82 @@ interface ClientInterface
* relative path to append to the base path of the client. The URL can
* contain the query string as well.
*
* @param string $method HTTP method.
* @param string|UriInterface $uri URI object or string.
* @param array $options Request options to apply.
* @param string $method HTTP method.
* @param string|UriInterface $uri URI object or string.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply.
*
* @throws GuzzleException
*/
@ -58,27 +285,86 @@ interface ClientInterface
*
* Use an absolute path to override the base path of the client, or a
* relative path to append to the base path of the client. The URL can
* contain the query string as well. Use an array to provide a URL
* template and additional variables to use in the URL template expansion.
* contain the query string as well.
*
* @param string $method HTTP method
* @param string|UriInterface $uri URI object or string.
* @param array $options Request options to apply.
* @param string $method HTTP method
* @param string|UriInterface $uri URI object or string.
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options Request options to apply.
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function requestAsync(string $method, $uri, array $options = []): PromiseInterface;
/**
* Get a client configuration option.
*
* These options include default request options of the client, a "handler"
* (if utilized by the concrete client), and a "base_uri" if utilized by
* the concrete client.
*
* @param string|null $option The config option to retrieve.
*
* @return mixed
*
* @deprecated ClientInterface::getConfig will be removed in guzzlehttp/guzzle:8.0.
*/
public function getConfig(?string $option = null);
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\HostIdentity;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -10,15 +14,17 @@ use Psr\Http\Message\ResponseInterface;
*/
class CookieJar implements CookieJarInterface
{
private const MAX_SET_COOKIE_FIELD_LENGTH = 8190;
private const MAX_SET_COOKIE_FIELDS = 50;
private const MAX_REQUEST_COOKIES = 150;
private const MAX_COOKIE_HEADER_LENGTH = 8190;
/**
* @var SetCookie[] Loaded cookie data
*/
private $cookies = [];
private array $cookies = [];
/**
* @var bool
*/
private $strictMode;
private bool $strictMode;
/**
* @param bool $strictMode Set to true to throw exceptions when invalid
@ -27,8 +33,11 @@ class CookieJar implements CookieJarInterface
* arrays that can be used with the SetCookie
* constructor
*/
public function __construct(bool $strictMode = false, array $cookieArray = [])
{
public function __construct(
bool $strictMode = false,
#[\SensitiveParameter]
array $cookieArray = []
) {
$this->strictMode = $strictMode;
foreach ($cookieArray as $cookie) {
@ -45,14 +54,21 @@ class CookieJar implements CookieJarInterface
* @param array $cookies Cookies to create the jar from
* @param string $domain Domain to set the cookies to
*/
public static function fromArray(array $cookies, string $domain): self
{
public static function fromArray(
#[\SensitiveParameter]
array $cookies,
string $domain
): self {
$cookieJar = new self();
foreach ($cookies as $name => $value) {
if (!\is_scalar($value) && !(\is_object($value) && \method_exists($value, '__toString'))) {
throw new \InvalidArgumentException('Cookie value must be scalar or stringable');
}
$cookieJar->setCookie(new SetCookie([
'Domain' => $domain,
'Name' => $name,
'Value' => $value,
'Name' => (string) $name,
'Value' => (string) $value,
'Discard' => true,
]));
}
@ -88,7 +104,7 @@ class CookieJar implements CookieJarInterface
public function getCookieByName(string $name): ?SetCookie
{
foreach ($this->cookies as $cookie) {
if ($cookie->getName() !== null && \strcasecmp($cookie->getName(), $name) === 0) {
if ($cookie->getName() === $name) {
return $cookie;
}
}
@ -109,14 +125,14 @@ class CookieJar implements CookieJarInterface
$this->cookies = [];
return;
} elseif (!$path) {
} elseif ($path === null) {
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $cookie) use ($domain): bool {
return $cookie->getDomain() === null || !$cookie->matchesDomain($domain);
}
);
} elseif (!$name) {
} elseif ($name === null) {
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $cookie) use ($path, $domain): bool {
@ -128,9 +144,9 @@ class CookieJar implements CookieJarInterface
} else {
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $cookie) use ($path, $domain, $name) {
static function (SetCookie $cookie) use ($path, $domain, $name): bool {
return !($cookie->getDomain() !== null
&& $cookie->getName() == $name
&& $cookie->getName() === $name
&& $cookie->matchesPath($path)
&& $cookie->matchesDomain($domain));
}
@ -148,8 +164,10 @@ class CookieJar implements CookieJarInterface
);
}
public function setCookie(SetCookie $cookie): bool
{
public function setCookie(
#[\SensitiveParameter]
SetCookie $cookie
): bool {
// If the name string is empty (but not 0), ignore the set-cookie
// string entirely.
$name = $cookie->getName();
@ -168,13 +186,23 @@ class CookieJar implements CookieJarInterface
return false;
}
$maxAge = $cookie->getMaxAge();
if ($maxAge !== null && $maxAge <= 0) {
if ($cookie->getDomain() !== null) {
$this->removeCookie($cookie);
}
return false;
}
// Resolve conflicts with previously set cookies
foreach ($this->cookies as $i => $c) {
// Two cookies are identical, when their path, and domain are
// identical.
if ($c->getPath() != $cookie->getPath()
|| $c->getDomain() != $cookie->getDomain()
|| $c->getName() != $cookie->getName()
if ($c->getPath() !== $cookie->getPath()
|| $c->getDomain() !== $cookie->getDomain()
|| $c->getHostOnly() !== $cookie->getHostOnly()
|| $c->getName() !== $cookie->getName()
) {
continue;
}
@ -221,27 +249,105 @@ class CookieJar implements CookieJarInterface
return new \ArrayIterator(\array_values($this->cookies));
}
public function extractCookies(RequestInterface $request, ResponseInterface $response): void
{
public function extractCookies(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response
): void {
if ($cookieHeader = $response->getHeader('Set-Cookie')) {
$uri = $request->getUri();
$requestHost = HostIdentity::canonicalHost($uri->getHost());
$secure = $uri->getScheme() === 'https';
$accepted = 0;
foreach ($cookieHeader as $cookie) {
if (\strlen($cookie) > self::MAX_SET_COOKIE_FIELD_LENGTH) {
continue;
}
$sc = SetCookie::fromString($cookie);
if (!$sc->getDomain()) {
$sc->setDomain($request->getUri()->getHost());
$domain = $sc->getDomain();
if ($domain === null || $domain === '') {
$sc->setDomain($requestHost);
$sc->setHostOnly(true);
} else {
$sc->setHostOnly(false);
}
if (0 !== \strpos($sc->getPath(), '/')) {
$sc->setPath($this->getCookiePathFromRequest($request));
}
if (!$sc->matchesDomain($request->getUri()->getHost())) {
if (!$sc->matchesDomain($requestHost)) {
continue;
}
if (!$secure && ($sc->getSecure() || $this->overlaysSecureCookie($sc))) {
continue;
}
$prefixName = Psr7\Utils::asciiToLower((string) $sc->getName());
if (\str_starts_with($prefixName, '__secure-') && !$sc->getSecure()) {
continue;
}
if (\str_starts_with($prefixName, '__host-') && (!$sc->getSecure() || !$sc->getHostOnly() || $sc->getPath() !== '/' || !self::hasPathAttribute($cookie))) {
continue;
}
// Note: At this point `$sc->getDomain()` being a public suffix should
// be rejected, but we don't want to pull in the full PSL dependency.
$this->setCookie($sc);
if ($this->setCookie($sc) && ++$accepted === self::MAX_SET_COOKIE_FIELDS) {
break;
}
}
}
}
private function overlaysSecureCookie(SetCookie $cookie): bool
{
foreach ($this->cookies as $stored) {
if (self::isSecureCookieOverlay($cookie, $stored)) {
return true;
}
}
return false;
}
private static function isSecureCookieOverlay(SetCookie $cookie, SetCookie $stored): bool
{
if ($stored->getName() !== $cookie->getName() || !$stored->getSecure() || $stored->isExpired()) {
return false;
}
$domain = $cookie->getDomain();
$storedDomain = $stored->getDomain();
if ($domain === null || $storedDomain === null) {
return false;
}
if (!HostIdentity::cookieDomainMatches($storedDomain, $domain) && !HostIdentity::cookieDomainMatches($domain, $storedDomain)) {
return false;
}
return $stored->matchesPath($cookie->getPath());
}
/**
* Mirrors SetCookie::fromString()'s splitting because parsed cookies cannot
* distinguish an absent Path attribute from a defaulted path.
*/
private static function hasPathAttribute(string $header): bool
{
$parts = \explode(';', $header);
\array_shift($parts);
foreach ($parts as $part) {
$separator = \strpos($part, '=');
if ($separator !== false && Psr7\Utils::caselessEquals(\trim(\substr($part, 0, $separator), " \t"), 'Path')) {
return true;
}
}
return false;
}
/**
* Computes cookie path following RFC 6265 section 5.1.4
*
@ -267,12 +373,15 @@ class CookieJar implements CookieJarInterface
return \substr($uriPath, 0, $lastSlashPos);
}
public function withCookieHeader(RequestInterface $request): RequestInterface
{
public function withCookieHeader(
#[\SensitiveParameter]
RequestInterface $request
): RequestInterface {
$values = [];
$headerLength = 8;
$uri = $request->getUri();
$scheme = $uri->getScheme();
$host = $uri->getHost();
$host = HostIdentity::canonicalHost($uri->getHost());
$path = $uri->getPath() ?: '/';
foreach ($this->cookies as $cookie) {
@ -282,8 +391,19 @@ class CookieJar implements CookieJarInterface
&& !$cookie->isExpired()
&& (!$cookie->getSecure() || $scheme === 'https')
) {
$values[] = $cookie->getName().'='
.$cookie->getValue();
$name = (string) $cookie->getName();
$value = (string) $cookie->getValue();
$separatorLength = $values === [] ? 0 : 2;
$valueLength = \strlen($name) + 1 + \strlen($value);
if ($headerLength + $separatorLength + $valueLength > self::MAX_COOKIE_HEADER_LENGTH) {
break;
}
$values[] = $name.'='.$value;
$headerLength += $separatorLength + $valueLength;
if (\count($values) === self::MAX_REQUEST_COOKIES) {
break;
}
}
}
@ -300,11 +420,20 @@ class CookieJar implements CookieJarInterface
{
$cookieValue = $cookie->getValue();
if (($cookieValue === null || $cookieValue === '') && $cookie->getDomain() !== null) {
$this->clear(
$cookie->getDomain(),
$cookie->getPath(),
$cookie->getName()
);
$this->removeCookie($cookie);
}
}
private function removeCookie(SetCookie $cookie): void
{
$this->cookies = \array_filter(
$this->cookies,
static function (SetCookie $stored) use ($cookie): bool {
return !($stored->getName() === $cookie->getName()
&& $stored->getPath() === $cookie->getPath()
&& $stored->getDomain() === $cookie->getDomain()
&& $stored->getHostOnly() === $cookie->getHostOnly());
}
);
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use Psr\Http\Message\RequestInterface;

View file

@ -1,23 +1,37 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\Utils;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\DiagnosticValue;
/**
* Persists non-session cookies using a JSON formatted file
*/
class FileCookieJar extends CookieJar
{
use NonSerializableTrait;
/**
* @var string filename
*/
private $filename;
private string $filename;
/**
* @var bool Control whether to persist session cookies or not.
*/
private $storeSessionCookies;
private bool $storeSessionCookies;
/**
* @var bool Whether to save the cookie jar on destruction.
*
* Disabled by __wakeup() to prevent FileCookieJar from being used as a
* PHP object injection file-write gadget when an application unserializes
* attacker-controlled data.
*/
private bool $autoSave = false;
/**
* Create a new FileCookieJar object
@ -26,7 +40,7 @@ class FileCookieJar extends CookieJar
* @param bool $storeSessionCookies Set to true to store session cookies
* in the cookie jar.
*
* @throws \RuntimeException if the file cannot be found or created
* @throws \RuntimeException if the file cannot be loaded or is invalid
*/
public function __construct(string $cookieFile, bool $storeSessionCookies = false)
{
@ -37,6 +51,8 @@ class FileCookieJar extends CookieJar
if (\file_exists($cookieFile)) {
$this->load($cookieFile);
}
$this->autoSave = true;
}
/**
@ -44,7 +60,24 @@ class FileCookieJar extends CookieJar
*/
public function __destruct()
{
$this->save($this->filename);
if ($this->autoSave) {
$this->save($this->filename);
}
}
/**
* Disable automatic persistence after unserialization.
*/
public function __wakeup(): void
{
$this->autoSave = false;
}
public function __unserialize(array $data): void
{
$this->autoSave = false;
throw new \LogicException(static::class.' should never be unserialized');
}
/**
@ -52,7 +85,8 @@ class FileCookieJar extends CookieJar
*
* @param string $filename File to save
*
* @throws \RuntimeException if the file cannot be found or created
* @throws \RuntimeException if the cookie data cannot be encoded or the
* file cannot be written
*/
public function save(string $filename): void
{
@ -60,42 +94,75 @@ class FileCookieJar extends CookieJar
/** @var SetCookie $cookie */
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
$data = $cookie->toArray();
$data['HostOnly'] = $cookie->getHostOnly();
$json[] = $data;
}
}
$jsonStr = Utils::jsonEncode($json);
if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) {
throw new \RuntimeException("Unable to save file {$filename}");
try {
$jsonStr = \json_encode($json, \JSON_HEX_TAG | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException('Unable to encode cookie data', 0, $e);
}
if (false === \file_put_contents($filename, $jsonStr, \LOCK_EX)) {
throw new \RuntimeException(\sprintf('Unable to save file %s', DiagnosticValue::escape($filename)));
}
// Best-effort: restrict the cookie file to the owner so persisted
// cookies are not world-readable.
@\chmod($filename, 0600);
}
/**
* Load cookies from a JSON formatted file.
*
* Old cookies are kept unless overwritten by newly loaded ones.
* Cookie records are constructed before any are passed to setCookie().
*
* @param string $filename Cookie file to load.
*
* @throws \RuntimeException if the file cannot be loaded.
* @throws \RuntimeException if the file cannot be loaded or is invalid
*/
public function load(string $filename): void
{
$json = \file_get_contents($filename);
if (false === $json) {
throw new \RuntimeException("Unable to load file {$filename}");
throw new \RuntimeException(\sprintf('Unable to load file %s', DiagnosticValue::escape($filename)));
}
if ($json === '') {
return;
}
$data = Utils::jsonDecode($json, true);
if (\is_array($data)) {
foreach ($data as $cookie) {
$this->setCookie(new SetCookie($cookie));
$message = \sprintf('Invalid cookie file: %s', DiagnosticValue::escape($filename));
try {
$data = \json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException($message, 0, $e);
}
// Associative decoding turns JSON objects into arrays, so inspect the root syntax too.
if (!\is_array($data) || \substr($json, \strspn($json, " \t\n\r"), 1) !== '[') {
throw new \RuntimeException($message);
}
$cookies = [];
foreach ($data as $cookie) {
if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) {
throw new \RuntimeException($message);
}
} elseif (\is_scalar($data) && !empty($data)) {
throw new \RuntimeException("Invalid cookie file: {$filename}");
try {
$cookies[] = new SetCookie($cookie);
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException($message, 0, $e);
}
}
foreach ($cookies as $cookie) {
$this->setCookie($cookie);
}
}
}

View file

@ -1,21 +1,36 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\NonSerializableTrait;
/**
* Persists cookies in the client session
*/
class SessionCookieJar extends CookieJar
{
use NonSerializableTrait;
/**
* @var string session key
*/
private $sessionKey;
private string $sessionKey;
/**
* @var bool Control whether to persist session cookies or not.
*/
private $storeSessionCookies;
private bool $storeSessionCookies;
/**
* @var bool Whether to save the cookie jar on destruction.
*
* Disabled by __wakeup() to prevent SessionCookieJar from being used as a
* PHP object injection $_SESSION-write gadget when an application
* unserializes attacker-controlled data.
*/
private bool $autoSave = false;
/**
* Create a new SessionCookieJar object
@ -24,6 +39,8 @@ class SessionCookieJar extends CookieJar
* data in session
* @param bool $storeSessionCookies Set to true to store session cookies
* in the cookie jar.
*
* @throws \RuntimeException if the session contains invalid cookie data
*/
public function __construct(string $sessionKey, bool $storeSessionCookies = false)
{
@ -31,6 +48,7 @@ class SessionCookieJar extends CookieJar
$this->sessionKey = $sessionKey;
$this->storeSessionCookies = $storeSessionCookies;
$this->load();
$this->autoSave = true;
}
/**
@ -38,11 +56,30 @@ class SessionCookieJar extends CookieJar
*/
public function __destruct()
{
$this->save();
if ($this->autoSave) {
$this->save();
}
}
/**
* Save cookies to the client session
* Disable automatic persistence after unserialization.
*/
public function __wakeup(): void
{
$this->autoSave = false;
}
public function __unserialize(array $data): void
{
$this->autoSave = false;
throw new \LogicException(static::class.' should never be unserialized');
}
/**
* Save cookies to the client session.
*
* @throws \RuntimeException if the cookie data cannot be encoded
*/
public function save(): void
{
@ -50,20 +87,25 @@ class SessionCookieJar extends CookieJar
/** @var SetCookie $cookie */
foreach ($this as $cookie) {
if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
$json[] = $cookie->toArray();
$data = $cookie->toArray();
$data['HostOnly'] = $cookie->getHostOnly();
$json[] = $data;
}
}
$json = \json_encode($json);
if (false === $json) {
throw new \RuntimeException('Unable to encode cookie data');
try {
$json = \json_encode($json, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException('Unable to encode cookie data', 0, $e);
}
$_SESSION[$this->sessionKey] = $json;
}
/**
* Load the contents of the client session into the data array
* Load cookies from the client session.
*
* @throws \RuntimeException if the session contains invalid cookie data
*/
protected function load(): void
{
@ -71,22 +113,38 @@ class SessionCookieJar extends CookieJar
return;
}
$message = 'Invalid cookie data';
$json = $_SESSION[$this->sessionKey];
if (!\is_string($json)) {
throw new \RuntimeException('Invalid cookie data');
throw new \RuntimeException($message);
}
$data = \json_decode($json, true);
if (\is_array($data)) {
foreach ($data as $cookie) {
if (!\is_array($cookie)) {
throw new \RuntimeException('Invalid cookie data');
}
try {
$data = \json_decode($json, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \RuntimeException($message, 0, $e);
}
$this->setCookie(new SetCookie($cookie));
// Associative decoding turns JSON objects into arrays, so inspect the root syntax too.
if (!\is_array($data) || \substr($json, \strspn($json, " \t\n\r"), 1) !== '[') {
throw new \RuntimeException($message);
}
$cookies = [];
foreach ($data as $cookie) {
if (!\is_array($cookie) || !\array_key_exists('HostOnly', $cookie) || !\is_bool($cookie['HostOnly'])) {
throw new \RuntimeException($message);
}
} elseif (\is_scalar($data) && \strlen((string) $data)) {
throw new \RuntimeException('Invalid cookie data');
try {
$cookies[] = new SetCookie($cookie);
} catch (\InvalidArgumentException $e) {
throw new \RuntimeException($message, 0, $e);
}
}
foreach ($cookies as $cookie) {
$this->setCookie($cookie);
}
}
}

View file

@ -1,7 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Cookie;
use GuzzleHttp\HostIdentity;
use GuzzleHttp\Psr7;
/**
* Set-Cookie object
*/
@ -10,7 +15,7 @@ class SetCookie
/**
* @var array
*/
private static $defaults = [
private const DEFAULTS = [
'Name' => null,
'Value' => null,
'Domain' => null,
@ -25,19 +30,28 @@ class SetCookie
/**
* @var array Cookie data
*/
private $data;
private array $data;
/**
* @var bool Whether this cookie was set without a Domain attribute.
*/
private bool $hostOnly = false;
/**
* Create a new SetCookie object from a string.
*
* @param string $cookie Set-Cookie header string
*/
public static function fromString(string $cookie): self
{
public static function fromString(
#[\SensitiveParameter]
string $cookie
): self {
// Create the default return array
$data = self::$defaults;
$data = self::DEFAULTS;
// Explode the cookie string using a series of semicolons
$pieces = \array_filter(\array_map('trim', \explode(';', $cookie)));
$pieces = \array_filter(\array_map(static function (string $piece): string {
return \trim($piece, " \t");
}, \explode(';', $cookie)));
// The name of the cookie (first kvp) must exist and include an equal sign.
if (!isset($pieces[0]) || \strpos($pieces[0], '=') === false) {
return new self($data);
@ -46,9 +60,9 @@ class SetCookie
// Add the cookie pieces into the parsed data array
foreach ($pieces as $part) {
$cookieParts = \explode('=', $part, 2);
$key = \trim($cookieParts[0]);
$key = \trim($cookieParts[0], " \t");
$value = isset($cookieParts[1])
? \trim($cookieParts[1], " \n\r\t\0\x0B")
? \trim($cookieParts[1], " \t")
: true;
// Only check for non-cookies when cookies have been found
@ -56,22 +70,28 @@ class SetCookie
$data['Name'] = $key;
$data['Value'] = $value;
} else {
foreach (\array_keys(self::$defaults) as $search) {
if (!\strcasecmp($search, $key)) {
foreach (\array_keys(self::DEFAULTS) as $search) {
if (Psr7\Utils::caselessEquals($search, $key)) {
if ($search === 'Max-Age') {
if (is_numeric($value)) {
$data[$search] = (int) $value;
if (\is_string($value) && \preg_match('/^[+-]?[0-9]+$/D', $value) === 1) {
$maxAge = self::parseNumericInteger($value);
if ($maxAge !== null) {
$data[$search] = $maxAge;
}
}
} elseif ($search === 'Secure' || $search === 'Discard' || $search === 'HttpOnly') {
if ($value) {
$data[$search] = true;
}
} else {
} elseif (\is_string($value)) {
$data[$search] = $value;
}
continue 2;
}
}
if (Psr7\Utils::caselessEquals('HostOnly', $key)) {
continue;
}
$data[$key] = $value;
}
}
@ -82,9 +102,17 @@ class SetCookie
/**
* @param array $data Array of cookie data provided by a Cookie parser
*/
public function __construct(array $data = [])
{
$this->data = self::$defaults;
public function __construct(
#[\SensitiveParameter]
array $data = []
) {
$this->data = self::DEFAULTS;
self::validateFieldTypes($data);
if (\array_key_exists('HostOnly', $data)) {
$this->setHostOnly($data['HostOnly']);
unset($data['HostOnly']);
}
if (isset($data['Name'])) {
$this->setName($data['Name']);
@ -123,23 +151,24 @@ class SetCookie
}
// Set the remaining values that don't have extra validation logic
foreach (array_diff(array_keys($data), array_keys(self::$defaults)) as $key) {
foreach (array_diff(array_keys($data), array_keys(self::DEFAULTS)) as $key) {
$this->data[$key] = $data[$key];
}
// Extract the Expires value and turn it into a UNIX timestamp if needed
if (!$this->getExpires() && $this->getMaxAge()) {
// Calculate the Expires date
$this->setExpires(\time() + $this->getMaxAge());
} elseif (null !== ($expires = $this->getExpires()) && !\is_numeric($expires)) {
$this->setExpires($expires);
$maxAge = $this->getMaxAge();
if ($maxAge !== null) {
$this->setExpires(self::maxAgeToExpires($maxAge, \time()));
}
}
public function __toString()
public function __toString(): string
{
$str = $this->data['Name'].'='.($this->data['Value'] ?? '').'; ';
foreach ($this->data as $k => $v) {
if ($k === 'Domain' && $this->getHostOnly()) {
continue;
}
if ($k !== 'Name' && $k !== 'Value' && $v !== null && $v !== false) {
if ($k === 'Expires') {
$str .= 'Expires='.\gmdate('D, d M Y H:i:s \G\M\T', $v).'; ';
@ -154,15 +183,18 @@ class SetCookie
public function toArray(): array
{
return $this->data;
$data = $this->data;
if ($this->getHostOnly()) {
$data['HostOnly'] = true;
}
return $data;
}
/**
* Get the cookie name.
*
* @return string
*/
public function getName()
public function getName(): ?string
{
return $this->data['Name'];
}
@ -172,21 +204,15 @@ class SetCookie
*
* @param string $name Cookie name
*/
public function setName($name): void
public function setName(string $name): void
{
if (!is_string($name)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Name'] = (string) $name;
$this->data['Name'] = $name;
}
/**
* Get the cookie value.
*
* @return string|null
*/
public function getValue()
public function getValue(): ?string
{
return $this->data['Value'];
}
@ -196,21 +222,15 @@ class SetCookie
*
* @param string $value Cookie value
*/
public function setValue($value): void
public function setValue(string $value): void
{
if (!is_string($value)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Value'] = (string) $value;
$this->data['Value'] = $value;
}
/**
* Get the domain.
*
* @return string|null
*/
public function getDomain()
public function getDomain(): ?string
{
return $this->data['Domain'];
}
@ -218,23 +238,35 @@ class SetCookie
/**
* Set the domain of the cookie.
*
* @param string|null $domain
* @param string|null $domain Domain of the cookie
*/
public function setDomain($domain): void
public function setDomain(?string $domain): void
{
if (!is_string($domain) && null !== $domain) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Domain'] = null === $domain ? null : self::normalizeDomain($domain);
}
$this->data['Domain'] = null === $domain ? null : (string) $domain;
/**
* Get whether this cookie is scoped to the origin host only.
*/
public function getHostOnly(): bool
{
return $this->hostOnly;
}
/**
* Set whether this cookie is scoped to the origin host only.
*
* @param bool $hostOnly Set to true for host-only cookies
*/
public function setHostOnly(bool $hostOnly): void
{
$this->hostOnly = $hostOnly;
}
/**
* Get the path.
*
* @return string
*/
public function getPath()
public function getPath(): string
{
return $this->data['Path'];
}
@ -244,21 +276,15 @@ class SetCookie
*
* @param string $path Path of the cookie
*/
public function setPath($path): void
public function setPath(string $path): void
{
if (!is_string($path)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Path'] = (string) $path;
$this->data['Path'] = $path;
}
/**
* Maximum lifetime of the cookie in seconds.
*
* @return int|null
*/
public function getMaxAge()
public function getMaxAge(): ?int
{
return null === $this->data['Max-Age'] ? null : (int) $this->data['Max-Age'];
}
@ -268,21 +294,15 @@ class SetCookie
*
* @param int|null $maxAge Max age of the cookie in seconds
*/
public function setMaxAge($maxAge): void
public function setMaxAge(?int $maxAge): void
{
if (!is_int($maxAge) && null !== $maxAge) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Max-Age'] = $maxAge === null ? null : (int) $maxAge;
$this->data['Max-Age'] = $maxAge;
}
/**
* The UNIX timestamp when the cookie Expires.
*
* @return string|int|null
*/
public function getExpires()
public function getExpires(): ?int
{
return $this->data['Expires'];
}
@ -295,26 +315,36 @@ class SetCookie
public function setExpires($timestamp): void
{
if (!is_int($timestamp) && !is_string($timestamp) && null !== $timestamp) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing an int, string or null to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
// TODO: Move this to the parameter definition in 9.0.
throw new \TypeError(__METHOD__.'(): Argument #1 ($timestamp) must be of type int|string|null');
}
if (null === $timestamp) {
if ($timestamp === null) {
$this->data['Expires'] = null;
} elseif (\is_numeric($timestamp)) {
$this->data['Expires'] = (int) $timestamp;
} else {
// Store unparseable dates as session cookies, not as expired cookies.
$expires = \strtotime((string) $timestamp);
$this->data['Expires'] = $expires === false ? null : $expires;
return;
}
if (\is_string($timestamp)) {
if (\is_numeric($timestamp)) {
$this->data['Expires'] = self::parseNumericInteger($timestamp);
return;
}
} elseif (\is_int($timestamp)) {
$this->data['Expires'] = $timestamp;
return;
}
$expires = \strtotime($timestamp);
$this->data['Expires'] = $expires === false ? null : $expires;
}
/**
* Get whether or not this is a secure cookie.
*
* @return bool
*/
public function getSecure()
public function getSecure(): bool
{
return $this->data['Secure'];
}
@ -324,21 +354,15 @@ class SetCookie
*
* @param bool $secure Set to true or false if secure
*/
public function setSecure($secure): void
public function setSecure(bool $secure): void
{
if (!is_bool($secure)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Secure'] = (bool) $secure;
$this->data['Secure'] = $secure;
}
/**
* Get whether or not this is a session cookie.
*
* @return bool|null
*/
public function getDiscard()
public function getDiscard(): bool
{
return $this->data['Discard'];
}
@ -348,21 +372,15 @@ class SetCookie
*
* @param bool $discard Set to true or false if this is a session cookie
*/
public function setDiscard($discard): void
public function setDiscard(bool $discard): void
{
if (!is_bool($discard)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['Discard'] = (bool) $discard;
$this->data['Discard'] = $discard;
}
/**
* Get whether or not this is an HTTP only cookie.
*
* @return bool
*/
public function getHttpOnly()
public function getHttpOnly(): bool
{
return $this->data['HttpOnly'];
}
@ -372,13 +390,9 @@ class SetCookie
*
* @param bool $httpOnly Set to true or false if this is HTTP only
*/
public function setHttpOnly($httpOnly): void
public function setHttpOnly(bool $httpOnly): void
{
if (!is_bool($httpOnly)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a bool to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
}
$this->data['HttpOnly'] = (bool) $httpOnly;
$this->data['HttpOnly'] = $httpOnly;
}
/**
@ -401,7 +415,7 @@ class SetCookie
$cookiePath = $this->getPath();
// Match on exact matches or when path is the default empty "/"
if ($cookiePath === '/' || $cookiePath == $requestPath) {
if ($cookiePath === '/' || $cookiePath === $requestPath) {
return true;
}
@ -427,28 +441,15 @@ class SetCookie
public function matchesDomain(string $domain): bool
{
$cookieDomain = $this->getDomain();
if (null === $cookieDomain) {
return true;
}
// Remove the leading '.' as per spec in RFC 6265.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.2.3
$cookieDomain = \ltrim(\strtolower($cookieDomain), '.');
$domain = \strtolower($domain);
// Domain not set or exact match.
if ('' === $cookieDomain || $domain === $cookieDomain) {
return true;
}
// Matching the subdomain according to RFC 6265.
// https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
if (\filter_var($domain, \FILTER_VALIDATE_IP)) {
if (null === $cookieDomain || $cookieDomain === '') {
return false;
}
return (bool) \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/', $domain);
if ($this->getHostOnly()) {
return HostIdentity::canonicalCookieDomain($domain) === HostIdentity::canonicalCookieDomain($cookieDomain);
}
return HostIdentity::cookieDomainMatches($domain, $cookieDomain);
}
/**
@ -462,20 +463,17 @@ class SetCookie
/**
* Check if the cookie is valid according to RFC 6265.
*
* @return bool|string Returns true if valid or an error message if invalid
* @return string|true Returns true if valid or an error message if invalid
*/
public function validate()
{
$name = $this->getName();
if ($name === '') {
if ($name === null || $name === '') {
return 'The cookie name must not be empty';
}
// Check if any of the invalid characters are present in the cookie name
if (\preg_match(
'/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/',
$name
)) {
if (\preg_match('/[\x00-\x20\x22\x28-\x29\x2c\x2f\x3a-\x40\x5c\x7b\x7d\x7f]/', $name) !== 0) {
return 'Cookie name must not contain invalid characters: ASCII '
.'Control characters (0-31;127), space, tab and the '
.'following characters: ()<>@,;:\"/?={}';
@ -491,10 +489,108 @@ class SetCookie
// Domains must not be empty, but may be omitted. "0" is not a valid
// internet domain, but may be used as server name in a private network.
$domain = $this->getDomain();
if ($domain === '') {
if ($domain === '' || ($domain !== null && \ltrim(\trim($domain, " \n\r\t\0\x0B"), '.') === '')) {
return 'The cookie domain must not be empty';
}
if ($this->getHostOnly() && $domain === null) {
return 'Host-only cookies must have a domain';
}
return true;
}
private static function normalizeDomain(string $domain): string
{
$domain = Psr7\Utils::asciiToLower($domain);
// Treat trailing-dot domains as host-only, but keep pure-dot domains invalid.
if ($domain !== '' && \substr($domain, -1) === '.' && \trim($domain, '.') !== '') {
return '';
}
if ($domain !== '' && $domain !== '.' && $domain[0] === '.') {
$domain = \substr_replace($domain, '', 0, 1);
}
return HostIdentity::canonicalCookieDomain($domain);
}
private static function maxAgeToExpires(int $maxAge, int $now): int
{
if ($maxAge <= 0) {
return $now - 1;
}
// Clamp absurd Max-Age values so addition cannot promote to float
if ($maxAge > \PHP_INT_MAX - $now) {
return \PHP_INT_MAX;
}
return $now + $maxAge;
}
private static function parseNumericInteger(string $value): ?int
{
if (!\is_numeric($value)) {
return null;
}
if (\preg_match('/^[+-]?[0-9]+$/D', $value) === 1) {
$negative = $value[0] === '-';
$digits = \ltrim($value, '+-');
$digits = \ltrim($digits, '0');
$digits = $digits === '' ? '0' : $digits;
$limit = $negative ? \substr((string) \PHP_INT_MIN, 1) : (string) \PHP_INT_MAX;
if (\strlen($digits) > \strlen($limit) || (\strlen($digits) === \strlen($limit) && \strcmp($digits, $limit) > 0)) {
return null;
}
return (int) ($negative ? '-'.$digits : $digits);
}
$number = (float) $value;
if (!\is_finite($number) || $number < \PHP_INT_MIN || $number > \PHP_INT_MAX) {
return null;
}
if (\PHP_INT_SIZE === 8 && ($number <= (float) \PHP_INT_MIN || $number >= (float) \PHP_INT_MAX)) {
return null;
}
return (int) $number;
}
/**
* @param mixed[] $data
*/
private static function validateFieldTypes(
#[\SensitiveParameter]
array $data
): void {
foreach (['Name', 'Value', 'Domain', 'Path'] as $field) {
if (isset($data[$field]) && !\is_string($data[$field])) {
throw new \InvalidArgumentException(\sprintf('Cookie field "%s" must be a string', $field));
}
}
if (isset($data['Max-Age']) && !\is_int($data['Max-Age'])) {
throw new \InvalidArgumentException('Cookie field "Max-Age" must be an integer');
}
if (isset($data['Expires']) && !\is_int($data['Expires']) && !\is_string($data['Expires'])) {
throw new \InvalidArgumentException('Cookie field "Expires" must be an integer or string');
}
foreach (['Secure', 'Discard', 'HttpOnly'] as $field) {
if (isset($data[$field]) && !\is_bool($data[$field])) {
throw new \InvalidArgumentException(\sprintf('Cookie field "%s" must be a boolean', $field));
}
}
if (\array_key_exists('HostOnly', $data) && !\is_bool($data['HostOnly'])) {
throw new \InvalidArgumentException('Cookie field "HostOnly" must be a boolean');
}
}
}

33
vendor/guzzlehttp/guzzle/src/Env.php vendored Normal file
View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* Reads configuration from the process environment.
*
* Intentionally separate from Handler\ProxyEnv, which has different
* lookup semantics.
*
* @internal
*/
final class Env
{
private function __construct()
{
}
public static function get(string $name): ?string
{
if (isset($_SERVER[$name])) {
return (string) $_SERVER[$name];
}
if (\PHP_SAPI === 'cli' && ($value = \getenv($name)) !== false && $value !== null) {
return (string) $value;
}
return null;
}
}

View file

@ -1,39 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Exception when an HTTP error occurs (4xx or 5xx error)
* Exception thrown for HTTP responses with 4xx or 5xx status codes.
*/
class BadResponseException extends RequestException
class BadResponseException extends ResponseException
{
public function __construct(
string $message,
RequestInterface $request,
ResponseInterface $response,
?\Throwable $previous = null,
array $handlerContext = []
) {
parent::__construct($message, $request, $response, $previous, $handlerContext);
}
/**
* Current exception and the ones that extend it will always have a response.
*/
public function hasResponse(): bool
{
return true;
}
/**
* This function narrows the return type from the parent class and does not allow it to be nullable.
*/
public function getResponse(): ResponseInterface
{
/** @var ResponseInterface */
return parent::getResponse();
}
}

View file

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception when a client error is encountered (4xx codes)
* Exception thrown for HTTP responses with 4xx status codes.
*/
class ClientException extends BadResponseException
{

View file

@ -1,54 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
/**
* Exception thrown when a connection cannot be established.
* Exception thrown when connection establishment fails.
*/
class ConnectException extends TransferException implements NetworkExceptionInterface
class ConnectException extends NetworkException
{
/**
* @var RequestInterface
*/
private $request;
/**
* @var array
*/
private $handlerContext;
public function __construct(
string $message,
RequestInterface $request,
?\Throwable $previous = null,
array $handlerContext = []
) {
parent::__construct($message, 0, $previous);
$this->request = $request;
$this->handlerContext = $handlerContext;
}
/**
* Get the request that caused the exception
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
/**
* Get contextual information about the error from the underlying handler.
*
* The contents of this array will vary depending on which handler you are
* using. It may also be just an empty array. Relying on this data will
* couple you to a specific handler, but can give more debug information
* when needed.
*/
public function getHandlerContext(): array
{
return $this->handlerContext;
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when connection establishment exceeds the time limit.
*/
class ConnectTimeoutException extends ConnectException
{
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Client\ClientExceptionInterface;

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a handler is closed before a transfer completes.
*/
class HandlerClosedException extends TransferException
{
}

View file

@ -1,7 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when an invalid argument is supplied to Guzzle.
*/
final class InvalidArgumentException extends \InvalidArgumentException implements GuzzleException
{
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
/**
* Base exception for transfer failures without a response.
*/
class NetworkException extends TransferException implements NetworkExceptionInterface
{
public function __construct(
string $message,
RequestInterface $request,
?\Throwable $previous = null
) {
parent::__construct($message, $request, 0, $previous);
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a transfer times out before response headers are
* received.
*/
class NetworkTimeoutException extends NetworkException
{
}

View file

@ -1,154 +1,91 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use GuzzleHttp\BodySummarizer;
use GuzzleHttp\BodySummarizerInterface;
use GuzzleHttp\Psr7\DiagnosticValue;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* HTTP Request exception
* Base exception for request failures associated with a request.
*/
class RequestException extends TransferException implements RequestExceptionInterface
{
/**
* @var RequestInterface
*/
private $request;
/**
* @var ResponseInterface|null
*/
private $response;
/**
* @var array
*/
private $handlerContext;
public function __construct(
string $message,
RequestInterface $request,
?ResponseInterface $response = null,
?\Throwable $previous = null,
array $handlerContext = []
int $code = 0,
?\Throwable $previous = null
) {
// Set the code of the exception if the response is set and not future.
$code = $response ? $response->getStatusCode() : 0;
parent::__construct($message, $code, $previous);
$this->request = $request;
$this->response = $response;
$this->handlerContext = $handlerContext;
}
/**
* Wrap non-RequestExceptions with a RequestException
*
* @deprecated since 7.11. Create a RequestException directly instead.
*/
public static function wrapException(RequestInterface $request, \Throwable $e): RequestException
{
\trigger_deprecation('guzzlehttp/guzzle', '7.11', '%s::wrapException() is deprecated and will be removed in 8.0. Create a %s directly instead.', self::class, self::class);
return $e instanceof RequestException ? $e : new RequestException($e->getMessage(), $request, null, $e);
parent::__construct($message, $request, $code, $previous);
}
/**
* Factory method to create a new exception with a normalized error message
*
* @param RequestInterface $request Request sent
* @param ResponseInterface $response Response received
* @param ResponseInterface|null $response Response received, if any
* @param \Throwable|null $previous Previous exception
* @param array $handlerContext Optional handler context
* @param BodySummarizerInterface|null $bodySummarizer Optional body summarizer
*/
public static function create(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
?ResponseInterface $response = null,
#[\SensitiveParameter]
?\Throwable $previous = null,
array $handlerContext = [],
?BodySummarizerInterface $bodySummarizer = null
): self {
if (!$response) {
return new self(
'Error completing request',
$request,
null,
$previous,
$handlerContext
0,
$previous
);
}
$level = (int) \floor($response->getStatusCode() / 100);
if ($level === 4) {
$label = 'Client error';
$className = ClientException::class;
} elseif ($level === 5) {
$label = 'Server error';
$className = ServerException::class;
} else {
$label = 'Unsuccessful request';
$className = __CLASS__;
}
$uri = \GuzzleHttp\Psr7\Utils::redactUserInfo($request->getUri());
// Client Error: `GET /` resulted in a `404 Not Found` response:
// <html> ... (truncated)
// Client error: `GET /` resulted in a `404 Not Found` response: <html> ... (truncated)
$message = \sprintf(
'%s: `%s %s` resulted in a `%s %s` response',
$label,
$request->getMethod(),
$uri->__toString(),
DiagnosticValue::escape($request->getMethod()),
DiagnosticValue::escape($uri->__toString()),
$response->getStatusCode(),
$response->getReasonPhrase()
DiagnosticValue::escape($response->getReasonPhrase())
);
$summary = ($bodySummarizer ?? new BodySummarizer())->summarize($response);
if ($summary !== null) {
$message .= ":\n{$summary}\n";
$message .= \sprintf(': %s', DiagnosticValue::escape($summary));
}
return new $className($message, $request, $response, $previous, $handlerContext);
}
if ($level === 4) {
return new ClientException($message, $request, $response, $previous);
}
/**
* Get the request that caused the exception
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
if ($level === 5) {
return new ServerException($message, $request, $response, $previous);
}
/**
* Get the associated response
*/
public function getResponse(): ?ResponseInterface
{
return $this->response;
}
/**
* Check if a response was received
*/
public function hasResponse(): bool
{
return $this->response !== null;
}
/**
* Get contextual information about the error from the underlying handler.
*
* The contents of this array will vary depending on which handler you are
* using. It may also be just an empty array. Relying on this data will
* couple you to a specific handler, but can give more debug information
* when needed.
*/
public function getHandlerContext(): array
{
return $this->handlerContext;
return new ResponseException($message, $request, $response, $previous);
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Exception thrown when a request fails after response headers are received.
*/
class ResponseException extends RequestException
{
private ResponseInterface $response;
final public function __construct(
string $message,
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
?\Throwable $previous = null
) {
parent::__construct($message, $request, $response->getStatusCode(), $previous);
$this->response = $response;
}
/**
* Return a new exception instance with an updated response.
*
* @param \Throwable|null $previous Previous exception, or the current
* previous exception when omitted.
*
* @return static
*/
public function withResponse(
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
?\Throwable $previous = null
): self {
if ($response->getStatusCode() !== $this->response->getStatusCode()) {
throw new InvalidArgumentException('Cannot replace response with a different status code.');
}
return new static(
$this->getMessage(),
$this->getRequest(),
$response,
$previous ?? $this->getPrevious()
);
}
/**
* Get the associated response.
*/
public function getResponse(): ResponseInterface
{
return $this->response;
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a transfer times out after response headers are
* received.
*/
class ResponseTimeoutException extends ResponseTransferException
{
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception thrown when a response transfer fails after response headers are
* received.
*/
class ResponseTransferException extends ResponseException
{
}

View file

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
/**
* Exception when a server error is encountered (5xx codes)
* Exception thrown for HTTP responses with 5xx status codes.
*/
class ServerException extends BadResponseException
{

View file

@ -1,7 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
class TooManyRedirectsException extends RequestException
/**
* Exception thrown when redirect middleware reaches the redirect limit.
*/
class TooManyRedirectsException extends ResponseException
{
}

View file

@ -1,7 +1,33 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Exception;
use Psr\Http\Message\RequestInterface;
/**
* Base exception for transfer failures.
*/
class TransferException extends \RuntimeException implements GuzzleException
{
private RequestInterface $request;
public function __construct(
string $message,
RequestInterface $request,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
$this->request = $request;
}
/**
* Get the request that caused the exception.
*/
public function getRequest(): RequestInterface
{
return $this->request;
}
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
/**
* @internal
*/
final class Clock
{
private function __construct()
{
}
/**
* Returns the current monotonic clock reading in seconds.
*/
public static function now(): float
{
return \hrtime(true) / 1e9;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use Psr\Http\Message\RequestInterface;
@ -9,6 +11,10 @@ interface CurlFactoryInterface
/**
* Creates a cURL handle resource.
*
* Implementations must return an EasyHandle whose public state properties,
* including the request and sink, contain values compatible with Guzzle's
* cURL handlers.
*
* @param RequestInterface $request Request
* @param array $options Transfer options
*

View file

@ -1,10 +1,16 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\DiagnosticValue;
use GuzzleHttp\TransportSharing;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* HTTP handler that uses cURL easy handles as a transport layer.
@ -12,20 +18,23 @@ use Psr\Http\Message\RequestInterface;
* When using the CurlHandler, custom curl options can be specified as an
* associative array of curl option constants mapping to values in the
* **curl** key of the "client" key of the request.
*
* @final
*/
class CurlHandler
final class CurlHandler
{
/**
* @var CurlFactoryInterface
*/
private $factory;
use NonSerializableTrait;
/**
* @var CurlShareHandleState|null
*/
private $shareHandleState;
private const KNOWN_CONSTRUCTOR_OPTIONS = [
'handle_factory' => true,
'transport_sharing' => true,
];
private CurlFactoryInterface $factory;
private bool $ownsFactory;
private bool $closed = false;
private ?CurlShareHandleState $shareHandleState;
/**
* Accepts an associative array of options:
@ -37,6 +46,12 @@ class CurlHandler
*/
public function __construct(array $options = [])
{
foreach ($options as $name => $_) {
if (!isset(self::KNOWN_CONSTRUCTOR_OPTIONS[$name])) {
throw new InvalidArgumentException(\sprintf('Invalid CurlHandler constructor option "%s".', DiagnosticValue::escape((string) $name)));
}
}
CurlShareHandleState::assertNoRequiredSharingCustomFactoryConflict($options, 'CurlHandler');
$transportSharing = $options['transport_sharing'] ?? null;
$sharingMode = CurlShareHandleState::normalizeMode($transportSharing, 'transport_sharing');
@ -44,6 +59,7 @@ class CurlHandler
if (\array_key_exists('handle_factory', $options) && $options['handle_factory'] !== null) {
$this->shareHandleState = null;
$this->factory = $options['handle_factory'];
$this->ownsFactory = false;
return;
}
@ -53,20 +69,94 @@ class CurlHandler
: null;
$this->factory = $this->shareHandleState !== null
? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState->handle)
? new CurlFactory(3, $this->shareHandleState->mode, $this->shareHandleState)
: new CurlFactory(3);
$this->ownsFactory = true;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$this->assertOpen();
HostValidator::assertRequestHost($request);
if (isset($options['delay'])) {
\usleep($options['delay'] * 1000);
\usleep((int) ($options['delay'] * 1000));
}
// A Multiplexing::NONE request option holds unconditionally here:
// the transfer runs alone during the blocking curl_exec(), and even
// under persistent transport sharing an in-use connection cannot be
// joined from another multi handle, so it never shares its
// connection with a concurrent transfer.
$easy = $this->factory->create($request, $options);
\curl_exec($easy->handle);
$easy->errno = \curl_errno($easy->handle);
return CurlFactory::finish($this, $easy, $this->factory);
}
/**
* Closes native cURL resources owned by this handler.
*
* After closing, the handler is terminal and must not be reused.
*/
public function close(): void
{
$this->doClose(true);
}
public function __destruct()
{
try {
$this->doClose(false);
} catch (\Throwable $e) {
// Destructors must not throw.
}
}
public function __unserialize(array $data): void
{
$this->closed = true;
throw new \LogicException(static::class.' should never be unserialized');
}
private function assertOpen(): void
{
if ($this->closed) {
// Programmer misuse (reusing a closed handler), not a transfer failure;
// intentionally a LogicException outside the GuzzleException hierarchy.
throw new \BadMethodCallException('Cannot use the cURL handler after it has been closed.');
}
}
private function doClose(bool $explicit): void
{
if ($this->closed) {
return;
}
$this->closed = true;
try {
if ($this->ownsFactory && $this->factory instanceof CurlFactory) {
$this->factory->close();
}
} catch (\Throwable $e) {
if ($explicit) {
throw $e;
}
} finally {
$this->shareHandleState = null;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,27 +1,29 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\TransportSharing;
use GuzzleHttp\Utils;
/**
* @internal
*/
final class CurlShareHandleState
{
use NonSerializableTrait;
/**
* @var resource|\CurlShareHandle|null
* @var resource|\CurlShareHandle|\CurlSharePersistentHandle|null
*/
public $handle;
/**
* @var string
*/
public $mode;
public string $mode;
/**
* @param resource|\CurlShareHandle|null $handle
* @param resource|\CurlShareHandle|\CurlSharePersistentHandle|null $handle
*/
private function __construct(string $mode, $handle)
{
@ -47,7 +49,15 @@ final class CurlShareHandleState
return self::createHandlerShareOrNull($mode);
}
return self::createHandlerShare($mode);
if ($mode === TransportSharing::HANDLER_REQUIRE) {
return self::createHandlerShare($mode);
}
if ($mode === TransportSharing::PERSISTENT_PREFER) {
return self::createPersistentShareOrFallback();
}
return self::createPersistentShare($mode);
}
/**
@ -63,14 +73,19 @@ final class CurlShareHandleState
return TransportSharing::NONE;
}
if ($sharing === TransportSharing::HANDLER_PREFER || $sharing === TransportSharing::HANDLER_REQUIRE) {
if (
$sharing === TransportSharing::HANDLER_PREFER
|| $sharing === TransportSharing::HANDLER_REQUIRE
|| $sharing === TransportSharing::PERSISTENT_PREFER
|| $sharing === TransportSharing::PERSISTENT_REQUIRE
) {
return $sharing;
}
throw new \InvalidArgumentException(\sprintf(
throw new InvalidArgumentException(\sprintf(
'The "%s" option must be null or a GuzzleHttp\\TransportSharing::* constant; received %s.',
$option,
Utils::describeType($sharing)
\get_debug_type($sharing)
));
}
@ -81,11 +96,11 @@ final class CurlShareHandleState
}
$mode = self::normalizeMode($options['transport_sharing'] ?? null, 'transport_sharing');
if ($mode !== TransportSharing::HANDLER_REQUIRE) {
if (!\in_array($mode, [TransportSharing::HANDLER_REQUIRE, TransportSharing::PERSISTENT_REQUIRE], true)) {
return;
}
throw new \InvalidArgumentException(\sprintf(
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" %s option cannot require sharing with a custom "handle_factory" because Guzzle cannot ensure that the custom factory applies CURLOPT_SHARE.',
$handlerName
));
@ -103,12 +118,12 @@ final class CurlShareHandleState
private static function createHandlerShare(string $mode): self
{
if (!\function_exists('curl_share_init') || !\function_exists('curl_share_setopt')) {
throw new \InvalidArgumentException('The "transport_sharing" option requires cURL share support.');
throw new InvalidArgumentException('The "transport_sharing" option requires cURL share support.');
}
self::requireCurlConstant('CURLOPT_SHARE');
$shareOption = self::requireCurlConstant('CURLSHOPT_SHARE');
$locks = self::handlerLocks();
$locks = self::handlerLocks($mode);
$handle = curl_share_init();
try {
@ -116,11 +131,11 @@ final class CurlShareHandleState
try {
$success = curl_share_setopt($handle, $shareOption, $lock);
} catch (\Throwable $e) {
throw new \InvalidArgumentException('Unable to configure cURL share handle: '.$e->getMessage(), 0, $e);
throw new InvalidArgumentException('Unable to configure cURL share handle: '.$e->getMessage(), 0, $e);
}
if (!$success) {
throw new \InvalidArgumentException(\sprintf('Unable to configure cURL share handle with lock data %d.', $lock));
throw new InvalidArgumentException(\sprintf('Unable to configure cURL share handle with lock data %d.', $lock));
}
}
} catch (\Throwable $e) {
@ -132,13 +147,84 @@ final class CurlShareHandleState
return new self($mode, $handle);
}
private static function createPersistentShareOrFallback(): ?self
{
if (self::supportsPersistentShare()) {
try {
return self::createPersistentShare(TransportSharing::PERSISTENT_PREFER);
} catch (\Throwable $e) {
// Fall back to handler-lifetime best effort below.
}
}
return self::createHandlerShareOrNull(TransportSharing::HANDLER_PREFER);
}
private static function createPersistentShare(string $mode): self
{
CurlVersion::ensureConnectionSharingSupported();
CurlVersion::ensureSslSessionSharingSupported();
if (!self::supportsPersistentShare()) {
throw new InvalidArgumentException('The "transport_sharing" option requires persistent cURL share handle support.');
}
self::requireCurlConstant('CURLOPT_SHARE');
try {
$handle = curl_share_init_persistent(self::persistentLocks());
} catch (\Throwable $e) {
throw new InvalidArgumentException(
'Unable to create persistent cURL share handle: '.$e->getMessage(),
0,
$e
);
}
return new self($mode, $handle);
}
private static function supportsPersistentShare(): bool
{
return CurlVersion::supportsConnectionSharing()
&& CurlVersion::supportsSslSessionSharing()
&& \function_exists('curl_share_init_persistent')
&& \class_exists('CurlSharePersistentHandle')
&& \defined('CURL_LOCK_DATA_DNS')
&& \defined('CURL_LOCK_DATA_CONNECT')
&& \defined('CURL_LOCK_DATA_SSL_SESSION');
}
/**
* @return int[]
*/
private static function handlerLocks(): array
private static function handlerLocks(string $mode): array
{
CurlVersion::ensureHandlerSharingSupported();
if ($mode === TransportSharing::HANDLER_REQUIRE) {
CurlVersion::ensureSslSessionSharingSupported();
}
$locks = [
self::requireCurlConstant('CURL_LOCK_DATA_DNS'),
];
if (CurlVersion::supportsSslSessionSharing()) {
$locks[] = self::requireCurlConstant('CURL_LOCK_DATA_SSL_SESSION');
}
return $locks;
}
/**
* @return int[]
*/
private static function persistentLocks(): array
{
return [
self::requireCurlConstant('CURL_LOCK_DATA_DNS'),
self::requireCurlConstant('CURL_LOCK_DATA_CONNECT'),
self::requireCurlConstant('CURL_LOCK_DATA_SSL_SESSION'),
];
}
@ -146,7 +232,7 @@ final class CurlShareHandleState
private static function requireCurlConstant(string $constant): int
{
if (!\defined($constant)) {
throw new \InvalidArgumentException(\sprintf(
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires %s, but it is not available in the installed PHP cURL extension.',
$constant
));
@ -154,7 +240,7 @@ final class CurlShareHandleState
$value = \constant($constant);
if (!\is_int($value)) {
throw new \InvalidArgumentException(\sprintf('The cURL constant %s must resolve to an integer.', $constant));
throw new InvalidArgumentException(\sprintf('The cURL constant %s must resolve to an integer.', $constant));
}
return $value;

View file

@ -0,0 +1,392 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\InvalidArgumentException;
use Psr\Http\Message\RequestInterface;
/**
* @internal
*/
final class CurlVersion
{
private const MIN_VERSION = '7.34.0';
private const TLS_13_VERSION = '7.52.0';
// CURLOPT_PIPEWAIT exists since libcurl 7.43.0, and multi handles have
// multiplexed by default since 7.62.0 - but a 7.65.0-7.65.1 regression
// dropped that default, which 7.65.2 restored, so 7.65.2 is the floor at
// which PIPEWAIT is reliably effective.
private const MULTIPLEX_VERSION = '7.65.2';
// libcurl's connection matcher refuses to hand a transfer wanting
// HTTP/1.x a pooled connection that already negotiated HTTP/2 or newer
// from 7.77.0: ConnectionExists() in lib/url.c gained the check between
// the 7.76.0 and 7.77.0 releases. The HTTP/2 branch of the check
// regressed to a debug log in 8.11.0 (curl commit 433d730) and was
// restored in 8.13.0 via the negotiation mask (curl commit db72b8d), so
// 8.11.0 through 8.12.1 are vulnerable again.
private const HTTP_VERSION_REUSE_MATCH_VERSION = '7.77.0';
private const HTTP_VERSION_REUSE_MATCH_REGRESSION = '8.11.0';
private const HTTP_VERSION_REUSE_MATCH_RESTORED = '8.13.0';
// CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE restricts the ALPN offer to h2 only
// since libcurl 8.10.0, and connection reuse matching stopped handing
// lower-version connections to prior-knowledge transfers in 8.14.0; below
// that, a required HTTP/2 request could silently be sent over a reused
// HTTP/1.1 connection.
private const REQUIRED_HTTP2_MULTIPLEX_VERSION = '8.14.0';
// Version-aware connection reuse matching arrived in libcurl 8.13.0 with
// an HTTP/3-only mask for CURL_HTTP_VERSION_3ONLY transfers; below that,
// a required HTTP/3 request could silently ride a reused HTTP/2
// connection.
private const REQUIRED_HTTP3_MULTIPLEX_VERSION = '8.13.0';
// curl 7.52.0 introduced HTTPS proxy support, advertised by a feature bit
// (a build can meet the version yet lack the feature). Earlier libcurl
// mishandles an https:// proxy: before 7.50.2 it silently downgrades to a
// plaintext HTTP proxy, and 7.50.2 through 7.51 reject it at connect time.
// The 7.52.0 TLS rework also shipped verification flaws in exactly this
// path (CVE-2017-2629, CVE-2017-7468); the latter affects 7.52.0-7.53.1
// and was fixed in 7.54.0, so TLS to a proxy is only trusted from there.
private const HTTPS_PROXY_VERSION = '7.54.0';
// HTTP/3 arrived in libcurl 7.66.0, but CURL_HTTP_VERSION_3ONLY only
// exists from 7.88.0; requiring it keeps every HTTP/3-capable runtime
// able to pin HTTP/3 with no downgrade.
private const HTTP_3_VERSION = '7.88.0';
private const PROTOCOLS_STR_VERSION = '7.85.0';
private const HANDLER_SHARING_VERSION = '7.35.0';
private const SSL_SESSION_SHARING_VERSION = '8.6.0';
private const CONNECTION_SHARING_VERSION = '8.12.0';
// curl 7.57.0 added share-handle connection caches through
// CURL_LOCK_DATA_CONNECT; older share objects can only hold DNS, TLS
// session, and cookie data, never connections. This is libcurl's raw
// capability floor for the opaque share safeguards; Guzzle-managed
// connection sharing is separately gated by CONNECTION_SHARING_VERSION.
private const SHARE_CONNECTION_CACHE_VERSION = '7.57.0';
// curl 7.83.1 added proxy TLS-SRP to the connection-reuse match
// (CVE-2022-27782); the proxy client certificate was matched from 7.52.0,
// so proxy TLS credentials are trusted from 7.83.1 onwards.
private const PROXY_TLS_CREDENTIAL_REUSE_VERSION = '7.83.1';
// curl 8.19.0 fixed proxy tunnel reuse after credential changes
// (CVE-2026-3784), but related proxy credential leak flaws were only
// fixed in 8.20.0, so connection reuse is trusted from 8.20.0 onwards.
private const PROXY_CREDENTIAL_REUSE_VERSION = '8.20.0';
// curl 7.69.0 started comparing SOCKS proxy credentials when matching
// connections for reuse (curl #4835); older libcurl matches a SOCKS proxy
// by type, host, and port only.
private const SOCKS_PROXY_CREDENTIAL_REUSE_VERSION = '7.69.0';
private const PROXY_HEADER_SEPARATION_VERSION = '7.37.0';
// CURLOPT_SUPPRESS_CONNECT_HEADERS arrived in curl 7.54.0; proxy CONNECT
// tunneling is gated on it so the proxy's interim reply can never surface
// as a phantom response on any build that can tunnel.
private const PROXY_TUNNEL_VERSION = '7.54.0';
/**
* @var array{version: string, features: int}|false|null
*/
private static $versionInfo;
private function __construct()
{
}
public static function supportsCurlHandler(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_SSL')
&& \defined('CURL_SSLVERSION_TLSv1_2')
&& \defined('CURLMOPT_MAX_HOST_CONNECTIONS')
&& \defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')
&& null !== $versionInfo
&& version_compare($versionInfo['version'], self::MIN_VERSION, '>=')
&& 0 !== (\CURL_VERSION_SSL & $versionInfo['features']);
}
public static function supportsTls13(): bool
{
$version = self::get();
return \defined('CURL_SSLVERSION_TLSv1_3')
&& null !== $version
&& version_compare($version, self::TLS_13_VERSION, '>=');
}
public static function supportsMultiplex(): bool
{
$version = self::get();
return \defined('CURLOPT_PIPEWAIT')
&& null !== $version
&& version_compare($version, self::MULTIPLEX_VERSION, '>=');
}
public static function supportsHttpVersionReuseMatching(): bool
{
$version = self::get();
if (null === $version || version_compare($version, self::HTTP_VERSION_REUSE_MATCH_VERSION, '<')) {
return false;
}
return version_compare($version, self::HTTP_VERSION_REUSE_MATCH_REGRESSION, '<')
|| version_compare($version, self::HTTP_VERSION_REUSE_MATCH_RESTORED, '>=');
}
public static function supportsRequiredHttp2Multiplex(): bool
{
$version = self::get();
return \defined('CURL_HTTP_VERSION_2_PRIOR_KNOWLEDGE')
&& null !== $version
&& self::supportsHttp2()
&& version_compare($version, self::REQUIRED_HTTP2_MULTIPLEX_VERSION, '>=');
}
public static function supportsHttp2(): bool
{
$versionInfo = self::getVersionInfo();
// Requiring dependable CURLOPT_PIPEWAIT support keeps waiting from
// ever being silently unavailable where HTTP/2 works.
return \defined('CURL_VERSION_HTTP2')
&& null !== $versionInfo
&& self::supportsMultiplex()
&& 0 !== (\CURL_VERSION_HTTP2 & $versionInfo['features']);
}
public static function supportsHttp3(): bool
{
if (!\defined('CURL_VERSION_HTTP3') || !\defined('CURL_HTTP_VERSION_3') || !\defined('CURL_HTTP_VERSION_3ONLY')) {
return false;
}
$versionInfo = self::getVersionInfo();
if (null === $versionInfo || version_compare($versionInfo['version'], self::HTTP_3_VERSION, '<')) {
return false;
}
return 0 !== ((int) \constant('CURL_VERSION_HTTP3') & $versionInfo['features']);
}
public static function supportsRequiredHttp3Multiplex(): bool
{
$version = self::get();
return self::supportsHttp3()
&& null !== $version
&& version_compare($version, self::REQUIRED_HTTP3_MULTIPLEX_VERSION, '>=');
}
public static function supportsHttpsProxy(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_HTTPS_PROXY')
&& null !== $versionInfo
&& version_compare($versionInfo['version'], self::HTTPS_PROXY_VERSION, '>=')
&& 0 !== (\CURL_VERSION_HTTPS_PROXY & $versionInfo['features']);
}
public static function supportsHandlerSharing(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::HANDLER_SHARING_VERSION, '>=');
}
public static function ensureHandlerSharingSupported(): void
{
if (!self::supportsHandlerSharing()) {
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher for cURL share handles.',
self::HANDLER_SHARING_VERSION
));
}
}
public static function supportsSslSessionSharing(): bool
{
$versionInfo = self::getVersionInfo();
return \defined('CURL_VERSION_SSL')
&& null !== $versionInfo
&& version_compare($versionInfo['version'], self::SSL_SESSION_SHARING_VERSION, '>=')
&& 0 !== (\CURL_VERSION_SSL & $versionInfo['features']);
}
public static function ensureSslSessionSharingSupported(): void
{
if (!self::supportsSslSessionSharing()) {
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher with SSL support for SSL session sharing.',
self::SSL_SESSION_SHARING_VERSION
));
}
}
public static function supportsConnectionSharing(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::CONNECTION_SHARING_VERSION, '>=');
}
public static function ensureConnectionSharingSupported(): void
{
if (!self::supportsConnectionSharing()) {
throw new InvalidArgumentException(\sprintf(
'The "transport_sharing" option requires libcurl %s or higher for persistent connection sharing.',
self::CONNECTION_SHARING_VERSION
));
}
}
public static function supportsShareConnectionCaches(): bool
{
$version = self::get();
// An undetectable libcurl version is treated as capable so the
// opaque share safeguards fail closed.
return null === $version
|| version_compare($version, self::SHARE_CONNECTION_CACHE_VERSION, '>=');
}
public static function supportsProxyTlsCredentialAwareConnectionReuse(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::PROXY_TLS_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsProxyCredentialAwareConnectionReuse(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::PROXY_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsSocksProxyCredentialAwareConnectionReuse(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::SOCKS_PROXY_CREDENTIAL_REUSE_VERSION, '>=');
}
public static function supportsProxyHeaderSeparation(): bool
{
$version = self::get();
return null !== $version
&& version_compare($version, self::PROXY_HEADER_SEPARATION_VERSION, '>=')
&& \defined('CURLOPT_PROXYHEADER')
&& \defined('CURLOPT_HEADEROPT')
&& \defined('CURLHEADER_SEPARATE');
}
public static function supportsProtocolsStr(): bool
{
$version = self::get();
return \defined('CURLOPT_PROTOCOLS_STR')
&& null !== $version
&& version_compare($version, self::PROTOCOLS_STR_VERSION, '>=');
}
public static function supportsProxyTunneling(): bool
{
$version = self::get();
return \defined('CURLOPT_SUPPRESS_CONNECT_HEADERS')
&& null !== $version
&& version_compare($version, self::PROXY_TUNNEL_VERSION, '>=');
}
public static function ensureSupported(
#[\SensitiveParameter]
RequestInterface $request
): void {
if (self::supportsCurlHandler()) {
return;
}
$version = self::get();
if (null === $version || version_compare($version, self::MIN_VERSION, '<')) {
throw new ConnectException(\sprintf(
'cURL %s or higher is required by the cURL handler; %s is installed.',
self::MIN_VERSION,
$version ?? 'an unknown version'
), $request);
}
if (!\defined('CURL_SSLVERSION_TLSv1_2')) {
throw new ConnectException(\sprintf(
'The PHP cURL extension must be built against cURL %s or higher to use the cURL handler.',
self::MIN_VERSION
), $request);
}
if (!\defined('CURLMOPT_MAX_HOST_CONNECTIONS') || !\defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) {
throw new ConnectException('The PHP cURL extension must expose CURLMOPT_MAX_HOST_CONNECTIONS and CURLMOPT_MAX_TOTAL_CONNECTIONS to use the cURL handler.', $request);
}
throw new ConnectException('The cURL handler requires libcurl SSL support.', $request);
}
private static function get(): ?string
{
$versionInfo = self::getVersionInfo();
return null === $versionInfo ? null : $versionInfo['version'];
}
/**
* @return array{version: string, features: int}|null
*/
private static function getVersionInfo(): ?array
{
if (null === self::$versionInfo) {
if (!\function_exists('curl_version')) {
self::$versionInfo = false;
} else {
$versionInfo = \curl_version();
self::$versionInfo = \is_array($versionInfo)
&& isset($versionInfo['version'], $versionInfo['features'])
&& \is_string($versionInfo['version'])
&& \is_int($versionInfo['features'])
? [
'version' => $versionInfo['version'],
'features' => $versionInfo['features'],
]
: false;
}
}
return false === self::$versionInfo ? null : self::$versionInfo;
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Decorates the transport stream of a buffered response so every read
* observes the wall-clock deadline of the "timeout" request option.
*
* The transport resource must be in non-blocking mode: PHP's filtered
* stream reads (chunked and compressed responses) otherwise block until
* the full requested length arrives, so a deadline check between reads
* never runs while a server keeps sending small pieces of data. Reads
* return whatever bytes are available and poll briefly for more, so the
* deadline is checked between packets rather than between buffers. The
* wait is a bounded sleep because stream_select() cannot watch a stream
* that carries a filter.
*
* @internal
*/
final class DeadlineSourceStream implements StreamInterface
{
use NonSerializableTrait;
use StreamDecoratorTrait;
private const POLL_INTERVAL = 0.05;
private StreamInterface $stream;
private float $deadline;
private ?float $readTimeout;
private bool $timedOut = false;
/**
* @param StreamInterface $stream Transport stream in non-blocking mode.
* @param float $deadline Deadline based on Clock::now().
* @param float|null $readTimeout Optional idle timeout per read, in seconds.
*/
public function __construct(StreamInterface $stream, float $deadline, ?float $readTimeout)
{
$this->stream = $stream;
$this->deadline = $deadline;
$this->readTimeout = $readTimeout;
}
public function read(int $length): string
{
$idleStart = null;
while (true) {
$remaining = $this->deadline - Clock::now();
if ($remaining <= 0) {
$this->timedOut = true;
throw new TimeoutException('Unable to read from stream: timed out');
}
$data = $this->stream->read($length);
if ($data !== '') {
return $data;
}
if ($this->stream->eof()) {
return '';
}
$now = Clock::now();
if ($idleStart === null) {
$idleStart = $now;
}
if ($this->readTimeout !== null && $now - $idleStart >= $this->readTimeout) {
$this->timedOut = true;
throw new TimeoutException('Unable to read from stream: timed out');
}
$wait = \min($remaining, self::POLL_INTERVAL);
if ($this->readTimeout !== null) {
$wait = \min($wait, $this->readTimeout - ($now - $idleStart));
}
\usleep((int) \ceil($wait * 1e6));
}
}
/**
* Reports the deadline timeout through the timed_out metadata: reads on
* a decoding layer above this stream surface as a generic read failure,
* and its timeout recovery consults this stream's metadata to classify
* the failure. The transport itself never reports timed_out while in
* non-blocking mode.
*
* @return mixed
*/
public function getMetadata(?string $key = null)
{
if ($key === 'timed_out') {
return $this->timedOut || $this->stream->getMetadata($key) === true;
}
$metadata = $this->stream->getMetadata($key);
if ($key === null && \is_array($metadata) && $this->timedOut) {
$metadata['timed_out'] = true;
}
return $metadata;
}
}

View file

@ -1,10 +1,20 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Exception\ResponseTransferException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\DiagnosticValue;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\RequestOptions;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
@ -15,50 +25,118 @@ use Psr\Http\Message\StreamInterface;
*/
final class EasyHandle
{
use NonSerializableTrait;
/**
* @var resource|\CurlHandle cURL resource
*/
public $handle;
/**
* @var StreamInterface Where data is being written
*/
public $sink;
public StreamInterface $sink;
public RequestInterface $request;
/**
* @var array Received HTTP headers so far
* @var list<string> Received HTTP headers so far
*/
public $headers = [];
public array $headers = [];
/**
* @var list<string> Valid trailer lines, retained only when an
* on_trailers callback is configured
*/
public array $trailers = [];
/**
* @var bool Whether this handle was configured with CURLOPT_PIPEWAIT
*/
public bool $usesPipewait = false;
/**
* @var ResponseInterface|null Received response (if any)
*/
public $response;
/**
* @var RequestInterface Request being sent
*/
public $request;
public ?ResponseInterface $response = null;
/**
* @var array Request options
*/
public $options = [];
public array $options = [];
/**
* @var int cURL error number (if any)
*/
public $errno = 0;
public int $errno = 0;
/**
* @var string|null Effective CURLOPT_PROXY value the handle was created with (if any)
*/
public ?string $effectiveProxy = null;
/**
* Proxy tunnel or SOCKS proxy section signature for connection-reuse
* isolation, or null when the request does not require sectioning.
*/
public ?string $proxyTunnelSignature = null;
/**
* @var \Throwable|null Exception during on_headers (if any)
*/
public $onHeadersException;
public ?\Throwable $onHeadersException = null;
/**
* @var \Throwable|null Exception during progress callback (if any)
*/
public ?\Throwable $progressException = null;
/**
* @var bool Whether the progress callback requested abort
*/
public bool $progressAborted = false;
/**
* @var \Throwable|null Exception during createResponse (if any)
*/
public $createResponseException;
public ?\Throwable $createResponseException = null;
/**
* @var ResponseException|null Response header failure, if any.
*/
public ?ResponseException $responseHeaderException = null;
/**
* @var TimeoutException|null Exception during request body read timeout.
*/
public ?TimeoutException $bodyReadTimeoutException = null;
/**
* @var \Throwable|null Exception during request body read.
*/
public ?\Throwable $bodyReadException = null;
/**
* @var TimeoutException|null Exception during response sink write timeout.
*/
public ?TimeoutException $sinkWriteTimeoutException = null;
/**
* @var \Throwable|null Exception during response sink write.
*/
public ?\Throwable $sinkWriteException = null;
/**
* @var bool Whether the response sink accepted a different byte count.
*/
public bool $sinkWriteIncomplete = false;
/**
* @var int Number of response body bytes accepted by the sink.
*/
public int $responseBodyBytes = 0;
/**
* @var \OverflowException|null Unrepresentable response body size or byte count.
*/
public ?\OverflowException $responseBodySizeException = null;
/**
* Attach a response to the easy handle based on the received headers.
@ -69,46 +147,99 @@ final class EasyHandle
public function createResponse(): void
{
$this->response = null;
$this->responseBodyBytes = 0;
$this->responseBodySizeException = null;
$this->responseHeaderException = null;
[$ver, $status, $reason, $headers] = HeaderProcessor::parseHeaders($this->headers);
$normalizedKeys = Utils::normalizeHeaderKeys($headers);
// Non-101 informational responses precede the final response. Do not
// expose them as the response for a transfer that ends before the final
// response arrives. 101 switches protocol and is kept as terminal.
if ($status < 200 && $status !== 101) {
return;
}
if (!empty($this->options['decode_content']) && isset($normalizedKeys['content-encoding'])) {
$framingFailure = null;
try {
$declaredLength = HeaderProcessor::validateResponseFraming($this->request->getMethod(), $status, $headers);
HeaderProcessor::assertContentLengthWithinPlatformLimit($declaredLength);
} catch (\RuntimeException $e) {
$framingFailure = $e;
}
$normalizedKeys = Utils::normalizeHeaderKeys($headers);
$decodeContent = $this->options['decode_content'] ?? false;
if ($framingFailure === null && $decodeContent !== false && isset($normalizedKeys['content-encoding'])) {
$headers['x-encoded-content-encoding'] = $headers[$normalizedKeys['content-encoding']];
unset($headers[$normalizedKeys['content-encoding']]);
if (isset($normalizedKeys['content-length'])) {
$headers['x-encoded-content-length'] = $headers[$normalizedKeys['content-length']];
$encodedContentLength = HeaderProcessor::removeHeader('Content-Length', $headers);
if ($encodedContentLength !== []) {
$headers['x-encoded-content-length'] = $encodedContentLength;
$bodyLength = (int) $this->sink->getSize();
try {
$bodyLength = $this->sink->getSize();
} catch (\Exception $e) {
$bodyLength = null;
}
if ($bodyLength) {
$headers[$normalizedKeys['content-length']] = [(string) $bodyLength];
} else {
unset($headers[$normalizedKeys['content-length']]);
$headers['Content-Length'] = [(string) $bodyLength];
}
}
}
// Attach a response to the easy handle with the parsed headers.
$this->response = new Response(
$status,
$headers,
$this->sink,
$ver,
$reason
);
// Attach a response to the easy handle with the parsed headers. Any
// exception propagates to the caller (CurlFactory), which records it as
// the createResponseException — do not catch it here.
$responseFactory = self::requireResponseFactory($this->options[RequestOptions::RESPONSE_FACTORY] ?? new HttpFactory());
$response = $responseFactory->createResponse($status, $reason ?? '')->withProtocolVersion($ver);
foreach ($headers as $name => $value) {
$response = $response->withAddedHeader((string) $name, $value);
}
$this->response = $response->withBody($this->sink);
if ($framingFailure instanceof \OverflowException) {
$this->responseHeaderException = new ResponseException(
$framingFailure->getMessage(),
$this->request,
$this->response,
$framingFailure
);
} elseif ($framingFailure !== null) {
$this->responseHeaderException = new ResponseTransferException(
$framingFailure->getMessage(),
$this->request,
$this->response,
$framingFailure
);
}
}
/**
* @param mixed $factory
*/
private static function requireResponseFactory($factory): ResponseFactoryInterface
{
if (!$factory instanceof ResponseFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::RESPONSE_FACTORY,
ResponseFactoryInterface::class
));
}
return $factory;
}
/**
* @param string $name
*
* @return void
*
* @throws \BadMethodCallException
*/
public function __get($name)
public function __get(string $name): void
{
$msg = $name === 'handle' ? 'The EasyHandle has been released' : 'Invalid property: '.$name;
$msg = $name === 'handle'
? 'The EasyHandle has been released'
: \sprintf('Invalid property: %s', DiagnosticValue::escape($name));
throw new \BadMethodCallException($msg);
}
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
/**
* Tracks encoded response bytes beneath a content-decoding stream.
*
* Reads are bounded by a positive, representable Content-Length so a decoder
* cannot consume bytes beyond the response message boundary.
*
* @internal
*/
final class EncodedBodyStream implements StreamInterface
{
use NonSerializableTrait;
use StreamDecoratorTrait;
private StreamInterface $stream;
private ?int $limit;
private int $bytesRead = 0;
public function __construct(StreamInterface $stream, string $declaredLength)
{
$this->stream = $stream;
$limit = HeaderProcessor::contentLengthToInt($declaredLength);
$this->limit = $limit !== null && $limit > 0 ? $limit : null;
}
public function eof(): bool
{
return ($this->limit !== null && $this->bytesRead >= $this->limit)
|| $this->stream->eof();
}
public function isSeekable(): bool
{
return false;
}
public function seek(int $offset, int $whence = \SEEK_SET): void
{
throw new \RuntimeException('Cannot seek a stream while tracking encoded response bytes');
}
public function read(int $length): string
{
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->limit !== null) {
$remaining = $this->limit - $this->bytesRead;
if ($remaining === 0) {
return '';
}
$length = \min($length, $remaining);
}
$data = $this->stream->read($length);
$this->bytesRead = TransferByteCounter::add(
$this->bytesRead,
\strlen($data),
'Response body exceeds the maximum integer size supported on this platform'
);
return $data;
}
public function getBytesRead(): int
{
return $this->bytesRead;
}
}

View file

@ -1,7 +1,10 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Psr7;
use GuzzleHttp\Utils;
/**
@ -9,6 +12,10 @@ use GuzzleHttp\Utils;
*/
final class HeaderProcessor
{
private function __construct()
{
}
/**
* Returns the HTTP version, status code, reason phrase, and headers.
*
@ -32,29 +39,193 @@ final class HeaderProcessor
}
$parts = \explode(' ', $statusLine, 3);
$version = \explode('/', $parts[0])[1] ?? null;
$protocol = $parts[0];
if ($version === null) {
if (!Psr7\Utils::caselessEquals(\substr($protocol, 0, 5), 'HTTP/')) {
throw new \RuntimeException('HTTP version missing from header data');
}
$version = \substr($protocol, 5);
if (!\preg_match('/^\d+(?:\.\d+)?$/D', $version)) {
throw new \RuntimeException('HTTP version is invalid');
}
$status = $parts[1] ?? null;
if ($status === null) {
throw new \RuntimeException('HTTP status code missing from header data');
}
if (!\preg_match('/^\d{3}$/', $status)) {
if (!\preg_match('/^[1-5]\d{2}$/D', $status)) {
throw new \RuntimeException('HTTP status code is invalid');
}
$reason = $parts[2] ?? null;
if ($reason !== null && !\preg_match('/^[\x09\x20-\x7E\x80-\xFF]*$/D', $reason)) {
throw new \RuntimeException('HTTP reason phrase is invalid');
}
foreach ($headers as $header) {
if (\strpos($header, ':') === false) {
throw new \RuntimeException('HTTP header line is invalid');
}
}
return [$version, (int) $status, $parts[2] ?? null, Utils::headersFromLines($headers)];
return [$version, (int) $status, $reason, Utils::headersFromLines($headers)];
}
public static function isStatusLineCandidate(string $line): bool
{
return \preg_match('/^HTTP\/[0-9]+(?:\.[0-9]+)? [0-9]{3}(?: [^\r\n]*)?(?:\r\n|\r|\n)?$/iD', $line) === 1;
}
public static function isValidHeaderFieldLine(string $line): bool
{
$parts = \explode(':', $line, 2);
if (!isset($parts[1])) {
return false;
}
if (!\preg_match('/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D', $parts[0])) {
return false;
}
return \preg_match('/^[\x20\x09\x21-\x7E\x80-\xFF]*(?:\r\n|\r|\n)?$/D', \trim($parts[1], " \t")) === 1;
}
/**
* Returns a normalized decimal Content-Length, or null when absent.
*
* @param string[] $values
*
* @throws \RuntimeException when Content-Length is malformed or conflicting.
*/
public static function parseContentLength(array $values): ?string
{
$length = null;
foreach ($values as $value) {
foreach (\explode(',', $value) as $part) {
$part = \trim($part, " \t");
if (\preg_match('/^[0-9]+$/D', $part) !== 1) {
throw new \RuntimeException('value is not a non-negative decimal integer');
}
$part = \ltrim($part, '0');
$part = $part === '' ? '0' : $part;
if ($length !== null && $part !== $length) {
throw new \RuntimeException('values conflict');
}
$length = $part;
}
}
if ($length === null) {
return null;
}
return $length;
}
public static function contentLengthToInt(?string $length): ?int
{
if ($length === null) {
return null;
}
$max = (string) \PHP_INT_MAX;
if (
\strlen($length) > \strlen($max)
|| (\strlen($length) === \strlen($max) && \strcmp($length, $max) > 0)
) {
return null;
}
return (int) $length;
}
public static function assertContentLengthWithinPlatformLimit(?string $length): void
{
if ($length === null || self::contentLengthToInt($length) !== null) {
return;
}
throw new \OverflowException('Content-Length exceeds the maximum integer size supported on this platform');
}
/**
* Validates response framing and returns its normalized Content-Length.
* Returns null when absent or when ordinary body framing does not apply.
*
* @param array<string, string[]> $headers
*
* @throws \RuntimeException when Content-Length is malformed, conflicting,
* or combined with Transfer-Encoding
*/
public static function validateResponseFraming(
string $method,
int $status,
array $headers
): ?string {
if (!self::responseCanHaveBody($method, $status)) {
return null;
}
$normalizedKeys = Utils::normalizeHeaderKeys($headers);
$contentLength = self::removeHeader('Content-Length', $headers);
try {
$length = self::parseContentLength($contentLength);
} catch (\RuntimeException $e) {
throw new \RuntimeException('Invalid Content-Length response header: '.$e->getMessage(), 0, $e);
}
if ($length !== null && isset($normalizedKeys['transfer-encoding'])) {
throw new \RuntimeException('A response must not contain both Content-Length and Transfer-Encoding');
}
return $length;
}
/**
* Removes every case-insensitive occurrence of a header and returns all
* removed values in their original field order.
*
* @param array<string, string[]> $headers
*
* @return string[] Removed values across all header-name casings
*/
public static function removeHeader(string $name, array &$headers): array
{
$values = [];
foreach ($headers as $key => $headerValues) {
if (Psr7\Utils::caselessEquals((string) $key, $name)) {
\array_push($values, ...$headerValues);
unset($headers[$key]);
}
}
return $values;
}
/**
* Whether a response uses ordinary body framing. A response to HEAD, a
* response with a 1xx, 204, or 304 status code, or a 2xx response to
* CONNECT never has a body, whatever its framing headers claim. A 205
* remains subject to framing even though its semantics require no content.
*/
public static function responseCanHaveBody(string $method, int $status): bool
{
return $method !== 'HEAD'
&& !($method === 'CONNECT' && $status >= 200 && $status < 300)
&& $status >= 200
&& $status !== 204
&& $status !== 304;
}
/**
@ -67,7 +238,7 @@ final class HeaderProcessor
$lastStatusLine = 0;
foreach ($headers as $index => $line) {
if (\preg_match('/^HTTP\/\S+\s+/i', $line)) {
if (self::isStatusLineCandidate($line)) {
$lastStatusLine = $index;
}
}

View file

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7;
use Psr\Http\Message\RequestInterface;
/**
* Rejects request hosts that a handler could resolve differently from the host
* the request names.
*
* @internal
*/
final class HostValidator
{
private function __construct()
{
}
/**
* Asserts that a request names one unambiguous network host.
*
* A handler reparses the URI but sends the Host header as given. The URI
* host must therefore be printable ASCII, free of percent escapes, a valid
* RFC 3986 host, and not numeric-looking parts followed by trailing dots.
* Every Host header value must be printable ASCII.
*
* @throws RequestException
*/
public static function assertRequestHost(
#[\SensitiveParameter]
RequestInterface $request
): void {
self::assertUriHost($request->getUri()->getHost(), $request);
foreach ($request->getHeader('Host') as $value) {
self::assertPrintableAscii((string) $value, 'The request Host header "%s" must contain only printable ASCII characters, because an intermediary or an origin server can otherwise read it as an authority that differs from the one the request names. An internationalized host name has an A-label form that this rule accepts.', $request);
}
}
/**
* Only the URI host is reparsed for the connection. A Host header is sent
* verbatim and may carry a port, brackets, and an RFC 6874 zone identifier.
*
* @throws RequestException
*/
private static function assertUriHost(
string $host,
#[\SensitiveParameter]
RequestInterface $request
): void {
self::assertPrintableAscii($host, 'The request URI host "%s" must contain only printable ASCII characters, because a handler can otherwise connect to a host that differs from the one the request names. An internationalized host name has an A-label form that this rule accepts.', $request);
if (\strpos($host, '%') !== false) {
throw new RequestException(\sprintf('The request URI host "%s" must not contain a percent escape, because a handler decodes it and can then connect to a host that differs from the one the request names.', self::escape($host)), $request);
}
// GuzzleHttp\Psr7\Uri already enforces this predicate, so it only
// rejects invalid third-party UriInterface values.
if (!Psr7\Rfc3986::isValidHost($host)) {
throw new RequestException(\sprintf('The request URI host "%s" must be a valid RFC 3986 host, because a handler reparses the URI and can then connect to a host that differs from the one the request names.', self::escape($host)), $request);
}
// libcurl 8.21.0 drops a trailing dot from inet_aton-style numeric
// hosts before connecting, while other readers treat the input as a
// name. Test the shape rather than the range so malformed numeric
// values fail closed. rtrim() also covers multiple trailing dots if
// libcurl later relaxes its current guard. Plain shorthand stays
// accepted. HostIdentity uses a broader grammar for cookie matching.
//
// @see \GuzzleHttp\HostIdentity::canonicalHost()
if (\str_ends_with($host, '.') && self::isNumericIpv4Host(\rtrim($host, '.'))) {
throw new RequestException(\sprintf('The request URI host "%s" must not be written as one to four decimal, octal or hexadecimal parts followed by one or more trailing dots, because a handler can read that spelling as an IPv4 address and connect to that address while the rest of the process reads a name.', self::escape($host)), $request);
}
}
/**
* Reports whether a host has libcurl's inet_aton-style shape: one to four
* decimal, 0-prefixed octal, or 0x-prefixed hexadecimal parts.
*
* Range and 32-bit overflow checks are deliberately omitted. This may
* reject a trailing-dot spelling the transport reads as a name, but avoids
* missing one it resolves as an address.
*/
public static function isNumericIpv4Host(string $host): bool
{
if ($host === '') {
return false;
}
$parts = \explode('.', $host);
if (\count($parts) > 4) {
return false;
}
foreach ($parts as $part) {
if (!self::isNumericIpv4Part($part)) {
return false;
}
}
return true;
}
private static function isNumericIpv4Part(string $part): bool
{
if ($part === '') {
return false;
}
if ($part[0] === '0' && isset($part[1]) && ($part[1] === 'x' || $part[1] === 'X')) {
return \strlen($part) > 2 && \strspn($part, '0123456789abcdefABCDEF', 2) === \strlen($part) - 2;
}
$digits = $part[0] === '0' ? '01234567' : '0123456789';
return \strspn($part, $digits) === \strlen($part);
}
/**
* @throws RequestException
*/
private static function assertPrintableAscii(
string $value,
string $message,
#[\SensitiveParameter]
RequestInterface $request
): void {
// Match positively so a PCRE failure rejects.
if (\preg_match('/\A[\x21-\x7E]*\z/D', $value) !== 1) {
throw new RequestException(\sprintf($message, self::escape($value)), $request);
}
}
/**
* Escapes non-printable bytes as uppercase \xNN for safe diagnostics.
*
* Psr7\DiagnosticValue is not used because it preserves valid non-ASCII
* UTF-8, including invisible characters rejected here.
*/
private static function escape(string $value): string
{
$escaped = '';
for ($offset = 0, $length = \strlen($value); $offset < $length; ++$offset) {
$byte = \ord($value[$offset]);
if ($byte >= 0x20 && $byte <= 0x7E) {
$escaped .= $value[$offset];
continue;
}
$escaped .= \sprintf('\\x%02X', $byte);
}
return $escaped;
}
}

View file

@ -1,46 +1,46 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\TransferStats;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
/**
* Handler that returns responses or throw exceptions from a queue.
*
* @final
* Handler that returns responses or rejection reasons from a queue.
*/
class MockHandler implements \Countable
final class MockHandler implements \Countable
{
/**
* @var array
*/
private $queue = [];
use NonSerializableTrait;
/**
* @var RequestInterface|null
* @var list<ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>|callable(RequestInterface, array<array-key, mixed>): (ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>)>
*/
private $lastRequest;
private array $queue = [];
private ?RequestInterface $lastRequest = null;
/**
* @var array
* @var array<array-key, mixed>
*/
private $lastOptions = [];
private array $lastOptions = [];
/**
* @var callable|null
* @var (callable(ResponseInterface|null): mixed)|null
*/
private $onFulfilled;
/**
* @var callable|null
* @var (callable(mixed): mixed)|null
*/
private $onRejected;
@ -48,26 +48,35 @@ class MockHandler implements \Countable
* Creates a new MockHandler that uses the default handler stack list of
* middlewares.
*
* @param array|null $queue Array of responses, callables, or exceptions.
* @param callable|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param callable|null $onRejected Callback to invoke when the return value is rejected.
* @param array<array-key, ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>|callable(RequestInterface, array<array-key, mixed>): (ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>)>|null $queue Array of responses, promises, callables, or throwables.
* @param (callable(ResponseInterface|null): mixed)|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param (callable(mixed): mixed)|null $onRejected Callback to invoke when the return value is rejected.
*
* @return HandlerStack<callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>>
*/
public static function createWithMiddleware(?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null): HandlerStack
{
public static function createWithMiddleware(
#[\SensitiveParameter]
?array $queue = null,
?callable $onFulfilled = null,
?callable $onRejected = null
): HandlerStack {
return HandlerStack::create(new self($queue, $onFulfilled, $onRejected));
}
/**
* The passed in value must be an array of
* {@see ResponseInterface} objects, Exceptions,
* callables, or Promises.
* {@see ResponseInterface} objects, throwables, callables, or promises.
*
* @param array<int, mixed>|null $queue The parameters to be passed to the append function, as an indexed array.
* @param callable|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param callable|null $onRejected Callback to invoke when the return value is rejected.
* @param array<array-key, ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>|callable(RequestInterface, array<array-key, mixed>): (ResponseInterface|\Throwable|PromiseInterface<ResponseInterface, mixed>)>|null $queue The parameters to be passed to the append function, as an indexed array.
* @param (callable(ResponseInterface|null): mixed)|null $onFulfilled Callback to invoke when the return value is fulfilled.
* @param (callable(mixed): mixed)|null $onRejected Callback to invoke when the return value is rejected.
*/
public function __construct(?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null)
{
public function __construct(
#[\SensitiveParameter]
?array $queue = null,
?callable $onFulfilled = null,
?callable $onRejected = null
) {
$this->onFulfilled = $onFulfilled;
$this->onRejected = $onRejected;
@ -77,30 +86,41 @@ class MockHandler implements \Countable
}
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
if (!$this->queue) {
// Test-setup error (more requests made than responses queued);
// intentionally a bare SPL exception, not a GuzzleException.
throw new \OutOfBoundsException('Mock queue is empty');
}
if (isset($options['delay']) && \is_numeric($options['delay'])) {
\usleep((int) $options['delay'] * 1000);
\usleep((int) ($options['delay'] * 1000));
}
if (isset($options['on_stats']) && !\is_callable($options['on_stats'])) {
throw new InvalidArgumentException('on_stats must be callable');
}
$this->lastRequest = $request;
$this->lastOptions = $options;
$response = \array_shift($this->queue);
$onHeaders = null;
$onHeadersResponse = null;
if (isset($options['on_headers'])) {
if (!\is_callable($options['on_headers'])) {
throw new \InvalidArgumentException('on_headers must be callable');
}
try {
$options['on_headers']($response);
} catch (\Exception $e) {
$msg = 'An error was encountered during the on_headers event';
$response = new RequestException($msg, $request, $response, $e);
throw new InvalidArgumentException('on_headers must be callable');
}
$onHeaders = $options['on_headers'];
}
if (\is_callable($response)) {
@ -111,8 +131,36 @@ class MockHandler implements \Countable
? P\Create::rejectionFor($response)
: P\Create::promiseFor($response);
return $response->then(
function (?ResponseInterface $value) use ($request, $options) {
if (\is_callable($onHeaders)) {
$response = $response->then(
static function (
#[\SensitiveParameter]
$value
) use ($onHeaders, $request, &$onHeadersResponse) {
if (!$value instanceof ResponseInterface) {
return $value;
}
try {
$onHeaders($value, $request);
} catch (\Throwable $e) {
$msg = 'An error was encountered during the on_headers event';
$onHeadersResponse = $value;
throw new ResponseException($msg, $request, $value, $e);
}
return $value;
}
);
}
$promise = $response->then(
function (
#[\SensitiveParameter]
$value
) use ($request, $options): ?ResponseInterface {
/** @var ResponseInterface|null $value */
$this->invokeStats($request, $options, $value);
if ($this->onFulfilled) {
($this->onFulfilled)($value);
@ -133,8 +181,11 @@ class MockHandler implements \Countable
return $value;
},
function ($reason) use ($request, $options) {
$this->invokeStats($request, $options, null, $reason);
function (
#[\SensitiveParameter]
$reason
) use ($request, $options, &$onHeadersResponse): PromiseInterface {
$this->invokeStats($request, $options, $onHeadersResponse, $reason);
if ($this->onRejected) {
($this->onRejected)($reason);
}
@ -142,16 +193,21 @@ class MockHandler implements \Countable
return P\Create::rejectionFor($reason);
}
);
/** @var PromiseInterface<ResponseInterface, mixed> $promise */
return $promise;
}
/**
* Adds one or more variadic requests, exceptions, callables, or promises
* to the queue.
*
* @param mixed ...$values
* @param mixed ...$values Responses, promises, throwables, or request-aware callables.
*/
public function append(...$values): void
{
public function append(
#[\SensitiveParameter]
...$values
): void {
foreach ($values as $value) {
if ($value instanceof ResponseInterface
|| $value instanceof \Throwable
@ -160,7 +216,7 @@ class MockHandler implements \Countable
) {
$this->queue[] = $value;
} else {
throw new \TypeError('Expected a Response, Promise, Throwable or callable. Found '.Utils::describeType($value));
throw new \TypeError('Expected a Response, Promise, Throwable or callable. Found '.\get_debug_type($value));
}
}
}
@ -198,14 +254,22 @@ class MockHandler implements \Countable
* @param mixed $reason Promise or reason.
*/
private function invokeStats(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
?ResponseInterface $response = null,
#[\SensitiveParameter]
$reason = null
): void {
if (isset($options['on_stats'])) {
$transferTime = $options['transfer_time'] ?? 0;
$stats = new TransferStats($request, $response, $transferTime, $reason);
$transferTime = $options['transfer_time'] ?? 0.0;
if (!\is_int($transferTime) && !\is_float($transferTime) && (!\is_string($transferTime) || !\is_numeric($transferTime))) {
throw new InvalidArgumentException('transfer_time must be a number of seconds');
}
$stats = new TransferStats($request, $response, (float) $transferTime, $reason);
($options['on_stats'])($stats);
}
}

View file

@ -1,30 +1,40 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Provides basic proxies for handlers.
*
* @final
*/
class Proxy
final class Proxy
{
private function __construct()
{
}
/**
* Sends synchronous requests to a specific handler while sending all other
* requests to another handler.
*
* @param callable(RequestInterface, array): PromiseInterface $default Handler used for normal responses
* @param callable(RequestInterface, array): PromiseInterface $sync Handler used for synchronous responses.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $default Handler used for normal responses
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $sync Handler used for synchronous responses.
*
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> Returns the composed handler.
*/
public static function wrapSync(callable $default, callable $sync): callable
{
return static function (RequestInterface $request, array $options) use ($default, $sync): PromiseInterface {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($default, $sync): PromiseInterface {
return empty($options[RequestOptions::SYNCHRONOUS]) ? $default($request, $options) : $sync($request, $options);
};
}
@ -37,14 +47,19 @@ class Proxy
* performance benefits of curl while still supporting true streaming
* through the StreamHandler.
*
* @param callable(RequestInterface, array): PromiseInterface $default Handler used for non-streaming responses
* @param callable(RequestInterface, array): PromiseInterface $streaming Handler used for streaming responses
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $default Handler used for non-streaming responses
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $streaming Handler used for streaming responses
*
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> Returns the composed handler.
*/
public static function wrapStreaming(callable $default, callable $streaming): callable
{
return static function (RequestInterface $request, array $options) use ($default, $streaming): PromiseInterface {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($default, $streaming): PromiseInterface {
return empty($options['stream']) ? $default($request, $options) : $streaming($request, $options);
};
}

View file

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\ProxyOptions;
use GuzzleHttp\ProxySelection;
use GuzzleHttp\Psr7;
use Psr\Http\Message\UriInterface;
/**
* Resolves proxy configuration from the process environment with the same
* semantics libcurl applies, shared by the built-in handlers: the cURL
* handlers pin CURLOPT_PROXY and CURLOPT_NOPROXY explicitly so libcurl never
* reads the environment itself, and the stream handler resolves the same way.
*
* @internal
*/
final class ProxyEnv
{
private function __construct()
{
}
/**
* Resolves the proxy selection for a request, falling back to the proxy
* environment variables when the proxy request option makes no decision.
*
* The environment no_proxy list is tokenized the way libcurl tokenizes
* it and matched here with the same rules as the proxy option's "no"
* list, so behavior does not depend on the installed libcurl's matcher.
*
* @param mixed $proxyOption
*/
public static function resolveProxySelection(
UriInterface $uri,
#[\SensitiveParameter]
$proxyOption
): ProxySelection {
$selection = ProxyOptions::resolve($uri, $proxyOption);
// Any option decision (proxy, bypassed, or disabled) is final; only
// a none() selection leaves room for the environment.
if ($selection->hasProxy() || $selection->shouldDisableProxy()) {
return $selection;
}
$envProxy = self::getProxyForScheme($uri->getScheme());
if ($envProxy === null) {
return $selection;
}
$noProxy = self::getNoProxy();
if ($noProxy !== null && ProxyOptions::isUriInNoProxy($uri, self::splitNoProxy($noProxy))) {
return ProxySelection::bypassed();
}
// $envProxy is never '' (empty env values are treated as unset).
return ProxySelection::proxy($envProxy);
}
/**
* Resolves the proxy to use for the given request scheme.
*
* The lookup mirrors libcurl for the http and https schemes the handlers
* accept: the lowercase scheme-specific variable first, its uppercase
* variant next (except for "http", where uppercase HTTP_PROXY is never
* read), then all_proxy/ALL_PROXY.
*
* @return string|null The proxy to use; null when the environment
* configures none.
*/
public static function getProxyForScheme(string $scheme): ?string
{
$scheme = Psr7\Utils::asciiToLower($scheme);
$candidates = [$scheme.'_proxy'];
if ($scheme !== 'http') {
// Uppercase HTTP_PROXY is deliberately never consulted: a CGI
// request header "Proxy:" becomes HTTP_PROXY in the environment.
// See https://httpoxy.org for more information.
$candidates[] = Psr7\Utils::asciiToUpper($scheme).'_PROXY';
}
$candidates[] = 'all_proxy';
$candidates[] = 'ALL_PROXY';
foreach ($candidates as $name) {
$value = self::getenv($name);
if ($value !== null) {
return $value;
}
}
return null;
}
/**
* @return string|null The no-proxy list; null when nothing is set.
*/
public static function getNoProxy(): ?string
{
foreach (['no_proxy', 'NO_PROXY'] as $name) {
$value = self::getenv($name);
if ($value !== null) {
return $value;
}
}
return null;
}
/**
* Splits a no_proxy environment value into matchable entries.
*
* Entries may be separated by commas or blanks, the way libcurl tokenizes
* the value.
*
* @return string[]
*/
public static function splitNoProxy(string $noProxy): array
{
$entries = [];
$split = \preg_split('/[\s,]+/', $noProxy);
if ($split === false) {
throw new \RuntimeException('Unable to split the no_proxy value: '.\preg_last_error_msg());
}
foreach ($split as $entry) {
if ($entry !== '') {
$entries[] = $entry;
}
}
return $entries;
}
private static function getenv(string $name): ?string
{
// Windows environment variables are case-insensitive, so the
// lowercase-only httpoxy defence does not hold there. Outside the
// CLI SAPI on Windows, environment proxies are not resolved at all
// (a safe-side divergence from libcurl).
if (\PHP_OS_FAMILY === 'Windows' && \PHP_SAPI !== 'cli') {
return null;
}
// local_only: the OS environment and putenv() only - the same
// environ(7) libcurl reads. SAPI request environments such as
// fastcgi_param or SetEnv are deliberately excluded.
$value = \getenv($name, true);
// libcurl's GetEnv (lib/getenv.c) treats variables set to an empty
// string as unset on every version, so the lookup falls through to
// the next candidate.
return $value === false || $value === '' ? null : $value;
}
}

View file

@ -0,0 +1,256 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\NonSerializableTrait;
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Exception\TimeoutException;
use Psr\Http\Message\RequestInterface;
/**
* Validates and normalizes request framing before a handler performs I/O.
*
* Body size describes the bytes available under handler rewind semantics.
* Content-Length is the selected transport boundary and may remain unknown.
*
* @internal
*/
final class RequestFraming
{
use NonSerializableTrait;
public RequestInterface $request;
/**
* Number of bytes available after applying handler rewind semantics, if
* known.
*/
public ?int $bodySize;
/** Canonical Content-Length boundary selected for transport, if any. */
public ?int $contentLength;
private function __construct(RequestInterface $request, ?int $bodySize, ?int $contentLength)
{
$this->request = $request;
$this->bodySize = $bodySize;
$this->contentLength = $contentLength;
}
/**
* Finalizes request framing for a handler dispatch.
*
* When $sendBody is false, body-dependent validation and size probing are
* skipped. Content-Length is still canonicalized and Transfer-Encoding is
* removed.
*
* @throws RequestException when framing or body metadata is unsafe
*/
public static function analyze(
#[\SensitiveParameter]
RequestInterface $request,
bool $sendBody = true
): self {
try {
$length = HeaderProcessor::parseContentLength($request->getHeader('Content-Length'));
} catch (\RuntimeException $e) {
throw new RequestException('Invalid Content-Length request header: '.$e->getMessage(), $request, 0, $e);
}
try {
HeaderProcessor::assertContentLengthWithinPlatformLimit($length);
} catch (\OverflowException $e) {
throw new RequestException($e->getMessage(), $request, 0, $e);
}
$contentLength = HeaderProcessor::contentLengthToInt($length);
if ($length !== null) {
$request = $request->withHeader('Content-Length', $length);
}
if (!$sendBody) {
return new self($request->withoutHeader('Transfer-Encoding'), null, $contentLength);
}
$transferEncoding = $request->getHeader('Transfer-Encoding');
if ($length !== null && $transferEncoding !== []) {
throw new RequestException(
'A request must not contain both Content-Length and Transfer-Encoding',
$request
);
}
if ($transferEncoding !== []) {
$tokens = [];
foreach ($transferEncoding as $value) {
foreach (\explode(',', $value) as $part) {
$tokens[] = \trim($part, " \t");
}
}
if (\count($tokens) !== 1 || !Psr7\Utils::caselessEquals($tokens[0], 'chunked') || $request->getProtocolVersion() !== '1.1') {
throw new RequestException('Unsupported Transfer-Encoding request header', $request);
}
}
$bodySize = self::bodySize($request);
if ($bodySize !== null && $contentLength !== null && $bodySize !== $contentLength) {
throw new RequestException('Content-Length does not match the request body size', $request);
}
if ($bodySize === null && $contentLength === null && $request->getProtocolVersion() === '1.0') {
throw new RequestException('An unknown-size HTTP/1.0 request body requires Content-Length', $request);
}
if ($length === null && $bodySize !== null && ($bodySize > 0 || \in_array($request->getMethod(), ['PUT', 'POST'], true))) {
$contentLength = $bodySize;
$request = $request->withHeader('Content-Length', (string) $bodySize);
}
return new self($request->withoutHeader('Transfer-Encoding'), $bodySize, $contentLength);
}
/**
* Returns the number of bytes available for dispatch, or null when unknown.
*
* Seekable bodies use their total size because handlers rewind them.
* Positioned non-seekable bodies use the total size minus their current
* position.
*
* @throws RequestException when body metadata cannot be read safely
*/
public static function bodySize(
#[\SensitiveParameter]
RequestInterface $request
): ?int {
$body = $request->getBody();
try {
$size = $body->getSize();
if ($size === null) {
return null;
}
if ($size < 0) {
throw new \RuntimeException('Request body size must not be negative');
}
$seekable = $body->isSeekable();
} catch (\Exception $e) {
throw self::bodyException(
$request,
$e,
'Timed out while determining the request body size',
'Failed to determine the request body size'
);
}
if ($seekable) {
return $size;
}
try {
$position = $body->tell();
} catch (\RuntimeException $e) {
return null;
}
if ($position < 0 || $position > $size) {
throw new RequestException('The request body position is outside the stream size', $request);
}
return $size - $position;
}
/**
* Materializes the body, stopping at the selected Content-Length when
* present.
*
* @throws RequestException when the body cannot be fully rewound or read
*/
public function materialize(): string
{
$body = $this->request->getBody();
if ($this->contentLength === null) {
try {
return (string) $body;
} catch (\Exception $e) {
throw self::bodyException(
$this->request,
$e,
'Timed out while reading the request body',
'Failed to read the request body'
);
}
}
if ($this->contentLength === 0) {
return '';
}
try {
if ($body->isSeekable()) {
$body->rewind();
}
} catch (\Exception $e) {
throw self::bodyException(
$this->request,
$e,
'Timed out while rewinding the request body',
'Failed to rewind the request body'
);
}
$contents = '';
$remaining = $this->contentLength;
while ($remaining > 0) {
$limit = \min(8192, $remaining);
try {
$chunk = $body->read($limit);
} catch (\Exception $e) {
throw self::bodyException(
$this->request,
$e,
'Timed out while reading the request body',
'Failed to read the request body'
);
}
if ($chunk === '') {
throw new RequestException(
'Request body ended before the declared Content-Length was reached',
$this->request
);
}
if (\strlen($chunk) > $limit) {
throw new RequestException('Request body stream returned more bytes than requested', $this->request);
}
$contents .= $chunk;
$remaining -= \strlen($chunk);
}
return $contents;
}
private static function bodyException(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
\Exception $exception,
string $timeoutMessage,
string $fallbackMessage
): RequestException {
$message = $exception instanceof TimeoutException
? $timeoutMessage
: ($exception->getMessage() !== '' ? $exception->getMessage() : $fallbackMessage);
return new RequestException($message, $request, 0, $exception);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
/**
* @internal
*/
final class Timeout
{
private function __construct()
{
}
/**
* Converts a request timeout option to integer milliseconds.
*
* @param mixed $value
*/
public static function toMilliseconds($value, string $option): int
{
if (!\is_int($value) && !\is_float($value) && (!\is_string($value) || !\is_numeric($value))) {
throw new InvalidArgumentException($option.' must be a number of seconds');
}
$seconds = (float) $value;
if (!\is_finite($seconds) || $seconds < 0) {
throw new InvalidArgumentException($option.' must be 0 or greater than or equal to 0.001 seconds');
}
$milliseconds = (int) ($seconds * 1000);
if ($seconds > 0 && $milliseconds === 0) {
throw new InvalidArgumentException($option.' must be 0 or greater than or equal to 0.001 seconds');
}
return $milliseconds;
}
}

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
use GuzzleHttp\Exception\InvalidArgumentException;
/**
* @internal
*/
final class TlsVersion
{
/**
* @param mixed $value
*/
public static function ordinal(string $option, $value): int
{
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) {
return 10;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) {
return 11;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) {
return 12;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) {
return 13;
}
throw new InvalidArgumentException(\sprintf('Invalid %s request option: unknown version provided', $option));
}
/**
* @param mixed $min
* @param mixed $max
*/
public static function assertRange($min, $max): void
{
if ($min === null || $max === null) {
return;
}
if (self::ordinal('crypto_method_max', $max) < self::ordinal('crypto_method', $min)) {
throw new InvalidArgumentException('Invalid crypto_method_max request option: maximum TLS version must be greater than or equal to crypto_method');
}
}
/**
* @param mixed $value
*/
public static function streamProtocolVersion(string $option, $value): int
{
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_0;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_1;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_2;
}
if ($value === \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT) {
return \STREAM_CRYPTO_PROTO_TLSv1_3;
}
throw new InvalidArgumentException(\sprintf('Invalid %s request option: unknown version provided', $option));
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Handler;
/**
* @internal
*/
final class TransferByteCounter
{
private function __construct()
{
}
/**
* @param mixed $value
*/
public static function progressValueToInt($value): int
{
if (\is_int($value)) {
if ($value < 0) {
throw new \OverflowException('Progress byte count exceeds the maximum integer size supported on this platform');
}
return $value;
}
if (!\is_float($value)) {
throw new \UnexpectedValueException('Progress byte count must be an integer or float');
}
if (
!\is_finite($value)
|| $value < 0
|| $value > \PHP_INT_MAX
|| (\PHP_INT_SIZE === 8 && $value >= (float) \PHP_INT_MAX)
) {
throw new \OverflowException('Progress byte count exceeds the maximum integer size supported on this platform');
}
$intValue = (int) $value;
if ($intValue < 0) {
throw new \OverflowException('Progress byte count exceeds the maximum integer size supported on this platform');
}
return $intValue;
}
public static function add(int $current, int $delta, string $message): int
{
if ($current < 0 || $delta < 0) {
throw new \OverflowException($message);
}
if ($delta > \PHP_INT_MAX - $current) {
throw new \OverflowException($message);
}
return $current + $delta;
}
}

View file

@ -1,8 +1,11 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\DiagnosticValue;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@ -10,22 +13,26 @@ use Psr\Http\Message\ResponseInterface;
* Creates a composed Guzzle handler function by stacking middlewares on top of
* an HTTP handler function.
*
* @template THandler
*
* @final
*/
class HandlerStack
{
use NonSerializableTrait;
/**
* @var (callable(RequestInterface, array): PromiseInterface)|null
* @var (callable&THandler)|null
*/
private $handler;
/**
* @var array{(callable(callable(RequestInterface, array): PromiseInterface): callable), (string|null)}[]
* @var array<int, array{0: callable(callable&THandler): (callable&THandler), 1: string|null}>
*/
private $stack = [];
private array $stack = [];
/**
* @var (callable(RequestInterface, array): PromiseInterface)|null
* @var (callable&THandler)|null
*/
private $cached;
@ -33,22 +40,25 @@ class HandlerStack
* Creates a default handler stack that can be used by clients.
*
* The returned handler will wrap the provided handler or use the most
* appropriate default handler for your system. The returned HandlerStack has
* support for cookies, redirects, HTTP error exceptions, and preparing a body
* before sending.
* appropriate default handler for your system. The returned HandlerStack
* has support for authentication, cookies, redirects, HTTP error
* exceptions, and preparing a body before sending.
*
* The returned handler stack can be passed to a client in the "handler"
* option.
*
* @param (callable(RequestInterface, array): PromiseInterface)|null $handler HTTP handler function to use with the stack. If no
* handler is provided, the best handler for your
* system will be utilized.
* @param (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)|null $handler HTTP handler function to use with the stack. If no
* handler is provided, the best handler for your
* system will be utilized.
*
* @return self<callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>>
*/
public static function create(?callable $handler = null): self
{
$stack = new self($handler ?: Utils::chooseHandler());
$stack->push(Middleware::httpErrors(), 'http_errors');
$stack->push(Middleware::redirect(), 'allow_redirects');
$stack->push(Middleware::auth(), 'auth');
$stack->push(Middleware::cookies(), 'cookies');
$stack->push(Middleware::prepareBody(), 'prepare_body');
@ -56,7 +66,7 @@ class HandlerStack
}
/**
* @param (callable(RequestInterface, array): PromiseInterface)|null $handler Underlying HTTP handler.
* @param (callable&THandler)|null $handler Underlying handler.
*/
public function __construct(?callable $handler = null)
{
@ -66,50 +76,23 @@ class HandlerStack
/**
* Invokes the handler stack as a composed handler
*
* @return ResponseInterface|PromiseInterface
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(RequestInterface $request, array $options)
{
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) {
$handler = $this->resolve();
return $handler($request, $options);
}
/**
* Dumps a string representation of the stack.
*
* @return string
*/
public function __toString()
{
$depth = 0;
$stack = [];
if ($this->handler !== null) {
$stack[] = '0) Handler: '.$this->debugCallable($this->handler);
}
$result = '';
foreach (\array_reverse($this->stack) as $tuple) {
++$depth;
$str = "{$depth}) Name: '{$tuple[1]}', ";
$str .= 'Function: '.$this->debugCallable($tuple[0]);
$result = "> {$str}\n{$result}";
$stack[] = $str;
}
foreach (\array_keys($stack) as $k) {
$result .= "< {$stack[$k]}\n";
}
return $result;
}
/**
* Set the HTTP handler that actually returns a promise.
*
* @param callable(RequestInterface, array): PromiseInterface $handler Accepts a request and array of options and
* returns a Promise.
* @param callable&THandler $handler Accepts a request and array of options and returns a value expected by the stack.
*/
public function setHandler(callable $handler): void
{
@ -128,8 +111,8 @@ class HandlerStack
/**
* Unshift a middleware to the bottom of the stack.
*
* @param callable(callable): callable $middleware Middleware function
* @param string $name Name to register for this middleware.
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $name Name to register for this middleware.
*/
public function unshift(callable $middleware, ?string $name = null): void
{
@ -140,8 +123,8 @@ class HandlerStack
/**
* Push a middleware to the top of the stack.
*
* @param callable(callable): callable $middleware Middleware function
* @param string $name Name to register for this middleware.
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $name Name to register for this middleware.
*/
public function push(callable $middleware, string $name = ''): void
{
@ -152,9 +135,9 @@ class HandlerStack
/**
* Add a middleware before another middleware by name.
*
* @param string $findName Middleware to find
* @param callable(callable): callable $middleware Middleware function
* @param string $withName Name to register for this middleware.
* @param string $findName Middleware to find
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $withName Name to register for this middleware.
*/
public function before(string $findName, callable $middleware, string $withName = ''): void
{
@ -164,9 +147,9 @@ class HandlerStack
/**
* Add a middleware after another middleware by name.
*
* @param string $findName Middleware to find
* @param callable(callable): callable $middleware Middleware function
* @param string $withName Name to register for this middleware.
* @param string $findName Middleware to find
* @param callable(callable&THandler): (callable&THandler) $middleware Middleware function
* @param string $withName Name to register for this middleware.
*/
public function after(string $findName, callable $middleware, string $withName = ''): void
{
@ -176,12 +159,13 @@ class HandlerStack
/**
* Remove a middleware by instance or name from the stack.
*
* @param callable|string $remove Middleware to remove by instance or name.
* @param (callable(callable&THandler): (callable&THandler))|string $remove Middleware to remove by instance or name.
*/
public function remove($remove): void
{
if (!is_string($remove) && !is_callable($remove)) {
\trigger_deprecation('guzzlehttp/guzzle', '7.4', 'Not passing a callable or string to %s::%s() is deprecated and will cause an error in 8.0.', __CLASS__, __FUNCTION__);
if (!\is_string($remove) && !\is_callable($remove)) {
// TODO: Move this to the parameter definition in 9.0.
throw new \TypeError(__METHOD__.'(): Argument #1 ($remove) must be of type callable|string');
}
$this->cached = null;
@ -190,7 +174,7 @@ class HandlerStack
$count = \count($this->stack);
$this->stack = \array_values(\array_filter(
$this->stack,
static function ($tuple) use ($remove) {
static function (array $tuple) use ($remove): bool {
return $tuple[1] !== $remove;
}
));
@ -202,7 +186,7 @@ class HandlerStack
$this->stack = \array_values(\array_filter(
$this->stack,
static function ($tuple) use ($remove) {
static function (array $tuple) use ($remove): bool {
return $tuple[0] !== $remove;
}
));
@ -211,7 +195,7 @@ class HandlerStack
/**
* Compose the middleware and handler into a single callable function.
*
* @return callable(RequestInterface, array): PromiseInterface
* @return callable&THandler
*/
public function resolve(): callable
{
@ -220,9 +204,20 @@ class HandlerStack
throw new \LogicException('No handler has been specified');
}
if (!\is_callable($prev)) {
throw new \LogicException('Handler must be callable');
}
foreach (\array_reverse($this->stack) as $fn) {
/** @var callable(RequestInterface, array): PromiseInterface $prev */
if (!\is_array($fn) || !\array_key_exists(0, $fn) || !\is_callable($fn[0])) {
throw new \LogicException('Middleware must be callable');
}
$prev = $fn[0]($prev);
if (!\is_callable($prev)) {
throw new \LogicException('Middleware must return a callable');
}
}
$this->cached = $prev;
@ -231,6 +226,15 @@ class HandlerStack
return $this->cached;
}
public function __unserialize(array $data): void
{
$this->handler = null;
$this->stack = [];
$this->cached = null;
throw new \LogicException(static::class.' should never be unserialized');
}
private function findByName(string $name): int
{
foreach ($this->stack as $k => $v) {
@ -239,11 +243,13 @@ class HandlerStack
}
}
throw new \InvalidArgumentException("Middleware not found: $name");
throw new \InvalidArgumentException(\sprintf('Middleware not found: %s', DiagnosticValue::escape($name)));
}
/**
* Splices a function into the middleware list at a specific position.
*
* @param callable(callable&THandler): (callable&THandler) $middleware
*/
private function splice(string $findName, string $withName, callable $middleware, bool $before): void
{
@ -265,25 +271,4 @@ class HandlerStack
\array_splice($this->stack, $idx, 1, $replacement);
}
}
/**
* Provides a debug string for a given callable.
*
* @param callable|string $fn Function to write as a string.
*/
private function debugCallable($fn): string
{
if (\is_string($fn)) {
return "callable({$fn})";
}
if (\is_array($fn)) {
return \is_string($fn[0])
? "callable({$fn[0]}::{$fn[1]})"
: "callable(['".\get_class($fn[0])."', '{$fn[1]}'])";
}
/** @var object $fn */
return 'callable('.\spl_object_hash($fn).')';
}
}

View file

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* Shared host identity normalization for features that key state on a
* logical host.
*
* @internal
*/
final class HostIdentity
{
/**
* Returns the identity form of a URI host. Valid bracketed IPv6 literals
* are canonicalized to their RFC 5952 form so equivalent spellings of one
* address share a single identity; IPvFuture literals, zone-bearing
* values, and invalid bracketed text fall back to ASCII case folding.
*/
public static function canonicalHost(string $host): string
{
if (\str_starts_with($host, '[') && \str_ends_with($host, ']')) {
try {
return '['.Psr7\Rfc3986::canonicalizeIpv6(\substr($host, 1, -1)).']';
} catch (\InvalidArgumentException $e) {
// Fall back to case folding below.
}
}
return Psr7\Utils::asciiToLower($host);
}
/**
* Returns the identity form of a cookie domain. In addition to the
* canonicalHost() rules, valid bare IPv6 addresses, which the cookie API
* permissively accepts even though a URI host requires brackets, are
* canonicalized to their RFC 5952 form without gaining brackets, so bare
* and bracketed forms remain distinct identities.
*/
public static function canonicalCookieDomain(string $domain): string
{
if (!\str_starts_with($domain, '[') && \strpos($domain, ':') !== false) {
try {
return Psr7\Rfc3986::canonicalizeIpv6($domain);
} catch (\InvalidArgumentException $e) {
return Psr7\Utils::asciiToLower($domain);
}
}
return self::canonicalHost($domain);
}
/**
* Whether a request domain matches a cookie domain according to RFC 6265.
*
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-5.1.3
*/
public static function cookieDomainMatches(string $domain, string $cookieDomain): bool
{
$domain = self::canonicalCookieDomain($domain);
$cookieDomain = self::canonicalCookieDomain($cookieDomain);
if ($domain === $cookieDomain) {
return true;
}
// A percent-escaped cookie domain can decode to another host spelling.
// Keep it exact-match-only; decoding a request host cannot create a
// suffix match that its original text lacked.
if (\strpos($cookieDomain, '%') !== false) {
return false;
}
if (!self::isDnsSuffixEligible($domain) || !self::isDnsSuffixEligible($cookieDomain)) {
return false;
}
return \preg_match('/\.'.\preg_quote($cookieDomain, '/').'$/D', $domain) === 1;
}
/**
* Returns the identity form of a Host header value. A single bracketed
* `host[:port]` authority, where any explicit port must be a valid decimal
* port number, has its IPv6 literal canonicalized like canonicalHost() with
* the port text retained; every other value, including malformed and
* multiple values, falls back to ASCII case folding of the raw text.
*/
public static function canonicalHostHeader(string $header): string
{
if (\str_starts_with($header, '[')) {
$end = \strpos($header, ']');
if ($end !== false) {
$rest = \substr($header, $end + 1);
if ($rest === '' || (\str_starts_with($rest, ':') && Psr7\Rfc3986::isValidPort(\substr($rest, 1)))) {
return self::canonicalHost(\substr($header, 0, $end + 1)).$rest;
}
}
}
return Psr7\Utils::asciiToLower($header);
}
/**
* Whether the host may participate in RFC 6265 DNS suffix matching. IP
* literals, IP addresses, numeric hosts, literal-like text containing a
* raw bracket or colon, and invalid host text are exact-match-only.
*/
private static function isDnsSuffixEligible(string $host): bool
{
if (\strpbrk($host, '[]:') !== false) {
return false;
}
if (!Psr7\Rfc3986::isValidHost($host)) {
return false;
}
return !self::isIpAddressOrNumericHost($host);
}
private static function isIpAddressOrNumericHost(string $host): bool
{
// Strip one root dot before detection so trailing-dot numeric hosts
// still cannot be matched by subdomains.
if ($host !== '' && \str_ends_with($host, '.')) {
$host = \substr($host, 0, -1);
}
if (\str_starts_with($host, '[') && \str_ends_with($host, ']')) {
$host = \substr($host, 1, -1);
}
if (\filter_var($host, \FILTER_VALIDATE_IP) !== false) {
return true;
}
// Public DNS names do not have an all-numeric rightmost label; treat
// those private/internal hosts as exact-match-only too.
$labels = \explode('.', $host);
$last = (string) \end($labels);
if ($last !== '' && \ctype_digit($last)) {
return true;
}
// libcurl also parses a 0x-prefixed hexadecimal rightmost label as a
// numerical IPv4 address, such as 0x7f000001 for 127.0.0.1.
return \str_starts_with($last, '0x') && \strlen($last) > 2 && \ctype_xdigit(\substr($last, 2));
}
/**
* Returns the four-byte binary form of a host that a transport reads as a
* numeric IPv4 address, or null when it reads it as a name.
*
* The shape test is Handler\HostValidator::isNumericIpv4Host(); this
* method adds the range checks that predicate omits: every part but the
* last must fit one octet, and the last must fit the octets the earlier
* parts left. A trailing root dot is not swallowed, unlike libcurl 8.21.0
* and later, because assertRequestHost() rejects that spelling first.
*/
public static function numericIpv4ToBinary(string $host): ?string
{
if (!Handler\HostValidator::isNumericIpv4Host($host)) {
return null;
}
$values = [];
foreach (\explode('.', $host) as $part) {
$values[] = self::numericIpv4PartValue($part);
}
// Every accepted value is a whole number no larger than 0xFFFFFFFF,
// which a float holds exactly, so the arithmetic below is correct on a
// 32-bit build too, where the widest part overflows an integer.
$address = (float) \array_pop($values);
$packed = '';
foreach ($values as $value) {
if ($value > 255.0) {
return null;
}
$packed .= \chr((int) $value);
}
$width = 4 - \count($values);
if ($address >= 256.0 ** $width) {
return null;
}
for ($shift = $width - 1; $shift >= 0; --$shift) {
$packed .= \chr((int) \fmod(\floor($address / 256.0 ** $shift), 256.0));
}
return $packed;
}
/**
* Returns the value of one accepted part as a float, so a part filling
* all four octets such as 2130706433 stays exact on every integer width.
*/
private static function numericIpv4PartValue(string $part): float
{
if ($part[0] === '0' && isset($part[1]) && ($part[1] === 'x' || $part[1] === 'X')) {
return (float) \hexdec((string) \substr($part, 2));
}
if ($part[0] === '0') {
return (float) \octdec($part);
}
return (float) $part;
}
private function __construct()
{
// cannot be instantiated
}
}

87
vendor/guzzlehttp/guzzle/src/Idn.php vendored Normal file
View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\InvalidArgumentException;
use Psr\Http\Message\UriInterface;
/**
* @internal
*/
final class Idn
{
private function __construct()
{
}
/**
* @param mixed $value
*/
public static function normalizeConversionOption($value): ?int
{
if ($value === null || $value === false) {
return null;
}
if ($value === true) {
return \IDNA_DEFAULT;
}
if (\is_int($value)) {
return $value;
}
throw new InvalidArgumentException('idn_conversion must be true, false, null, or an integer IDNA_* bitmask');
}
/**
* @throws InvalidArgumentException
*/
public static function convertUri(UriInterface $uri, int $options = 0): UriInterface
{
if ($uri->getHost()) {
$asciiHost = self::idnToAsci($uri->getHost(), $options, $info);
if ($asciiHost === false) {
$errorBitSet = $info['errors'] ?? 0;
$errorConstants = array_filter(array_keys(get_defined_constants()), static function (string $name): bool {
return substr($name, 0, 11) === 'IDNA_ERROR_';
});
$errors = [];
foreach ($errorConstants as $errorConstant) {
if ($errorBitSet & constant($errorConstant)) {
$errors[] = $errorConstant;
}
}
$errorMessage = 'IDN conversion failed';
if ($errors) {
$errorMessage .= ' (errors: '.implode(', ', $errors).')';
}
throw new InvalidArgumentException($errorMessage);
}
if ($uri->getHost() !== $asciiHost) {
// Replace URI only if the ASCII version is different
$uri = $uri->withHost($asciiHost);
}
}
return $uri;
}
/**
* @return string|false
*/
private static function idnToAsci(string $domain, int $options, ?array &$info = [])
{
if (\function_exists('idn_to_ascii') && \defined('INTL_IDNA_VARIANT_UTS46')) {
return \idn_to_ascii($domain, $options, \INTL_IDNA_VARIANT_UTS46, $info);
}
throw new \Error('ext-idn or symfony/polyfill-intl-idn not loaded or too old');
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\MessageInterface;
@ -26,16 +28,16 @@ use Psr\Http\Message\ResponseInterface;
* - {code}: Status code of the response (if available)
* - {phrase}: Reason phrase of the response (if available)
* - {error}: Any error messages (if available)
* - {req_header_*}: Replace `*` with the lowercased name of a request header to add to the message
* - {res_header_*}: Replace `*` with the lowercased name of a response header to add to the message
* - {req_header_*}: Replace `*` with the lowercased name of a request header
* to add to the message
* - {res_header_*}: Replace `*` with the lowercased name of a response header
* to add to the message
* - {req_headers}: Request headers
* - {res_headers}: Response headers
* - {req_body}: Request body
* - {res_body}: Response body
*
* @final
*/
class MessageFormatter implements MessageFormatterInterface
final class MessageFormatter implements MessageFormatterInterface
{
/**
* Apache Common Log Format.
@ -51,7 +53,7 @@ class MessageFormatter implements MessageFormatterInterface
/**
* @var string Template used to format log messages
*/
private $template;
private string $template;
/**
* @param string $template Log message template
@ -72,10 +74,9 @@ class MessageFormatter implements MessageFormatterInterface
{
$cache = [];
/** @var string */
return \preg_replace_callback(
$result = \preg_replace_callback(
'/{\s*([A-Za-z_\-\.0-9]+)\s*}/',
function (array $matches) use ($request, $response, $error, &$cache) {
function (array $matches) use ($request, $response, $error, &$cache): string {
if (isset($cache[$matches[1]])) {
return $cache[$matches[1]];
}
@ -90,7 +91,7 @@ class MessageFormatter implements MessageFormatterInterface
break;
case 'req_headers':
$result = \trim($request->getMethod()
.' '.$request->getRequestTarget())
.' '.$request->getRequestTarget(), " \n\r\t\0\x0B")
.' HTTP/'.$request->getProtocolVersion()."\r\n"
.$this->headers($request);
break;
@ -176,12 +177,19 @@ class MessageFormatter implements MessageFormatterInterface
}
}
$result = (string) $result;
$cache[$matches[1]] = $result;
return $result;
},
$this->template
);
if ($result === null) {
throw new \RuntimeException('Unable to format message: '.\preg_last_error_msg());
}
return $result;
}
/**
@ -194,6 +202,6 @@ class MessageFormatter implements MessageFormatterInterface
$result .= $name.': '.\implode(', ', $values)."\r\n";
}
return \trim($result);
return \trim($result, " \n\r\t\0\x0B");
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\RequestInterface;

View file

@ -1,9 +1,12 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
@ -15,18 +18,42 @@ use Psr\Log\LoggerInterface;
*/
final class Middleware
{
private function __construct()
{
}
/**
* Middleware that applies built-in Basic authentication and handles Digest
* authentication challenges when the "auth" request option is set.
*
* @param bool $reuseChallenges Whether Digest challenges may be reused preemptively.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function auth(bool $reuseChallenges = true): callable
{
return static function (callable $handler) use ($reuseChallenges): AuthMiddleware {
return new AuthMiddleware($handler, null, $reuseChallenges);
};
}
/**
* Middleware that adds cookies to requests.
*
* The options array must be set to a CookieJarInterface in order to use
* cookies. This is typically handled for you by a client.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function cookies(): callable
{
return static function (callable $handler): callable {
return static function ($request, array $options) use ($handler) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler): PromiseInterface {
if (empty($options['cookies'])) {
return $handler($request, $options);
} elseif (!$options['cookies'] instanceof CookieJarInterface) {
@ -37,7 +64,10 @@ final class Middleware
return $handler($request, $options)
->then(
static function (ResponseInterface $response) use ($cookieJar, $request): ResponseInterface {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($cookieJar, $request): ResponseInterface {
$cookieJar->extractCookies($request, $response);
return $response;
@ -53,23 +83,31 @@ final class Middleware
*
* @param BodySummarizerInterface|null $bodySummarizer The body summarizer to use in exception messages.
*
* @return callable(callable): callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function httpErrors(?BodySummarizerInterface $bodySummarizer = null): callable
{
return static function (callable $handler) use ($bodySummarizer): callable {
return static function ($request, array $options) use ($handler, $bodySummarizer) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $bodySummarizer): PromiseInterface {
if (empty($options['http_errors'])) {
return $handler($request, $options);
}
return $handler($request, $options)->then(
static function (ResponseInterface $response) use ($request, $bodySummarizer) {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $bodySummarizer): ResponseInterface {
$code = $response->getStatusCode();
if ($code < 400) {
return $response;
}
throw RequestException::create($request, $response, null, [], $bodySummarizer);
throw RequestException::create($request, $response, null, $bodySummarizer);
}
);
};
@ -79,9 +117,9 @@ final class Middleware
/**
* Middleware that pushes history data to an ArrayAccess container.
*
* @param array|\ArrayAccess<int, array> $container Container to hold the history (by reference).
* @param array<array-key, array{request: RequestInterface, response: ResponseInterface|null, error: mixed, options: array<array-key, mixed>}>|\ArrayAccess<int, array{request: RequestInterface, response: ResponseInterface|null, error: mixed, options: array<array-key, mixed>}> $container Container to hold the history (by reference).
*
* @return callable(callable): callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*
* @throws \InvalidArgumentException if container is not an array or ArrayAccess.
*/
@ -92,9 +130,17 @@ final class Middleware
}
return static function (callable $handler) use (&$container): callable {
return static function (RequestInterface $request, array $options) use ($handler, &$container) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, &$container): PromiseInterface {
return $handler($request, $options)->then(
static function ($value) use ($request, &$container, $options) {
static function (
#[\SensitiveParameter]
ResponseInterface $value
) use ($request, &$container, $options): ResponseInterface {
$container[] = [
'request' => $request,
'response' => $value,
@ -104,7 +150,10 @@ final class Middleware
return $value;
},
static function ($reason) use ($request, &$container, $options) {
static function (
#[\SensitiveParameter]
$reason
) use ($request, &$container, $options): PromiseInterface {
$container[] = [
'request' => $request,
'response' => null,
@ -120,22 +169,29 @@ final class Middleware
}
/**
* Middleware that invokes a callback before and after sending a request.
* Middleware that observes requests and responses as they flow through the
* stack without modifying them. This is useful for metrics, tracing, and
* debugging.
*
* The provided listener cannot modify or alter the response. It simply
* "taps" into the chain to be notified before returning the promise. The
* before listener accepts a request and options array, and the after
* listener accepts a request, options array, and response promise.
*
* @param callable $before Function to invoke before forwarding the request.
* @param callable $after Function invoked after forwarding.
* @param (callable(RequestInterface, array<array-key, mixed>): mixed)|null $before Function to invoke before forwarding the request.
* @param (callable(RequestInterface, array<array-key, mixed>, PromiseInterface<ResponseInterface, mixed>): mixed)|null $after Function invoked after forwarding.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function tap(?callable $before = null, ?callable $after = null): callable
{
return static function (callable $handler) use ($before, $after): callable {
return static function (RequestInterface $request, array $options) use ($handler, $before, $after) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $before, $after): PromiseInterface {
if ($before) {
$before($request, $options);
}
@ -152,7 +208,7 @@ final class Middleware
/**
* Middleware that handles request redirects.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function redirect(): callable
{
@ -168,13 +224,14 @@ final class Middleware
* If no delay function is provided, a simple implementation of exponential
* backoff will be utilized.
*
* @param callable $decider Function that accepts the number of retries,
* a request, [response], and [exception] and
* returns true if the request is to be retried.
* @param callable $delay Function that accepts the number of retries and
* returns the number of milliseconds to delay.
* @param callable(int, RequestInterface, ResponseInterface|null, mixed): bool $decider Function that accepts the number of retries,
* a request, [response], and [rejection reason]
* and returns true if the request is to be retried.
* @param (callable(int, ResponseInterface|null, RequestInterface): int)|null $delay Function that accepts the number of retries,
* [response], and request, and returns the
* number of milliseconds to delay.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function retry(callable $decider, ?callable $delay = null): callable
{
@ -187,32 +244,39 @@ final class Middleware
* Middleware that logs requests, responses, and errors using a message
* formatter.
*
* @param LoggerInterface $logger Logs messages.
* @param MessageFormatterInterface|MessageFormatter $formatter Formatter used to create message strings.
* @param string $logLevel Level at which to log requests.
* @param LoggerInterface $logger Logs messages.
* @param MessageFormatterInterface $formatter Formatter used to create message strings.
* @param string $logLevel Level at which to log requests.
*
* @phpstan-param \Psr\Log\LogLevel::* $logLevel Level at which to log requests.
*
* @return callable Returns a function that accepts the next handler.
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function log(LoggerInterface $logger, $formatter, string $logLevel = 'info'): callable
public static function log(LoggerInterface $logger, MessageFormatterInterface $formatter, string $logLevel = 'info'): callable
{
// To be compatible with Guzzle 7.1.x we need to allow users to pass a MessageFormatter
if (!$formatter instanceof MessageFormatter && !$formatter instanceof MessageFormatterInterface) {
throw new \LogicException(sprintf('Argument 2 to %s::log() must be of type %s', self::class, MessageFormatterInterface::class));
}
return static function (callable $handler) use ($logger, $formatter, $logLevel): callable {
return static function (RequestInterface $request, array $options = []) use ($handler, $logger, $formatter, $logLevel) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options = []
) use ($handler, $logger, $formatter, $logLevel): PromiseInterface {
return $handler($request, $options)->then(
static function ($response) use ($logger, $request, $formatter, $logLevel): ResponseInterface {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($logger, $request, $formatter, $logLevel): ResponseInterface {
$message = $formatter->format($request, $response);
$logger->log($logLevel, $message);
return $response;
},
static function ($reason) use ($logger, $request, $formatter): PromiseInterface {
$response = $reason instanceof RequestException ? $reason->getResponse() : null;
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
static function (
#[\SensitiveParameter]
$reason
) use ($logger, $request, $formatter): PromiseInterface {
$response = $reason instanceof ResponseException ? $reason->getResponse() : null;
$message = $formatter->format($request, $response, P\Create::exceptionFor($reason));
$logger->error($message);
@ -226,6 +290,8 @@ final class Middleware
/**
* This middleware adds a default content-type if possible, a default
* content-length or transfer-encoding header, and the expect header.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function prepareBody(): callable
{
@ -238,13 +304,20 @@ final class Middleware
* Middleware that applies a map function to the request before passing to
* the next handler.
*
* @param callable $fn Function that accepts a RequestInterface and returns
* a RequestInterface.
* @param callable(RequestInterface): RequestInterface $fn Function that accepts a RequestInterface and returns
* a RequestInterface.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function mapRequest(callable $fn): callable
{
return static function (callable $handler) use ($fn): callable {
return static function (RequestInterface $request, array $options) use ($handler, $fn) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $fn): PromiseInterface {
return $handler($fn($request), $options);
};
};
@ -254,13 +327,20 @@ final class Middleware
* Middleware that applies a map function to the resolved promise's
* response.
*
* @param callable $fn Function that accepts a ResponseInterface and
* returns a ResponseInterface.
* @param callable(ResponseInterface): ResponseInterface $fn Function that accepts a ResponseInterface and
* returns a ResponseInterface.
*
* @return callable((callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)): (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)
*/
public static function mapResponse(callable $fn): callable
{
return static function (callable $handler) use ($fn): callable {
return static function (RequestInterface $request, array $options) use ($handler, $fn) {
return static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
) use ($handler, $fn): PromiseInterface {
return $handler($request, $options)->then($fn);
};
};

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* Multiplexing modes for the "multiplex" request option.
*
* NONE disables multiplexing for a whole handler when passed as the
* "multiplex" client configuration option or, when constructing a handler
* directly, as the CurlMultiHandler "multiplex" constructor option. As a
* request option value it guarantees the transfer does not share its
* connection with any concurrent transfer, and is accepted only where that
* guarantee holds.
*/
final class Multiplexing
{
public const NONE = 'none';
public const EAGER = 'eager';
public const WAIT = 'wait';
public const REQUIRE_EAGER = 'require_eager';
public const REQUIRE_WAIT = 'require_wait';
private function __construct()
{
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
/**
* @internal
*/
trait NonSerializableTrait
{
public function __serialize(): array
{
throw new \LogicException(static::class.' should never be serialized');
}
public function __unserialize(array $data): void
{
throw new \LogicException(static::class.' should never be unserialized');
}
}

View file

@ -1,45 +1,138 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Cookie\CookieJarInterface;
use GuzzleHttp\Promise\EachPromise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\PromisorInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
* Sends an iterator of requests concurrently using a capped pool size.
*
* The pool will read from an iterator until it is cancelled or until the
* iterator is consumed. When a request is yielded, the request is sent after
* applying the "request_options" request options (if provided in the ctor).
* applying the "options" request options (if provided in the ctor). Any
* observer callbacks in "options" (on_headers, on_trailers, on_stats,
* progress, and allow_redirects.on_redirect) also receive the request's
* iterable key as a trailing argument.
*
* When a function is yielded by the iterator, the function is provided the
* "request_options" array that should be merged on top of any existing
* options, and the function MUST then return a wait-able promise.
* "options" array that should be merged on top of any existing options, and
* the function MUST then return a response or a wait-able response promise.
*
* @final
*
* @implements PromisorInterface<mixed, mixed>
*/
class Pool implements PromisorInterface
{
/**
* @var EachPromise
*/
private $each;
use NonSerializableTrait;
/**
* @param ClientInterface $client Client used to send the requests.
* @param array|\Iterator $requests Requests or functions that return
* requests to send concurrently.
* @param array $config Associative array of options
* - concurrency: (int) Maximum number of requests to send concurrently
* - options: Array of request options to apply to each request.
* - fulfilled: (callable) Function to invoke when a request completes.
* - rejected: (callable) Function to invoke when a request is rejected.
* @var EachPromise<array-key, ResponseInterface, mixed>
*/
public function __construct(ClientInterface $client, $requests, array $config = [])
{
private EachPromise $each;
/**
* @param ClientInterface $client Client used to send the requests.
* @param iterable<array-key, RequestInterface|callable(array<array-key, mixed>): (ResponseInterface|PromiseInterface<ResponseInterface, mixed>)> $requests Requests or functions that return responses or response promises.
* @param array{
* concurrency?: int|(callable(int): int),
* options?: array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface, int|string): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface, int|string): mixed,
* on_stats?: callable(TransferStats, int|string): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface, int|string): mixed,
* progress?: callable(int, int, int, int, int|string): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* },
* fulfilled?: callable(ResponseInterface, int|string, PromiseInterface<mixed, mixed>): mixed,
* rejected?: callable(mixed, int|string, PromiseInterface<mixed, mixed>): mixed
* } $config Pool configuration.
*/
public function __construct(
ClientInterface $client,
iterable $requests,
#[\SensitiveParameter]
array $config = []
) {
if (!isset($config['concurrency'])) {
$config['concurrency'] = 25;
}
@ -51,36 +144,27 @@ class Pool implements PromisorInterface
$opts = [];
}
if (!\is_iterable($requests)) {
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.11',
'Passing a non-iterable request collection to %s::__construct() or %s::batch() is deprecated; guzzlehttp/guzzle 8.0 will require an iterable.',
__CLASS__,
__CLASS__
);
$requestGenerator = static function () use ($requests, $client, $opts): \Generator {
foreach ($requests as $key => $rfn) {
$keyedOpts = self::keyedRequestOptions($opts, $key);
$requests = [$requests];
}
$iterable = P\Create::iterFor($requests);
$requests = static function () use ($iterable, $client, $opts) {
foreach ($iterable as $key => $rfn) {
if ($rfn instanceof RequestInterface) {
yield $key => $client->sendAsync($rfn, $opts);
yield $key => $client->sendAsync($rfn, $keyedOpts);
} elseif (\is_callable($rfn)) {
yield $key => $rfn($opts);
yield $key => $rfn($keyedOpts);
} else {
throw new \InvalidArgumentException('Each value yielded by the iterator must be a Psr7\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Psr7\Message\Http\ResponseInterface object.');
throw new \InvalidArgumentException('Each value yielded by the iterator must be a Psr\Http\Message\RequestInterface or a callable that returns a promise that fulfills with a Psr\Http\Message\ResponseInterface object.');
}
}
};
$this->each = new EachPromise($requests(), $config);
$this->each = new EachPromise($requestGenerator(), $config);
}
/**
* Get promise
*
* @return PromiseInterface<mixed, mixed>
*/
public function promise(): PromiseInterface
{
@ -95,18 +179,98 @@ class Pool implements PromisorInterface
* as such, is NOT recommended when sending a large number or an
* indeterminate number of requests concurrently.
*
* @param ClientInterface $client Client used to send the requests
* @param array|\Iterator $requests Requests to send concurrently.
* @param array $options Passes through the options available in
* {@see Pool::__construct}
* @param ClientInterface $client Client used to send the requests
* @param iterable<array-key, RequestInterface|callable(array<array-key, mixed>): (ResponseInterface|PromiseInterface<ResponseInterface, mixed>)> $requests Requests or functions that return responses or response promises.
* @param array{
* concurrency?: int|(callable(int): int),
* options?: array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface, int|string): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface, int|string): mixed,
* on_stats?: callable(TransferStats, int|string): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface, int|string): mixed,
* progress?: callable(int, int, int, int, int|string): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* },
* fulfilled?: callable(ResponseInterface, int|string): mixed,
* rejected?: callable(mixed, int|string): mixed
* } $options Passes through the options available in {@see Pool::__construct}.
*
* @return array Returns an array containing the response or an exception
* in the same order that the requests were sent.
* @return array<array-key, mixed> Returns an array containing the response or rejection reason in the same order that the requests were sent.
*
* @throws \InvalidArgumentException if the event format is incorrect.
*/
public static function batch(ClientInterface $client, $requests, array $options = []): array
{
public static function batch(
ClientInterface $client,
iterable $requests,
#[\SensitiveParameter]
array $options = []
): array {
$res = [];
self::cmpCallback($options, 'fulfilled', $res);
self::cmpCallback($options, 'rejected', $res);
@ -123,15 +287,238 @@ class Pool implements PromisorInterface
private static function cmpCallback(array &$options, string $name, array &$results): void
{
if (!isset($options[$name])) {
$options[$name] = static function ($v, $k) use (&$results) {
$options[$name] = static function (
#[\SensitiveParameter]
$v,
$k
) use (&$results): void {
$results[$k] = $v;
};
} else {
$currentFn = $options[$name];
$options[$name] = static function ($v, $k) use (&$results, $currentFn) {
$options[$name] = static function (
#[\SensitiveParameter]
$v,
$k
) use (&$results, $currentFn): void {
$currentFn($v, $k);
$results[$k] = $v;
};
}
}
/**
* Returns the request options with any observer callbacks wrapped so that
* they also receive the request's iterable key as a trailing argument.
*
* @param array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface, int|string): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface, int|string): mixed,
* on_stats?: callable(TransferStats, int|string): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface, int|string): mixed,
* progress?: callable(int, int, int, int, int|string): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* } $options
* @param int|string $key
*
* @return array{
* base_uri?: string|UriInterface,
* allow_redirects?: bool|array{
* max?: int,
* strict?: bool,
* referer?: bool,
* protocols?: non-empty-array<array-key, string>,
* on_redirect?: callable(RequestInterface, ResponseInterface, UriInterface): mixed,
* track_redirects?: bool
* },
* auth?: array{
* 0: string,
* 1: string,
* 2?: string|null
* }|string|false|null,
* body?: resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable,
* cert?: string|array{
* 0: string,
* 1?: string|null
* },
* cert_type?: string,
* connect_timeout?: int|float,
* cookies?: false|CookieJarInterface,
* crypto_method?: int,
* crypto_method_max?: int,
* debug?: bool|resource,
* decode_content?: bool|string,
* delay?: int|float,
* expect?: bool|int,
* form_params?: array<array-key, string|int|float|bool|null|array>,
* force_ip_resolve?: string,
* headers?: array<array-key, string|non-empty-array<array-key, string>>|null,
* http_errors?: bool,
* idn_conversion?: bool|int|null,
* json?: mixed,
* multipart?: array<array-key, array{
* name: string|int,
* contents: mixed,
* headers?: array<array-key, string>,
* filename?: string
* }>,
* multiplex?: string,
* on_headers?: callable(ResponseInterface, RequestInterface): mixed,
* on_stats?: callable(TransferStats): mixed,
* on_trailers?: callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed,
* progress?: callable(int, int, int, int): mixed,
* protocols?: non-empty-array<array-key, string>,
* proxy?: string|array{
* http?: string|null,
* https?: string|null,
* no?: string|array<array-key, string>|null
* },
* query?: array<array-key, mixed>|string,
* read_timeout?: int|float,
* retries?: int,
* request_factory?: RequestFactoryInterface,
* response_factory?: ResponseFactoryInterface,
* sink?: resource|string|StreamInterface,
* ssl_key?: string|array{
* 0: string,
* 1?: string|null
* },
* ssl_key_type?: string,
* stream?: bool,
* stream_factory?: StreamFactoryInterface,
* stream_context?: array<array-key, mixed>,
* synchronous?: bool,
* timeout?: int|float,
* uri_factory?: UriFactoryInterface,
* verify?: bool|string,
* version?: string|int|float,
* curl?: array<int|string, mixed>,
* ...
* }
*/
private static function keyedRequestOptions(array $options, $key): array
{
if (\is_array($options['allow_redirects'] ?? null)
&& \is_callable($options['allow_redirects']['on_redirect'] ?? null)
) {
$onRedirect = $options['allow_redirects']['on_redirect'];
$options['allow_redirects']['on_redirect'] = static function (
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
UriInterface $uri
) use ($onRedirect, $key): void {
$onRedirect($request, $response, $uri, $key);
};
}
if (\is_callable($options['on_headers'] ?? null)) {
$onHeaders = $options['on_headers'];
$options['on_headers'] = static function (
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
RequestInterface $request
) use ($onHeaders, $key): void {
$onHeaders($response, $request, $key);
};
}
if (\is_callable($options['on_stats'] ?? null)) {
$onStats = $options['on_stats'];
$options['on_stats'] = static function (TransferStats $stats) use ($onStats, $key): void {
$onStats($stats, $key);
};
}
if (\is_callable($options['on_trailers'] ?? null)) {
$onTrailers = $options['on_trailers'];
$options['on_trailers'] = static function (
array $trailers,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
RequestInterface $request
) use ($onTrailers, $key): void {
$onTrailers($trailers, $response, $request, $key);
};
}
if (\is_callable($options['progress'] ?? null)) {
$progress = $options['progress'];
$options['progress'] = static function (int $downloadTotal, int $downloadedBytes, int $uploadTotal, int $uploadedBytes) use ($progress, $key) {
return $progress($downloadTotal, $downloadedBytes, $uploadTotal, $uploadedBytes, $key);
};
}
return $options;
}
}

View file

@ -1,9 +1,13 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Handler\RequestFraming;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Prepares requests that contain a body, adding the Content-Length,
@ -13,25 +17,35 @@ use Psr\Http\Message\RequestInterface;
*/
class PrepareBodyMiddleware
{
use NonSerializableTrait;
/**
* @var callable(RequestInterface, array): PromiseInterface
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler Next handler to invoke.
*/
public function __construct(callable $nextHandler)
{
$this->nextHandler = $nextHandler;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$fn = $this->nextHandler;
$bodySize = RequestFraming::bodySize($request);
// Don't do anything if the request has no body.
if ($request->getBody()->getSize() === 0) {
if ($bodySize === 0) {
return $fn($request, $options);
}
@ -50,16 +64,15 @@ class PrepareBodyMiddleware
if (!$request->hasHeader('Content-Length')
&& !$request->hasHeader('Transfer-Encoding')
) {
$size = $request->getBody()->getSize();
if ($size !== null) {
$modify['set_headers']['Content-Length'] = (string) $size;
} else {
if ($bodySize !== null) {
$modify['set_headers']['Content-Length'] = (string) $bodySize;
} elseif ($request->getProtocolVersion() === '1.1') {
$modify['set_headers']['Transfer-Encoding'] = 'chunked';
}
}
// Add the expect header if needed.
$this->addExpectHeader($request, $options, $modify);
$this->addExpectHeader($request, $options, $modify, $bodySize);
return $fn(Psr7\Utils::modifyRequest($request, $modify), $options);
}
@ -67,8 +80,14 @@ class PrepareBodyMiddleware
/**
* Add expect header
*/
private function addExpectHeader(RequestInterface $request, array $options, array &$modify): void
{
private function addExpectHeader(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
array &$modify,
?int $bodySize
): void {
// Determine if the Expect header should be used
if ($request->hasHeader('Expect')) {
return;
@ -76,8 +95,8 @@ class PrepareBodyMiddleware
$expect = $options['expect'] ?? null;
// Return if disabled or using HTTP/1.0
if ($expect === false || $request->getProtocolVersion() === '1.0') {
// Return if disabled or not using HTTP/1.1.
if ($expect === false || '1.1' !== $request->getProtocolVersion()) {
return;
}
@ -96,9 +115,8 @@ class PrepareBodyMiddleware
// Always add if the body cannot be rewound, the size cannot be
// determined, or the size is greater than the cutoff threshold
$body = $request->getBody();
$size = $body->getSize();
if ($size === null || $size >= (int) $expect || !$body->isSeekable()) {
if ($bodySize === null || $bodySize >= (int) $expect || !$body->isSeekable()) {
$modify['set_headers']['Expect'] = '100-Continue';
}
}

View file

@ -0,0 +1,603 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Psr7\Rfc3986;
use Psr\Http\Message\UriInterface;
final class ProxyOptions
{
private function __construct()
{
}
/**
* Resolve Guzzle's documented proxy request option for a request URI.
*
* @param mixed $proxy Proxy option as passed via request transfer options.
*
* @throws InvalidArgumentException
*/
public static function resolve(
UriInterface $uri,
#[\SensitiveParameter]
$proxy
): ProxySelection {
if ($proxy === null) {
return ProxySelection::none();
}
if (!\is_array($proxy)) {
if (!\is_string($proxy)) {
throw new InvalidArgumentException('proxy must be a string or array');
}
return ProxySelection::proxy($proxy);
}
$schemeProxy = $proxy[$uri->getScheme()] ?? null;
if ($schemeProxy !== null && !\is_string($schemeProxy)) {
throw new InvalidArgumentException('proxy values must be strings');
}
// A matching "no" entry is always a final decision, even when the
// array selects no proxy for the request scheme. Without this, an
// option-level bypass could fall through to handler-level environment
// fallback and route through a proxy the user excluded.
$noProxy = isset($proxy['no']) ? self::normalizeNoProxy($proxy['no']) : [];
if ($noProxy !== [] && self::isUriInNoProxy($uri, $noProxy)) {
return ProxySelection::bypassed();
}
if ($schemeProxy === null) {
return ProxySelection::none();
}
return ProxySelection::proxy($schemeProxy);
}
/**
* Validate a proxy URL and return its lowercased scheme.
*
* A proxy is an authority ([userinfo@]host[:port]) with an optional scheme;
* a scheme-less value is an HTTP proxy. The scheme is matched anchored at
* the start, so leading junk before it is rejected as malformed, and the
* host and port grammar is delegated to Psr7\Rfc3986. The whole string is
* validated up front so a malformed proxy fails the same way on every
* handler, but the original value is what callers pin, so no normalization
* reaches the wire. The error message never includes the proxy, which may
* carry credentials.
*
* @throws InvalidArgumentException on a malformed proxy URL
*/
public static function proxyScheme(
#[\SensitiveParameter]
string $proxy
): string {
$parts = \explode('://', $proxy, 2);
if (\count($parts) === 1) {
$scheme = 'http';
$authority = $proxy;
} else {
[$scheme, $authority] = $parts;
$scheme = Psr7\Utils::asciiToLower($scheme);
if ($scheme === '' || !Rfc3986::isValidScheme($scheme)) {
throw new InvalidArgumentException('Invalid proxy URL.');
}
}
if (!self::isValidProxyAuthority($authority)) {
throw new InvalidArgumentException('Invalid proxy URL.');
}
return $scheme;
}
/**
* Whether the string is a valid proxy authority: [userinfo@]host[:port].
*
* A single trailing slash is tolerated; anything else after the authority
* (a path, query, or fragment) is rejected. Userinfo is not policed here
* (the proxy handles its own credentials); host and port grammar are
* delegated to Psr7\Rfc3986, where a zero port is accepted and left for the
* transport to handle as it did before.
*/
private static function isValidProxyAuthority(string $authority): bool
{
// A single trailing slash is tolerated; once removed, a proxy authority
// ([userinfo@]host[:port]) cannot contain a path, query, or fragment
// delimiter anywhere — including one before a later '@'.
if (\str_ends_with($authority, '/')) {
$authority = \substr($authority, 0, \strlen($authority) - 1);
}
if ($authority === '' || \strpbrk($authority, '/?#') !== false) {
return false;
}
// The host cannot contain '@', so userinfo is everything before the
// last one. It is not validated here. A value that is only userinfo,
// with nothing after the last '@', has no host and is rejected.
$segments = \explode('@', $authority);
$authority = $segments[\count($segments) - 1];
if ($authority === '') {
return false;
}
$port = null;
if (\str_starts_with($authority, '[')) {
$parts = \explode(']', $authority, 2);
if (\count($parts) !== 2) {
return false;
}
$host = $parts[0].']';
$remainder = $parts[1];
if ($remainder !== '') {
if (!\str_starts_with($remainder, ':')) {
return false;
}
[, $port] = \explode(':', $remainder, 2);
}
} elseif (\strpos($authority, ':') !== false) {
[$host, $port] = \explode(':', $authority, 2);
} else {
$host = $authority;
}
// A dangling "host:" carries no port, matching the previous behavior.
if ($port === '') {
$port = null;
}
if ($host === '' || !Rfc3986::isValidHost($host)) {
return false;
}
return $port === null || Rfc3986::isValidPort($port);
}
/**
* Normalize a no-proxy list from request options or NO_PROXY.
*
* @param mixed $noProxy No-proxy value as passed via request transfer options.
*
* @return string[]
*
* @throws InvalidArgumentException
*/
public static function normalizeNoProxy($noProxy): array
{
if ($noProxy === null) {
return [];
}
if (\is_string($noProxy)) {
// Entries may be separated by whitespace as well as commas,
// matching the no_proxy environment variable conventions.
$noProxy = \preg_split('/[\s,]+/', $noProxy);
if ($noProxy === false) {
throw new \RuntimeException('Unable to split the proxy no list: '.\preg_last_error_msg());
}
} elseif (!\is_array($noProxy)) {
throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings');
}
$result = [];
foreach ($noProxy as $area) {
if (!\is_string($area)) {
throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings');
}
$area = \trim($area, " \n\r\t\0\x0B");
if ($area !== '') {
$result[] = $area;
}
}
return $result;
}
/**
* Returns true if the provided URI matches any of the no-proxy areas.
*
* @param string[] $noProxy An array of host, host-and-port, or CIDR patterns.
*
* @throws InvalidArgumentException
*/
public static function isUriInNoProxy(UriInterface $uri, array $noProxy): bool
{
self::assertNoProxyList($noProxy);
$target = self::parseNoProxyTarget($uri);
if ($target === null) {
return false;
}
foreach ($noProxy as $area) {
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '*') {
return true;
}
$rule = self::parseNoProxyRule($area);
if ($rule !== null && self::noProxyRuleMatches($target, $rule)) {
return true;
}
}
return false;
}
/**
* Returns true if the provided host matches any of the no-proxy areas.
*
* This method will strip a port from the host if it is present. Domain
* patterns are matched case-insensitively. Exact IP literal patterns are
* matched by their normalized binary address.
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
* 2. An exact domain or IP literal match.
* 3. A bare domain or a leading-dot domain matches itself and its
* subdomains. e.g. 'mit.edu' and '.mit.edu' both match 'mit.edu'
* and 'foo.mit.edu'.
* 4. IP CIDR entries match IP literal hosts. e.g. '192.168.0.0/16' will
* match '192.168.1.10' and 'fd00::/8' will match '[fd00::1]'.
*
* @param string $host Host to check against the patterns.
* @param string[] $noProxy An array of host or CIDR patterns.
*
* @throws InvalidArgumentException
*/
public static function isHostInNoProxy(string $host, array $noProxy): bool
{
if ($host === '') {
throw new InvalidArgumentException('Empty host provided');
}
self::assertNoProxyList($noProxy);
$target = self::parseNoProxyHostString($host);
if ($target === null) {
return false;
}
foreach ($noProxy as $area) {
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '*') {
return true;
}
$rule = self::parseNoProxyRule($area);
if ($rule !== null && self::noProxyRuleMatches($target, $rule)) {
return true;
}
}
return false;
}
/**
* @param array<array-key, mixed> $noProxy
*
* @throws InvalidArgumentException
*/
private static function assertNoProxyList(array $noProxy): void
{
foreach ($noProxy as $area) {
if (!\is_string($area)) {
throw new InvalidArgumentException('proxy no list must be null, a string, or an array of strings');
}
}
}
/**
* @return array{type: string, value: string, port: int|null}|null
*/
private static function parseNoProxyTarget(UriInterface $uri): ?array
{
$host = $uri->getHost();
if ($host === '') {
return null;
}
return self::parseNoProxyHost($host, $uri->getPort() ?? self::getDefaultPort($uri->getScheme()));
}
/**
* @return array{type: string, value: string, port: int|null}|null
*/
private static function parseNoProxyHostString(string $host): ?array
{
$hostAndPort = self::splitNoProxyHostAndPort($host);
if ($hostAndPort === null) {
return null;
}
[$host] = $hostAndPort;
return self::parseNoProxyHost($host, null);
}
/**
* @return array{type: string, value: string, port: int|null}|array{type: string, value: string, prefix: int}|null
*/
private static function parseNoProxyRule(string $area): ?array
{
$area = \trim($area, " \n\r\t\0\x0B");
if ($area === '' || $area === '*') {
return null;
}
// A single leading dot is ignored: ".example.com" matches
// example.com and its subdomains exactly like a bare domain,
// consistent with every libcurl era. The strip runs before the
// CIDR check so ".10.0.0.0/8" is a live rule on every path.
if ($area[0] === '.') {
$area = \substr($area, 1);
if ($area === '') {
return null;
}
}
if (\strpos($area, '/') !== false) {
return self::parseNoProxyCidrRule($area);
}
$hostAndPort = self::splitNoProxyHostAndPort($area);
if ($hostAndPort === null) {
return null;
}
[$host, $port] = $hostAndPort;
if ($host === '*') {
return [
'type' => 'wildcard',
'value' => '*',
'port' => $port,
];
}
return self::parseNoProxyHost($host, $port);
}
/**
* @return array{type: string, value: string, port: int|null}|null
*/
private static function parseNoProxyHost(string $host, ?int $port): ?array
{
if ($host !== '' && $host[0] === '[') {
if (\substr($host, -1) !== ']') {
return null;
}
$address = \substr($host, 1, -1);
if (!\filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return null;
}
$host = $address;
}
$packedIp = self::packIpAddress($host);
if ($packedIp !== false) {
return [
'type' => 'ip',
'value' => $packedIp,
'port' => $port,
];
}
if ($host === '' || \strpos($host, ':') !== false) {
return null;
}
// Normalize a single DNS root dot for no-proxy domain matching.
if (\substr($host, -1) === '.') {
$host = \substr($host, 0, -1);
if ($host === '') {
return null;
}
}
return [
'type' => 'domain',
'value' => Psr7\Utils::asciiToLower($host),
'port' => $port,
];
}
/**
* @return array{0: string, 1: int|null}|null
*/
private static function splitNoProxyHostAndPort(string $area): ?array
{
if ($area !== '' && $area[0] === '[') {
$closingBracket = \strpos($area, ']');
if ($closingBracket === false) {
return null;
}
$host = \substr($area, 0, $closingBracket + 1);
$tail = \substr($area, $closingBracket + 1);
if ($tail === '') {
return [$host, null];
}
if ($tail[0] !== ':') {
return null;
}
$port = self::parseNoProxyPort(\substr($tail, 1));
return $port === null ? null : [$host, $port];
}
if (self::packIpAddress($area) !== false) {
return [$area, null];
}
$colon = \strrpos($area, ':');
if ($colon === false) {
return [$area, null];
}
$port = self::parseNoProxyPort(\substr($area, $colon + 1));
if ($port === null) {
return null;
}
return [\substr($area, 0, $colon), $port];
}
private static function parseNoProxyPort(string $port): ?int
{
return self::parseBoundedUnsignedInteger($port, 65535);
}
private static function getDefaultPort(string $scheme): ?int
{
if ($scheme === 'http') {
return 80;
}
if ($scheme === 'https') {
return 443;
}
return null;
}
/**
* @return array{type: string, value: string, prefix: int}|null
*/
private static function parseNoProxyCidrRule(string $area): ?array
{
$slash = \strpos($area, '/');
if ($slash === false) {
return null;
}
$prefix = \substr($area, $slash + 1);
$network = \substr($area, 0, $slash);
if ($network !== '' && $network[0] === '[' && \substr($network, -1) === ']') {
$network = \substr($network, 1, -1);
}
$network = self::packIpAddress($network);
if ($network === false) {
return null;
}
$prefix = self::parseBoundedUnsignedInteger($prefix, \strlen($network) * 8);
if ($prefix === null) {
return null;
}
return [
'type' => 'cidr',
'value' => $network,
'prefix' => $prefix,
];
}
private static function parseBoundedUnsignedInteger(string $value, int $max): ?int
{
if ($value === '' || !\ctype_digit($value)) {
return null;
}
$normalized = \ltrim($value, '0');
$normalized = $normalized === '' ? '0' : $normalized;
$limit = (string) $max;
if (\strlen($normalized) > \strlen($limit) || (\strlen($normalized) === \strlen($limit) && \strcmp($normalized, $limit) > 0)) {
return null;
}
return (int) $normalized;
}
/**
* @param array{type: string, value: string, port: int|null} $target
* @param array{type: string, value: string, port?: int|null, prefix?: int|null} $rule
*/
private static function noProxyRuleMatches(array $target, array $rule): bool
{
if ($rule['type'] === 'wildcard') {
return ($rule['port'] ?? null) === null || $rule['port'] === $target['port'];
}
if ($rule['type'] === 'cidr') {
if ($target['type'] !== 'ip' || !isset($rule['prefix'])) {
return false;
}
if (\strlen($target['value']) !== \strlen($rule['value'])) {
return false;
}
return self::ipMatchesPrefix($target['value'], $rule['value'], $rule['prefix']);
}
if (($rule['port'] ?? null) !== null && $rule['port'] !== $target['port']) {
return false;
}
if ($rule['type'] !== $target['type']) {
return false;
}
if ($rule['type'] === 'ip') {
return $rule['value'] === $target['value'];
}
if ($target['value'] === $rule['value']) {
return true;
}
$suffix = '.'.$rule['value'];
return \substr($target['value'], -\strlen($suffix)) === $suffix;
}
/**
* @return string|false
*/
private static function packIpAddress(string $ip)
{
if (!\filter_var($ip, \FILTER_VALIDATE_IP)) {
return false;
}
return \inet_pton($ip);
}
private static function ipMatchesPrefix(string $address, string $network, int $prefix): bool
{
$fullBytes = \intdiv($prefix, 8);
$remainingBits = $prefix % 8;
if ($fullBytes > 0 && \substr($address, 0, $fullBytes) !== \substr($network, 0, $fullBytes)) {
return false;
}
if ($remainingBits === 0) {
return true;
}
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
return (\ord($address[$fullBytes]) & $mask) === (\ord($network[$fullBytes]) & $mask);
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
final class ProxySelection
{
private ?string $proxy;
private bool $bypassed;
private bool $disabled;
private function __construct(?string $proxy, bool $bypassed, bool $disabled)
{
$this->proxy = $proxy;
$this->bypassed = $bypassed;
$this->disabled = $disabled;
}
public static function none(): self
{
return new self(null, false, false);
}
public static function proxy(string $proxy): self
{
return $proxy === '' ? self::disabled() : new self($proxy, false, false);
}
public static function bypassed(): self
{
return new self(null, true, false);
}
public static function disabled(): self
{
return new self(null, false, true);
}
public function getProxy(): ?string
{
return $this->proxy;
}
public function hasProxy(): bool
{
return $this->proxy !== null;
}
public function isBypassed(): bool
{
return $this->bypassed;
}
public function isDisabled(): bool
{
return $this->disabled;
}
public function shouldDisableProxy(): bool
{
return $this->bypassed || $this->disabled;
}
}

View file

@ -1,12 +1,20 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Exception\ResponseException;
use GuzzleHttp\Exception\TooManyRedirectsException;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Psr7\HttpFactory;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
/**
@ -19,6 +27,8 @@ use Psr\Http\Message\UriInterface;
*/
class RedirectMiddleware
{
use NonSerializableTrait;
public const HISTORY_HEADER = 'X-Guzzle-Redirect-History';
public const STATUS_HISTORY_HEADER = 'X-Guzzle-Redirect-Status-History';
@ -26,7 +36,7 @@ class RedirectMiddleware
/**
* @var array
*/
public static $defaultSettings = [
public const DEFAULT_SETTINGS = [
'max' => 5,
'protocols' => ['http', 'https'],
'strict' => false,
@ -35,20 +45,27 @@ class RedirectMiddleware
];
/**
* @var callable(RequestInterface, array): PromiseInterface
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler Next handler to invoke.
*/
public function __construct(callable $nextHandler)
{
$this->nextHandler = $nextHandler;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
$fn = $this->nextHandler;
if (empty($options['allow_redirects'])) {
@ -56,12 +73,12 @@ class RedirectMiddleware
}
if ($options['allow_redirects'] === true) {
$options['allow_redirects'] = self::$defaultSettings;
$options['allow_redirects'] = self::DEFAULT_SETTINGS;
} elseif (!\is_array($options['allow_redirects'])) {
throw new \InvalidArgumentException('allow_redirects must be true, false, or array');
throw new InvalidArgumentException('allow_redirects must be true, false, or array');
} else {
// Merge the default settings with the provided settings
$options['allow_redirects'] += self::$defaultSettings;
$options['allow_redirects'] += self::DEFAULT_SETTINGS;
}
if (empty($options['allow_redirects']['max'])) {
@ -69,17 +86,26 @@ class RedirectMiddleware
}
return $fn($request, $options)
->then(function (ResponseInterface $response) use ($request, $options) {
->then(function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($request, $options) {
return $this->checkRedirect($request, $options, $response);
});
}
/**
* @return ResponseInterface|PromiseInterface
* @return ResponseInterface|PromiseInterface<ResponseInterface, mixed>
*/
public function checkRedirect(RequestInterface $request, array $options, ResponseInterface $response)
{
if (\strpos((string) $response->getStatusCode(), '3') !== 0
public function checkRedirect(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
ResponseInterface $response
) {
if (!self::isRedirectStatusCode($response->getStatusCode())
|| !$response->hasHeader('Location')
) {
return $response;
@ -88,12 +114,16 @@ class RedirectMiddleware
$this->guardMax($request, $response, $options);
$nextRequest = $this->modifyRequest($request, $options, $response);
// If authorization is handled by curl, unset it if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri()) && defined('\CURLOPT_HTTPAUTH')) {
unset(
$options['curl'][\CURLOPT_HTTPAUTH],
$options['curl'][\CURLOPT_USERPWD]
);
// Remove HTTP origin credentials if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $nextRequest->getUri())) {
unset($options['auth'], $options['__guzzle_digest_retries']);
if (defined('\CURLOPT_HTTPAUTH') && defined('\CURLOPT_USERPWD')) {
unset(
$options['curl'][\CURLOPT_HTTPAUTH],
$options['curl'][\CURLOPT_USERPWD]
);
}
}
if (isset($options['allow_redirects']['on_redirect'])) {
@ -104,6 +134,10 @@ class RedirectMiddleware
);
}
// The caller's delay applies once, before the initial request, not
// before each followed redirect.
unset($options['delay']);
$promise = $this($nextRequest, $options);
// Add headers to be able to track history of redirects.
@ -120,11 +154,22 @@ class RedirectMiddleware
/**
* Enable tracking on promise.
*
* @param PromiseInterface<ResponseInterface, mixed> $promise
*
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function withTracking(PromiseInterface $promise, string $uri, int $statusCode): PromiseInterface
{
private function withTracking(
PromiseInterface $promise,
#[\SensitiveParameter]
string $uri,
int $statusCode
): PromiseInterface {
return $promise->then(
static function (ResponseInterface $response) use ($uri, $statusCode) {
static function (
#[\SensitiveParameter]
ResponseInterface $response
) use ($uri, $statusCode): ResponseInterface {
// Note that we are pushing to the front of the list as this
// would be an earlier response than what is currently present
// in the history header.
@ -144,8 +189,14 @@ class RedirectMiddleware
*
* @throws TooManyRedirectsException Too many redirects.
*/
private function guardMax(RequestInterface $request, ResponseInterface $response, array &$options): void
{
private function guardMax(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
#[\SensitiveParameter]
array &$options
): void {
$current = $options['__redirect_count']
?? 0;
$options['__redirect_count'] = $current + 1;
@ -156,48 +207,67 @@ class RedirectMiddleware
}
}
public function modifyRequest(RequestInterface $request, array $options, ResponseInterface $response): RequestInterface
{
// Request modifications to apply.
$modify = [];
public function modifyRequest(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
ResponseInterface $response
): RequestInterface {
$modify = self::getRedirectRequestModifiers($request, $options, $response);
$protocols = $options['allow_redirects']['protocols'];
// Use a GET request if this is an entity enclosing request and we are
// not forcing RFC compliance, but rather emulating what all browsers
// would do.
$statusCode = $response->getStatusCode();
if ($statusCode == 303
|| ($statusCode <= 302 && !$options['allow_redirects']['strict'])
) {
$safeMethods = ['GET', 'HEAD', 'OPTIONS'];
$requestMethod = $request->getMethod();
$modify['method'] = in_array($requestMethod, $safeMethods) ? $requestMethod : 'GET';
$modify['body'] = '';
$uriFactory = $options[RequestOptions::URI_FACTORY] ?? new HttpFactory();
if (!$uriFactory instanceof UriFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::URI_FACTORY,
UriFactoryInterface::class
));
}
$uri = self::redirectUri($request, $response, $protocols);
$idnOptions = Utils::normalizeIdnConversionOption($options['idn_conversion'] ?? null);
$uri = self::redirectUri($uriFactory, $request, $response, $protocols);
$idnOptions = Idn::normalizeConversionOption($options['idn_conversion'] ?? null);
if ($idnOptions !== null) {
$uri = Utils::idnUriConvert($uri, $idnOptions);
$uri = Idn::convertUri($uri, $idnOptions);
}
$modify['uri'] = $uri;
Psr7\Message::rewindBody($request);
// Add the Referer header if it is told to do so and only
// add the header if we are not redirecting from https to http.
// The body only needs to be rewound when the next request reuses it.
if (!isset($modify['body'])) {
try {
Psr7\Message::rewindBody($request);
} catch (\Exception $e) {
throw new ResponseException(
'Redirect failed because the request body could not be rewound',
$request,
$response,
$e
);
}
}
$crossOrigin = Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri']);
// Add a Referer only when the scheme is unchanged, and reduce it to the
// origin on cross-origin redirects so a secret-bearing path or query is
// not leaked (strict-origin-when-cross-origin, like modern browsers).
if ($options['allow_redirects']['referer']
&& $modify['uri']->getScheme() === $request->getUri()->getScheme()
) {
$uri = $request->getUri()->withUserInfo('');
$modify['set_headers']['Referer'] = (string) $uri;
$referer = $request->getUri()->withUserInfo('')->withFragment('');
if ($crossOrigin) {
$referer = $referer->withPath('/')->withQuery('');
}
$modify['set_headers']['Referer'] = (string) $referer;
} else {
$modify['remove_headers'][] = 'Referer';
}
// Remove Authorization and Cookie headers if URI is cross-origin.
if (Psr7\UriComparator::isCrossOrigin($request->getUri(), $modify['uri'])) {
if ($crossOrigin) {
$modify['remove_headers'][] = 'Authorization';
$modify['remove_headers'][] = 'Cookie';
}
@ -205,24 +275,90 @@ class RedirectMiddleware
return Psr7\Utils::modifyRequest($request, $modify);
}
private static function isRedirectStatusCode(int $statusCode): bool
{
return \in_array($statusCode, [301, 302, 303, 307, 308], true);
}
/**
* @return array{
* method?: string,
* body?: StreamInterface,
* remove_headers?: list<string>
* }
*/
private static function getRedirectRequestModifiers(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
ResponseInterface $response
): array {
$statusCode = $response->getStatusCode();
if ($statusCode !== 303
&& ($statusCode > 302 || $options['allow_redirects']['strict'])
) {
return [];
}
$requestMethod = $request->getMethod();
if ($requestMethod === 'QUERY' && \in_array($statusCode, [301, 302], true)) {
return [];
}
$streamFactory = $options[RequestOptions::STREAM_FACTORY] ?? new HttpFactory();
if (!$streamFactory instanceof StreamFactoryInterface) {
throw new InvalidArgumentException(\sprintf(
'%s must be an instance of %s',
RequestOptions::STREAM_FACTORY,
StreamFactoryInterface::class
));
}
return [
'method' => \in_array($requestMethod, ['GET', 'HEAD', 'OPTIONS'], true) ? $requestMethod : 'GET',
'body' => $streamFactory->createStream(''),
'remove_headers' => ['Content-Length', 'Transfer-Encoding'],
];
}
/**
* Set the appropriate URL on the request based on the location header.
*/
private static function redirectUri(
UriFactoryInterface $uriFactory,
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
ResponseInterface $response,
array $protocols
): UriInterface {
$location = Psr7\UriResolver::resolve(
$request->getUri(),
new Psr7\Uri($response->getHeaderLine('Location'))
);
$location = $response->getHeaderLine('Location');
// Ensure that the redirect URI is allowed based on the protocols.
if (!\in_array($location->getScheme(), $protocols)) {
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', $location, \implode(', ', $protocols)), $request, $response);
try {
$locationUri = $uriFactory->createUri($location);
$resolvedUri = Psr7\UriResolver::resolve(
$request->getUri(),
$locationUri
);
if (!$uriFactory instanceof HttpFactory
&& $locationUri->getScheme() === ''
&& $locationUri->getAuthority() === ''
) {
$resolvedUri = $uriFactory->createUri((string) $resolvedUri);
}
} catch (\InvalidArgumentException $e) {
throw new BadResponseException(\sprintf('Redirect URI, %s, is invalid: %s', Psr7\DiagnosticValue::escape($location), $e->getMessage()), $request, $response, $e);
}
return $location;
// Ensure that the redirect URI is allowed based on the protocols.
if (!\in_array($resolvedUri->getScheme(), $protocols, true)) {
throw new BadResponseException(\sprintf('Redirect URI, %s, does not use one of the allowed redirect protocols: %s', Psr7\DiagnosticValue::escape((string) $resolvedUri), \implode(', ', $protocols)), $request, $response);
}
return $resolvedUri;
}
}

View file

@ -1,35 +1,53 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriInterface;
/**
* This class contains a list of built-in Guzzle request options.
*
* @see https://github.com/guzzle/guzzle/blob/7.11/docs/request-options.md
* @see https://github.com/guzzle/guzzle/blob/8.0/docs/request-options.md
*/
final class RequestOptions
{
private function __construct()
{
}
/**
* allow_redirects: (bool|array) Controls redirect behavior. Pass false
* to disable redirects, pass true to enable redirects, pass an
* associative to provide custom redirect settings. Defaults to "false".
* to disable redirects, pass true to enable redirects, or pass an
* associative array to provide custom redirect settings. Clients enable
* redirects by default when the default redirect middleware is present.
* This option only works if your handler has the RedirectMiddleware. When
* passing an associative array, you can provide the following key value
* pairs:
* pairs. Only 301, 302, 303, 307, and 308 responses with a Location header
* are followed.
*
* - max: (int, default=5) maximum number of allowed redirects.
* - strict: (bool, default=false) Set to true to use strict redirects
* meaning redirect POST requests with POST requests vs. doing what most
* browsers do which is redirect POST requests with GET requests
* browsers do which is redirect POST requests with GET requests. The
* QUERY method keeps its method and body across non-strict 301 and 302
* redirects, and a 303 redirect is followed with a body-less GET.
* - referer: (bool, default=false) Set to true to enable the Referer
* header.
* - protocols: (non-empty-array<array-key, string>, default=['http', 'https'])
* Allowed redirect protocols. Redirect matching is case-sensitive; use
* "http" and "https".
* - on_redirect: (callable) PHP callable that is invoked when a redirect
* is encountered. The callable is invoked with the request, the redirect
* response that was received, and the effective URI. Any return value
* from the on_redirect function is ignored.
* - protocols: (non-empty-array<array-key, 'http'|'https'>,
* default=['http', 'https']) Allowed redirect protocols. Values are
* case-sensitive; only "http" and "https" are accepted.
* - on_redirect: (callable(RequestInterface, ResponseInterface, UriInterface): mixed)
* PHP callable that is invoked when a redirect is encountered. The
* callable is invoked with the request, the redirect response that was
* received, and the effective URI. Any return value is ignored. When
* this callback is supplied via GuzzleHttp\Pool's "options"
* configuration, the pool appends the request's iterable key as a
* fourth argument.
* - track_redirects: (bool, default=false) Track redirected URI and status
* history in response headers.
*/
@ -41,14 +59,22 @@ final class RequestOptions
* The array must contain the username in index [0], the password in index
* [1], and you can optionally provide a built-in authentication type in
* index [2]. Pass false or null to disable authentication for a request.
* String values are passed through for custom handlers.
* Built-in Basic and Digest authentication are applied by the auth
* middleware, normally from HandlerStack::create(). String values are
* passed through for custom handlers. Built-in Basic usernames must not
* contain a colon, and neither Basic usernames nor passwords may contain
* ASCII control characters. Basic passwords may contain colons.
*/
public const AUTH = 'auth';
/**
* body: (resource|string|null|int|float|bool|\Psr\Http\Message\StreamInterface|(callable&object)|\Iterator|\Stringable)
* Body to send in the request. Callable arrays are arrays, and arrays are
* not valid body values in Guzzle.
* body: (resource|string|null|StreamInterface|(callable&object)|\Iterator|\Stringable)
* Body to send in the request. Resource and stringable object values are
* converted using the configured stream_factory. Callable and iterator
* bodies use Guzzle's existing stream handling. Strings are used as literal
* body contents, even when they name a callable. Callable bodies may be
* closures or invokable objects; arrays, int, float, bool, and generic
* objects without __toString() are not valid body values.
*/
public const BODY = 'body';
@ -60,6 +86,8 @@ final class RequestOptions
* the certificate password in the second array element. A null password is
* treated the same as omitting it. Use cert_type to specify another
* supported certificate format.
*
* @see self::CERT_TYPE
*/
public const CERT = 'cert';
@ -78,28 +106,51 @@ final class RequestOptions
public const COOKIES = 'cookies';
/**
* connect_timeout: (int|float, default=0) Number of seconds to wait while
* trying to connect to a server. Use 0 to wait 300 seconds (the default
* behavior).
* connect_timeout: (int|float, default=60) Number of seconds to wait
* while trying to connect to a server. Use 0 to disable the connect
* timeout. Positive values below 0.001 seconds are rejected by the
* built-in cURL handler.
*/
public const CONNECT_TIMEOUT = 'connect_timeout';
/**
* crypto_method: (int) A value describing the minimum TLS protocol
* version to use.
* version to use. The built-in cURL and stream handlers default HTTPS
* requests to TLS 1.2 or newer.
*
* This setting must be set to one of the
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. PHP 7.4 or higher is
* required in order to use TLS 1.3, and cURL 7.34.0 or higher is required
* in order to specify a crypto method, with cURL 7.52.0 or higher being
* required to use TLS 1.3.
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. cURL 7.52.0 or higher
* is required to use TLS 1.3 with the cURL handler.
*/
public const CRYPTO_METHOD = 'crypto_method';
/**
* crypto_method_max: (int) A value describing the maximum TLS protocol
* version to use. The built-in cURL and stream handlers default HTTPS
* requests to TLS 1.2 or newer, so a lower maximum requires explicitly
* lowering crypto_method too.
*
* This setting must be set to one of the
* ``STREAM_CRYPTO_METHOD_TLS*_CLIENT`` constants. cURL 7.54.0 or higher
* is required in order to specify a maximum TLS version with the cURL
* handler.
*/
public const CRYPTO_METHOD_MAX = 'crypto_method_max';
/**
* curl: (array) Raw cURL options to apply when using a built-in cURL
* handler.
*/
public const CURL = 'curl';
/**
* debug: (bool|resource) Set to true or set to a PHP stream returned by
* fopen() enable debug output with the HTTP handler used to send a
* request.
* fopen() to enable debug output with the handler used to send a request.
* For example, when using cURL to transfer requests, cURL's verbose of
* CURLOPT_VERBOSE will be emitted. When using the PHP stream wrapper,
* stream wrapper notifications will be emitted. If set to true, the output
* is written to PHP's STDOUT. If a PHP stream is provided, output is
* written to the stream.
*/
public const DEBUG = 'debug';
@ -111,8 +162,8 @@ final class RequestOptions
public const DECODE_CONTENT = 'decode_content';
/**
* delay: (int|float) The amount of time to delay before sending in
* milliseconds.
* delay: (int|float) The finite, non-negative amount of time to delay
* before sending in milliseconds.
*/
public const DELAY = 'delay';
@ -144,14 +195,14 @@ final class RequestOptions
/**
* headers: (array<array-key, string|non-empty-array<array-key, string>>|null)
* Associative array of HTTP headers. Each value MUST be a string or non-empty
* array of strings.
* Associative array of HTTP headers. Each value MUST be a string or
* non-empty array of strings.
*/
public const HEADERS = 'headers';
/**
* http_errors: (bool, default=true) Set to false to disable exceptions
* when a non- successful HTTP response is received. By default,
* when a non-successful HTTP response is received. By default,
* exceptions will be thrown for 4xx and 5xx responses. This option only
* works if your handler has the `httpErrors` middleware.
*/
@ -160,7 +211,7 @@ final class RequestOptions
/**
* idn_conversion: (bool|int|null, default=false) A combination of IDNA_*
* constants for PHP's idn_to_ascii() function. Set to false or null to
* disable IDN support, or to true to use the default configuration
* disable IDN support, or true to use the default configuration
* (IDNA_DEFAULT constant).
*/
public const IDN_CONVERSION = 'idn_conversion';
@ -168,7 +219,8 @@ final class RequestOptions
/**
* json: (mixed) Adds JSON data to a request. The provided value is JSON
* encoded and a Content-Type header of application/json will be added to
* the request if no Content-Type header is already present.
* the request if no Content-Type header is already present. An Accept
* header is not added automatically.
*/
public const JSON = 'json';
@ -184,45 +236,158 @@ final class RequestOptions
public const MULTIPART = 'multipart';
/**
* on_headers: (callable) A callable that is invoked when the HTTP headers
* of the response have been received but the body has not yet begun to
* download.
* multiplex: (string, default=GuzzleHttp\Multiplexing::WAIT) Controls how a
* request sent through a built-in cURL handler relates to shared,
* multiplexed connections: how an HTTP/2 or HTTP/3 request pursues one, or,
* with Multiplexing::NONE, whether the transfer may share its connection at
* all. Use Multiplexing::EAGER to avoid waiting for pending connections,
* Multiplexing::WAIT to wait on libcurl-eligible pending connections with
* CURLOPT_PIPEWAIT, normally to the same origin,
* Multiplexing::REQUIRE_EAGER to fail unless a multiplexed protocol is
* guaranteed while dialing eagerly, or Multiplexing::REQUIRE_WAIT for the
* same guarantee while also waiting on pending connections. The required
* modes require a handler that permits actual multiplexing, not merely a
* multiplexed protocol, and are rejected on a Multiplexing::NONE handler.
* The stream handler ignores EAGER and WAIT, and rejects the required
* family; CurlHandler has no multi handle to multiplex over. The required
* family also rejects final CURLOPT_HTTPAUTH masks that permit NTLM, which
* libcurl retries over HTTP/1.1. Multiplexing::NONE disables multiplexing
* for a whole handler when passed as the "multiplex" client configuration
* option, which configures the default handler and also becomes the default
* request option, or, when constructing a handler directly, as the
* CurlMultiHandler "multiplex" constructor option. A handler configured
* with Multiplexing::NONE wins over the default WAIT: default requests run
* without waiting, explicitly requested wait modes are rejected as a
* configuration conflict when the transfer would actually wait, and the
* required modes are always rejected, because they require a handler that
* permits actual multiplexing, not merely a multiplexed protocol. As a
* request option value, Multiplexing::NONE guarantees the transfer does not
* share its connection with any concurrent transfer. Multiplexing::NONE
* does not force HTTP/1.1: on a Multiplexing::NONE handler, HTTP/2 still
* negotiates and each transfer keeps its connection to itself.
*
* The request option value is accepted exactly where the guarantee holds
* and can be verified: on a CurlMultiHandler configured with
* Multiplexing::NONE, for requests whose declared protocol version is
* HTTP/1.x, on CurlHandler, and on the stream handler, which never
* multiplexes. An HTTP/2 or HTTP/3 request with a Multiplexing::NONE
* request option is rejected on a CurlMultiHandler that permits
* multiplexing. Acceptance is decided from the request's declared protocol
* version, before any transport-level downgrade: an HTTP/3 request sent
* through a proxy is delivered over HTTP/2 or HTTP/1.1 on the wire, but is
* still rejected. On a CurlMultiHandler that permits multiplexing,
* Multiplexing::NONE is also rejected with a custom "handle_factory", when
* the handler requires persistent transport sharing (the safeguards can
* require a fresh connection, which required sharing forbids), when the
* request carries an Expect: 100-continue header (its 417 retries select
* connections outside the safeguards; remove an explicitly supplied
* header, or set the "expect" request option to false to prevent it being
* added automatically), and combined with a raw CURLOPT_HTTPAUTH cURL
* option, whose authentication retries do the same.
*
* On a client whose multi handler permits multiplexing, the ordinary
* non-streaming default stack - both cURL handlers available and no
* connection caps forcing multi-only routing - runs synchronous requests on
* the CurlHandler path, which satisfies the guarantee for any protocol
* version, while asynchronous requests run on the CurlMultiHandler, so an
* HTTP/2 request with Multiplexing::NONE succeeds synchronously and is
* rejected asynchronously on the same client. Keep-alive reuse between
* consecutive transfers is unaffected, except on libcurl versions below
* 7.77.0 and from 8.11.0 through 8.12.1, where an accepted HTTP/1.x request
* on a multiplexing CurlMultiHandler forces a fresh connection. Custom
* handlers receive the "multiplex" option unchanged: its semantics are
* handler-defined, Guzzle does not guarantee it is honored, and a
* client-level Multiplexing::NONE with a custom handler flows to it as a
* default request option without client-side enforcement.
*/
public const MULTIPLEX = 'multiplex';
/**
* on_headers: (callable(ResponseInterface, RequestInterface): mixed) A
* callable that is invoked when the HTTP headers of the final response, or
* a 101 Switching Protocols response, have been received but the body has
* not yet begun to download. The callable is passed the response and
* request as {@see ResponseInterface} and {@see RequestInterface} objects,
* respectively. If it throws, the request promise is rejected with a
* GuzzleHttp\Exception\ResponseException (a RequestException subtype)
* wrapping the thrown exception. When this callback is supplied via
* GuzzleHttp\Pool's "options" configuration, the pool appends the request's
* iterable key as a third argument.
*/
public const ON_HEADERS = 'on_headers';
/**
* on_stats: (callable) allows you to get access to transfer statistics of
* a request and access the lower level transfer details of the handler
* associated with your client. ``on_stats`` is a callable that is invoked
* when a handler has finished sending a request. The callback is invoked
* with transfer statistics about the request, the response received, or
* the error encountered. Included in the data is the total amount of time
* taken to send the request.
* on_stats: (callable(TransferStats): mixed) allows you to get access to
* transfer statistics of a request and access the lower level transfer
* details of the handler associated with your client. ``on_stats`` is a
* callable that is invoked when a handler has finished sending a request.
* The callback is invoked with transfer statistics about the request, the
* response received, or the error encountered. Included in the data is the
* total amount of time taken to send the request. Exceptions thrown by
* on_stats are not wrapped by Guzzle. Built-in handlers reject non-callable
* values before starting the transfer. The built-in cURL handlers release
* native easy handles before invoking on_stats and invoke it per low-level
* transfer attempt. When this callback is supplied via GuzzleHttp\Pool's
* "options" configuration, the pool appends the request's iterable key as a
* second argument.
*/
public const ON_STATS = 'on_stats';
/**
* progress: (callable) Defines a function to invoke when transfer
* progress is made. The function accepts the following positional
* arguments: the total number of bytes expected to be downloaded, the
* number of bytes downloaded so far, the number of bytes expected to be
* uploaded, the number of bytes uploaded so far.
* on_trailers: (callable(array<string, list<string>>, ResponseInterface, RequestInterface): mixed)
* A callable that is invoked exactly once when a transfer completes
* successfully, with the HTTP trailer fields of the response. The callable
* is passed an associative array of trailer field names mapped to lists of
* field values, the response, and the request. The array is empty when the
* response carried no trailer fields, and trailer field names are
* lowercased and grouped case-insensitively while values keep their wire
* order. The callable is never invoked for failed transfers. If it throws,
* the request promise is rejected with a
* GuzzleHttp\Exception\ResponseException (a RequestException subtype)
* wrapping the thrown exception. Only the built-in cURL handlers invoke
* on_trailers; the built-in stream handler rejects the option because it
* cannot observe trailer fields, and the mock handler ignores it.
* Malformed trailer field lines are discarded before parsing. Trailer
* fields are reported separately from response headers and are never
* merged into the response. When this callback is supplied via
* GuzzleHttp\Pool's "options" configuration, the pool appends the
* request's iterable key as a fourth argument.
*/
public const ON_TRAILERS = 'on_trailers';
/**
* progress: (callable(int, int, int, int): mixed)
* Defines a function to invoke when transfer progress is made. The function
* accepts the following positional arguments: the total number of bytes
* expected to be downloaded, the number of bytes downloaded so far, the
* number of bytes expected to be uploaded, the number of bytes uploaded so
* far. With the built-in cURL handlers, returning a truthy value aborts the
* transfer and throwing rejects the promise with a RequestException. The
* built-in stream handler ignores return values. When this callback is
* supplied via GuzzleHttp\Pool's "options" configuration, the pool appends
* the request's iterable key as a fifth argument.
*/
public const PROGRESS = 'progress';
/**
* protocols: (non-empty-array<array-key, string>, default=['http', 'https'])
* Allowed URI schemes. Built-in handlers accept only the case-sensitive
* values "http" and "https".
* protocols: (non-empty-array<array-key, 'http'|'https'>,
* default=['http', 'https']) Allowed URI schemes. Values are
* case-sensitive; only "http" and "https" are accepted.
*/
public const PROTOCOLS = 'protocols';
/**
* proxy: (string|array) Pass a string to specify an HTTP proxy, or an
* array to specify different proxies for different protocols (where the
* key is the protocol and the value is a proxy string or null). Provide a
* "no" key as a comma-delimited string, array of strings, or null to
* specify hosts or host-and-port pairs that should not be proxied.
* proxy: (string|array) Pass a string to specify an HTTP proxy, or an array
* to specify different proxies for different protocols (where the key is
* the protocol and the value is a proxy string or null). Provide a "no" key
* as a comma- or whitespace-delimited string, array of strings, or null to
* specify hosts, host-and-port pairs, IP literals, IP CIDR rules, or
* wildcard rules that should not be proxied. Domain rules are matched
* case-insensitively. Exact IP literals are normalized before matching.
* CIDR rules match IP literals only and are not port-specific. Custom
* handlers can use ProxyOptions::resolve() to apply Guzzle-compatible proxy
* selection; the built-in handlers' environment-variable fallback is not
* part of that helper.
*/
public const PROXY = 'proxy';
@ -235,9 +400,33 @@ final class RequestOptions
public const QUERY = 'query';
/**
* sink: (resource|string|\Psr\Http\Message\StreamInterface) Where the data
* of the response is written to. Defaults to a PHP temp stream. Providing
* a string will write data to a file by the given name.
* request_factory: (Psr\Http\Message\RequestFactoryInterface) PSR-17
* request factory used when creating requests through request() and
* requestAsync().
*/
public const REQUEST_FACTORY = 'request_factory';
/**
* stream_factory: (Psr\Http\Message\StreamFactoryInterface) PSR-17
* stream factory used when creating request body streams from body,
* form_params, and json request options, and when the built-in handlers
* create response body streams.
*/
public const STREAM_FACTORY = 'stream_factory';
/**
* response_factory: (Psr\Http\Message\ResponseFactoryInterface) PSR-17
* response factory used by the built-in handlers when creating the
* response message.
*/
public const RESPONSE_FACTORY = 'response_factory';
/**
* sink: (resource|string|StreamInterface) Where the data of the
* response is written to. Defaults to a PHP temp stream. Providing a
* string will write data to a file by the given name. Built-in handlers
* treat PHP resources as caller-owned; callers are responsible for closing
* resource sinks.
*/
public const SINK = 'sink';
@ -256,6 +445,8 @@ final class RequestOptions
* the first array element followed by the key password in the second
* element. A null password is treated the same as omitting it. Use
* ssl_key_type to specify another supported key format.
*
* @see self::SSL_KEY_TYPE
*/
public const SSL_KEY = 'ssl_key';
@ -270,6 +461,12 @@ final class RequestOptions
*/
public const STREAM = 'stream';
/**
* stream_context: (array) PHP stream context options to merge into the
* context used by the built-in stream handler.
*/
public const STREAM_CONTEXT = 'stream_context';
/**
* verify: (bool|string, default=true) Describes the SSL certificate
* verification behavior of a request. Set to true to enable SSL
@ -281,26 +478,48 @@ final class RequestOptions
public const VERIFY = 'verify';
/**
* timeout: (int|float, default=0) Number describing the timeout of the
* request in seconds. Use 0 to wait indefinitely (the default behavior).
* timeout: (int|float, default=0) Number of seconds to use as the total
* timeout of the request. Use 0 to disable the total timeout (the default
* behavior). Positive values below 0.001 seconds are rejected by the
* built-in handlers.
*/
public const TIMEOUT = 'timeout';
/**
* read_timeout: (int|float, default=default_socket_timeout ini setting)
* Number describing the body read timeout, for stream requests.
* read_timeout: (int|float, default=60) Number of seconds the connection
* may sit silent at any stage of the request. Use 0 to disable the idle
* timeout. Positive values below 0.001 seconds are rejected by the
* built-in stream handler.
*/
public const READ_TIMEOUT = 'read_timeout';
/**
* version: (string|int|float) Specifies the HTTP protocol version to attempt
* to use.
* retries: (int) Current retry count used by the retry middleware.
*/
public const RETRIES = 'retries';
/**
* uri_factory: (Psr\Http\Message\UriFactoryInterface) PSR-17 URI factory
* used when creating URI objects from string request URI, base_uri, and
* redirect Location values.
*/
public const URI_FACTORY = 'uri_factory';
/**
* version: (string|int|float, default=1.1) Specifies the HTTP protocol
* version to attempt to use.
*
* Guzzle defaults to HTTP/1.1. The built-in stream handler supports
* HTTP/1.0 and HTTP/1.1. The built-in cURL handler also supports HTTP/2
* and HTTP/3 when the installed cURL stack reports those features. For
* HTTP/2 and HTTP/3, libcurl may use a lower HTTP version when
* negotiation or connection setup falls back.
*/
public const VERSION = 'version';
/**
* force_ip_resolve: (string) Set to "v4" to force IPv4 resolution or "v6"
* for IPv6 resolution when supported by the handler.
* force_ip_resolve: ("v4"|"v6") Set to "v4" to force IPv4 resolution or
* "v6" to force IPv6 resolution when supported by the handler.
*/
public const FORCE_IP_RESOLVE = 'force_ip_resolve';
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Promise as P;
@ -15,63 +17,56 @@ use Psr\Http\Message\ResponseInterface;
*/
class RetryMiddleware
{
use NonSerializableTrait;
/**
* @var callable(RequestInterface, array): PromiseInterface
* @var callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private $nextHandler;
/**
* @var callable
* @var callable(int, RequestInterface, ResponseInterface|null, mixed): bool
*/
private $decider;
/**
* @var callable(int)
* @var callable(int, ResponseInterface|null, RequestInterface): int
*/
private $delay;
/**
* @param callable $decider Function that accepts the number of retries,
* a request, [response], and [exception] and
* returns true if the request is to be
* retried.
* @param callable(RequestInterface, array): PromiseInterface $nextHandler Next handler to invoke.
* @param (callable(int): int)|null $delay Function that accepts the number of retries
* and returns the number of
* milliseconds to delay.
* @param callable(int, RequestInterface, ResponseInterface|null, mixed): bool $decider Function that accepts the number of retries,
* a request, [response], and [rejection reason]
* and returns true if the request is to be retried.
* @param callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> $nextHandler Next handler to invoke.
* @param (callable(int, ResponseInterface|null, RequestInterface): int)|null $delay Function that returns the number of milliseconds to delay.
*/
public function __construct(callable $decider, callable $nextHandler, ?callable $delay = null)
{
$this->decider = $decider;
$this->nextHandler = $nextHandler;
$this->delay = $delay ?: static function (int $retries): int {
return (int) 2 ** ($retries - 1) * 1000;
return (int) ((2 ** ($retries - 1)) * 1000);
};
}
/**
* Default exponential backoff delay function.
*
* @return int milliseconds.
*
* @deprecated since 7.11, will be removed in 8.0.
* @return PromiseInterface<ResponseInterface, mixed>
*/
public static function exponentialDelay(int $retries): int
{
\trigger_deprecation('guzzlehttp/guzzle', '7.11', '%s::%s() is deprecated and will be removed in 8.0.', __CLASS__, __FUNCTION__);
return (int) 2 ** ($retries - 1) * 1000;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
public function __invoke(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options
): PromiseInterface {
if (!isset($options['retries'])) {
$options['retries'] = 0;
} elseif (!\is_int($options['retries'])) {
throw new \InvalidArgumentException('retries must be an integer');
}
$fn = $this->nextHandler;
return $fn($request, $options)
/** @var PromiseInterface<ResponseInterface, mixed> */
return ($this->nextHandler)($request, $options)
->then(
$this->onFulfilled($request, $options),
$this->onRejected($request, $options)
@ -83,7 +78,10 @@ class RetryMiddleware
*/
private function onFulfilled(RequestInterface $request, array $options): callable
{
return function ($value) use ($request, $options) {
return function (
#[\SensitiveParameter]
$value
) use ($request, $options) {
if (!($this->decider)(
$options['retries'],
$request,
@ -102,7 +100,10 @@ class RetryMiddleware
*/
private function onRejected(RequestInterface $req, array $options): callable
{
return function ($reason) use ($req, $options) {
return function (
#[\SensitiveParameter]
$reason
) use ($req, $options): PromiseInterface {
if (!($this->decider)(
$options['retries'],
$req,
@ -112,13 +113,24 @@ class RetryMiddleware
return P\Create::rejectionFor($reason);
}
/** @var PromiseInterface<mixed, mixed> */
return $this->doRetry($req, $options);
};
}
private function doRetry(RequestInterface $request, array $options, ?ResponseInterface $response = null): PromiseInterface
{
$options['delay'] = ($this->delay)(++$options['retries'], $response, $request);
/**
* @return PromiseInterface<ResponseInterface, mixed>
*/
private function doRetry(
#[\SensitiveParameter]
RequestInterface $request,
#[\SensitiveParameter]
array $options,
#[\SensitiveParameter]
?ResponseInterface $response = null
): PromiseInterface {
++$options['retries'];
$options['delay'] = ($this->delay)($options['retries'], $response, $request);
return $this($request, $options);
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use Psr\Http\Message\RequestInterface;
@ -12,25 +14,13 @@ use Psr\Http\Message\UriInterface;
*/
final class TransferStats
{
/**
* @var RequestInterface
*/
private $request;
private RequestInterface $request;
/**
* @var ResponseInterface|null
*/
private $response;
private ?ResponseInterface $response;
/**
* @var float|null
*/
private $transferTime;
private ?float $transferTime;
/**
* @var array
*/
private $handlerStats;
private array $handlerStats;
/**
* @var mixed|null

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
final class TransportSharing
@ -7,6 +9,8 @@ final class TransportSharing
public const NONE = 'none';
public const HANDLER_PREFER = 'handler_prefer';
public const HANDLER_REQUIRE = 'handler_require';
public const PERSISTENT_PREFER = 'persistent_prefer';
public const PERSISTENT_REQUIRE = 'persistent_require';
private function __construct()
{

View file

@ -1,42 +1,24 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp;
use GuzzleHttp\Exception\InvalidArgumentException;
use GuzzleHttp\Handler\CurlHandler;
use GuzzleHttp\Handler\CurlMultiHandler;
use GuzzleHttp\Handler\CurlShareHandleState;
use GuzzleHttp\Handler\CurlVersion;
use GuzzleHttp\Handler\Proxy;
use GuzzleHttp\Handler\StreamHandler;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Message\ResponseInterface;
final class Utils
{
/**
* Debug function used to describe the provided value type and class.
*
* @param mixed $input
*
* @return string Returns a string containing the type of the variable and
* if a class is provided, the class name.
*/
public static function describeType($input): string
private function __construct()
{
switch (\gettype($input)) {
case 'object':
return 'object('.\get_class($input).')';
case 'array':
return 'array('.\count($input).')';
default:
\ob_start();
\var_dump($input);
// normalize float vs double
/** @var string $varDumpContent */
$varDumpContent = \ob_get_clean();
return \str_replace('double(', 'float(', \rtrim($varDumpContent));
}
}
/**
@ -51,7 +33,7 @@ final class Utils
foreach ($lines as $line) {
$parts = \explode(':', $line, 2);
$headers[\trim($parts[0])][] = isset($parts[1]) ? \trim($parts[1]) : null;
$headers[\trim($parts[0], " \n\r\t\0\x0B")][] = isset($parts[1]) ? \trim($parts[1], " \n\r\t\0\x0B") : null;
}
return $headers;
@ -81,56 +63,162 @@ final class Utils
*
* The returned handler is not wrapped by any default middlewares.
*
* @param array{transport_sharing?: mixed} $handlerOptions Handler constructor options.
* @param array{transport_sharing?: mixed, max_host_connections?: mixed, max_total_connections?: mixed, multiplex?: mixed} $handlerOptions Handler constructor options.
*
* @return callable(RequestInterface, array): Promise\PromiseInterface Returns the best handler for the given system.
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed> Returns the best handler for the given system.
*
* @throws \RuntimeException if no viable Handler is available.
*/
public static function chooseHandler(array $handlerOptions = []): callable
{
$handler = null;
$sharingMode = CurlShareHandleState::normalizeMode($handlerOptions['transport_sharing'] ?? null, 'transport_sharing');
$sharingRequested = $sharingMode !== TransportSharing::NONE;
$sharingRequired = $sharingMode === TransportSharing::HANDLER_REQUIRE;
$curlHandlerOptions = [];
$curlSupported = \defined('CURLOPT_CUSTOMREQUEST')
&& \function_exists('curl_version')
&& version_compare(curl_version()['version'], '7.21.2') >= 0
&& (\function_exists('curl_multi_exec') || \function_exists('curl_exec'));
$sharingRequired = self::isTransportSharingRequired($sharingMode);
$connectionCapsRequired = self::hasConnectionCapOptions($handlerOptions);
if ($sharingRequired && !$curlSupported) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and libcurl 7.21.2 or higher.');
if ($connectionCapsRequired && $sharingMode === TransportSharing::PERSISTENT_REQUIRE) {
throw new InvalidArgumentException('The "max_host_connections" and "max_total_connections" options cannot be combined with required persistent transport sharing because libcurl does not reliably apply connection caps to shared connection pools.');
}
if ($curlSupported) {
if ($sharingRequested) {
$shareState = CurlShareHandleState::fromOption($sharingMode);
if ($shareState !== null) {
$curlHandlerOptions['transport_sharing'] = $shareState;
}
}
if ($connectionCapsRequired && $sharingMode === TransportSharing::PERSISTENT_PREFER) {
// libcurl does not apply cURL multi connection caps to transfers
// using a shared connection pool, so the best honorable offer for
// preferred persistent sharing is a handler-lifetime share.
$sharingMode = TransportSharing::HANDLER_PREFER;
}
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$handler = Proxy::wrapSync(new CurlMultiHandler($curlHandlerOptions), new CurlHandler($curlHandlerOptions));
} elseif (\function_exists('curl_exec')) {
$handler = new CurlHandler($curlHandlerOptions);
} elseif (\function_exists('curl_multi_exec')) {
$handler = new CurlMultiHandler($curlHandlerOptions);
}
$handler = self::createCurlHandler($sharingMode, $handlerOptions);
if ($sharingRequired && $handler === null) {
throw new \RuntimeException('Required transport sharing requires the PHP cURL extension, curl_exec() or curl_multi_exec(), and a supported libcurl version with SSL support.');
}
if (\ini_get('allow_url_fopen')) {
$streamHandler = new StreamHandler(['transport_sharing' => $sharingMode]);
$handler = $handler
? Proxy::wrapStreaming($handler, $streamHandler)
: $streamHandler;
} elseif (!$handler) {
throw new \RuntimeException('GuzzleHttp requires cURL, the allow_url_fopen ini setting, or a custom HTTP handler.');
return self::addStreamHandler($handler, $sharingMode, self::connectionCapOptions($handlerOptions));
}
return $handler;
if ($handler !== null) {
return $handler;
}
if ($connectionCapsRequired) {
throw new \RuntimeException('Connection cap options require a cap-capable cURL multi handler or the allow_url_fopen ini setting for stream fallback.');
}
throw new \RuntimeException('GuzzleHttp requires a supported cURL version with SSL support, the allow_url_fopen ini setting, or a custom HTTP handler.');
}
private static function isTransportSharingRequired(string $sharingMode): bool
{
return \in_array($sharingMode, [TransportSharing::HANDLER_REQUIRE, TransportSharing::PERSISTENT_REQUIRE], true);
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed} $handlerOptions
*/
private static function hasConnectionCapOptions(array $handlerOptions): bool
{
return self::connectionCapOptions($handlerOptions) !== [];
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed, multiplex?: mixed} $handlerOptions
*
* @return (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)|null
*/
private static function createCurlHandler(string $sharingMode, array $handlerOptions): ?callable
{
if (!CurlVersion::supportsCurlHandler()) {
return null;
}
$connectionCapOptions = self::connectionCapOptions($handlerOptions);
if ($connectionCapOptions !== [] && !\function_exists('curl_multi_exec')) {
return null;
}
$curlHandlerOptions = self::createCurlHandlerOptions($sharingMode);
$curlMultiHandlerOptions = $curlHandlerOptions + $connectionCapOptions;
if (($handlerOptions['multiplex'] ?? null) === Multiplexing::NONE) {
// Forwarded to the CurlMultiHandler only: CurlHandler and
// StreamHandler validate known options, and both satisfy NONE
// per-request without a handler option.
$curlMultiHandlerOptions['multiplex'] = Multiplexing::NONE;
}
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$multiHandler = new CurlMultiHandler($curlMultiHandlerOptions);
if ($connectionCapOptions !== []) {
// Connection caps only govern transfers on the multi handle, so
// the synchronous CurlHandler fast path would escape them.
return $multiHandler;
}
return Proxy::wrapSync($multiHandler, new CurlHandler($curlHandlerOptions));
}
if ($connectionCapOptions === [] && \function_exists('curl_exec')) {
return new CurlHandler($curlHandlerOptions);
}
if (\function_exists('curl_multi_exec')) {
return new CurlMultiHandler($curlMultiHandlerOptions);
}
return null;
}
/**
* @return array<string, mixed>
*/
private static function createCurlHandlerOptions(string $sharingMode): array
{
if ($sharingMode === TransportSharing::NONE) {
return [];
}
$shareState = CurlShareHandleState::fromOption($sharingMode);
return $shareState === null ? [] : ['transport_sharing' => $shareState];
}
/**
* @param array{max_host_connections?: mixed, max_total_connections?: mixed} $handlerOptions
*
* @return array{max_host_connections?: int, max_total_connections?: int}
*/
private static function connectionCapOptions(array $handlerOptions): array
{
$options = [];
foreach (['max_host_connections', 'max_total_connections'] as $capOption) {
$value = $handlerOptions[$capOption] ?? null;
if ($value === null) {
continue;
}
if (!\is_int($value) || $value < 1) {
throw new InvalidArgumentException(\sprintf('%s must be a positive integer.', $capOption));
}
$options[$capOption] = $value;
}
return $options;
}
/**
* @param (callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>)|null $handler
* @param array{max_host_connections?: int, max_total_connections?: int} $connectionCapOptions
*
* @return callable(RequestInterface, array<array-key, mixed>): PromiseInterface<ResponseInterface, mixed>
*/
private static function addStreamHandler(?callable $handler, string $sharingMode, array $connectionCapOptions): callable
{
$streamHandler = new StreamHandler(['transport_sharing' => $sharingMode] + $connectionCapOptions);
return $handler
? Proxy::wrapStreaming($handler, $streamHandler)
: $streamHandler;
}
/**
@ -141,77 +229,6 @@ final class Utils
return sprintf('GuzzleHttp/%d', ClientInterface::MAJOR_VERSION);
}
/**
* Returns the default cacert bundle for the current system.
*
* First, the openssl.cafile and curl.cainfo php.ini settings are checked.
* If those settings are not configured, then the common locations for
* bundles found on Red Hat, CentOS, Fedora, Ubuntu, Debian, FreeBSD, OS X
* and Windows are checked. If any of these file locations are found on
* disk, they will be utilized.
*
* Note: the result of this function is cached for subsequent calls.
*
* @throws \RuntimeException if no bundle can be found.
*
* @deprecated Utils::defaultCaBundle will be removed in guzzlehttp/guzzle:8.0. This method is not needed in PHP 5.6+.
*/
public static function defaultCaBundle(): string
{
static $cached = null;
static $cafiles = [
// Red Hat, CentOS, Fedora (provided by the ca-certificates package)
'/etc/pki/tls/certs/ca-bundle.crt',
// Ubuntu, Debian (provided by the ca-certificates package)
'/etc/ssl/certs/ca-certificates.crt',
// FreeBSD (provided by the ca_root_nss package)
'/usr/local/share/certs/ca-root-nss.crt',
// SLES 12 (provided by the ca-certificates package)
'/var/lib/ca-certificates/ca-bundle.pem',
// OS X provided by homebrew (using the default path)
'/usr/local/etc/openssl/cert.pem',
// Google app engine
'/etc/ca-certificates.crt',
// Windows?
'C:\\windows\\system32\\curl-ca-bundle.crt',
'C:\\windows\\curl-ca-bundle.crt',
];
if ($cached) {
return $cached;
}
if ($ca = \ini_get('openssl.cafile')) {
return $cached = $ca;
}
if ($ca = \ini_get('curl.cainfo')) {
return $cached = $ca;
}
foreach ($cafiles as $filename) {
if (\file_exists($filename)) {
return $cached = $filename;
}
}
throw new \RuntimeException(
<<< EOT
No system CA bundle could be found in any of the the common system locations.
PHP versions earlier than 5.6 are not properly configured to use the system's
CA bundle by default. In order to verify peer certificates, you will need to
supply the path on disk to a certificate bundle to the 'verify' request
option: https://github.com/guzzle/guzzle/blob/7.11/docs/request-options.md#verify. If
you do not need a specific certificate bundle, then Mozilla provides a commonly
used CA bundle which can be downloaded here (provided by the maintainer of
cURL): https://curl.haxx.se/ca/cacert.pem. Once you have a CA bundle available
on disk, you can set the 'openssl.cafile' PHP ini setting to point to the path
to the file, allowing you to omit the 'verify' request option. See
https://curl.haxx.se/docs/sslcerts.html for more information.
EOT
);
}
/**
* Creates an associative array of lowercase header names to the actual
* header casing.
@ -220,7 +237,7 @@ EOT
{
$result = [];
foreach (\array_keys($headers) as $key) {
$result[\strtolower((string) $key)] = $key;
$result[Psr7\Utils::asciiToLower((string) $key)] = $key;
}
return $result;
@ -255,374 +272,4 @@ EOT
return \array_keys($normalized);
}
/**
* Returns true if the provided host matches any of the no proxy areas.
*
* This method will strip a port from the host if it is present. Each pattern
* can be matched with an exact match (e.g., "foo.com" == "foo.com") or a
* partial match: (e.g., "foo.com" == "baz.foo.com" and ".foo.com" ==
* "baz.foo.com", but ".foo.com" != "foo.com").
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
* 2. An exact match.
* 3. The area starts with "." and the area is the last part of the host. e.g.
* '.mit.edu' will match any host that ends with '.mit.edu'.
*
* @param string $host Host to check against the patterns.
* @param string[] $noProxyArray An array of host patterns.
*
* @throws InvalidArgumentException
*/
public static function isHostInNoProxy(string $host, array $noProxyArray): bool
{
if (\strlen($host) === 0) {
throw new InvalidArgumentException('Empty host provided');
}
$host = self::normalizeNoProxyHost($host, true);
foreach ($noProxyArray as $area) {
// Always match on wildcards.
if ($area === '*') {
return true;
}
if ($area === '') {
continue;
}
$area = self::normalizeNoProxyHost($area, false);
if ($area === $host) {
// Exact matches.
return true;
}
// Special match if the area when prefixed with ".". Remove any
// existing leading "." and add a new leading ".".
$area = '.'.\ltrim($area, '.');
if (
\strpos($host, ':') === false
&& \strpos($area, ':') === false
&& \substr($host, -\strlen($area)) === $area
) {
return true;
}
}
return false;
}
/**
* Returns true if the provided URI matches any of the no proxy areas.
*
* @param mixed $noProxy No-proxy host patterns.
*
* @internal
*/
public static function isUriInNoProxy(UriInterface $uri, $noProxy): bool
{
if (\is_string($noProxy)) {
$noProxy = \explode(',', $noProxy);
}
if (!\is_array($noProxy)) {
return false;
}
$host = $uri->getHost();
if ($host === '') {
return false;
}
$port = $uri->getPort();
if ($port === null) {
$port = self::getDefaultPort($uri->getScheme());
}
foreach ($noProxy as $area) {
if (!\is_string($area)) {
continue;
}
$area = \trim($area);
// Always match on wildcards.
if ($area === '*') {
return true;
}
if ($area === '') {
continue;
}
[$area, $areaPort] = self::splitNoProxyHostAndPort($area);
if ($areaPort !== null && $areaPort !== $port) {
continue;
}
if (self::isHostInNoProxy($host, [$area])) {
return true;
}
}
return false;
}
private static function normalizeNoProxyHost(string $host, bool $stripPort): string
{
if ($host !== '' && $host[0] === '[') {
$closingBracket = \strpos($host, ']');
if ($closingBracket !== false) {
$address = \substr($host, 1, $closingBracket - 1);
$tail = \substr($host, $closingBracket + 1);
if (
($tail === '' || ($stripPort && \preg_match('/^:\d+$/', $tail)))
&& \filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)
) {
return \strtolower($address);
}
}
}
if (\filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return \strtolower($host);
}
if ($stripPort) {
[$host] = \explode(':', $host, 2);
}
return $host;
}
/**
* @return array{0: string, 1: int|null}
*/
private static function splitNoProxyHostAndPort(string $area): array
{
if ($area !== '' && $area[0] === '[') {
$closingBracket = \strpos($area, ']');
if ($closingBracket !== false) {
$tail = \substr($area, $closingBracket + 1);
if ($tail !== '' && $tail[0] === ':') {
$port = self::parseNoProxyPort(\substr($tail, 1));
if ($port !== null) {
return [\substr($area, 0, $closingBracket + 1), $port];
}
}
}
return [$area, null];
}
if (\filter_var($area, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) {
return [$area, null];
}
$colon = \strrpos($area, ':');
if ($colon === false) {
return [$area, null];
}
$port = self::parseNoProxyPort(\substr($area, $colon + 1));
if ($port === null) {
return [$area, null];
}
return [\substr($area, 0, $colon), $port];
}
private static function parseNoProxyPort(string $port): ?int
{
if ($port === '' || !\ctype_digit($port)) {
return null;
}
$port = (int) $port;
return $port <= 65535 ? $port : null;
}
private static function getDefaultPort(string $scheme): ?int
{
if ($scheme === 'http') {
return 80;
}
if ($scheme === 'https') {
return 443;
}
return null;
}
/**
* Wrapper for json_decode that throws when an error occurs.
*
* @param string $json JSON data to parse
* @param bool $assoc When true, returned objects will be converted
* into associative arrays.
* @param int $depth User specified recursion depth.
* @param int $options Bitmask of JSON decode options.
*
* @return object|array|string|int|float|bool|null
*
* @throws InvalidArgumentException if the JSON cannot be decoded.
*
* @see https://www.php.net/manual/en/function.json-decode.php
*/
public static function jsonDecode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
if ($depth < 1) {
throw new InvalidArgumentException('json_decode error: Maximum stack depth exceeded');
}
$data = \json_decode($json, $assoc, $depth, $options);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_decode error: '.\json_last_error_msg());
}
return $data;
}
/**
* Wrapper for JSON encoding that throws when an error occurs.
*
* @param mixed $value The value being encoded
* @param int $options JSON encode option bitmask
* @param int $depth Set the maximum depth. Must be greater than zero.
*
* @throws InvalidArgumentException if the JSON cannot be encoded.
*
* @see https://www.php.net/manual/en/function.json-encode.php
*/
public static function jsonEncode($value, int $options = 0, int $depth = 512): string
{
$json = \json_encode($value, $options, $depth);
if (\JSON_ERROR_NONE !== \json_last_error()) {
throw new InvalidArgumentException('json_encode error: '.\json_last_error_msg());
}
/** @var string */
return $json;
}
/**
* Wrapper for the hrtime() or microtime() functions
* (depending on the PHP version, one of the two is used)
*
* @return float UNIX timestamp
*
* @internal
*/
public static function currentTime(): float
{
return (float) \function_exists('hrtime') ? \hrtime(true) / 1e9 : \microtime(true);
}
/**
* @param mixed $value
*
* @internal
*/
public static function normalizeIdnConversionOption($value): ?int
{
if ($value === null || $value === false) {
return null;
}
if ($value === true) {
return \IDNA_DEFAULT;
}
if (\is_int($value)) {
return $value;
}
if ((\is_string($value) && \is_numeric($value)) || (\is_float($value) && \is_finite($value))) {
\trigger_deprecation(
'guzzlehttp/guzzle',
'7.11',
'Passing %s as the "idn_conversion" request option is deprecated; guzzlehttp/guzzle 8.0 will reject values that are not true, false, null, or an integer IDNA_* bitmask.',
self::describeType($value)
);
return (int) $value;
}
throw new InvalidArgumentException('idn_conversion must be true, false, null, or an integer IDNA_* bitmask');
}
/**
* @throws InvalidArgumentException
*
* @internal
*/
public static function idnUriConvert(UriInterface $uri, int $options = 0): UriInterface
{
if ($uri->getHost()) {
$asciiHost = self::idnToAsci($uri->getHost(), $options, $info);
if ($asciiHost === false) {
$errorBitSet = $info['errors'] ?? 0;
$errorConstants = array_filter(array_keys(get_defined_constants()), static function (string $name): bool {
return substr($name, 0, 11) === 'IDNA_ERROR_';
});
$errors = [];
foreach ($errorConstants as $errorConstant) {
if ($errorBitSet & constant($errorConstant)) {
$errors[] = $errorConstant;
}
}
$errorMessage = 'IDN conversion failed';
if ($errors) {
$errorMessage .= ' (errors: '.implode(', ', $errors).')';
}
throw new InvalidArgumentException($errorMessage);
}
if ($uri->getHost() !== $asciiHost) {
// Replace URI only if the ASCII version is different
$uri = $uri->withHost($asciiHost);
}
}
return $uri;
}
/**
* @internal
*/
public static function getenv(string $name): ?string
{
if (isset($_SERVER[$name])) {
return (string) $_SERVER[$name];
}
if (\PHP_SAPI === 'cli' && ($value = \getenv($name)) !== false && $value !== null) {
return (string) $value;
}
return null;
}
/**
* @return string|false
*/
private static function idnToAsci(string $domain, int $options, ?array &$info = [])
{
if (\function_exists('idn_to_ascii') && \defined('INTL_IDNA_VARIANT_UTS46')) {
return \idn_to_ascii($domain, $options, \INTL_IDNA_VARIANT_UTS46, $info);
}
throw new \Error('ext-idn or symfony/polyfill-intl-idn not loaded or too old');
}
}

View file

@ -1,167 +0,0 @@
<?php
namespace GuzzleHttp;
/**
* Debug function used to describe the provided value type and class.
*
* @param mixed $input Any type of variable to describe the type of. This
* parameter misses a typehint because of that.
*
* @return string Returns a string containing the type of the variable and
* if a class is provided, the class name.
*
* @deprecated describe_type will be removed in guzzlehttp/guzzle:8.0. Use Utils::describeType instead.
*/
function describe_type($input): string
{
return Utils::describeType($input);
}
/**
* Parses an array of header lines into an associative array of headers.
*
* @param iterable $lines Header lines array of strings in the following
* format: "Name: Value"
*
* @deprecated headers_from_lines will be removed in guzzlehttp/guzzle:8.0. Use Utils::headersFromLines instead.
*/
function headers_from_lines(iterable $lines): array
{
return Utils::headersFromLines($lines);
}
/**
* Returns a debug stream based on the provided variable.
*
* @param mixed $value Optional value
*
* @return resource
*
* @deprecated debug_resource will be removed in guzzlehttp/guzzle:8.0. Use Utils::debugResource instead.
*/
function debug_resource($value = null)
{
return Utils::debugResource($value);
}
/**
* Chooses and creates a default handler to use based on the environment.
*
* The returned handler is not wrapped by any default middlewares.
*
* @return callable(\Psr\Http\Message\RequestInterface, array): Promise\PromiseInterface Returns the best handler for the given system.
*
* @throws \RuntimeException if no viable Handler is available.
*
* @deprecated choose_handler will be removed in guzzlehttp/guzzle:8.0. Use Utils::chooseHandler instead.
*/
function choose_handler(): callable
{
return Utils::chooseHandler();
}
/**
* Get the default User-Agent string to use with Guzzle.
*
* @deprecated default_user_agent will be removed in guzzlehttp/guzzle:8.0. Use Utils::defaultUserAgent instead.
*/
function default_user_agent(): string
{
return Utils::defaultUserAgent();
}
/**
* Returns the default cacert bundle for the current system.
*
* First, the openssl.cafile and curl.cainfo php.ini settings are checked.
* If those settings are not configured, then the common locations for
* bundles found on Red Hat, CentOS, Fedora, Ubuntu, Debian, FreeBSD, OS X
* and Windows are checked. If any of these file locations are found on
* disk, they will be utilized.
*
* Note: the result of this function is cached for subsequent calls.
*
* @throws \RuntimeException if no bundle can be found.
*
* @deprecated default_ca_bundle will be removed in guzzlehttp/guzzle:8.0. This function is not needed in PHP 5.6+.
*/
function default_ca_bundle(): string
{
return Utils::defaultCaBundle();
}
/**
* Creates an associative array of lowercase header names to the actual
* header casing.
*
* @deprecated normalize_header_keys will be removed in guzzlehttp/guzzle:8.0. Use Utils::normalizeHeaderKeys instead.
*/
function normalize_header_keys(array $headers): array
{
return Utils::normalizeHeaderKeys($headers);
}
/**
* Returns true if the provided host matches any of the no proxy areas.
*
* This method will strip a port from the host if it is present. Each pattern
* can be matched with an exact match (e.g., "foo.com" == "foo.com") or a
* partial match: (e.g., "foo.com" == "baz.foo.com" and ".foo.com" ==
* "baz.foo.com", but ".foo.com" != "foo.com").
*
* Areas are matched in the following cases:
* 1. "*" (without quotes) always matches any hosts.
* 2. An exact match.
* 3. The area starts with "." and the area is the last part of the host. e.g.
* '.mit.edu' will match any host that ends with '.mit.edu'.
*
* @param string $host Host to check against the patterns.
* @param string[] $noProxyArray An array of host patterns.
*
* @throws Exception\InvalidArgumentException
*
* @deprecated is_host_in_noproxy will be removed in guzzlehttp/guzzle:8.0. Use Utils::isHostInNoProxy instead.
*/
function is_host_in_noproxy(string $host, array $noProxyArray): bool
{
return Utils::isHostInNoProxy($host, $noProxyArray);
}
/**
* Wrapper for json_decode that throws when an error occurs.
*
* @param string $json JSON data to parse
* @param bool $assoc When true, returned objects will be converted
* into associative arrays.
* @param int $depth User specified recursion depth.
* @param int $options Bitmask of JSON decode options.
*
* @return object|array|string|int|float|bool|null
*
* @throws Exception\InvalidArgumentException if the JSON cannot be decoded.
*
* @see https://www.php.net/manual/en/function.json-decode.php
* @deprecated json_decode will be removed in guzzlehttp/guzzle:8.0. Use Utils::jsonDecode instead.
*/
function json_decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
return Utils::jsonDecode($json, $assoc, $depth, $options);
}
/**
* Wrapper for JSON encoding that throws when an error occurs.
*
* @param mixed $value The value being encoded
* @param int $options JSON encode option bitmask
* @param int $depth Set the maximum depth. Must be greater than zero.
*
* @throws Exception\InvalidArgumentException if the JSON cannot be encoded.
*
* @see https://www.php.net/manual/en/function.json-encode.php
* @deprecated json_encode will be removed in guzzlehttp/guzzle:8.0. Use Utils::jsonEncode instead.
*/
function json_encode($value, int $options = 0, int $depth = 512): string
{
return Utils::jsonEncode($value, $options, $depth);
}

View file

@ -1,6 +0,0 @@
<?php
// Don't redefine the functions if included multiple times.
if (!\function_exists('GuzzleHttp\describe_type')) {
require __DIR__.'/functions.php';
}

View file

@ -1,6 +1,51 @@
# CHANGELOG
## 3.0.1 - 2026-08-05
### Changed
- Changed the default `TReason` of `FulfilledPromise` and `Create::promiseFor()` to `never`
- Changed the default `TValue` of `RejectedPromise` and `Create::rejectionFor()` to `never`
### Fixed
- Fixed `EachPromise` abandoning its aggregate when the pending window drains unsettled
- Fixed `EachPromise` admitting new work after its aggregate has settled
## 3.0.0 - 2026-07-20
### Added
- Added `concurrency` config support to `Utils::all()` and `Each::of()`
- Added generic PHPDoc annotations to promise APIs and collection callbacks
- Added recursive and `concurrency` config support to `Utils::settle()`
- Allowed promises to be resolved without passing a value
### Changed
- Changed `Utils::inspect()` to return actual rejection reasons
- Changed `Utils::inspect()` to prefer the settled state over late wait function exceptions
- Changed late rejection callbacks to follow rejected promises
- Reject native PHP serialization of in-flight runtime objects
- Made static helper classes non-instantiable
- Require iterable inputs for promise collection helpers and `EachPromise`
- Iterate `IteratorAggregate` inputs to collection helpers instead of treating them as a single value
- Improved recursive `Utils::all()` handling of dynamically-added settled values and raw values
### Removed
- Dropped support for PHP 7.2 and 7.3
## 2.5.1 - 2026-07-08
### Fixed
- Fixed recursive `Utils::all()` rejecting generator inputs
## 2.5.0 - 2026-06-02
### Deprecated

View file

@ -1,536 +1,93 @@
# Guzzle Promises
[Promises/A+](https://promisesaplus.com/) implementation that handles promise
chaining and resolution iteratively, allowing for "infinite" promise chaining
while keeping the stack size constant. Read [this blog post](https://blog.domenic.me/youre-missing-the-point-of-promises/)
for a general introduction to promises.
- [Features](#features)
- [Quick start](#quick-start)
- [Synchronous wait](#synchronous-wait)
- [Cancellation](#cancellation)
- [API](#api)
- [Promise](#promise)
- [FulfilledPromise](#fulfilledpromise)
- [RejectedPromise](#rejectedpromise)
- [Promise interop](#promise-interop)
- [Implementation notes](#implementation-notes)
## Features
- [Promises/A+](https://promisesaplus.com/) implementation.
- Promise resolution and chaining is handled iteratively, allowing for
"infinite" promise chaining.
- Promises have a synchronous `wait` method.
- Promises can be cancelled.
- Works with any object that has a `then` function.
- C# style async/await coroutine promises using
`GuzzleHttp\Promise\Coroutine::of()`.
`guzzlehttp/promises` is a small promise library used by Guzzle for asynchronous
operations. It implements promise chaining, synchronous waiting, cancellation,
and helpers for working with groups of promises.
Most application developers use this package through
[`guzzlehttp/guzzle`](https://github.com/guzzle/guzzle/blob/8.0/README.md) by
calling methods such as `requestAsync()`. Install this package directly when you
need promise composition without the full HTTP client.
## Installation
```shell
```bash
composer require guzzlehttp/promises
```
## Version Guidance
| Version | Status | PHP Version |
|---------|---------------------|--------------|
| 1.x | Security fixes only | >=5.5,<8.3 |
| 2.x | Latest | >=7.2.5,<8.6 |
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 3.0 | Latest | >=7.4,<8.6 |
| 2.5 | Maintenance | >=7.2.5,<8.6 |
| 1.5 | End of Life | >=5.5,<8.3 |
## Quick Start
A *promise* represents the eventual result of an asynchronous operation. The
primary way of interacting with a promise is through its `then` method, which
registers callbacks to receive either a promise's eventual value or the reason
why the promise cannot be fulfilled.
### Callbacks
Callbacks are registered with the `then` method by providing an optional
`$onFulfilled` followed by an optional `$onRejected` function.
```php
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\Utils;
$promise = new Promise();
$promise->then(
// $onFulfilled
function ($value) {
echo 'The promise was fulfilled.';
echo 'Fulfilled: ' . $value;
},
// $onRejected
function ($reason) {
echo 'The promise was rejected.';
}
);
```
*Resolving* a promise means that you either fulfill a promise with a *value* or
reject a promise with a *reason*. Resolving a promise triggers callbacks
registered with the promise's `then` method. These callbacks are triggered
only once and in the order in which they were added.
### Resolving a Promise
Promises are fulfilled using the `resolve($value)` method. Resolving a promise
with any value other than a `GuzzleHttp\Promise\RejectedPromise` will trigger
all of the onFulfilled callbacks (resolving a promise with a rejected promise
will reject the promise and trigger the `$onRejected` callbacks).
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise
->then(function ($value) {
// Return a value and don't break the chain
return "Hello, " . $value;
})
// This then is executed after the first then and receives the value
// returned from the first then.
->then(function ($value) {
echo $value;
});
// Resolving the promise triggers the $onFulfilled callbacks and outputs
// "Hello, reader."
$promise->resolve('reader.');
```
### Promise Forwarding
Promises can be chained one after the other. Each then in the chain is a new
promise. The return value of a promise is what's forwarded to the next
promise in the chain. Returning a promise in a `then` callback will cause the
subsequent promises in the chain to only be fulfilled when the returned promise
has been fulfilled. The next promise in the chain will be invoked with the
resolved value of the promise.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$nextPromise = new Promise();
$promise
->then(function ($value) use ($nextPromise) {
echo $value;
return $nextPromise;
})
->then(function ($value) {
echo $value;
});
// Triggers the first callback and outputs "A"
$promise->resolve('A');
// Triggers the second callback and outputs "B"
$nextPromise->resolve('B');
```
### Promise Rejection
When a promise is rejected, the `$onRejected` callbacks are invoked with the
rejection reason.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise->then(null, function ($reason) {
echo $reason;
});
$promise->reject('Error!');
// Outputs "Error!"
```
### Rejection Forwarding
If an exception is thrown in an `$onRejected` callback, subsequent
`$onRejected` callbacks are invoked with the thrown exception as the reason.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise->then(null, function ($reason) {
throw new Exception($reason);
})->then(null, function ($reason) {
assert($reason->getMessage() === 'Error!');
});
$promise->reject('Error!');
```
You can also forward a rejection down the promise chain by returning a
`GuzzleHttp\Promise\RejectedPromise` in either an `$onFulfilled` or
`$onRejected` callback.
```php
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\RejectedPromise;
$promise = new Promise();
$promise->then(null, function ($reason) {
return new RejectedPromise($reason);
})->then(null, function ($reason) {
assert($reason === 'Error!');
});
$promise->reject('Error!');
```
If an exception is not thrown in a `$onRejected` callback and the callback
does not return a rejected promise, downstream `$onFulfilled` callbacks are
invoked using the value returned from the `$onRejected` callback.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise();
$promise
->then(null, function ($reason) {
return "It's ok";
})
->then(function ($value) {
assert($value === "It's ok");
});
$promise->reject('Error!');
```
## Synchronous Wait
You can synchronously force promises to complete using a promise's `wait`
method. When creating a promise, you can provide a wait function that is used
to synchronously force a promise to complete. When a wait function is invoked
it is expected to deliver a value to the promise or reject the promise. If the
wait function does not deliver a value, then an exception is thrown. The wait
function provided to a promise constructor is invoked when the `wait` function
of the promise is called.
```php
$promise = new Promise(function () use (&$promise) {
$promise->resolve('foo');
});
// Calling wait will return the value of the promise.
echo $promise->wait(); // outputs "foo"
```
If a throwable is encountered while invoking the wait function of a promise,
the promise is rejected with the throwable and the throwable is thrown.
```php
$promise = new Promise(function () use (&$promise) {
throw new Exception('foo');
});
$promise->wait(); // throws the exception.
```
Calling `wait` on a promise that has been fulfilled will not trigger the wait
function. It will simply return the previously resolved value.
```php
$promise = new Promise(function () { die('this is not called!'); });
$promise->resolve('foo');
echo $promise->wait(); // outputs "foo"
```
Calling `wait` on a promise that has been rejected will throw. If the rejection
reason is an instance of `\Throwable` the reason is thrown.
Otherwise, a `GuzzleHttp\Promise\RejectionException` is thrown and the reason
can be obtained by calling the `getReason` method of the exception.
```php
$promise = new Promise();
$promise->reject('foo');
$promise->wait();
```
> PHP Fatal error: Uncaught exception 'GuzzleHttp\Promise\RejectionException' with message 'The promise was rejected with value: foo'
### Unwrapping a Promise
When synchronously waiting on a promise, you are joining the state of the
promise into the current state of execution (i.e., return the value of the
promise if it was fulfilled or throw an exception if it was rejected). This is
called "unwrapping" the promise. Waiting on a promise will by default unwrap
the promise state.
You can force a promise to resolve and *not* unwrap the state of the promise
by passing `false` to the first argument of the `wait` function:
```php
$promise = new Promise();
$promise->reject('foo');
// This will not throw an exception. It simply ensures the promise has
// been resolved.
$promise->wait(false);
```
When unwrapping a promise, the resolved value of the promise will be waited
upon until the unwrapped value is not a promise. This means that if you resolve
promise A with a promise B and unwrap promise A, the value returned by the
wait function will be the value delivered to promise B.
**Note**: when you do not unwrap the promise, no value is returned.
## Cancellation
You can cancel a promise that has not yet been fulfilled using the `cancel()`
method of a promise. When creating a promise you can provide an optional
cancel function that when invoked cancels the action of computing a resolution
of the promise.
## API
### Promise
When creating a promise object, you can provide an optional `$waitFn` and
`$cancelFn`. `$waitFn` is a function that is invoked with no arguments and is
expected to resolve the promise. `$cancelFn` is a function with no arguments
that is expected to cancel the computation of a promise. It is invoked when the
`cancel()` method of a promise is called.
```php
use GuzzleHttp\Promise\Promise;
$promise = new Promise(
function () use (&$promise) {
$promise->resolve('waited');
},
function () {
// do something that will cancel the promise computation (e.g., close
// a socket, cancel a database query, etc...)
echo 'Rejected: ' . $reason;
}
);
assert('waited' === $promise->wait());
$promise->resolve('done');
Utils::queue()->run();
```
A promise has the following methods:
- `then(?callable $onFulfilled = null, ?callable $onRejected = null) : PromiseInterface`
Appends fulfillment and rejection handlers to the promise, and returns a new
promise resolving to the return value of the called handler. If a handler is
omitted, the original fulfillment value or rejection reason is forwarded.
- `otherwise(callable $onRejected) : PromiseInterface`
Appends a rejection handler callback to the promise, and returns a new promise resolving to the return value of the callback if it is called, or to its original fulfillment value if the promise is instead fulfilled.
- `wait($unwrap = true) : mixed`
Synchronously waits on the promise to complete.
`$unwrap` controls whether or not the value of the promise is returned for a
fulfilled promise or if an exception is thrown if the promise is rejected.
This is set to `true` by default.
- `cancel()`
Attempts to cancel the promise if possible. The promise being cancelled and
the parent most ancestor that has not yet been resolved will also be
cancelled. Any promises waiting on the cancelled promise to resolve will also
be cancelled.
- `getState() : string`
Returns the state of the promise. One of `pending`, `fulfilled`, or
`rejected`.
- `resolve($value)`
Fulfills the promise with the given `$value`.
- `reject($reason)`
Rejects the promise with the given `$reason`.
### FulfilledPromise
A fulfilled promise can be created to represent a promise that has been
fulfilled.
You can wait for a promise to complete synchronously:
```php
use GuzzleHttp\Promise\FulfilledPromise;
$promise = new FulfilledPromise('value');
// Fulfilled callbacks are immediately invoked.
$promise->then(function ($value) {
echo $value;
});
$value = $promise->wait();
```
### RejectedPromise
A rejected promise can be created to represent a promise that has been
rejected.
When using Guzzle HTTP requests, asynchronous methods return
`GuzzleHttp\Promise\PromiseInterface` instances:
```php
use GuzzleHttp\Promise\RejectedPromise;
$promise = new RejectedPromise('Error');
// Rejected callbacks are immediately invoked.
$promise->then(null, function ($reason) {
echo $reason;
});
$promise = $client->requestAsync('GET', 'https://example.com');
$response = $promise->wait();
```
## Documentation
## Promise Interoperability
This library works with foreign promises that have a `then` method. This means
you can use Guzzle promises with [React promises](https://github.com/reactphp/promise)
for example. When a foreign promise is returned inside of a then method
callback, promise resolution will occur recursively.
```php
// Create a React promise
$deferred = new React\Promise\Deferred();
$reactPromise = $deferred->promise();
// Create a Guzzle promise that is fulfilled with a React promise.
$guzzlePromise = new GuzzleHttp\Promise\Promise();
$guzzlePromise->then(function ($value) use ($reactPromise) {
// Do something something with the value...
// Return the React promise
return $reactPromise;
});
```
Please note that wait and cancel chaining is no longer possible when forwarding
a foreign promise. You will need to wrap a third-party promise with a Guzzle
promise in order to utilize wait and cancel functions with foreign promises.
### Event Loop Integration
In order to keep the stack size constant, Guzzle promises are resolved
asynchronously using a task queue. When waiting on promises synchronously, the
task queue will be automatically run to ensure that the blocking promise and
any forwarded promises are resolved. When using promises asynchronously in an
event loop, you will need to run the task queue on each tick of the loop. If
you do not run the task queue, then promises will not be resolved.
You can run the task queue using the `run()` method of the global task queue
instance.
```php
// Get the global task queue
$queue = GuzzleHttp\Promise\Utils::queue();
$queue->run();
```
For example, you could use Guzzle promises with React using a short periodic
timer. Avoid zero-interval timers because they may keep the loop busy even when
there is no promise work to run.
```php
$loop = React\EventLoop\Factory::create();
$loop->addPeriodicTimer(0.01, [$queue, 'run']);
```
## Implementation Notes
### Promise Resolution and Chaining is Handled Iteratively
By shuffling pending handlers from one owner to another, promises are
resolved iteratively, allowing for "infinite" then chaining.
```php
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Promise\Promise;
$parent = new Promise();
$p = $parent;
for ($i = 0; $i < 1000; $i++) {
$p = $p->then(function ($v) {
// The stack size remains constant (a good thing)
echo xdebug_get_stack_depth() . ', ';
return $v + 1;
});
}
$parent->resolve(0);
var_dump($p->wait()); // int(1000)
```
When a promise is fulfilled or rejected with a non-promise value, the promise
then takes ownership of the handlers of each child promise and delivers values
down the chain without using recursion.
When a promise is resolved with another promise, the original promise transfers
all of its pending handlers to the new promise. When the new promise is
eventually resolved, all of the pending handlers are delivered the forwarded
value.
### A Promise is the Deferred
Some promise libraries implement promises using a deferred object to represent
a computation and a promise object to represent the delivery of the result of
the computation. This is a nice separation of computation and delivery because
consumers of the promise cannot modify the value that will be eventually
delivered.
One side effect of being able to implement promise resolution and chaining
iteratively is that you need to be able for one promise to reach into the state
of another promise to shuffle around ownership of handlers. In order to achieve
this without making the handlers of a promise publicly mutable, a promise is
also the deferred value, allowing promises of the same parent class to reach
into and modify the private properties of promises of the same type. While this
does allow consumers of the value to modify the resolution or rejection of the
deferred, it is a small price to pay for keeping the stack size constant.
```php
$promise = new Promise();
$promise->then(function ($value) { echo $value; });
// The promise is the deferred value, so you can deliver a value to it.
$promise->resolve('foo');
// prints "foo"
```
## Upgrading
See [UPGRADING.md](UPGRADING.md) for package upgrade notes.
- [Promise Quick Start](docs/promise-quick-start.md)
- [Promise API](docs/promise-api.md)
- [Promise Interoperability](docs/promise-interoperability.md)
- [Implementation Notes](docs/implementation-notes.md)
- [Upgrade Guide](UPGRADING.md)
- [Changelog](CHANGELOG.md)
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/promises/security/policy) for more information.
If you discover a security vulnerability within this package, please send an
email to security@tidelift.com. All security vulnerabilities will be promptly
addressed. Please do not disclose security-related issues publicly until a fix
has been announced. Please see
[Security Policy](https://github.com/guzzle/promises/security/policy) for more
information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
Guzzle is made available under the MIT License (MIT). Please see
[License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-promises?utm_source=packagist-guzzlehttp-promises&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
The maintainers of Guzzle and thousands of other packages are working with
Tidelift to deliver commercial support and maintenance for the open source
dependencies you use to build your applications. Save time, reduce risk, and
improve code health, while paying the maintainers of the exact dependencies you
use.
[Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-promises?utm_source=packagist-guzzlehttp-promises&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

View file

@ -1,6 +1,202 @@
Guzzle Promises Upgrade Guide
=============================
2.x to 3.0
----------
Guzzle Promises 3.0 is a major release that raises the minimum PHP version,
updates promise and collection helper signatures, adds generic PHPDoc types for
static analyzers, tightens collection helper inputs, improves recursive
collection behavior, and clarifies rejection inspection and late rejection
callback behavior.
#### PHP Version and Dependencies
Guzzle Promises 3.0 requires PHP `^7.4 || ^8.0`. Guzzle Promises 2.x supported
PHP `^7.2.5 || ^8.0`.
If your application still supports PHP 7.2 or 7.3, continue using Guzzle
Promises 2.x until your minimum PHP version is raised.
Guzzle Promises 3.0 has no runtime package dependencies beyond PHP. It removes
the 2.x runtime dependency on `symfony/deprecation-contracts`; require that
package directly if your application uses it.
#### Optional Promise Resolution Values
`PromiseInterface::resolve()` now accepts an optional value. Calling `resolve()`
without an argument fulfills the promise with `null`.
Custom implementations of `PromiseInterface`, and subclasses that override
`resolve()` on `Promise`, `FulfilledPromise`, or `RejectedPromise`, must update
their method signature from `resolve($value): void` to
`resolve($value = null): void`.
#### Generic PHPDoc Types
`PromiseInterface`, `PromisorInterface`, and the built-in promise classes now
include generic PHPDoc annotations for static analysis tools. The first template
type represents the fulfillment value and the second represents the rejection
reason. This is a static-analysis-only change and does not alter runtime
behavior, but projects with stricter static analysis may see new or different
diagnostics:
```php
use GuzzleHttp\Promise\PromiseInterface;
/** @var PromiseInterface<string, \Throwable> */
$promise = $factory->createPromise();
$value = $promise->wait();
```
Code that uses unparameterized promise types continues to work and is treated as
`PromiseInterface<mixed, mixed>`. If your project implements promise interfaces,
extends promise classes, or has stricter static analysis, you may need to update
your PHPDoc annotations to include the generic value and reason types. The
expanded PHPDoc also preserves promise-chain fulfillment and rejection types
more precisely through `then()` and `otherwise()`, and documents collection
callbacks with value or reason, key, and aggregate-promise arguments so
callbacks may declare only the arguments they use.
#### Collection Helper Inputs
Promise collection helpers now require iterable inputs. Passing a single promise
or scalar value directly now throws a `TypeError`.
Wrap single promises or values in an array before passing them to
`Create::iterFor()`, `Each::of()`, `Each::ofLimit()`, `Each::ofLimitAll()`,
`EachPromise`, or the `Utils` collection helpers.
```php
use GuzzleHttp\Promise\Each;
// 2.x
$promise = Each::ofLimit($singlePromise, 2);
// 3.0
$promise = Each::ofLimit([$singlePromise], 2);
```
`IteratorAggregate` inputs are now iterated via `getIterator()`. In 2.x an
aggregate was treated as a single value and produced one result; in 3.0 its
entries are consumed individually.
```php
use GuzzleHttp\Promise\Utils;
$aggregate = new \ArrayObject([$promiseA, $promiseB]);
// 2.x: one result — the ArrayObject itself
// 3.0: two results — the fulfillment values of $promiseA and $promiseB
$promise = Utils::all($aggregate);
```
#### Collection Helper Signatures
`Utils::all()`, `Utils::settle()`, and `Each::of()` now accept trailing optional
arguments. Direct calls using the 2.x argument lists continue to work, and the
affected helper classes are final so subclass signatures do not need to change.
Code that mirrors or reflects exact helper signatures may need to be updated.
Pass the recursive flag before the config array when using `Utils::all()` or
`Utils::settle()`:
```php
use GuzzleHttp\Promise\Utils;
$promise = Utils::all($promises, false, ['concurrency' => 5]);
$promise = Utils::settle($promises, false, ['concurrency' => 5]);
```
Only `concurrency` is honored by these helper config arrays. Callback config
keys such as `fulfilled` and `rejected` are ignored; pass callbacks to
`Each::of()` directly or use `EachPromise`.
#### Recursive Collection Helpers
Existing `Utils::all($promises, true)` calls may return different results in
3.0. Recursive mode now detects dynamically-added settled promises and raw
values. In 2.x, recursive mode only checked for pending promises.
If you previously worked around the lack of recursive `Utils::settle()` support,
you can replace that workaround with the new `$recursive` argument:
```php
use GuzzleHttp\Promise\Utils;
$promise = Utils::settle($promises, true);
```
When `$recursive` is true, collection helpers continue taking passes over the
collection until no new entries are found and no visible promises remain
pending. This is intended for rewindable mutable collections such as
`ArrayIterator`. Generators are safe to pass, but they cannot be traversed
again once consumed, so recursive mode degrades to a single pass; use a
rewindable mutable collection when recursion needs to observe added values.
#### Promise Inspection
`Utils::inspect()` and `Utils::inspectAll()` now return the actual rejection
reason delivered to rejection callbacks. They no longer unwrap
`RejectionException` instances to their inner reason.
For example, a promise rejected with a `RejectionException` now inspects with
that exception as the reason:
```php
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Promise\RejectionException;
use GuzzleHttp\Promise\Utils;
$reason = new RejectionException('reason');
$result = Utils::inspect(new RejectedPromise($reason));
assert($result['reason'] === $reason);
```
Cancelled promises now inspect with a `CancellationException` reason. If you
need the string reason from a `RejectionException` or subclass, call
`getReason()` on the exception.
`Utils::inspect()` still reports wait-function failures as rejection reasons
when the wait function does not settle the promise. If the wait function
settles the promise and then throws, `inspect()` reports the settled
fulfillment or rejection state; direct `Promise::wait()` calls continue to
throw that late exception.
#### Late Rejection Callbacks
Rejection callbacks registered after a promise was resolved with a rejected
promise are now invoked with the nested rejection reason.
```php
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Promise\Utils;
$promise = new Promise();
$promise->resolve(new RejectedPromise('reason'));
$promise->then(null, function ($reason): void {
assert($reason === 'reason');
});
Utils::queue()->run();
```
#### Non-instantiable Helper Classes
Static helper classes such as `Create`, `Each`, `Is`, and `Utils` now have
private constructors. Replace any accidental instantiation with static method
calls.
#### Native PHP Serialization of Runtime Objects
`Promise`, `TaskQueue`, `EachPromise`, and `Coroutine` no longer support native
PHP `serialize()` or `unserialize()`. Persist application values instead of
promise runtime state.
1.x to 2.0
----------

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace GuzzleHttp\Promise;
use Generator;
use Throwable;
/**
* Creates a promise that is resolved using a generator that yields values or
@ -37,29 +36,35 @@ use Throwable;
* // Outputs "abc"
* $promise->then(function ($v) { echo $v; });
*
* @param callable $generatorFn Generator function to wrap into a promise.
* @template TValue = mixed
* @template TReason = mixed
*
* @return Promise
* @implements PromiseInterface<TValue, TReason>
*
* @see https://github.com/petkaantonov/bluebird/blob/master/API.md#generators inspiration
*/
final class Coroutine implements PromiseInterface
{
/**
* @var PromiseInterface|null
*/
private $currentPromise;
use NonSerializableTrait;
/**
* @var Generator
* @var PromiseInterface<mixed, mixed>|null
*/
private $generator;
private ?PromiseInterface $currentPromise = null;
/**
* @var Promise
* @var Generator<mixed, mixed, mixed, mixed>
*/
private $result;
private Generator $generator;
/**
* @var Promise<TValue, TReason>
*/
private PromiseInterface $result;
/**
* @param callable(): Generator<mixed, mixed, mixed, mixed> $generatorFn
*/
public function __construct(callable $generatorFn)
{
$this->generator = $generatorFn();
@ -70,19 +75,34 @@ final class Coroutine implements PromiseInterface
});
try {
$this->nextCoroutine($this->generator->current());
} catch (Throwable $throwable) {
} catch (\Throwable $throwable) {
$this->result->reject($throwable);
}
}
/**
* Create a new coroutine.
*
* @param callable(): Generator<mixed, mixed, mixed, mixed> $generatorFn
*
* @return self<mixed, mixed>
*/
public static function of(callable $generatorFn): self
{
return new self($generatorFn);
}
/**
* @template TFulfilledValue = never
* @template TFulfilledReason = never
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param (callable(TValue): (TFulfilledValue|PromiseInterface<TFulfilledValue, TFulfilledReason>))|null $onFulfilled Invoked when the promise fulfills.
* @param (callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>))|null $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<($onFulfilled is null ? TValue : TFulfilledValue)|($onRejected is null ? never : TRejectedValue), ($onFulfilled is null ? never : TFulfilledReason|\Throwable)|($onRejected is null ? TReason : TRejectedReason|\Throwable)>
*/
public function then(
?callable $onFulfilled = null,
?callable $onRejected = null
@ -90,6 +110,14 @@ final class Coroutine implements PromiseInterface
return $this->result->then($onFulfilled, $onRejected);
}
/**
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>) $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<TValue|TRejectedValue, TRejectedReason|\Throwable>
*/
public function otherwise(callable $onRejected): PromiseInterface
{
return $this->result->otherwise($onRejected);
@ -105,7 +133,7 @@ final class Coroutine implements PromiseInterface
return $this->result->getState();
}
public function resolve($value): void
public function resolve($value = null): void
{
$this->result->resolve($value);
}
@ -135,7 +163,8 @@ final class Coroutine implements PromiseInterface
*/
public function _handleSuccess($value): void
{
unset($this->currentPromise);
$this->currentPromise = null;
try {
$next = $this->generator->send($value);
if ($this->generator->valid()) {
@ -143,7 +172,7 @@ final class Coroutine implements PromiseInterface
} else {
$this->result->resolve($value);
}
} catch (Throwable $throwable) {
} catch (\Throwable $throwable) {
$this->result->reject($throwable);
}
}
@ -153,12 +182,13 @@ final class Coroutine implements PromiseInterface
*/
public function _handleFailure($reason): void
{
unset($this->currentPromise);
$this->currentPromise = null;
try {
$nextYield = $this->generator->throw(Create::exceptionFor($reason));
// The throw was caught, so keep iterating on the coroutine
$this->nextCoroutine($nextYield);
} catch (Throwable $throwable) {
} catch (\Throwable $throwable) {
$this->result->reject($throwable);
}
}

View file

@ -6,10 +6,21 @@ namespace GuzzleHttp\Promise;
final class Create
{
private function __construct()
{
}
/**
* Creates a promise for a value if the value is not a promise.
* Returns `$value` when it is already a Guzzle promise, wraps foreign
* thenables in a Guzzle promise, or returns a fulfilled promise for plain
* values.
*
* @param mixed $value Promise or value.
* @template TValue
* @template TPromise of PromiseInterface<mixed, mixed> = PromiseInterface<mixed, mixed>
*
* @param TValue|TPromise $value Promise or value.
*
* @return ($value is PromiseInterface ? TPromise : FulfilledPromise<TValue, never>)
*/
public static function promiseFor($value): PromiseInterface
{
@ -31,10 +42,16 @@ final class Create
}
/**
* Creates a rejected promise for a reason if the reason is not a promise.
* If the provided reason is a promise, then it is returned as-is.
* Returns `$reason` when it is already a promise, or returns a rejected
* promise for plain reasons.
*
* @param mixed $reason Promise or reason.
* @template TReason
* @template TValue = never
* @template TPromise of PromiseInterface<mixed, mixed> = PromiseInterface<mixed, mixed>
*
* @param TReason|TPromise $reason Promise or reason.
*
* @return ($reason is PromiseInterface ? TPromise : RejectedPromise<TValue, TReason>)
*/
public static function rejectionFor($reason): PromiseInterface
{
@ -46,9 +63,12 @@ final class Create
}
/**
* Create an exception for a rejected promise value.
* Returns throwable reasons as-is, or wraps non-throwable reasons in
* `RejectionException`.
*
* @param mixed $reason
* @template TReason
*
* @param TReason $reason
*/
public static function exceptionFor($reason): \Throwable
{
@ -60,11 +80,17 @@ final class Create
}
/**
* Returns an iterator for the given value.
* Returns an iterator for arrays, iterators, iterator aggregates, and
* traversables.
*
* @param mixed $value
* @template TKey of array-key
* @template TValue
*
* @param iterable<TKey, TValue> $value
*
* @return \Iterator<TKey, TValue>
*/
public static function iterFor($value): \Iterator
public static function iterFor(iterable $value): \Iterator
{
if ($value instanceof \Iterator) {
return $value;
@ -74,16 +100,10 @@ final class Create
return new \ArrayIterator($value);
}
if (!is_iterable($value)) {
\trigger_deprecation(
'guzzlehttp/promises',
'2.5',
'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.',
__CLASS__,
__FUNCTION__
);
if ($value instanceof \IteratorAggregate) {
return self::iterFor($value->getIterator());
}
return new \ArrayIterator([$value]);
return new \IteratorIterator($value);
}
}

View file

@ -6,32 +6,58 @@ namespace GuzzleHttp\Promise;
final class Each
{
private function __construct()
{
}
/**
* Given an iterator that yields promises or values, returns a promise that
* is fulfilled with a null value when the iterator has been consumed or
* the aggregate promise has been fulfilled or rejected.
*
* $onFulfilled is a function that accepts the fulfilled value, iterator
* index, and the aggregate promise. The callback can invoke any necessary
* $onFulfilled is a function that accepts the fulfilled value, iterable
* key, and the aggregate promise. The callback can invoke any necessary
* side effects and choose to resolve or reject the aggregate if needed.
*
* $onRejected is a function that accepts the rejection reason, iterator
* index, and the aggregate promise. The callback can invoke any necessary
* $onRejected is a function that accepts the rejection reason, iterable
* key, and the aggregate promise. The callback can invoke any necessary
* side effects and choose to resolve or reject the aggregate if needed.
*
* @param mixed $iterable Iterator or array to iterate over.
* The config array accepts a concurrency option matching {@see ofLimit}.
* Other config keys are ignored by this wrapper.
*
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, TValue|PromiseInterface<TValue, TReason>> $iterable Iterator or array to iterate over.
* @param (callable(TValue, TKey, PromiseInterface<mixed, mixed>): mixed)|null $onFulfilled
* @param (callable(TReason, TKey, PromiseInterface<mixed, mixed>): mixed)|null $onRejected
* @param array{concurrency?: int|(callable(int): int)} $config Configuration options.
*
* @return PromiseInterface<mixed, mixed>
*/
public static function of(
$iterable,
iterable $iterable,
?callable $onFulfilled = null,
?callable $onRejected = null
?callable $onRejected = null,
array $config = []
): PromiseInterface {
$iterable = self::prepareIterable($iterable, __FUNCTION__);
$eachConfig = [];
return (new EachPromise($iterable, [
'fulfilled' => $onFulfilled,
'rejected' => $onRejected,
]))->promise();
if (null !== $onFulfilled) {
$eachConfig['fulfilled'] = $onFulfilled;
}
if (null !== $onRejected) {
$eachConfig['rejected'] = $onRejected;
}
if (isset($config['concurrency'])) {
$eachConfig['concurrency'] = $config['concurrency'];
}
return (new EachPromise($iterable, $eachConfig))->promise();
}
/**
@ -40,41 +66,46 @@ final class Each
*
* $concurrency may be an integer or a function that accepts the number of
* pending promises and returns a numeric concurrency limit value to allow
* for dynamic a concurrency size.
* for a dynamic concurrency size.
*
* @param mixed $iterable
* @param int|callable $concurrency
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, TValue|PromiseInterface<TValue, TReason>> $iterable
* @param int|(callable(int): int) $concurrency
* @param (callable(TValue, TKey, PromiseInterface<mixed, mixed>): mixed)|null $onFulfilled
* @param (callable(TReason, TKey, PromiseInterface<mixed, mixed>): mixed)|null $onRejected
*
* @return PromiseInterface<mixed, mixed>
*/
public static function ofLimit(
$iterable,
iterable $iterable,
$concurrency,
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
$iterable = self::prepareIterable($iterable, __FUNCTION__);
return (new EachPromise($iterable, [
'fulfilled' => $onFulfilled,
'rejected' => $onRejected,
'concurrency' => $concurrency,
]))->promise();
return self::of($iterable, $onFulfilled, $onRejected, ['concurrency' => $concurrency]);
}
/**
* Like limit, but ensures that no promise in the given $iterable argument
* is rejected. If any promise is rejected, then the aggregate promise is
* rejected with the encountered rejection.
* Like ofLimit, but rejects the aggregate promise on the first rejection.
*
* @param mixed $iterable
* @param int|callable $concurrency
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, TValue|PromiseInterface<TValue, TReason>> $iterable
* @param int|(callable(int): int) $concurrency
* @param (callable(TValue, TKey, PromiseInterface<mixed, mixed>): mixed)|null $onFulfilled
*
* @return PromiseInterface<mixed, mixed>
*/
public static function ofLimitAll(
$iterable,
iterable $iterable,
$concurrency,
?callable $onFulfilled = null
): PromiseInterface {
$iterable = self::prepareIterable($iterable, __FUNCTION__);
return self::ofLimit(
$iterable,
$concurrency,
@ -84,21 +115,4 @@ final class Each
}
);
}
private static function prepareIterable($iterable, string $method): iterable
{
if (is_iterable($iterable)) {
return $iterable;
}
\trigger_deprecation(
'guzzlehttp/promises',
'2.5',
'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.',
self::class,
$method
);
return [$iterable];
}
}

View file

@ -8,31 +8,41 @@ namespace GuzzleHttp\Promise;
* Represents a promise that iterates over many promises and invokes
* side-effect functions in the process.
*
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @implements PromisorInterface<mixed, mixed>
*
* @final
*/
class EachPromise implements PromisorInterface
{
private $pending = [];
use NonSerializableTrait;
private $nextPendingIndex = 0;
/** @var array<int, PromiseInterface<mixed, mixed>>|null */
private ?array $pending = [];
/** @var \Iterator|null */
private $iterable;
private int $nextPendingIndex = 0;
/** @var callable|int|null */
/** @var \Iterator<TKey, TValue|PromiseInterface<TValue, TReason>>|null */
private ?\Iterator $iterable;
/** @var (callable(int): int)|int|null */
private $concurrency;
/** @var callable|null */
/** @var (callable(TValue, TKey, PromiseInterface<mixed, mixed>): mixed)|null */
private $onFulfilled;
/** @var callable|null */
/** @var (callable(TReason, TKey, PromiseInterface<mixed, mixed>): mixed)|null */
private $onRejected;
/** @var Promise|null */
private $aggregate;
/** @var Promise<mixed, mixed>|null */
private ?Promise $aggregate = null;
/** @var bool|null */
private $mutex;
private ?bool $mutex = null;
private bool $stepWhileLocked = false;
/**
* Configuration hash can include the following key value pairs:
@ -52,23 +62,15 @@ class EachPromise implements PromisorInterface
* allowed number of outstanding concurrently executing promises,
* creating a capped pool of promises. There is no limit by default.
*
* @param mixed $iterable Promises or values to iterate.
* @param array $config Configuration options
* @param iterable<TKey, TValue|PromiseInterface<TValue, TReason>> $iterable Promises or values to iterate.
* @param array{
* fulfilled?: callable(TValue, TKey, PromiseInterface<mixed, mixed>): mixed,
* rejected?: callable(TReason, TKey, PromiseInterface<mixed, mixed>): mixed,
* concurrency?: int|(callable(int): int)
* } $config Configuration options
*/
public function __construct($iterable, array $config = [])
public function __construct(iterable $iterable, array $config = [])
{
if (!is_iterable($iterable)) {
\trigger_deprecation(
'guzzlehttp/promises',
'2.5',
'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.',
__CLASS__,
__FUNCTION__
);
$iterable = [$iterable];
}
$this->iterable = Create::iterFor($iterable);
if (isset($config['concurrency'])) {
@ -84,7 +86,9 @@ class EachPromise implements PromisorInterface
}
}
/** @psalm-suppress InvalidNullableReturnType */
/**
* @return PromiseInterface<mixed, mixed>
*/
public function promise(): PromiseInterface
{
if ($this->aggregate) {
@ -93,7 +97,6 @@ class EachPromise implements PromisorInterface
try {
$this->createPromise();
/** @psalm-assert Promise $this->aggregate */
$this->iterable->rewind();
$this->refillPending();
if (!$this->pending) {
@ -113,9 +116,6 @@ class EachPromise implements PromisorInterface
$this->aggregate->reject($e);
}
/**
* @psalm-suppress NullableReturnStatement
*/
return $this->aggregate;
}
@ -123,16 +123,23 @@ class EachPromise implements PromisorInterface
{
$this->mutex = false;
$this->aggregate = new Promise(function (): void {
if ($this->checkIfFinished()) {
return;
}
reset($this->pending);
// Consume a potentially fluctuating list of promises while
// ensuring that indexes are maintained (precluding array_shift).
while ($promise = current($this->pending)) {
next($this->pending);
$promise->wait();
if (Is::settled($this->aggregate)) {
while (true) {
if ($this->checkIfFinished()) {
return;
}
reset($this->pending);
// Consume a potentially fluctuating list of promises while
// ensuring that indexes are maintained (precluding array_shift).
while ($promise = current($this->pending)) {
next($this->pending);
$promise->wait();
if (Is::settled($this->aggregate)) {
return;
}
}
// Refill and re-sweep; give up only when nothing remains.
$this->refillPending();
if (Is::settled($this->aggregate) || !$this->pending) {
return;
}
}
@ -162,6 +169,10 @@ class EachPromise implements PromisorInterface
$concurrency = is_callable($this->concurrency)
? ($this->concurrency)(count($this->pending))
: $this->concurrency;
// The callable can settle the aggregate; admit nothing more.
if (Is::settled($this->aggregate)) {
return;
}
$concurrency = max($concurrency - count($this->pending), 0);
// Concurrency may be set to 0 to disallow new promises.
if (!$concurrency) {
@ -223,6 +234,8 @@ class EachPromise implements PromisorInterface
// Place a lock on the iterator so that we ensure to not recurse,
// preventing fatal generator errors.
if ($this->mutex) {
$this->stepWhileLocked = true;
return false;
}
@ -231,14 +244,22 @@ class EachPromise implements PromisorInterface
try {
$this->iterable->next();
$this->mutex = false;
return true;
} catch (\Throwable $e) {
$this->aggregate->reject($e);
$this->mutex = false;
return false;
}
// Run the completion check that locked steps skipped.
if ($this->stepWhileLocked) {
$this->stepWhileLocked = false;
if (!Is::settled($this->aggregate)) {
$this->checkIfFinished();
}
}
return true;
}
private function step(int $idx): void
@ -259,6 +280,7 @@ class EachPromise implements PromisorInterface
}
}
/** @phpstan-impure */
private function checkIfFinished(): bool
{
if (!$this->pending && !$this->iterable->valid()) {

View file

@ -10,14 +10,20 @@ namespace GuzzleHttp\Promise;
* Thenning off of this promise will invoke the onFulfilled callback
* immediately and ignore other callbacks.
*
* @template TValue = mixed
* @template TReason = never
*
* @implements PromiseInterface<TValue, TReason>
*
* @final
*/
class FulfilledPromise implements PromiseInterface
{
/** @var TValue */
private $value;
/**
* @param mixed $value
* @param TValue $value
*/
public function __construct($value)
{
@ -30,6 +36,17 @@ class FulfilledPromise implements PromiseInterface
$this->value = $value;
}
/**
* @template TFulfilledValue = never
* @template TFulfilledReason = never
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param (callable(TValue): (TFulfilledValue|PromiseInterface<TFulfilledValue, TFulfilledReason>))|null $onFulfilled Invoked when the promise fulfills.
* @param (callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>))|null $onRejected Invoked when the promise is rejected.
*
* @return ($onFulfilled is null ? self<TValue, TReason> : PromiseInterface<TFulfilledValue, TFulfilledReason|\Throwable>)
*/
public function then(
?callable $onFulfilled = null,
?callable $onRejected = null
@ -55,6 +72,11 @@ class FulfilledPromise implements PromiseInterface
return $p;
}
/**
* @param callable(TReason): mixed $onRejected Invoked when the promise is rejected.
*
* @return self<TValue, TReason>
*/
public function otherwise(callable $onRejected): PromiseInterface
{
return $this->then(null, $onRejected);
@ -70,7 +92,7 @@ class FulfilledPromise implements PromiseInterface
return self::FULFILLED;
}
public function resolve($value): void
public function resolve($value = null): void
{
if ($value !== $this->value) {
throw new \LogicException('Cannot resolve a fulfilled promise');

View file

@ -6,6 +6,10 @@ namespace GuzzleHttp\Promise;
final class Is
{
private function __construct()
{
}
/**
* Returns true if a promise is pending.
*/

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Promise;
/**
* @internal
*/
trait NonSerializableTrait
{
public function __serialize(): array
{
throw new \LogicException(static::class.' should never be serialized');
}
public function __unserialize(array $data): void
{
throw new \LogicException(static::class.' should never be unserialized');
}
}

View file

@ -7,22 +7,40 @@ namespace GuzzleHttp\Promise;
/**
* Promises/A+ implementation that avoids recursion when possible.
*
* @template TValue = mixed
* @template TReason = mixed
*
* @implements PromiseInterface<TValue, TReason>
*
* @see https://promisesaplus.com/
*
* @final
*/
class Promise implements PromiseInterface
{
private $state = self::PENDING;
use NonSerializableTrait;
/** @var self::PENDING|self::FULFILLED|self::REJECTED */
private string $state = self::PENDING;
/** @var TValue|TReason|PromiseInterface<TValue, TReason>|null */
private $result;
/** @var (callable(): void)|null */
private $cancelFn;
/** @var (callable(bool): void)|null */
private $waitFn;
private $waitList;
private $handlers = [];
/** @var list<Promise<mixed, mixed>>|null */
private ?array $waitList = null;
/** @var list<array{0: PromiseInterface<mixed, mixed>, 1: callable|null, 2: callable|null}>|null */
private ?array $handlers = [];
/**
* @param callable $waitFn Fn that when invoked resolves the promise.
* @param callable $cancelFn Fn that when invoked cancels the promise.
* @param (callable(bool): void)|null $waitFn Fn that when invoked resolves the promise.
* @param (callable(): void)|null $cancelFn Fn that when invoked cancels the promise.
*/
public function __construct(
?callable $waitFn = null,
@ -32,6 +50,17 @@ class Promise implements PromiseInterface
$this->cancelFn = $cancelFn;
}
/**
* @template TFulfilledValue = never
* @template TFulfilledReason = never
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param (callable(TValue): (TFulfilledValue|PromiseInterface<TFulfilledValue, TFulfilledReason>))|null $onFulfilled Invoked when the promise fulfills.
* @param (callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>))|null $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<($onFulfilled is null ? TValue : TFulfilledValue)|($onRejected is null ? never : TRejectedValue), ($onFulfilled is null ? never : TFulfilledReason|\Throwable)|($onRejected is null ? TReason : TRejectedReason|\Throwable)>
*/
public function then(
?callable $onFulfilled = null,
?callable $onRejected = null
@ -49,16 +78,27 @@ class Promise implements PromiseInterface
if ($this->state === self::FULFILLED) {
$promise = Create::promiseFor($this->result);
return $onFulfilled ? $promise->then($onFulfilled) : $promise;
return $promise->then($onFulfilled, $onRejected);
}
// It's either cancelled or rejected, so return a rejected promise
// and immediately invoke any callbacks.
$rejection = Create::rejectionFor($this->result);
return $onRejected ? $rejection->then(null, $onRejected) : $rejection;
/** @var PromiseInterface<($onFulfilled is null ? TValue : TFulfilledValue)|($onRejected is null ? never : TRejectedValue), ($onFulfilled is null ? never : TFulfilledReason|\Throwable)|($onRejected is null ? TReason : TRejectedReason|\Throwable)> $promise */
$promise = $onRejected ? $rejection->then(null, $onRejected) : $rejection;
return $promise;
}
/**
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>) $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<TValue|TRejectedValue, TRejectedReason|\Throwable>
*/
public function otherwise(callable $onRejected): PromiseInterface
{
return $this->then(null, $onRejected);
@ -78,6 +118,8 @@ class Promise implements PromiseInterface
// It's rejected so "unwrap" and throw an exception.
throw Create::exceptionFor($this->result);
}
return null;
}
public function getState(): string
@ -110,7 +152,7 @@ class Promise implements PromiseInterface
}
}
public function resolve($value): void
public function resolve($value = null): void
{
$this->settle(self::FULFILLED, $value);
}
@ -187,7 +229,7 @@ class Promise implements PromiseInterface
*/
private static function callHandler(int $index, $value, array $handler): void
{
/** @var PromiseInterface $promise */
/** @var PromiseInterface<mixed, mixed> $promise */
$promise = $handler[0];
// The promise may have been cancelled or resolved before placing

View file

@ -11,6 +11,9 @@ namespace GuzzleHttp\Promise;
* which registers callbacks to receive either a promises eventual value or
* the reason why the promise cannot be fulfilled.
*
* @template TValue = mixed
* @template TReason = mixed
*
* @see https://promisesaplus.com/
*/
interface PromiseInterface
@ -23,8 +26,15 @@ interface PromiseInterface
* Appends fulfillment and rejection handlers to the promise, and returns
* a new promise resolving to the return value of the called handler.
*
* @param callable $onFulfilled Invoked when the promise fulfills.
* @param callable $onRejected Invoked when the promise is rejected.
* @template TFulfilledValue = never
* @template TFulfilledReason = never
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param (callable(TValue): (TFulfilledValue|PromiseInterface<TFulfilledValue, TFulfilledReason>))|null $onFulfilled Invoked when the promise fulfills.
* @param (callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>))|null $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<($onFulfilled is null ? TValue : TFulfilledValue)|($onRejected is null ? never : TRejectedValue), ($onFulfilled is null ? never : TFulfilledReason|\Throwable)|($onRejected is null ? TReason : TRejectedReason|\Throwable)>
*/
public function then(
?callable $onFulfilled = null,
@ -37,7 +47,12 @@ interface PromiseInterface
* or to its original fulfillment value if the promise is instead
* fulfilled.
*
* @param callable $onRejected Invoked when the promise is rejected.
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>) $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<TValue|TRejectedValue, TRejectedReason|\Throwable>
*/
public function otherwise(callable $onRejected): PromiseInterface;
@ -46,22 +61,24 @@ interface PromiseInterface
*
* The three states can be checked against the constants defined on
* PromiseInterface: PENDING, FULFILLED, and REJECTED.
*
* @return self::PENDING|self::FULFILLED|self::REJECTED
*/
public function getState(): string;
/**
* Resolve the promise with the given value.
* Resolve the promise with the given value, or with null if no value is given.
*
* @param mixed $value
* @param TValue|PromiseInterface<TValue, TReason>|null $value
*
* @throws \RuntimeException if the promise is already resolved.
*/
public function resolve($value): void;
public function resolve($value = null): void;
/**
* Reject the promise with the given reason.
*
* @param mixed $reason
* @param TReason $reason
*
* @throws \RuntimeException if the promise is already resolved.
*/
@ -82,7 +99,7 @@ interface PromiseInterface
*
* If the promise cannot be waited on, then the promise will be rejected.
*
* @return mixed
* @return ($unwrap is true ? TValue : null)
*
* @throws \LogicException if the promise has no wait function or if the
* promise does not settle after waiting.

View file

@ -6,11 +6,16 @@ namespace GuzzleHttp\Promise;
/**
* Interface used with classes that return a promise.
*
* @template TValue = mixed
* @template TReason = mixed
*/
interface PromisorInterface
{
/**
* Returns a promise.
*
* @return PromiseInterface<TValue, TReason>
*/
public function promise(): PromiseInterface;
}

View file

@ -10,14 +10,20 @@ namespace GuzzleHttp\Promise;
* Thenning off of this promise will invoke the onRejected callback
* immediately and ignore other callbacks.
*
* @template TValue = never
* @template TReason = mixed
*
* @implements PromiseInterface<TValue, TReason>
*
* @final
*/
class RejectedPromise implements PromiseInterface
{
/** @var TReason */
private $reason;
/**
* @param mixed $reason
* @param TReason $reason
*/
public function __construct($reason)
{
@ -30,6 +36,17 @@ class RejectedPromise implements PromiseInterface
$this->reason = $reason;
}
/**
* @template TFulfilledValue = never
* @template TFulfilledReason = never
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param (callable(TValue): (TFulfilledValue|PromiseInterface<TFulfilledValue, TFulfilledReason>))|null $onFulfilled Invoked when the promise fulfills.
* @param (callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>))|null $onRejected Invoked when the promise is rejected.
*
* @return ($onRejected is null ? self<TValue, TReason> : PromiseInterface<TRejectedValue, TRejectedReason|\Throwable>)
*/
public function then(
?callable $onFulfilled = null,
?callable $onRejected = null
@ -57,6 +74,14 @@ class RejectedPromise implements PromiseInterface
return $p;
}
/**
* @template TRejectedValue = never
* @template TRejectedReason = never
*
* @param callable(TReason): (TRejectedValue|PromiseInterface<TRejectedValue, TRejectedReason>) $onRejected Invoked when the promise is rejected.
*
* @return PromiseInterface<TRejectedValue, TRejectedReason|\Throwable>
*/
public function otherwise(callable $onRejected): PromiseInterface
{
return $this->then(null, $onRejected);
@ -76,7 +101,7 @@ class RejectedPromise implements PromiseInterface
return self::REJECTED;
}
public function resolve($value): void
public function resolve($value = null): void
{
throw new \LogicException('Cannot resolve a rejected promise');
}

View file

@ -17,8 +17,11 @@ namespace GuzzleHttp\Promise;
*/
class TaskQueue implements TaskQueueInterface
{
private $enableShutdown = true;
private $queue = [];
use NonSerializableTrait;
private bool $enableShutdown = true;
/** @var list<callable(): void> */
private array $queue = [];
public function __construct(bool $withShutdown = true)
{
@ -40,6 +43,9 @@ class TaskQueue implements TaskQueueInterface
return !$this->queue;
}
/**
* @param callable(): void $task
*/
public function add(callable $task): void
{
$this->queue[] = $task;
@ -48,7 +54,7 @@ class TaskQueue implements TaskQueueInterface
public function run(): void
{
while ($task = array_shift($this->queue)) {
/** @var callable $task */
/** @var callable(): void $task */
$task();
}
}

View file

@ -14,6 +14,8 @@ interface TaskQueueInterface
/**
* Adds a task to the queue that will be executed the next time run is
* called.
*
* @param callable(): void $task
*/
public function add(callable $task): void;

View file

@ -6,6 +6,10 @@ namespace GuzzleHttp\Promise;
final class Utils
{
private function __construct()
{
}
/**
* Get the global task queue used for promise resolution.
*
@ -35,10 +39,14 @@ final class Utils
}
/**
* Adds a function to run in the task queue when it is next `run()` and
* returns a promise that is fulfilled or rejected with the result.
* Adds a task to the global queue and returns a promise that is fulfilled
* or rejected with the task result.
*
* @param callable $task Task function to run.
* @template TValue
*
* @param callable(): TValue $task Task function to run.
*
* @return PromiseInterface<TValue, \Throwable>
*/
public static function task(callable $task): PromiseInterface
{
@ -58,7 +66,7 @@ final class Utils
}
/**
* Synchronously waits on a promise to resolve and returns an inspection
* Synchronously waits on a promise to settle and returns an inspection
* state array.
*
* Returns a state associative array containing a "state" key mapping to a
@ -67,42 +75,75 @@ final class Utils
* promise. If the promise is rejected, the array will contain a "reason"
* key mapping to the rejection reason of the promise.
*
* @param PromiseInterface $promise Promise or value.
* @template TValue
* @template TReason
*
* @param PromiseInterface<TValue, TReason> $promise Promise to inspect.
*
* @return array{state: PromiseInterface::FULFILLED, value: TValue}|array{state: PromiseInterface::REJECTED, reason: TReason|\Throwable}|array{state: PromiseInterface::PENDING}
*/
public static function inspect(PromiseInterface $promise): array
{
$result = null;
$getResult = static function () use (&$result): ?array {
return $result;
};
$inspection = $promise->then(
static function ($value) use (&$result): void {
$result = ['state' => PromiseInterface::FULFILLED, 'value' => $value];
},
static function ($reason) use (&$result): void {
$result = ['state' => PromiseInterface::REJECTED, 'reason' => $reason];
}
);
try {
return [
'state' => PromiseInterface::FULFILLED,
'value' => $promise->wait(),
];
$inspection->wait(false);
} catch (\Throwable $e) {
if ($e instanceof AggregateException) {
return ['state' => PromiseInterface::REJECTED, 'reason' => $e];
$settled = $getResult();
if (null !== $settled) {
return $settled;
}
if ($e instanceof RejectionException) {
return ['state' => PromiseInterface::REJECTED, 'reason' => $e->getReason()];
if (Is::settled($promise)) {
try {
self::queue()->run();
} catch (\Throwable $queueError) {
return ['state' => PromiseInterface::REJECTED, 'reason' => $queueError];
}
$settled = $getResult();
if (null !== $settled) {
return $settled;
}
}
return ['state' => PromiseInterface::REJECTED, 'reason' => $e];
}
return $getResult() ?? ['state' => $promise->getState()];
}
/**
* Waits on all of the provided promises, but does not unwrap rejected
* promises as thrown exception.
* promises as a thrown exception.
*
* Returns an array of inspection state arrays.
* Returns an array of inspection state arrays keyed like the input
* iterable.
*
* @see inspect for the inspection state array format.
*
* @param PromiseInterface[] $promises Traversable of promises to wait upon.
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, PromiseInterface<TValue, TReason>> $promises Traversable of promises to wait upon.
*
* @return array<TKey, array{state: PromiseInterface::FULFILLED, value: TValue}|array{state: PromiseInterface::REJECTED, reason: TReason|\Throwable}|array{state: PromiseInterface::PENDING}>
*/
public static function inspectAll($promises): array
public static function inspectAll(iterable $promises): array
{
$promises = self::prepareIterable($promises, __FUNCTION__);
$results = [];
foreach ($promises as $key => $promise) {
$results[$key] = self::inspect($promise);
@ -118,14 +159,18 @@ final class Utils
* order the promises were provided). An exception is thrown if any of the
* promises are rejected.
*
* @param iterable<PromiseInterface> $promises Iterable of PromiseInterface objects to wait on.
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, PromiseInterface<TValue, TReason>> $promises Iterable of PromiseInterface objects to wait on.
*
* @return array<TKey, TValue>
*
* @throws \Throwable on error
*/
public static function unwrap($promises): array
public static function unwrap(iterable $promises): array
{
$promises = self::prepareIterable($promises, __FUNCTION__);
$results = [];
foreach ($promises as $key => $promise) {
$results[$key] = $promise->wait();
@ -142,24 +187,33 @@ final class Utils
* respective positions to the original array. If any promise in the array
* rejects, the returned promise is rejected with the rejection reason.
*
* @param mixed $promises Promises or values.
* @param bool $recursive If true, resolves new promises that might have been added to the stack during its own resolution.
* The config array accepts a concurrency option for lazy iterables. Other
* config keys are ignored by this wrapper.
*
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, TValue|PromiseInterface<TValue, TReason>> $promises Promises or values.
* @param bool $recursive If true, resolves newly-added entries until no unprocessed entries or pending promises remain.
* @param array{concurrency?: int|(callable(int): int)} $config Configuration options.
*
* @return PromiseInterface<array<TKey, TValue>, TReason|\Throwable>
*/
public static function all($promises, bool $recursive = false): PromiseInterface
public static function all(iterable $promises, bool $recursive = false, array $config = []): PromiseInterface
{
$promises = self::prepareIterable($promises, __FUNCTION__);
$results = [];
$promise = Each::of(
$promises,
function ($value, $idx) use (&$results): void {
$results[$idx] = $value;
},
function ($reason, $idx, Promise $aggregate): void {
function ($reason, $idx, PromiseInterface $aggregate): void {
if (Is::pending($aggregate)) {
$aggregate->reject($reason);
}
}
},
$config
)->then(function () use (&$results) {
ksort($results);
@ -167,11 +221,9 @@ final class Utils
});
if (true === $recursive) {
$promise = $promise->then(function ($results) use ($recursive, &$promises) {
foreach ($promises as $promise) {
if (Is::pending($promise)) {
return self::all($promises, $recursive);
}
$promise = $promise->then(function ($results) use (&$promises, $config) {
if (self::shouldRecurse($promises, $results)) {
return self::all($promises, true, $config);
}
return $results;
@ -187,22 +239,25 @@ final class Utils
*
* When count amount of promises have been fulfilled, the returned promise
* is fulfilled with an array that contains the fulfillment values of the
* winners in order of resolution.
* winners, in the order they appear in the input.
*
* This promise is rejected with a {@see AggregateException} if the number
* of fulfilled promises is less than the desired $count.
*
* @param int $count Total number of promises.
* @param mixed $promises Promises or values.
* @template TValue
* @template TReason
*
* @param int $count Total number of promises.
* @param iterable<TValue|PromiseInterface<TValue, TReason>> $promises Promises or values.
*
* @return PromiseInterface<list<TValue>, \Throwable>
*/
public static function some(int $count, $promises): PromiseInterface
public static function some(int $count, iterable $promises): PromiseInterface
{
$promises = self::prepareIterable($promises, __FUNCTION__);
$results = [];
$rejections = [];
return Each::of(
$promise = Each::of(
$promises,
function ($value, $idx, PromiseInterface $p) use (&$results, $count): void {
if (Is::settled($p)) {
@ -229,77 +284,107 @@ final class Utils
return array_values($results);
}
);
/** @var PromiseInterface<list<TValue>, \Throwable> $promise */
return $promise;
}
/**
* Like some(), with 1 as count. However, if the promise fulfills, the
* fulfillment value is not an array of 1 but the value directly.
*
* @param mixed $promises Promises or values.
* @template TValue
* @template TReason
*
* @param iterable<TValue|PromiseInterface<TValue, TReason>> $promises Promises or values.
*
* @return PromiseInterface<TValue, \Throwable>
*/
public static function any($promises): PromiseInterface
public static function any(iterable $promises): PromiseInterface
{
$promises = self::prepareIterable($promises, __FUNCTION__);
return self::some(1, $promises)->then(function ($values) {
return self::some(1, $promises)->then(function (array $values) {
return $values[0];
});
}
/**
* Returns a promise that is fulfilled when all of the provided promises have
* been fulfilled or rejected.
* Returns a promise that is fulfilled when all of the provided promises
* have been fulfilled or rejected.
*
* The returned promise is fulfilled with an array of inspection state arrays.
* The returned promise is fulfilled with an array of inspection state
* arrays.
*
* The config array accepts a concurrency option for lazy iterables. Other
* config keys are ignored by this wrapper.
*
* @see inspect for the inspection state array format.
*
* @param mixed $promises Promises or values.
* @template TKey of array-key
* @template TValue
* @template TReason
*
* @param iterable<TKey, TValue|PromiseInterface<TValue, TReason>> $promises Promises or values.
* @param bool $recursive If true, settles newly-added entries until no unprocessed entries or pending promises remain.
* @param array{concurrency?: int|(callable(int): int)} $config Configuration options.
*
* @return PromiseInterface<array<TKey, array{state: PromiseInterface::FULFILLED, value: TValue}|array{state: PromiseInterface::REJECTED, reason: TReason|\Throwable}>, \Throwable>
*/
public static function settle($promises): PromiseInterface
public static function settle(iterable $promises, bool $recursive = false, array $config = []): PromiseInterface
{
$promises = self::prepareIterable($promises, __FUNCTION__);
$results = [];
return Each::of(
$promise = Each::of(
$promises,
function ($value, $idx) use (&$results): void {
$results[$idx] = ['state' => PromiseInterface::FULFILLED, 'value' => $value];
},
function ($reason, $idx) use (&$results): void {
$results[$idx] = ['state' => PromiseInterface::REJECTED, 'reason' => $reason];
}
},
$config
)->then(function () use (&$results) {
ksort($results);
return $results;
});
}
private static function prepareIterable($promises, string $method): iterable
{
if (is_iterable($promises)) {
return $promises;
if (true === $recursive) {
$promise = $promise->then(function ($results) use (&$promises, $config) {
if (self::shouldRecurse($promises, $results)) {
return self::settle($promises, true, $config);
}
return $results;
});
}
self::triggerNonIterableDeprecation($promises, $method);
return [$promises];
return $promise;
}
private static function triggerNonIterableDeprecation($promises, string $method): void
/**
* @template TKey of array-key
*
* @param iterable<TKey, mixed> $promises Promises or values.
* @param array<TKey, mixed> $results Results already collected for a pass.
*/
private static function shouldRecurse(iterable $promises, array $results): bool
{
if (is_iterable($promises)) {
return;
// A consumed generator cannot be traversed again, so a recursive
// pass has nothing further to observe.
if ($promises instanceof \Generator) {
return false;
}
\trigger_deprecation(
'guzzlehttp/promises',
'2.5',
'Passing a non-iterable to %s::%s() is deprecated; guzzlehttp/promises 3.0 will require an iterable.',
self::class,
$method
);
foreach ($promises as $key => $promise) {
if (!array_key_exists($key, $results)) {
return true;
}
if ($promise instanceof PromiseInterface && Is::pending($promise)) {
return true;
}
}
return false;
}
}

View file

@ -5,6 +5,172 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 3.0.0 - 2026-07-20
### Added
- Add `DiagnosticValue::escape()` to escape controls and malformed UTF-8 in diagnostics
- Add `GuzzleHttp\Psr7\Exception\TimeoutException` for timed-out stream operations
- Add `GuzzleHttp\Psr7\Utils::redactUserInfoInString()` to redact the userinfo of a raw URI string within text
- Promote `GuzzleHttp\Psr7\Rfc3986` to public API with `isValid*()` predicates and `canonicalizeIpv6()`
- Add `GuzzleHttp\Psr7\UriNormalizer::CANONICALIZE_IPV6_HOST` to `PRESERVING_NORMALIZATIONS`
### Changed
- Require `psr/http-message:^2.0` and add native parameter and return types
- Require `psr/http-factory:^1.1`
- Reject native PHP serialization of stream implementations
- Preserve request method casing, except `ServerRequest::fromGlobals()` still uppercases
- Reject empty arrays and non-string values as header values
- Reject invalid uploaded file trees and invalid parsed body values
- Reject uploaded file specs missing `tmp_name`, `size`, or `error`
- Reject non-integer and negative uploaded file `error` values
- Reject invalid stream/upload sizes, buffer high-water marks, and dropping-stream limits
- Rewind seekable uploaded-file streams before copying in `UploadedFile::moveTo()`
- Reject negative `read()` lengths across all stream implementations
- Detect the `+` flag anywhere in a mode for `Stream::isReadable()`/`isWritable()`
- Reject empty strings returned by `PumpStream` source callables
- Discard buffered bytes on `PumpStream` close and detach
- Restore the original stream position after `Message::bodySummary()`
- Allow `null` for the `Message::bodySummary()` truncation length to use the default
- Validate `LimitStream` offset/limit and track non-seekable offset by bytes skipped
- Make `FnStream` close and detach terminal, calling close callbacks at most once
- Suppress exceptions from `FnStream` close callbacks during destructor cleanup
- Make `CachingStream::close()` idempotent, preserving remote cleanup after detach
- Do not move the `CachingStream` cursor when a `SEEK_END` target on an unknown-size stream is rejected
- Normalize multiple leading slashes in `Uri::getPath()` and origin-form request targets
- Serialize authority-less `file` URIs with rootless paths without the `//` separator
- Serialize authority-less `file` URIs with empty paths as `file:` instead of the unparseable `file://`
- Remove dot segments above the root per RFC 3986, prefixing authority-less `//` paths with `/.`
- Return a network-path reference from `UriResolver::relativize()` when an empty-path target requires one
- Stop returning an empty reference from `UriResolver::relativize()` when it would inherit the base fragment
- Stop throwing from `UriResolver::relativize()` when an equal-path target's last path segment contains a colon
- Harden URI host validation (delimiters, backslashes, IPv6, embedded ports); require schemes to start with a letter
- Validate `Uri::fromParts()` ports instead of casting them
- Treat ports 80 and 443 as defaults for the `ws` and `wss` schemes
- Use the `ws` and `wss` default ports in `UriComparator::isCrossOrigin()` port comparisons
- Redact all non-empty URI userinfo in `Utils::redactUserInfo()`
- Rebuild server request URIs from `$_SERVER` by `REQUEST_METHOD`, using target authority before `SERVER_PORT`
- Remove userinfo from absolute-form `REQUEST_URI` targets in `ServerRequest::fromGlobals()`
- Reject zero-port `HTTP_HOST` and malformed `SERVER_PORT` in `ServerRequest::getUriFromGlobals()`
- Reject malformed `REQUEST_METHOD` and `SERVER_PROTOCOL` server values in `ServerRequest::fromGlobals()`
- Reject zero-port `Host` and normalize leading-zero ports in `Message::parseRequest()`
- Reject duplicate `Host` headers and validate present values for all request-target forms
- Reject zero-port, hostless, and userinfo absolute-form request targets in `Message::parseRequest()`
- Synchronize the `Host` header in `Request::withUri()` when the URI changes or Host is empty
- Include the URI port in `Host` headers synthesized by `Message::toString()`
- Validate the Host header synthesized by `Message::toString()` from the request URI
- Include non-default URI ports in `Host` headers set by `Utils::modifyRequest()` URI changes
- Accept `OPTIONS *` and `CONNECT` authority-form request targets in `Message::parseRequest()`
- Hide credential-bearing URI, server, and cookie arguments in stack traces on PHP 8.2+
- Reject malformed HTTP request/response start-lines
- Unfold obsolete HTTP/1.0 line folding for all valid request method tokens
- Reject empty and control-character request targets in `Request::withRequestTarget()`
- Validate iterator chunks passed to `Utils::streamFor()`
- Validate unsupported values passed to `Query::build()`
- Reject non-finite float values in `Query::build()` and `MultipartStream` contents
- Reject non-finite float values in iterator chunks passed to `Utils::streamFor()`
- `Utils::streamFor()` now rejects non-string scalar bodies
- `Uri::withQueryValues()` now rejects non-string values
- Reject invalid `Utils::modifyRequest()` change values
- Use PHP debug type names in type error messages
- Changed `Utils::copyToStream()` to throw when destination streams cannot make progress
- Throw `TimeoutException` from `Stream` read/write and `Utils` copy/hash/readLine on stream timeouts
- Throw `TimeoutException` from `AppendStream::read()`, `CachingStream::read()`, and `Utils::tryGetContents()` on stream timeouts
- Re-throw `TimeoutException` from `InflateStream` when the decoded source stream times out
- Close the compressed source stream from `InflateStream::close()`
- Return the number of bytes copied from `Utils::copyToStream()`
- Throw `OverflowException` when stream byte counts or offsets exceed `PHP_INT_MAX`
- Translate `StreamWrapper` runtime failures to PHP stream failure values
- Stop adding default `Content-Length` to `multipart/form-data` parts (RFC 7578 §4.8)
- Escape multipart `Content-Disposition` parameters and reject unsafe boundaries and part headers
- Preserve trailing whitespace in custom `MultipartStream` part header values
- Preserve explicit custom `MultipartStream` boundary `'0'` instead of replacing it with a generated boundary
- Made static utility classes non-instantiable
- Validate bracketed IP-literal hosts consistently between parsing and `withHost()`, including userinfo forms
- Percent-encode raw control bytes in userinfo before bracketed IP-literal hosts instead of parsing mutated values
- Reject invalid UTF-8 and preserve percent-sequences in userinfo before bracketed IP-literal hosts
- Normalize percent-encoded octets in the URI host to uppercase hex
- Canonicalize IPv6 hosts to RFC 5952 form in `Uri` construction, `fromParts()`, and `withHost()`
- Canonicalize bracketed IPv6 hosts in `UriComparator::isCrossOrigin()`
- Reject malformed percent-sequences and percent-encoded bytes forbidden by the URI host policy
- Extend `CAPITALIZE_PERCENT_ENCODING` and `DECODE_UNRESERVED_CHARACTERS` to userinfo and host
- Trim header list elements with only spaces, horizontal tabs, and line terminators in `Header::splitList()`
- Report header parameter PCRE failures explicitly in `Header::parse()`
- Escape controls and malformed UTF-8 consistently in generated exception messages
### Removed
- Dropped support for PHP 7.2 and 7.3
- Removed the `ralouphie/getallheaders` dependency
- Removed deprecated `Header::normalize()` method
## 2.13.0 - 2026-07-16
### Added
- Add `Utils::` `asciiToLower`, `asciiToUpper`, `asciiUcFirst`, `caselessEquals`, `caselessContains`
### Changed
- Use locale-independent ASCII case folding everywhere case is normalized
- Trigger a runtime deprecation for previously deprecated functionality in 2.3.0
## 2.12.5 - 2026-07-13
### Fixed
- Compare header names and hosts with locale-independent ASCII lowercasing
- Compare hosts without locale sensitivity when detecting cross-origin redirects
## 2.12.4 - 2026-07-08
### Changed
- Pass explicit trim characters ahead of the PHP 8.6 trim default change
### Fixed
- Anchor server port and response start-line patterns to the true end of input
- Treat host-less origin-form request targets starting with `//` as paths in `Message::parseRequest()`
- Reject raw DEL bytes in bracketed IP-literal hosts instead of parsing a mutated host
- Reject invalid bytes after a bracketed IP-literal host instead of reparsing a different host
## 2.12.3 - 2026-06-23
### Security
- Validate the URI host so `getHost()` matches the URI authority (GHSA-c2w2-prh8-qm98)
## 2.12.2 - 2026-06-23
### Fixed
- Report URI parsing, filtering, and normalization PCRE failures explicitly
- Report HTTP message parser PCRE failures explicitly
- Fail closed when PCRE validation fails for request targets and hosts
## 2.12.1 - 2026-06-18
### Security
- Reject CR/LF in HTTP method, protocol version, and reason phrase (GHSA-vm85-hxw5-5432)
## 2.12.0 - 2026-06-16
### Deprecated
- Deprecated non-finite float values in `Query::build()` that guzzlehttp/psr7 3.0 rejects
- Deprecated non-finite float multipart contents that guzzlehttp/psr7 3.0 rejects
- Deprecated non-string scalar bodies in `Utils::streamFor()`; cast them to a string for 3.0
- Deprecated non-string `Uri::withQueryValues()` values; cast them to a string for 3.0
## 2.11.1 - 2026-06-12
### Fixed
- Fixed non-finite float values emitting coercion warnings on PHP 8.5
## 2.11.0 - 2026-06-02
### Changed

View file

@ -1,880 +1,88 @@
# PSR-7 Message Implementation
# Guzzle PSR-7
This repository contains a full [PSR-7](https://www.php-fig.org/psr/psr-7/)
message implementation, several stream decorators, and some helpful
functionality like query string parsing.
![CI](https://github.com/guzzle/psr7/workflows/CI/badge.svg)
![Static analysis](https://github.com/guzzle/psr7/workflows/Static%20analysis/badge.svg)
## Features
This package comes with a number of stream implementations and stream
decorators.
`guzzlehttp/psr7` is a PSR-7 HTTP message implementation for PHP. It provides
request, response, URI, uploaded file, and stream objects that work with Guzzle
and any other library using the PSR-7 interfaces.
Use this package directly when you need to create or inspect PSR-7 messages
without sending HTTP requests. If you only want to make HTTP requests, install
[`guzzlehttp/guzzle`](https://github.com/guzzle/guzzle/blob/8.0/README.md)
instead; it already depends on this package.
## Installation
```shell
```bash
composer require guzzlehttp/psr7
```
## Version Guidance
| Version | Status | PHP Version |
|---------|---------------------|--------------|
| 1.x | EOL (2024-06-30) | >=5.4,<8.2 |
| 2.x | Latest | >=7.2.5,<8.6 |
| Version | Status | PHP Version |
|---------|--------------|--------------|
| 3.0 | Latest | >=7.4,<8.6 |
| 2.13 | Maintenance | >=7.2.5,<8.6 |
| 1.9 | End of Life | >=5.4,<8.2 |
See [UPGRADING.md](UPGRADING.md) for notes on upgrading from 1.x to 2.0.
## AppendStream
`GuzzleHttp\Psr7\AppendStream`
Reads from multiple streams, one after the other.
## Quick Start
```php
use GuzzleHttp\Psr7;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
$a = Psr7\Utils::streamFor('abc, ');
$b = Psr7\Utils::streamFor('123.');
$composed = new Psr7\AppendStream([$a, $b]);
$request = new Request('GET', 'https://example.com/api');
$response = new Response(200, ['Content-Type' => 'text/plain'], 'OK');
$stream = Utils::streamFor('request or response body');
$composed->addStream(Psr7\Utils::streamFor(' Above all listen to me'));
echo $composed; // abc, 123. Above all listen to me.
echo $request->getMethod();
echo $response->getStatusCode();
echo $stream;
```
## BufferStream
`GuzzleHttp\Psr7\BufferStream`
Provides a buffer stream that can be written to fill a buffer, and read
from to remove bytes from the buffer.
This stream returns a "hwm" metadata value that tells upstream consumers
what the configured high water mark of the stream is, or the maximum
preferred size of the buffer.
PSR-7 messages and URIs are immutable. Methods such as `withHeader()` and
`withUri()` return a changed copy instead of modifying the original object.
Streams are mutable body handles; reading, writing, seeking, and closing a
stream can change its cursor, contents, or usability.
```php
use GuzzleHttp\Psr7;
// When more than 1024 bytes are in the buffer, it will begin returning
// 0 to writes. This is an indication that writers should slow down.
$buffer = new Psr7\BufferStream(1024);
$request = $request->withHeader('Accept', 'application/json');
```
## CachingStream
The CachingStream is used to allow seeking over previously read bytes on
non-seekable streams. This can be useful when transferring a non-seekable
entity body fails due to needing to rewind the stream (for example, resulting
from a redirect). Data that is read from the remote stream will be buffered in
a PHP temp stream so that previously read bytes are cached first in memory,
then on disk.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('http://www.google.com', 'r'));
$stream = new Psr7\CachingStream($original);
$stream->read(1024);
echo $stream->tell();
// 1024
$stream->seek(0);
echo $stream->tell();
// 0
```
## DroppingStream
`GuzzleHttp\Psr7\DroppingStream`
Stream decorator that begins dropping data once the size of the underlying
stream becomes too full.
```php
use GuzzleHttp\Psr7;
// Create an empty stream
$stream = Psr7\Utils::streamFor();
// Start dropping data when the stream has more than 10 bytes
$dropping = new Psr7\DroppingStream($stream, 10);
$dropping->write('01234567890123456789');
echo $stream; // 0123456789
```
## FnStream
`GuzzleHttp\Psr7\FnStream`
Compose stream implementations based on a hash of callables.
Allows for easy testing and extension of a provided stream without needing
to create a concrete class for a simple extension point.
```php
use GuzzleHttp\Psr7;
$stream = Psr7\Utils::streamFor('hi');
$fnStream = Psr7\FnStream::decorate($stream, [
'rewind' => function () use ($stream) {
echo 'About to rewind - ';
$stream->rewind();
echo 'rewound!';
}
]);
$fnStream->rewind();
// Outputs: About to rewind - rewound!
```
## InflateStream
`GuzzleHttp\Psr7\InflateStream`
Uses PHP's zlib.inflate filter to inflate zlib (HTTP deflate, RFC1950) or gzipped (RFC1952) content.
This stream decorator converts the provided stream to a PHP stream resource,
then appends the zlib.inflate filter. The stream is then converted back
to a Guzzle stream resource to be used as a Guzzle stream.
## LazyOpenStream
`GuzzleHttp\Psr7\LazyOpenStream`
Lazily reads or writes to a file that is opened only after an IO operation
take place on the stream.
```php
use GuzzleHttp\Psr7;
$stream = new Psr7\LazyOpenStream('/path/to/file', 'r');
// The file has not yet been opened...
echo $stream->read(10);
// The file is opened and read from only when needed.
```
## LimitStream
`GuzzleHttp\Psr7\LimitStream`
LimitStream can be used to read a subset or slice of an existing stream object.
This can be useful for breaking a large file into smaller pieces to be sent in
chunks (e.g. Amazon S3's multipart upload API).
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor(fopen('/tmp/test.txt', 'r+'));
echo $original->getSize();
// >>> 1048576
// Limit the size of the body to 1024 bytes and start reading from byte 2048
$stream = new Psr7\LimitStream($original, 1024, 2048);
echo $stream->getSize();
// >>> 1024
echo $stream->tell();
// >>> 0
```
## MultipartStream
`GuzzleHttp\Psr7\MultipartStream`
Stream that when read returns bytes for a streaming multipart or
multipart/form-data stream.
Each multipart element must contain a `name` and `contents` key. `contents` may
be any non-array value accepted by `GuzzleHttp\Psr7\Utils::streamFor()`,
including closures and invokable objects. Array contents are recursively
expanded into nested form fields.
## NoSeekStream
`GuzzleHttp\Psr7\NoSeekStream`
NoSeekStream wraps a stream and does not allow seeking.
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$noSeek = new Psr7\NoSeekStream($original);
echo $noSeek->read(3);
// foo
var_export($noSeek->isSeekable());
// false
$noSeek->seek(0);
var_export($noSeek->read(3));
// NULL
```
## PumpStream
`GuzzleHttp\Psr7\PumpStream`
Provides a read only stream that pumps data from a PHP callable.
When invoking the provided callable, the PumpStream will pass the suggested
number of bytes to read to the callable. The callable can choose to ignore
this value and return fewer or more bytes than requested. Any extra data
returned by the provided callable is buffered internally until drained using
the read() function of the PumpStream. The provided callable MUST return
false or null when there is no more data to read.
Userland callables that declare no parameters are tolerated by PHP, but
length-aware callables remain the recommended formal shape.
## Implementing stream decorators
Creating a stream decorator is very easy thanks to the
`GuzzleHttp\Psr7\StreamDecoratorTrait`. This trait provides methods that
implement `Psr\Http\Message\StreamInterface` by proxying to an underlying
stream. Just `use` the `StreamDecoratorTrait` and implement your custom
methods.
For example, let's say we wanted to call a specific function each time the last
byte is read from a stream. This could be implemented by overriding the
`read()` method.
```php
use Psr\Http\Message\StreamInterface;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
class EofCallbackStream implements StreamInterface
{
use StreamDecoratorTrait;
private $callback;
private $stream;
public function __construct(StreamInterface $stream, callable $cb)
{
$this->stream = $stream;
$this->callback = $cb;
}
public function read($length)
{
$result = $this->stream->read($length);
// Invoke the callback when EOF is hit.
if ($this->eof()) {
($this->callback)();
}
return $result;
}
}
```
This decorator could be added to any existing stream and used like so:
```php
use GuzzleHttp\Psr7;
$original = Psr7\Utils::streamFor('foo');
$eofStream = new EofCallbackStream($original, function () {
echo 'EOF!';
});
$eofStream->read(2);
$eofStream->read(1);
// echoes "EOF!"
$eofStream->seek(0);
$eofStream->read(3);
// echoes "EOF!"
```
## PHP StreamWrapper
You can use the `GuzzleHttp\Psr7\StreamWrapper` class if you need to use a
PSR-7 stream as a PHP stream resource.
Use the `GuzzleHttp\Psr7\StreamWrapper::getResource()` method to create a PHP
stream from a PSR-7 stream.
```php
use GuzzleHttp\Psr7\StreamWrapper;
$stream = GuzzleHttp\Psr7\Utils::streamFor('hello!');
$resource = StreamWrapper::getResource($stream);
echo fread($resource, 6); // outputs hello!
```
# Static API
There are various static methods available under the `GuzzleHttp\Psr7` namespace.
## `GuzzleHttp\Psr7\Message::toString`
`public static function toString(MessageInterface $message): string`
Returns the string representation of an HTTP message.
```php
$request = new GuzzleHttp\Psr7\Request('GET', 'http://example.com');
echo GuzzleHttp\Psr7\Message::toString($request);
```
## `GuzzleHttp\Psr7\Message::bodySummary`
`public static function bodySummary(MessageInterface $message, int $truncateAt = 120): string|null`
Get a short summary of the message body.
Will return `null` if the response is not printable.
## `GuzzleHttp\Psr7\Message::rewindBody`
`public static function rewindBody(MessageInterface $message): void`
Attempts to rewind a message body and throws an exception on failure.
The body of the message will only be rewound if a call to `tell()`
returns a value other than `0`.
## `GuzzleHttp\Psr7\Message::parseMessage`
`public static function parseMessage(string $message): array`
Parses an HTTP message into an associative array.
The array contains the "start-line" key containing the start line of
the message, "headers" key containing an associative array of header
array values, and a "body" key containing the body of the message.
## `GuzzleHttp\Psr7\Message::parseRequestUri`
`public static function parseRequestUri(string $path, array $headers): string`
Constructs a URI for an HTTP request message.
## `GuzzleHttp\Psr7\Message::parseRequest`
`public static function parseRequest(string $message): Request`
Parses a request message string into a request object.
## `GuzzleHttp\Psr7\Message::parseResponse`
`public static function parseResponse(string $message): Response`
Parses a response message string into a response object.
## `GuzzleHttp\Psr7\Header::parse`
`public static function parse(string|array $header): array`
Parse an array of header values containing ";" separated data into an
array of associative arrays representing the header key value pair data
of the header. When a parameter does not contain a value, but just
contains a key, this function will inject a key with a '' string value.
## `GuzzleHttp\Psr7\Header::splitList`
`public static function splitList(string|string[] $header): string[]`
Splits a HTTP header defined to contain a comma-separated list into
each individual value:
```
$knownEtags = Header::splitList($request->getHeader('if-none-match'));
```
Example headers include `accept`, `cache-control` and `if-none-match`.
## `GuzzleHttp\Psr7\Header::normalize` (deprecated)
`public static function normalize(string|array $header): array`
`Header::normalize()` is deprecated in favor of [`Header::splitList()`](README.md#guzzlehttppsr7headersplitlist)
which performs the same operation with a cleaned up API and improved
documentation.
Converts an array of header values that may contain comma separated
headers into an array of headers with no comma separated values.
## `GuzzleHttp\Psr7\Query::parse`
`public static function parse(string $str, int|bool $urlEncoding = true): array`
Parse a query string into an associative array.
If multiple values are found for the same key, the value of that key
value pair will become an array. This function does not parse nested
PHP style arrays into an associative array (e.g., `foo[a]=1&foo[b]=2`
will be parsed into `['foo[a]' => '1', 'foo[b]' => '2'])`.
## `GuzzleHttp\Psr7\Query::build`
`public static function build(array $params, int|false $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string`
Build a query string from an array of key value pairs.
This function can use the return value of `parse()` to build a query
string. This function does not modify the provided keys when an array is
encountered (like `http_build_query()` would).
## `GuzzleHttp\Psr7\Utils::caselessRemove`
`public static function caselessRemove(iterable<string> $keys, $keys, array $data): array`
Remove the items given by the keys, case insensitively from the data.
## `GuzzleHttp\Psr7\Utils::copyToStream`
`public static function copyToStream(StreamInterface $source, StreamInterface $dest, int $maxLen = -1): void`
Copy the contents of a stream into another stream until the given number
of bytes have been read.
The copy stops if the destination `write()` returns 0, for example a
`BufferStream` at its high water mark or a full `DroppingStream`. For a
guaranteed full copy, use a normal writable stream such as a file or
`php://temp` stream.
## `GuzzleHttp\Psr7\Utils::copyToString`
`public static function copyToString(StreamInterface $stream, int $maxLen = -1): string`
Copy the contents of a stream into a string until the given number of
bytes have been read.
## `GuzzleHttp\Psr7\Utils::hash`
`public static function hash(StreamInterface $stream, string $algo, bool $rawOutput = false): string`
Calculate a hash of a stream.
This method reads the entire stream to calculate a rolling hash, based on
PHP's `hash_init` functions.
## `GuzzleHttp\Psr7\Utils::modifyRequest`
`public static function modifyRequest(RequestInterface $request, array $changes): RequestInterface`
Clone and modify a request with the given changes.
This method is useful for reducing the number of clones needed to mutate
a message.
- method: (string) Changes the HTTP method.
- set_headers: (array) Sets the given headers.
- remove_headers: (array) Remove the given headers.
- body: (mixed) Sets the given body. Present non-null values are converted with
`GuzzleHttp\Psr7\Utils::streamFor()`, including scalar values, resources,
streams, iterators, callable arrays, closures, invokable objects, and
objects with `__toString()`. String inputs remain literal bodies.
- uri: (UriInterface) Set the URI.
- query: (string) Set the query string value of the URI.
- version: (string) Set the protocol version.
## `GuzzleHttp\Psr7\Utils::readLine`
`public static function readLine(StreamInterface $stream, ?int $maxLength = null): string`
Read a line from the stream up to the maximum allowed buffer length.
## `GuzzleHttp\Psr7\Utils::redactUserInfo`
`public static function redactUserInfo(UriInterface $uri): UriInterface`
Redact the password in the user info part of a URI.
## `GuzzleHttp\Psr7\Utils::streamFor`
`public static function streamFor(resource|string|null|int|float|bool|StreamInterface|callable|\Iterator $resource = '', array $options = []): StreamInterface`
Create a new stream based on the input type.
Options is an associative array that can contain the following keys:
- metadata: Array of custom metadata.
- size: Size of the stream.
This method accepts the following `$resource` types:
- `Psr\Http\Message\StreamInterface`: Returns the value as-is.
- `string`: Creates a stream object that uses the given string as the contents.
- `resource`: Creates a stream object that wraps the given PHP stream resource.
- `Iterator`: If the provided value implements `Iterator`, then a read-only
stream object will be created that wraps the given iterable. Each time the
stream is read from, data from the iterator will fill a buffer and will be
continuously called until the buffer is equal to the requested read size.
Subsequent read calls will first read from the buffer and then call `next`
on the underlying iterator until it is exhausted.
- `object` with `__toString()`: If the object has the `__toString()` method,
the object will be cast to a string and then a stream will be returned that
uses the string value.
- `NULL`: When `null` is passed, an empty stream object is returned.
- `callable`: When a callable array, closure, or invokable object is passed and
no earlier resource or object rule applies, a read-only stream object will be
created that invokes the given callable. The callable is invoked with the
suggested number of bytes to read. The callable can return fewer or more bytes
than requested, but MUST return `false` or `null` when there is no more data
to return. Any additional bytes will be buffered and used in subsequent reads.
String inputs are always treated as string bodies, even when they name
callable functions.
```php
$stream = GuzzleHttp\Psr7\Utils::streamFor('foo');
$stream = GuzzleHttp\Psr7\Utils::streamFor(fopen('/path/to/file', 'r'));
$generator = function ($bytes) {
for ($i = 0; $i < $bytes; $i++) {
yield ' ';
}
}
$stream = GuzzleHttp\Psr7\Utils::streamFor($generator(100));
```
## `GuzzleHttp\Psr7\Utils::tryFopen`
`public static function tryFopen(string $filename, string $mode): resource`
Safely opens a PHP stream resource using a filename.
When fopen fails, PHP normally raises a warning. This function adds an
error handler that checks for errors and throws an exception instead.
## `GuzzleHttp\Psr7\Utils::tryGetContents`
`public static function tryGetContents(resource $stream): string`
Safely gets the contents of a given stream.
When stream_get_contents fails, PHP normally raises a warning. This
function adds an error handler that checks for errors and throws an
exception instead.
## `GuzzleHttp\Psr7\Utils::uriFor`
`public static function uriFor(string|UriInterface $uri): UriInterface`
Returns a UriInterface for the given value.
This function accepts a string or UriInterface and returns a
UriInterface for the given value. If the value is already a
UriInterface, it is returned as-is.
## `GuzzleHttp\Psr7\MimeType::fromFilename`
`public static function fromFilename(string $filename): string|null`
Determines the mimetype of a file by looking at its extension.
## `GuzzleHttp\Psr7\MimeType::fromExtension`
`public static function fromExtension(string $extension): string|null`
Maps a file extensions to a mimetype.
# Additional URI Methods
Aside from the standard `Psr\Http\Message\UriInterface` implementation in form of the `GuzzleHttp\Psr7\Uri` class,
this library also provides additional functionality when working with URIs as static methods.
## URI Types
An instance of `Psr\Http\Message\UriInterface` can either be an absolute URI or a relative reference.
An absolute URI has a scheme. A relative reference is used to express a URI relative to another URI,
the base URI. Relative references can be divided into several forms according to
[RFC 3986 Section 4.2](https://datatracker.ietf.org/doc/html/rfc3986#section-4.2):
- network-path references, e.g. `//example.com/path`
- absolute-path references, e.g. `/path`
- relative-path references, e.g. `subpath`
The following methods can be used to identify the type of the URI.
### `GuzzleHttp\Psr7\Uri::isAbsolute`
`public static function isAbsolute(UriInterface $uri): bool`
Whether the URI is absolute, i.e. it has a scheme.
### `GuzzleHttp\Psr7\Uri::isNetworkPathReference`
`public static function isNetworkPathReference(UriInterface $uri): bool`
Whether the URI is a network-path reference. A relative reference that begins with two slash characters is
termed an network-path reference.
### `GuzzleHttp\Psr7\Uri::isAbsolutePathReference`
`public static function isAbsolutePathReference(UriInterface $uri): bool`
Whether the URI is a absolute-path reference. A relative reference that begins with a single slash character is
termed an absolute-path reference.
### `GuzzleHttp\Psr7\Uri::isRelativePathReference`
`public static function isRelativePathReference(UriInterface $uri): bool`
Whether the URI is a relative-path reference. A relative reference that does not begin with a slash character is
termed a relative-path reference.
### `GuzzleHttp\Psr7\Uri::isSameDocumentReference`
`public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool`
Whether the URI is a same-document reference. A same-document reference refers to a URI that is, aside from its
fragment component, identical to the base URI. When no base URI is given, only an empty URI reference
(apart from its fragment) is considered a same-document reference.
## URI Components
Additional methods to work with URI components.
### `GuzzleHttp\Psr7\Uri::isDefaultPort`
`public static function isDefaultPort(UriInterface $uri): bool`
Whether the URI has the default port of the current scheme. `Psr\Http\Message\UriInterface::getPort` may return null
or the standard port. This method can be used independently of the implementation.
### `GuzzleHttp\Psr7\Uri::composeComponents`
`public static function composeComponents($scheme, $authority, $path, $query, $fragment): string`
Composes a URI reference string from its various components according to
[RFC 3986 Section 5.3](https://datatracker.ietf.org/doc/html/rfc3986#section-5.3). Usually this method does not need
to be called manually but instead is used indirectly via `Psr\Http\Message\UriInterface::__toString`.
### `GuzzleHttp\Psr7\Uri::fromParts`
`public static function fromParts(array $parts): UriInterface`
Creates a URI from a hash of [`parse_url`](https://www.php.net/manual/en/function.parse-url.php) components.
### `GuzzleHttp\Psr7\Uri::withQueryValue`
`public static function withQueryValue(UriInterface $uri, $key, $value): UriInterface`
Creates a new URI with a specific query string value. Any existing query string values that exactly match the
provided key are removed and replaced with the given key value pair. A value of null will set the query string
key without a value, e.g. "key" instead of "key=value".
### `GuzzleHttp\Psr7\Uri::withQueryValues`
`public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface`
Creates a new URI with multiple query string values. It has the same behavior as `withQueryValue()` but for an
associative array of key => value.
### `GuzzleHttp\Psr7\Uri::withoutQueryValue`
`public static function withoutQueryValue(UriInterface $uri, $key): UriInterface`
Creates a new URI with a specific query string value removed. Any existing query string values that exactly match the
provided key are removed.
## Cross-Origin Detection
`GuzzleHttp\Psr7\UriComparator` provides methods to determine if a modified URL should be considered cross-origin.
### `GuzzleHttp\Psr7\UriComparator::isCrossOrigin`
`public static function isCrossOrigin(UriInterface $original, UriInterface $modified): bool`
Determines if a modified URL should be considered cross-origin with respect to an original URL.
Two URLs are cross-origin when their scheme, host, or effective port differ. Host comparison is case-insensitive, and missing ports use the default port for `http` or `https`. Other schemes do not receive implicit default ports.
This helper only compares URI origins. It does not implement redirect handling or credential policy.
## Reference Resolution
`GuzzleHttp\Psr7\UriResolver` provides methods to resolve a URI reference in the context of a base URI according
to [RFC 3986 Section 5](https://datatracker.ietf.org/doc/html/rfc3986#section-5). This is for example also what web
browsers do when resolving a link in a website based on the current request URI.
### `GuzzleHttp\Psr7\UriResolver::resolve`
`public static function resolve(UriInterface $base, UriInterface $rel): UriInterface`
Converts the relative URI into a new URI that is resolved against the base URI.
### `GuzzleHttp\Psr7\UriResolver::removeDotSegments`
`public static function removeDotSegments(string $path): string`
Removes dot segments from a path and returns the new path according to
[RFC 3986 Section 5.2.4](https://datatracker.ietf.org/doc/html/rfc3986#section-5.2.4).
### `GuzzleHttp\Psr7\UriResolver::relativize`
`public static function relativize(UriInterface $base, UriInterface $target): UriInterface`
Returns the target URI as a relative reference from the base URI. This method is the counterpart to resolve():
```php
(string) $target === (string) UriResolver::resolve($base, UriResolver::relativize($base, $target))
```
One use-case is to use the current request URI as base URI and then generate relative links in your documents
to reduce the document size or offer self-contained downloadable document archives.
```php
$base = new Uri('http://example.com/a/b/');
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/c')); // prints 'c'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/x/y')); // prints '../x/y'.
echo UriResolver::relativize($base, new Uri('http://example.com/a/b/?q')); // prints '?q'.
echo UriResolver::relativize($base, new Uri('http://example.org/a/b/')); // prints '//example.org/a/b/'.
```
## Normalization and Comparison
`GuzzleHttp\Psr7\UriNormalizer` provides methods to normalize and compare URIs according to
[RFC 3986 Section 6](https://datatracker.ietf.org/doc/html/rfc3986#section-6).
### `GuzzleHttp\Psr7\UriNormalizer::normalize`
`public static function normalize(UriInterface $uri, $flags = self::PRESERVING_NORMALIZATIONS): UriInterface`
Returns a normalized URI. The scheme and host component are already normalized to lowercase per PSR-7 UriInterface.
This methods adds additional normalizations that can be configured with the `$flags` parameter which is a bitmask
of normalizations to apply. The following normalizations are available:
- `UriNormalizer::PRESERVING_NORMALIZATIONS`
Default normalizations which only include the ones that preserve semantics.
- `UriNormalizer::CAPITALIZE_PERCENT_ENCODING`
All letters within a percent-encoding triplet (e.g., "%3A") are case-insensitive, and should be capitalized.
Example: `http://example.org/a%c2%b1b``http://example.org/a%C2%B1b`
- `UriNormalizer::DECODE_UNRESERVED_CHARACTERS`
Decodes percent-encoded octets of unreserved characters. For consistency, percent-encoded octets in the ranges of
ALPHA (%41%5A and %61%7A), DIGIT (%30%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E) should
not be created by URI producers and, when found in a URI, should be decoded to their corresponding unreserved
characters by URI normalizers.
Example: `http://example.org/%7Eusern%61me/``http://example.org/~username/`
- `UriNormalizer::CONVERT_EMPTY_PATH`
Converts the empty path to "/" for http and https URIs.
Example: `http://example.org``http://example.org/`
- `UriNormalizer::REMOVE_DEFAULT_HOST`
Removes the default host of the given URI scheme from the URI. Only the "file" scheme defines the default host
"localhost". All of `file:/myfile`, `file:///myfile`, and `file://localhost/myfile` are equivalent according to
RFC 3986.
Example: `file://localhost/myfile``file:///myfile`
- `UriNormalizer::REMOVE_DEFAULT_PORT`
Removes the default port of the given URI scheme from the URI.
Example: `http://example.org:80/``http://example.org/`
- `UriNormalizer::REMOVE_DOT_SEGMENTS`
Removes unnecessary dot-segments. Dot-segments in relative-path references are not removed as it would
change the semantics of the URI reference.
Example: `http://example.org/../a/b/../c/./d.html``http://example.org/a/c/d.html`
- `UriNormalizer::REMOVE_DUPLICATE_SLASHES`
Paths which include two or more adjacent slashes are converted to one. Webservers usually ignore duplicate slashes
and treat those URIs equivalent. But in theory those URIs do not need to be equivalent. So this normalization
may change the semantics. Encoded slashes (%2F) are not removed.
Example: `http://example.org//foo///bar.html``http://example.org/foo/bar.html`
- `UriNormalizer::SORT_QUERY_PARAMETERS`
Sort query parameters with their values in alphabetical order. However, the order of parameters in a URI may be
significant (this is not defined by the standard). So this normalization is not safe and may change the semantics
of the URI.
Example: `?lang=en&article=fred``?article=fred&lang=en`
### `GuzzleHttp\Psr7\UriNormalizer::isEquivalent`
`public static function isEquivalent(UriInterface $uri1, UriInterface $uri2, $normalizations = self::PRESERVING_NORMALIZATIONS): bool`
Whether two URIs can be considered equivalent. Both URIs are normalized automatically before comparison with the given
`$normalizations` bitmask. The method also accepts relative URI references and returns true when they are equivalent.
This of course assumes they will be resolved against the same base URI. If this is not the case, determination of
equivalence or difference of relative references does not mean anything.
## Documentation
- [PSR-7 Messages](docs/psr-7-messages.md)
- [Streams and Decorators](docs/streams-and-decorators.md)
- [URI Helpers](docs/uri-helpers.md)
- [PSR-17 Factories](docs/psr-17-factories.md)
- [Message Helpers](docs/message-helpers.md)
- [Diagnostic Values](docs/diagnostic-values.md)
- [Header and Query Helpers](docs/header-and-query-helpers.md)
- [Stream Helpers](docs/stream-helpers.md)
- [URI and MIME Helpers](docs/uri-and-mime-helpers.md)
- [Upgrade Guide](UPGRADING.md)
- [Changelog](CHANGELOG.md)
## Security
If you discover a security vulnerability within this package, please send an email to security@tidelift.com. All security vulnerabilities will be promptly addressed. Please do not disclose security-related issues publicly until a fix has been announced. Please see [Security Policy](https://github.com/guzzle/psr7/security/policy) for more information.
If you discover a security vulnerability within this package, please send an
email to security@tidelift.com. All security vulnerabilities will be promptly
addressed. Please do not disclose security-related issues publicly until a fix
has been announced. Please see
[Security Policy](https://github.com/guzzle/psr7/security/policy) for more
information.
## License
Guzzle is made available under the MIT License (MIT). Please see [License File](LICENSE) for more information.
Guzzle is made available under the MIT License (MIT). Please see
[License File](LICENSE) for more information.
## For Enterprise
Available as part of the Tidelift Subscription
The maintainers of Guzzle and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-psr7?utm_source=packagist-guzzlehttp-psr7&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)
The maintainers of Guzzle and thousands of other packages are working with
Tidelift to deliver commercial support and maintenance for the open source
dependencies you use to build your applications. Save time, reduce risk, and
improve code health, while paying the maintainers of the exact dependencies you
use.
[Learn more.](https://tidelift.com/subscription/pkg/packagist-guzzlehttp-psr7?utm_source=packagist-guzzlehttp-psr7&utm_medium=referral&utm_campaign=enterprise&utm_term=repo)

View file

@ -1,6 +1,892 @@
Guzzle PSR-7 Upgrade Guide
==========================
2.x to 3.0
----------
Guzzle PSR-7 3.0 is a major release that raises the minimum PHP version,
updates to the PSR-7 v2 interfaces, validates header values more strictly,
preserves explicit request method casing, and rejects several invalid URI,
request, response, upload, query, stream, and multipart values that 2.x
previously accepted or cast.
#### PHP Version and Dependencies
Guzzle PSR-7 3.0 requires PHP `^7.4 || ^8.0`. Guzzle PSR-7 2.x supported PHP
`^7.2.5 || ^8.0`.
If your application still supports PHP 7.2 or 7.3, continue using Guzzle PSR-7
2.x until your minimum PHP version is raised.
Guzzle PSR-7 3.0 requires `psr/http-message:^2.0` and
`psr/http-factory:^1.1`. Guzzle PSR-7 2.x supported
`psr/http-message:^1.1 || ^2.0` and `psr/http-factory:^1.0`. If your dependency
constraints pin `psr/http-message` to v1, update them before upgrading.
Guzzle PSR-7 no longer depends on `ralouphie/getallheaders` and no longer
provides a transitive global `getallheaders()` polyfill.
`ServerRequest::fromGlobals()` continues to collect request headers internally.
Applications that call `getallheaders()` directly on SAPIs where PHP does not
provide it should require their own polyfill.
#### Header Values
Header values must now be strings or non-empty arrays of strings. Empty strings
remain valid explicit header values, but empty arrays, `null`, `false`, integers,
floats, and other non-string values are no longer cast or accepted.
```php
// 2.x, no longer accepted in 3.0
$response = $response->withHeader('Api-Version', 1);
$response = $response->withHeader('Empty-List', []);
// 3.0
$response = $response->withHeader('Api-Version', '1');
$response = $response->withHeader('Empty-Value', '');
```
Use `withoutHeader()` to remove a header.
#### Request Method Casing
Request methods passed explicitly to `Request`, `ServerRequest`, `withMethod()`,
`Message::parseRequest()`, and the PSR-17 factories are no longer uppercased.
PSR-7 treats method names as case-sensitive, so these APIs now preserve the
method exactly as provided. If your application requires uppercase methods,
normalize methods before constructing or modifying requests.
`ServerRequest::fromGlobals()` is the compatibility-oriented exception. It
continues to uppercase string `REQUEST_METHOD` values read from PHP server
globals, matching Guzzle PSR-7 2.x and common server request behavior. This
normalization only applies when hydrating from globals; it does not apply to
methods passed explicitly to constructors, factories, or `withMethod()`.
```php
// 2.x
$request = new Request('get', '/');
$request->getMethod(); // GET
// 3.0
$request = new Request('get', '/');
$request->getMethod(); // get
// 3.0, server globals
$_SERVER['REQUEST_METHOD'] = 'post';
$request = ServerRequest::fromGlobals();
$request->getMethod(); // POST
```
#### Native PSR-7 Parameter Types
Guzzle PSR-7 3.0 requires the argument types documented by PSR-7 more strictly.
It adds the native parameter types from `psr/http-message` v2. Code passing
invalid argument types may now receive PHP `TypeError` exceptions instead of
package-specific `InvalidArgumentException` exceptions or implicit casts.
Native parameter type changes include:
- `MessageInterface::withProtocolVersion()` now requires `string`.
- Message header names now require `string`.
- `RequestInterface::withRequestTarget()` and `withMethod()` now require `string`.
- `RequestInterface::withUri()` now requires `bool` for `$preserveHost`.
- `ResponseInterface::withStatus()` now requires `int` status codes and `string` reason phrases.
- Server request attribute names now require `string`.
- `UriInterface::withPort()` now requires `int|null`.
- URI scheme, user info, host, path, query, and fragment mutators now require strings.
- Stream `seek()`, `read()`, `write()`, and `getMetadata()` now require their PSR-7 v2 parameter types.
- `UploadedFileInterface::moveTo()` now requires a string target path.
Update callers to pass values of the documented type before calling these
methods:
```php
// 2.x, no longer supported in 3.0
$response = $response->withStatus('201');
$uri = $uri->withPort('8080');
// 3.0
$response = $response->withStatus(201);
$uri = $uri->withPort(8080);
```
#### Request Modification Changes
`Utils::modifyRequest()` now validates recognized change values before applying
request modifications. Unknown change keys are still ignored. Explicit `null`
values are no longer treated as omitted recognized changes; omit the key instead.
Recognized change values must use the documented types:
- `method`: `string`
- `uri`: `UriInterface`
- `query`: `string`
- `version`: `string`
- `body`: `resource|string|StreamInterface|callable|\Iterator|\Stringable`
- `set_headers`: `array<array-key, string|non-empty-array<array-key, string>>`
- `remove_headers`: `array<array-key, string|int>`
When a `uri` change contains a host, the synthesized `Host` header now
includes any non-default URI port, matching the `Request` constructor. 2.x
omitted port zero and every port on schemes other than HTTP and HTTPS.
#### Uploaded Files
`ServerRequestInterface::withUploadedFiles()` now rejects invalid nested upload
trees. Every leaf must be an `UploadedFileInterface` instance.
`ServerRequest::normalizeFiles()` and `ServerRequest::fromGlobals()` now reject
malformed `$_FILES` specifications earlier. Single-file specifications must
contain non-null `tmp_name`, `size`, and `error` values. Single-file and nested
file `size` values and `error` values must be non-negative PHP integers;
numeric strings are no longer cast. If PHP supplies an upload size as a string
because the byte count cannot fit in `PHP_INT_MAX`, it is rejected rather than
truncated or cast. Nested specifications must provide `tmp_name`, `size`, and
`error` as arrays. Every key in `tmp_name` must also exist in `size` and
`error`; additional metadata entries without a matching `tmp_name` entry are
ignored. When nested `name` or `type` metadata is provided, it must also be an
array.
If your tests or adapters build `$_FILES` arrays manually, populate the full
shape or create `UploadedFile` instances directly.
```php
// 2.x, no longer accepted in 3.0
$files = ['file' => ['tmp_name' => '/tmp/php123', 'error' => '0']];
// 3.0
$files = ['file' => ['tmp_name' => '/tmp/php123', 'size' => 123, 'error' => UPLOAD_ERR_OK]];
```
For stream-backed uploads, `UploadedFile::moveTo()` now rewinds seekable streams
before copying them. If application code reads from a seekable uploaded stream
before calling `moveTo()`, 3.0 writes the full stream contents to the target
instead of only the unread suffix. Non-seekable stream-backed uploads continue to
copy from their current position because consumed bytes cannot be replayed.
#### Parsed Body Values
`ServerRequestInterface::withParsedBody()` now rejects values other than
`array`, `object`, or `null`.
```php
// 2.x, no longer accepted in 3.0
$request = $request->withParsedBody('name=value');
// 3.0
$request = $request->withParsedBody(['name' => 'value']);
```
#### URI Host and Scheme Validation
URI hosts containing URI delimiters, backslashes, embedded ports passed to
`withHost()`, malformed IP-literal brackets, unbracketed IPv6, or other
malformed host forms are no longer accepted. URI schemes containing whitespace
or control characters are also no longer accepted.
If you previously passed a host and port together to `withHost()`, split them
between `withHost()` and `withPort()`:
```php
// 2.x, no longer accepted in 3.0
$uri = $uri->withHost('example.com:8080');
// 3.0
$uri = $uri->withHost('example.com')->withPort(8080);
```
Normal URI strings with ports are still supported:
```php
$uri = new Uri('https://example.com:8080/path');
```
URI parsing now accepts bracketed IPv6 and IPvFuture hosts consistently with
`withHost()` for userinfo and network-path authorities such as
`http://user@[::1]/`, `//[::1]`, and `http://[v7.a:b]/`. Invalid
delimiter-free bracketed literals, such as `[gggg::1]`, are also reported with
the intact host. Bracketed literals containing authority/path delimiters, such
as `[a@b]` or `[v1.a/b]`, still reject after fallback parsing and may report the
mangled parsed host.
Userinfo before a bracketed IP-literal host is now percent-encoded ahead of
parsing, so raw control bytes yield encoded userinfo, such as `us%01er`,
instead of a silently mutated value, and raw DEL bytes in bracketed hosts are
rejected instead of parsed as a mutated host. Consistent with registered-name
authorities, such userinfo containing invalid UTF-8 is now rejected,
percent-sequences such as `u%41` are preserved rather than decoded, and a
literal `+` is preserved rather than decoded to a space.
Only an optional numeric port (which may be empty) and a path, query, or
fragment may follow a bracketed IP-literal host. Trailing bytes that are
neither, such as `http://[::1]:80@evil/` or `http://[::1]:80x/`, are now
rejected instead of being reparsed into a different host.
Parsing still URL-decodes bracketed IP-literal hosts before validation
(registered-name hosts round-trip unchanged), so a literal `+` in a bracketed
IP-literal decodes to a space and is rejected: `withHost('[v1.fe80::a+en1]')`
accepts the literal while parsing `http://[v1.fe80::a+en1]/` rejects it.
Percent-encoding inside a bracketed IP-literal is now rejected during parsing as
well, since RFC 3986 IP-literals contain no percent-encoding, so
`http://[%3A%3A1]/` no longer decodes to `[::1]`; this matches `withHost()` and
`Rfc3986::isValidHost()`.
Percent-encoded octets in a registered-name host are normalized to uppercase
hex, so a host such as `a%c3%a9b` is represented as `a%C3%A9b`. Malformed
percent sequences and percent-encoded octets that decode to a byte forbidden in
a host, such as `ex%zz` and `%2fhost`, are rejected.
`Uri::fromParts()` accepts integer and decimal digit string ports, but floats and
other port values are no longer cast.
Common host forms such as `localhost`, single-label hosts, underscores, Unicode
hosts, valid IPv6 literals, and normal host and port URI strings remain
supported.
URI schemes must now match RFC 3986 syntax and begin with a letter.
```php
// 2.x, no longer accepted in 3.0
$uri = (new Uri())->withScheme('0');
// 3.0
$uri = (new Uri())->withScheme('https');
```
The stricter validation also applies when a request is created or modified from
a custom `UriInterface` implementation and its host is used to generate or
update a `Host` header.
`ServerRequest::getUriFromGlobals()` now falls back to `SERVER_NAME`, then
`SERVER_ADDR`, then the existing default host behavior for malformed
`HTTP_HOST` values. It also rejects zero-port `HTTP_HOST` authorities and
malformed `SERVER_PORT` values when fallback authority reconstruction needs the
server port. When `REQUEST_URI` is absolute-form or CONNECT authority-form and
supplies a valid authority, that authority is used before fallback `SERVER_PORT`
validation. Origin-form, asterisk-form, missing `REQUEST_URI`, and fallback
reconstruction paths still reject malformed `SERVER_PORT` values when fallback
authority reconstruction needs the server port.
Absolute-form `REQUEST_URI` userinfo is removed when reconstructing the URI and
request target from globals, including empty userinfo such as
`http://@example.com/`. The host after the last raw `@` remains the URI host.
`Message::parseRequest()` now applies 3.0 authority rules when deriving a URI
from an origin-form or asterisk-form request target. Host ports with leading
zeroes are normalized for URI reconstruction, and port zero is rejected.
It also rejects duplicate `Host` field lines, including case-insensitive
duplicates. Any present raw `Host` field is validated before returning a parsed
request, even when the request target supplies the URI authority, such as
absolute-form and CONNECT requests. Valid `Host` values may still differ from
the absolute-form or CONNECT request-target authority.
For server globals, applications that need to reject malformed inbound `Host`
headers should validate the original server parameters before calling
`getUriFromGlobals()` or inspect them afterward.
`Message::parseRequest()` now applies the same HTTP authority validation to
absolute-form request targets. A zero or padded-zero port
(`http://example.com:0/admin`) is rejected instead of producing a request
whose synthesized `Host` header the same parser rejects elsewhere.
Absolute-form targets with no URI host, such as `file:///etc/passwd`, are
also rejected instead of producing a hostless request URI.
Absolute-form targets whose authority contains userinfo are also rejected,
including empty userinfo such as `http://@example.com/`. RFC 9110 deprecates
userinfo in http(s) target URIs and directs recipients to treat its presence
as an error; `Host` headers and CONNECT targets already reject it. These
rules apply to absolute-form targets of every scheme.
`ServerRequest::fromGlobals()` is unchanged and continues to strip
`REQUEST_URI` userinfo.
URI hosts now validate percent-encoding. Malformed sequences such as `ex%zz`,
and percent-encoded octets that decode to bytes the raw host grammar already
rejects (controls, space, DEL, `/`, `?`, `#`, `@`, `\`, `:`, `[`, `]`, and `%`
itself) throw `MalformedUriException` from URI parsing and
`InvalidArgumentException` from `Uri::withHost()`, and are rejected wherever
hosts are validated, including `Host` headers and request targets in
`Message::parseRequest()`. WHATWG-conformant browsers reject all of these hosts;
curl rejects them too, except encoded DEL (`%7F`), which it decodes and forwards
to name resolution. Other percent-encoded octets, including UTF-8 data such as
`a%C3%A9b`, remain accepted and are normalized to uppercase hex.
IPv6 hosts are now canonicalized to their RFC 5952 form when a URI is
constructed, so `getHost()`, `getAuthority()`, and `(string) $uri` return the
canonical spelling and synthesized `Host` headers use it. Leading zeros are
suppressed, hexadecimal fields are lowercase, and the longest run of two or
more zero fields is collapsed with `::`. Embedded dotted-decimal notation
follows the rendering policy of BIND-derived `inet_ntop()` implementations and
curl 8.11 and newer: exactly the IPv4-mapped (`::ffff:0:0/96`) and deprecated
IPv4-compatible (`::/96`) layouts use it, while other embedded-IPv4 forms,
including translated (NAT64) well-known prefixes such as `64:ff9b::/96`
(RFC 6052), serialize in pure hexadecimal fields.
```php
// 2.x preserved the spelling as given
(string) new Uri('http://[0:0:0:0:0:0:0:1]/'); // http://[0:0:0:0:0:0:0:1]/
// 3.0
(string) new Uri('http://[0:0:0:0:0:0:0:1]/'); // http://[::1]/
(string) new Uri('http://[::FFFF:7F00:1]/'); // http://[::ffff:127.0.0.1]/
(string) new Uri('http://[2001:db8:3:4::192.0.2.33]/'); // http://[2001:db8:3:4::c000:221]/
```
Applications that persist URI strings, for example as cache keys, will observe
the new canonical form for previously non-canonical IPv6 spellings. Equivalent
spellings of the same address now compare as same-origin in
`UriComparator::isCrossOrigin()`, which canonicalizes bracketed IPv6 literals
from any PSR-7 implementation before comparing hosts, and as equivalent in
`UriNormalizer::isEquivalent()`. The new
`UriNormalizer::CANONICALIZE_IPV6_HOST` flag, included in the default
`UriNormalizer::PRESERVING_NORMALIZATIONS`, requests the canonical host from
other PSR-7 implementations through `withHost()` and keeps the result only
when the returned `getHost()` exactly matches the requested spelling; a
nonexact result leaves that step unchanged while other selected normalizations
still apply, and setter exceptions propagate. `UriComparator` does not share
this limitation, since it canonicalizes the extracted host text directly. The
public helper `Rfc3986::canonicalizeIpv6()` exposes the underlying
transformation.
#### Request Host Synchronization
`Request::withUri()` now applies PSR-7 Host header synchronization before using
the same-URI no-op shortcut. When the provided URI is the same object already
attached to the request, the method may still return a new request if the URI
has a host and the current Host header is missing, empty, or stale.
With `$preserveHost = true`, a non-empty Host header is still preserved. Missing
or empty Host headers are treated as absent and are populated from the URI when
the URI contains a host.
```php
$request = (new Request('GET', 'http://example.com:8124/'))->withoutHeader('Host');
$updated = $request->withUri($request->getUri());
$updated->getHeaderLine('Host'); // example.com:8124
```
If your application intentionally sends an empty or stale Host header, set it
after calling `withUri()` or preserve a non-empty Host header explicitly.
`Message::toString()` now applies the same URI host and port synthesis when
serializing a request without a `Host` header. Generated `Host` lines include
non-null URI ports.
`Message::toString()` also validates the host it synthesizes from the request
URI and throws `InvalidArgumentException` for an invalid host, closing a header-
injection vector. This affects only a custom `UriInterface` implementation that
returns an invalid host when the request has no stored `Host` header; first-
party `Uri` instances always carry a valid host and are unaffected.
#### URI Paths and Request Targets
`Uri::getPath()` now normalizes multiple leading slashes to one slash when
returning the path in isolation. Casting the URI to string still preserves the
original URI representation.
```php
$uri = new Uri('http://example.org//valid///path');
$uri->getPath(); // /valid///path
(string) $uri; // http://example.org//valid///path
```
`Request::getRequestTarget()` applies the same normalization for URI-derived
origin-form request targets.
Reference resolution and normalization (`UriResolver`, `UriNormalizer`, and
`Uri::isSameDocumentReference()`) operate on the raw path from the URI string
form and are therefore unaffected by this normalization.
Authority-less `file` URIs with rootless paths now serialize without the `//`
authority separator: `(string) new Uri('file:foo/bar')` returns `file:foo/bar`
instead of `file://foo/bar`, which reparses with host `foo` and path `/bar`.
Rooted paths such as `file:///myfile` keep their existing serialization.
Authority-less `file` URIs with empty paths now serialize as `file:` instead of
`file://`, which `new Uri()` itself rejects as unparseable. This affects
degenerate URIs such as `new Uri('file:')` or
`Uri::fromParts(['scheme' => 'file'])`; the serialization of every file URI
with a non-empty path is unchanged.
`UriResolver::removeDotSegments()` now applies RFC 3986 Section 5.2.4 to `..`
segments above the root of an absolute path: excess `..` segments no longer
consume the root, so a following empty segment is preserved. Resolving `/..//a`
against `http://example.org/base` yields `http://example.org//a` where 2.x
produced `http://example.org/a`. When the resulting URI has no authority,
`UriResolver::resolve()` and `UriNormalizer::normalize()` serialize such a
`//`-leading path with a `/.` prefix (`mailto:/.//a`), like the WHATWG URL
Standard, instead of collapsing the slashes or throwing.
#### URI Reference Relativization
`UriResolver::relativize()` now returns a network-path reference (for example
`//example.com`) when the target URI has the same authority as the base URI
but an empty path that no other relative reference round-trips. No path
reference can express such a target, as resolving one always produces a path
of at least `/`, and an empty reference would keep the base path or inherit
the base query or fragment. The returned reference resolves back to the
exact target string, restoring the documented round-trip guarantee for these
targets.
```php
$base = new Uri('http://example.com/a');
$target = new Uri('http://example.com');
// 2.x
(string) UriResolver::relativize($base, $target); // ../
// which resolved back to http://example.com/
// 3.0
(string) UriResolver::relativize($base, $target); // //example.com
```
The same applies when the base URI has a query or fragment component that an
empty relative reference would otherwise inherit. When the base URI has an
empty path as well and nothing would be inherited, shorter references such
as the empty reference, `#fragment` or `?query` are still returned.
`relativize()` also no longer returns the empty reference when the target
path equals the base path but the base has a fragment the target lacks, as
the empty reference would reintroduce that fragment. A relative-path
reference, or a query reference when the target has a query, is returned
instead. When the relative-path reference would be a single path segment
containing a colon, which would be mistaken for a scheme name, it is
prefixed with `./` (for example `./a:b`); 2.x threw a `MalformedUriException`
for such targets when the base had a query the target lacked.
#### HTTP Start-line Parsing
`Message::parseRequest()` and `Message::parseResponse()` now validate HTTP
start-line fields more strictly. Malformed request methods, request targets
containing whitespace or control characters, malformed protocol versions,
invalid response status codes, invalid response spacing, and reason phrases
containing invalid control characters now throw `InvalidArgumentException`.
`Request` and `Response` constructors and mutators apply the same validation to
protocol versions, request targets, status codes, and reason phrases. If you
parse raw HTTP messages or construct messages from partially validated input,
normalize or reject invalid values before passing them to Guzzle PSR-7.
```php
// 2.x-style tolerant input, no longer accepted in 3.0
Message::parseRequest("GET /foo bar HTTP/1.1\r\nHost: example.com\r\n\r\n");
new Response(200, [], null, 'HTTP/1.1');
// 3.0
Message::parseRequest("GET /foo%20bar HTTP/1.1\r\nHost: example.com\r\n\r\n");
new Response(200, [], null, '1.1');
```
`ServerRequest::fromGlobals()` applies the same validation to the
`REQUEST_METHOD` and `SERVER_PROTOCOL` server values. Malformed values that 2.x
hydrated, such as the `SERVER_PROTOCOL` value `INCLUDED` that Apache sets for
server-side include subrequests, now throw `InvalidArgumentException`. Sanitize
`$_SERVER` before calling `fromGlobals()` if such environments must be
tolerated.
`Request::withRequestTarget('')` throws `InvalidArgumentException`; omit the
explicit request target to derive `/` or the URI-derived target automatically.
`Message::parseMessage()` no longer unfolds folded HTTP/1.0 messages whose
start line carries control bytes in the request target; such messages now
throw the obsolete-line-folding `InvalidArgumentException`.
`Message::parseRequest()` and `Message::parseResponse()` rejected these
messages either way.
#### Query Builder Values
`Query::build()` now rejects unsupported values instead of relying on PHP string
casts. Query values must be scalar, `null`, stringable objects, or flat arrays of
those values.
Nested arrays, resources, and objects without `__toString()` now throw
`InvalidArgumentException`.
```php
// Before: could produce warnings or silently mangle the value.
Query::build(['filter' => ['name' => ['value']]]);
// After: use explicit query keys for nested query shapes.
Query::build(['filter[name]' => 'value']);
```
Flat arrays are still supported for repeated query parameters:
```php
Query::build(['tag' => ['a', 'b']]);
// tag=a&tag=b
```
`Uri::withQueryValues()` is stricter than `Query::build()` and requires `string`
or `null` values; cast numeric and boolean query values to string.
#### Non-string Scalar Bodies
`Utils::streamFor()` and message bodies no longer accept `int`, `float`, or
`bool` values. Cast them to strings first.
```php
// 2.x, no longer accepted in 3.0
$response = new Response(200, [], 404);
// 3.0
$response = new Response(200, [], '404');
```
#### PumpStream Source Callables
`PumpStream` source callables must now return a non-empty string when producing
data. Returning an empty string now throws `RuntimeException` instead of being
retried indefinitely. Return `false` or `null` to signal EOF.
If your callable used `''` to mean "temporarily no data", update it to wait
until data is available, return a non-empty string, or return `false` or `null`
when the stream is complete.
#### Iterator-backed Streams
`Utils::streamFor()` now validates values yielded by `Iterator` instances before
passing them to the internal `PumpStream`. Strings, integers, finite floats,
booleans, `null`, and stringable objects are converted to string chunks.
Non-finite floats, arrays, resources, and non-stringable objects now throw
`UnexpectedValueException` when the stream is read.
Iterator exhaustion is now the only EOF signal for iterator-backed streams.
Yielding `false`, `null`, or an empty string no longer ends the stream; those
values are zero-length chunks and are skipped while the iterator advances. If
your iterator yielded `false` or `null` to stop streaming, update it to finish
iteration instead.
Avoid iterators that yield only zero-length chunks indefinitely. Such iterators
never produce bytes and never reach EOF, so they cannot satisfy stream reads.
```php
// Before: yielding false or null could stop an iterator-backed stream early.
$stream = Utils::streamFor(new ArrayIterator([false, 'body']));
// After: false and null are skipped chunks. End the iterator to signal EOF.
$stream = Utils::streamFor(new ArrayIterator(['body']));
```
#### Stream Behavior Changes
All stream implementations now reject negative `read()` lengths with
`RuntimeException`. In 2.x, some decorators passed negative lengths through,
some returned sliced data, and some behavior varied by PHP version.
`LimitStream` now rejects negative offsets and limits below `-1`. For
non-seekable streams, offsets are tracked by the number of bytes actually
skipped. Short reads are retried until the offset is reached, EOF is reached, or
the decorated stream stops making progress.
`StreamWrapper` now translates `RuntimeException` failures from the wrapped
PSR-7 stream into PHP stream-wrapper failure values. When using a resource from
`StreamWrapper::getResource()`, functions such as `fread()`, `fwrite()`,
`fseek()`, `feof()`, and `fstat()` may now return normal PHP failure values
instead of propagating the PSR-7 stream exception. Call the PSR-7 stream directly
if you need exception-based failure handling.
The `StreamWrapper::stream_read()` callback no longer declares a native return
type so read failures can return `false`. The `StreamWrapper::stream_tell()`
callback no longer declares a native return type so post-seek position lookup
failures can make `fseek()` fail.
#### Stream Mode Capabilities
`Stream::isReadable()` and `Stream::isWritable()` now follow PHP stream mode
semantics more closely. Update modes are detected by the presence of `+`,
including valid modes such as `rt+`, `wt+`, `at+`, `xt+`, and `ct+`.
Literal `rw` metadata is now treated as read-only, matching PHP real-file
streams. If a custom stream wrapper previously exposed `rw` for a writable
resource, open it with a valid update mode such as `r+`, `w+`, or `a+` instead.
#### Stream Copy Behavior
Stream sizes, offsets, high-water marks, and byte counts are now validated as
non-negative PHP integers where applicable. Operations that would overflow
`PHP_INT_MAX` throw `OverflowException` instead of silently wrapping or producing
an invalid position or size.
`Utils::copyToStream()` now returns the number of bytes copied and throws a
`RuntimeException` when the destination stream cannot make progress, for example
a `BufferStream` at its high-water mark or a full `DroppingStream`. Its
signature changed from `: void` to `: int`, but callers that ignore the return
value do not need to change anything. In 2.x, the copy stopped silently when the
destination could not make progress. For a guaranteed full copy, use a normal
writable stream such as a file or `php://temp` stream.
#### Stream Timeout Detection
Timed-out stream operations now throw
`GuzzleHttp\Psr7\Exception\TimeoutException`, which extends
`RuntimeException`. `Stream::read()`, `Stream::write()`,
`AppendStream::read()`, `CachingStream::read()`, `InflateStream::read()`,
`Utils::copyToStream()`, `Utils::copyToString()`, `Utils::hash()`,
`Utils::readLine()`, and `Utils::tryGetContents()` detect PHP-style stream
timeout metadata when a read or write operation cannot make progress. Timeout
detection is best-effort; custom stream implementations that do not expose
`timed_out` metadata continue to behave as before. Previously, timed-out reads
could be treated as EOF or return partial results, and timed-out writes could be
reported as generic write failures or no-progress writes.
#### Message Body Summaries
`Message::bodySummary()` still summarizes seekable bodies from the beginning,
even when the body was already partially read. It now restores the body cursor to
the position it had before the summary was created. In 2.x, calling
`bodySummary()` left seekable bodies rewound to the beginning. The optional
`$truncateAt` argument now accepts `null` as an explicit request for the default
summary length, matching the behavior of omitting the argument.
Most applications do not need to change anything. Check your code only if you
called `bodySummary()` and then read the same body while relying on
`bodySummary()` to leave the body rewound. If you need to read the body from the
beginning after summarizing it, call `Message::rewindBody()` explicitly.
#### Stream Lifecycle
`FnStream` now treats `close()` and successful `detach()` calls as terminal
lifecycle operations. Its configured `close` callback is invoked at most once;
repeated `close()` calls are no-ops, destruction after explicit close no longer
invokes the close callback, and closed or detached streams no longer forward
read, write, seek, metadata, or stringification callbacks. `FnStream` also
suppresses exceptions thrown by destructor-triggered close callbacks. Call
`close()` explicitly if cleanup failures must be observed.
`CachingStream::close()` is now idempotent. Calling `close()` after `detach()`
still closes the remote stream owned by the `CachingStream`, but it no longer
closes the detached cache resource returned to the caller. Repeated `close()`
calls are no-ops.
`InflateStream::close()` now also closes the compressed source stream that was
passed to its constructor. In 2.x, closing an `InflateStream` left the source
stream open. Call `detach()` instead of `close()` if the compressed source
stream must stay open; `close()` after `detach()` no longer closes the source.
`PumpStream::close()` and `PumpStream::detach()` now discard internally buffered
unread bytes. If a callable or iterator source returns more bytes than a read
requested, drain the stream before closing it if you need those buffered bytes.
#### Multipart Part Headers and Metadata
`MultipartStream` no longer adds default `Content-Length` headers to individual
`multipart/form-data` parts. RFC 7578 section 4.8 says multipart form-data
parts must not include `Content-*` headers other than the supported multipart
part headers, so 3.0 stops generating per-part `Content-Length` by default.
If your tests compare raw multipart payloads, remove the generated
`Content-Length` lines from expected strings:
```text
// 2.x generated:
--boundary\r\n
Content-Disposition: form-data; name="foo"\r\n
Content-Length: 3\r\n
\r\n
bar\r\n
// 3.0 generates:
--boundary\r\n
Content-Disposition: form-data; name="foo"\r\n
\r\n
bar\r\n
```
Applications can still pass an explicit `Content-Length` header in a multipart
element's `headers` array if a non-standard peer requires it:
```php
$body = new MultipartStream([
[
'name' => 'foo',
'contents' => 'bar',
'headers' => ['Content-Length' => '3'],
],
]);
```
`MultipartStream` now escapes generated `Content-Disposition` `name` and
`filename` parameters before serializing multipart part headers. Double quotes,
carriage returns, and line feeds are encoded as `%22`, `%0D`, and `%0A`. Literal
backslashes and other characters are serialized unchanged, matching browser
multipart form submission behavior.
```php
// Before: these values were interpolated into the generated part header.
$body = new MultipartStream([
[
'name' => "field\"\r\nname",
'filename' => "avatar\"\r\n.txt",
'contents' => 'body',
],
]);
// After: the generated Content-Disposition parameters contain
// field%22%0D%0Aname and avatar%22%0D%0A.txt.
```
Explicit custom boundaries are now validated using RFC 2046 multipart boundary
syntax. Omit the boundary or pass `null` to continue using a generated random
boundary.
The string `'0'` is now treated as an explicit custom boundary and is serialized
literally. In 2.x, PHP truthiness caused `new MultipartStream($elements, '0')`
to use a generated random boundary. Omit the boundary or pass `null` when you
want a generated boundary.
Custom multipart part header names and values are also validated before
serialization. Header names must be valid HTTP tokens, and header values must be
strings without CR, LF, or other invalid control bytes.
`MultipartStream` now preserves trailing spaces and tabs in custom multipart
part header values when serializing the body. In 2.x, the final serialized part
header line was trimmed as a side effect of removing the generated header
terminator. Normal multipart parsers treat this optional whitespace as
insignificant, but tests, signatures, or snapshots that compare raw multipart
body bytes may need updated expectations.
#### URI Userinfo Redaction
`Utils::redactUserInfo()` now redacts all non-empty URI userinfo, including
username-only userinfo. In 2.x, it only redacted the password portion when
userinfo contained a password delimiter.
```php
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\Utils;
// 2.x: https://TOKEN@example.com
// 3.0: https://***@example.com
(string) Utils::redactUserInfo(new Uri('https://TOKEN@example.com'));
// 2.x: https://user:***@example.com
// 3.0: https://***@example.com
(string) Utils::redactUserInfo(new Uri('https://user:pass@example.com'));
```
#### Header List Helpers
The deprecated `Header::normalize()` method was removed. Use
`Header::splitList()` to split HTTP headers that are defined as comma-separated
lists.
`Header::splitList()` now trims list elements with spaces, horizontal tabs,
carriage returns, and line feeds. 2.x also trimmed null bytes and vertical
tabs. Validated header values cannot contain those bytes, so this only affects
strings passed to `Header::splitList()` directly.
#### Non-instantiable Utility Classes
Static utility and constant classes such as `Header`, `Message`, `MimeType`,
`Query`, and `Utils` now have private constructors. Replace any accidental
instantiation with static method calls or constant access.
#### Native PHP Serialization of Streams
Guzzle PSR-7 stream implementations no longer support native PHP `serialize()`
or `unserialize()`. Persist stream contents explicitly and recreate streams with
`Utils::streamFor()` when needed.
#### URI Normalization of Userinfo and Host
`UriNormalizer::CAPITALIZE_PERCENT_ENCODING` and
`UriNormalizer::DECODE_UNRESERVED_CHARACTERS` now also apply to the userinfo and
host components. In 2.x, these normalizations only rewrote the path, query, and
fragment.
Since the host is case-insensitive and PSR-7 requires it to be lowercase, octets
decoded in the host are lowercased. Reserved percent-encoded octets such as
`%3A` are never decoded, so component boundaries cannot change, and these two
flags never modify bracketed IP-literal hosts, which only the separate
`UriNormalizer::CANONICALIZE_IPV6_HOST` normalization may canonicalize. Both
flags are part of `UriNormalizer::PRESERVING_NORMALIZATIONS`, so the output of
`UriNormalizer::normalize()` and the result of `UriNormalizer::isEquivalent()`
can change for URIs whose userinfo or host contains percent-encoded octets.
Custom `UriInterface` implementations now receive `withUserInfo()` or
`withHost()` calls from the normalizer when a normalization changes those
components; unchanged components are never rewritten. The rewrite is kept only
when the value returned by the implementation matches the normalized form, and
a userinfo with an empty user segment is never rewritten. If a setter returns a
different representation, that rewrite is discarded, while other selected
normalizations still apply, and setter exceptions propagate. No percent-encoding
normalization is applied to a component with malformed percent syntax, such as a
`%` not followed by two hexadecimal digits.
```php
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriNormalizer;
// 2.x: http://%75ser@ex%61mple.com/
// 3.0: http://user@example.com/
(string) UriNormalizer::normalize(new Uri('http://%75ser@ex%61mple.com/'));
```
#### URI Ports and Authority Handling
Several 3.0 changes affect how URI ports are accepted, validated, and rendered.
Each is described in its own section above:
- `UriInterface::withPort()` now requires `int|null`; see "Native PSR-7
Parameter Types".
- `Uri::fromParts()` validates ports instead of casting them, and `withHost()`
rejects embedded `host:port` values; see "URI Host and Scheme Validation".
- A generic `Uri` can represent ports 0 through 65535. Inbound HTTP authority
parsing is stricter: `Message::parseRequest()` rejects zero-valued ports but
accepts nonzero leading-zero ports, normalizing the reconstructed URI while
preserving the raw `Host` or request-target text; see "HTTP Start-line
Parsing" and "URI Host and Scheme Validation".
- Server globals reject a zero-valued `HTTP_HOST` and validate `SERVER_PORT`
when fallback authority reconstruction needs it. A recognized absolute-form
or CONNECT `REQUEST_URI` authority takes precedence and can still produce a
URI with port zero; see "URI Host and Scheme Validation".
- Synthesized `Host` headers now include any non-default URI port; see "Request
Modification Changes" and "Request Host Synchronization".
`Uri` now knows the default ports of the `ws` and `wss` schemes, 80 and 443 per
RFC 6455. An explicit default port on a `ws` or `wss` URI is removed when the
URI is constructed or modified, `Uri::isDefaultPort()` returns `true` for such
URIs, and `UriNormalizer::normalize()` with the `REMOVE_DEFAULT_PORT` flag
removes the port from other `UriInterface` implementations as well. In 2.x,
these ports were preserved.
```php
use GuzzleHttp\Psr7\Uri;
// 2.x: ws://example.com:80/chat
// 3.0: ws://example.com/chat
(string) new Uri('ws://example.com:80/chat');
// 2.x: 443
// 3.0: null
(new Uri('wss://example.com:443'))->getPort();
```
Because a native `Uri` never carries a default `ws` or `wss` port, the `Host`
header synchronized from such a request URI omits the port. `Request` and
`Message` `Host` synthesis and `Utils::modifyRequest()` still append an explicit
default port that a `ws` or `wss` URI from another `UriInterface` implementation
reports.
`UriComparator::isCrossOrigin()` now applies these default ports when comparing
effective ports, so two `ws` or `wss` URIs that differ only by an explicit
default port, such as `ws://example.com/` and `ws://example.com:80/`, are
same-origin no matter which `UriInterface` implementation supplies them. In 2.x,
such pairs were considered cross-origin. Schemes other than `http`, `https`,
`ws`, and `wss` still receive no implicit default port.
#### Sensitive Stack Trace Arguments
Credential-bearing URI, server-global, Authorization-header, and cookie
arguments are marked with `#[\SensitiveParameter]`. PHP 8.2 and later replace
those arguments in stack traces with `SensitiveParameterValue`. PHP 7.4 through
8.1 do not redact trace arguments.
This does not redact logs, exception messages, object properties, wire traffic,
captured variables, return values, user callbacks, or the separate executing
object in an explicit backtrace.
1.x to 2.0
----------

View file

@ -13,17 +13,16 @@ use Psr\Http\Message\StreamInterface;
*/
final class AppendStream implements StreamInterface
{
use NonSerializableStreamTrait;
/** @var StreamInterface[] Streams being decorated */
private $streams = [];
private array $streams = [];
/** @var bool */
private $seekable = true;
private bool $seekable = true;
/** @var int */
private $current = 0;
private int $current = 0;
/** @var int */
private $pos = 0;
private int $pos = 0;
/**
* @param StreamInterface[] $streams Streams to decorate. Each stream must
@ -38,18 +37,9 @@ final class AppendStream implements StreamInterface
public function __toString(): string
{
try {
$this->rewind();
$this->rewind();
return $this->getContents();
} catch (\Throwable $e) {
if (\PHP_VERSION_ID >= 70400) {
throw $e;
}
trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
return '';
}
return $this->getContents();
}
/**
@ -132,7 +122,7 @@ final class AppendStream implements StreamInterface
if ($s === null) {
return null;
}
$size += $s;
$size = Integers::add($size, $s);
}
return $size;
@ -153,26 +143,8 @@ final class AppendStream implements StreamInterface
/**
* Attempts to seek to the given position. Only supports SEEK_SET.
*/
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
if (!$this->seekable) {
throw new \RuntimeException('This AppendStream is not seekable');
} elseif ($whence !== SEEK_SET) {
@ -203,15 +175,10 @@ final class AppendStream implements StreamInterface
/**
* Reads from all of the appended streams until the length is met or EOF.
*/
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
if ($this->streams === []) {
@ -233,7 +200,7 @@ final class AppendStream implements StreamInterface
++$this->current;
}
$result = $this->streams[$this->current]->read($remaining);
$result = StreamTimeout::read($this->streams[$this->current], $remaining, 'Unable to read from stream: timed out');
if ($result === '') {
$progressToNext = true;
@ -244,7 +211,7 @@ final class AppendStream implements StreamInterface
$remaining = $length - strlen($buffer);
}
$this->pos += strlen($buffer);
$this->pos = Integers::add($this->pos, strlen($buffer));
return $buffer;
}
@ -264,34 +231,13 @@ final class AppendStream implements StreamInterface
return $this->seekable;
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
throw new \RuntimeException('Cannot write to an AppendStream');
}
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null): ?array
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
return $key ? null : [];
return $key === null ? [] : null;
}
}

View file

@ -16,22 +16,22 @@ use Psr\Http\Message\StreamInterface;
*/
final class BufferStream implements StreamInterface
{
/** @var int */
private $hwm;
use NonSerializableStreamTrait;
/** @var string */
private $buffer = '';
private int $hwm;
private string $buffer = '';
/**
* @param int $hwm High water mark, representing the preferred maximum
* buffer size. If the size of the buffer exceeds the high
* water mark, then calls to write will continue to succeed
* but will return 0 to inform writers to slow down
* buffer size. If the size of the buffer reaches or exceeds
* the high water mark, then calls to write will continue to
* succeed but will return 0 to inform writers to slow down
* until the buffer has been drained by reading from it.
*/
public function __construct(int $hwm = 16384)
{
$this->hwm = $hwm;
$this->hwm = Integers::assertNonNegativeInteger($hwm, 'High water mark');
}
public function __toString(): string
@ -84,26 +84,8 @@ final class BufferStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
throw new \RuntimeException('Cannot seek a BufferStream');
}
@ -120,15 +102,10 @@ final class BufferStream implements StreamInterface
/**
* Reads data from the buffer.
*/
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
$currentLength = strlen($this->buffer);
@ -149,17 +126,8 @@ final class BufferStream implements StreamInterface
/**
* Writes data to the buffer.
*/
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
$this->buffer .= $string;
if (strlen($this->buffer) >= $this->hwm) {
@ -172,21 +140,12 @@ final class BufferStream implements StreamInterface
/**
* @return mixed
*/
public function getMetadata($key = null)
public function getMetadata(?string $key = null)
{
if ($key !== null && !\is_string($key)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::getMetadata() is deprecated; guzzlehttp/psr7 3.0 requires string|null for $key.',
\get_debug_type($key)
);
}
if ($key === 'hwm') {
return $this->hwm;
}
return $key ? null : [];
return $key === null ? [] : null;
}
}

View file

@ -13,26 +13,30 @@ use Psr\Http\Message\StreamInterface;
final class CachingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var StreamInterface Stream being wrapped */
private $remoteStream;
private StreamInterface $remoteStream;
/** @var int Number of bytes to skip reading due to a write on the buffer */
private $skipReadBytes = 0;
private int $skipReadBytes = 0;
/**
* @var StreamInterface
*/
private $stream;
private StreamInterface $stream;
/** @var bool */
private $detached = false;
private bool $detached = false;
private bool $closed = false;
/**
* We will treat the buffer object as the body of the stream
*
* @param StreamInterface $stream Stream to cache. The cursor is assumed to be at the beginning of the stream.
* @param StreamInterface $target Optionally specify where data is cached
* @param StreamInterface $target Optionally specify where data is cached. Defaults to a "php://temp"
* stream. A custom target is used as a random-access byte buffer to
* replay the remote stream, so it must be readable, writable, and
* seekable, report an accurate position and size, and store writes
* losslessly. Lossy or non-seekable streams such as BufferStream and
* DroppingStream are not valid targets.
*/
public function __construct(
StreamInterface $stream,
@ -62,36 +66,31 @@ final class CachingStream implements StreamInterface
$this->seek(0);
}
public function seek($offset, $whence = SEEK_SET): void
public function seek(int $offset, int $whence = SEEK_SET): void
{
if (!\is_int($offset)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $offset.',
\get_debug_type($offset)
);
}
if (!\is_int($whence)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::seek() is deprecated; guzzlehttp/psr7 3.0 requires int for $whence.',
\get_debug_type($whence)
);
}
if ($whence === SEEK_SET) {
$byte = $offset;
} elseif ($whence === SEEK_CUR) {
$byte = $offset + $this->tell();
$byte = Integers::addSigned($this->tell(), $offset);
} elseif ($whence === SEEK_END) {
$size = $this->remoteStream->getSize();
if ($size === null) {
// Discovering the size reads the remote stream to EOF and
// moves the cursor, so restore the cursor if the computed
// target is rejected to keep a failed seek side-effect free.
$position = $this->tell();
$size = $this->cacheEntireStream();
try {
$byte = Integers::addSigned($size, $offset);
} catch (\Throwable $e) {
$this->stream->seek($position);
throw $e;
}
} else {
$byte = Integers::addSigned($size, $offset);
}
$byte = $size + $offset;
} else {
throw new \InvalidArgumentException('Invalid whence');
}
@ -119,15 +118,10 @@ final class CachingStream implements StreamInterface
}
}
public function read($length): string
public function read(int $length): string
{
if (!\is_int($length)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::read() is deprecated; guzzlehttp/psr7 3.0 requires int for $length.',
\get_debug_type($length)
);
if ($length < 0) {
throw new \RuntimeException('Length parameter cannot be negative');
}
// Perform a regular read on any previously read data from the buffer
@ -140,8 +134,10 @@ final class CachingStream implements StreamInterface
// been filled from the remote stream, then we must skip bytes on
// the remote stream to emulate overwriting bytes from that
// position. This mimics the behavior of other PHP stream wrappers.
$remoteData = $this->remoteStream->read(
$remaining + $this->skipReadBytes
$remoteData = StreamTimeout::read(
$this->remoteStream,
Integers::add($remaining, $this->skipReadBytes),
'Unable to read from stream: timed out'
);
if ($this->skipReadBytes) {
@ -161,24 +157,15 @@ final class CachingStream implements StreamInterface
return $data;
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
// When appending to the end of the currently read stream, you'll want
// to skip bytes from being read from the remote stream to emulate
// other stream wrappers. Basically replacing bytes of data of a fixed
// length.
$overflow = (strlen($string) + $this->tell()) - $this->remoteStream->tell();
$overflow = Integers::add(strlen($string), $this->tell()) - $this->remoteStream->tell();
if ($overflow > 0) {
$this->skipReadBytes += $overflow;
$this->skipReadBytes = Integers::add($this->skipReadBytes, $overflow);
}
return $this->stream->write($string);
@ -207,13 +194,39 @@ final class CachingStream implements StreamInterface
}
/**
* Close both the remote stream and buffer stream
* Close the remote stream and any attached cache stream.
*/
public function close(): void
{
$this->remoteStream->close();
$this->stream->close();
if ($this->closed) {
return;
}
$closeCache = !$this->detached;
$this->closed = true;
$this->detached = true;
$exception = null;
try {
$this->remoteStream->close();
} catch (\Throwable $e) {
$exception = $e;
}
if ($closeCache) {
try {
$this->stream->close();
} catch (\Throwable $e) {
if ($exception === null) {
$exception = $e;
}
}
}
if ($exception !== null) {
throw $exception;
}
}
private function cacheEntireStream(): int

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7;
/**
* Escapes control characters and malformed UTF-8 for use in diagnostics.
*/
final class DiagnosticValue
{
private function __construct()
{
}
/**
* Escapes C0, DEL, and C1 controls as uppercase `\xNN` sequences.
*
* ASCII bytes from 0x20 through 0x7E and valid UTF-8 characters outside
* those control ranges remain unchanged. If the input is malformed UTF-8 or
* PCRE cannot process it, every byte outside printable ASCII is escaped.
* Valid C1 characters are rendered as `\xNN` using their Unicode code
* points. During bytewise fallback, each original byte outside printable
* ASCII is rendered in the same form. The result is diagnostic text, not a
* reversible encoding.
*
* This does not encode values for HTML, JSON, shells, terminals, URLs, or
* protocol fields.
*/
public static function escape(string $value): string
{
$escaped = \preg_replace_callback(
'/[\x{0000}-\x{001F}\x{007F}-\x{009F}]/u',
static function (array $matches): string {
$character = $matches[0];
$codePoint = \strlen($character) === 1 ? \ord($character) : \ord($character[1]);
return \sprintf('\\x%02X', $codePoint);
},
$value
);
return $escaped ?? self::escapeBytes($value);
}
private static function escapeBytes(string $value): string
{
$escaped = '';
for ($offset = 0, $length = \strlen($value); $offset < $length; ++$offset) {
$byte = \ord($value[$offset]);
if ($byte >= 0x20 && $byte <= 0x7E) {
$escaped .= $value[$offset];
continue;
}
$escaped .= \sprintf('\\x%02X', $byte);
}
return $escaped;
}
}

View file

@ -13,12 +13,11 @@ use Psr\Http\Message\StreamInterface;
final class DroppingStream implements StreamInterface
{
use StreamDecoratorTrait;
use NonSerializableStreamTrait;
/** @var int */
private $maxLength;
private int $maxLength;
/** @var StreamInterface */
private $stream;
private StreamInterface $stream;
/**
* @param StreamInterface $stream Underlying stream to decorate.
@ -27,20 +26,11 @@ final class DroppingStream implements StreamInterface
public function __construct(StreamInterface $stream, int $maxLength)
{
$this->stream = $stream;
$this->maxLength = $maxLength;
$this->maxLength = Integers::assertNonNegativeInteger($maxLength, 'Maximum length');
}
public function write($string): int
public function write(string $string): int
{
if (!\is_string($string)) {
\trigger_deprecation(
'guzzlehttp/psr7',
'2.11',
'Passing %s to StreamInterface::write() is deprecated; guzzlehttp/psr7 3.0 requires string for $string.',
\get_debug_type($string)
);
}
$diff = $this->maxLength - $this->stream->getSize();
// Begin returning 0 when the underlying stream is too large.

View file

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace GuzzleHttp\Psr7\Exception;
use RuntimeException;
/**
* Exception thrown when a stream operation times out.
*/
class TimeoutException extends RuntimeException
{
}

Some files were not shown because too many files have changed in this diff Show more