This commit is contained in:
2025-05-12 14:25:25 +02:00
parent ab2db755ef
commit 9e378ca2b7
2719 changed files with 46505 additions and 60181 deletions

View File

@@ -3,6 +3,45 @@
Please refer to [UPGRADING](UPGRADING.md) guide for upgrading to a major version.
## 7.9.3 - 2025-03-27
### Changed
- Remove explicit content-length header for GET requests
- Improve compatibility with bad servers for boolean cookie values
## 7.9.2 - 2024-07-24
### Fixed
- Adjusted handler selection to use cURL if its version is 7.21.2 or higher, rather than 7.34.0
## 7.9.1 - 2024-07-19
### Fixed
- Fix TLS 1.3 check for HTTP/2 requests
## 7.9.0 - 2024-07-18
### Changed
- Improve protocol version checks to provide feedback around unsupported protocols
- Only select the cURL handler by default if 7.34.0 or higher is linked
- Improved `CurlMultiHandler` to avoid busy wait if possible
- Dropped support for EOL `guzzlehttp/psr7` v1
- Improved URI user info redaction in errors
## 7.8.2 - 2024-07-18
### Added
- Support for PHP 8.4
## 7.8.1 - 2023-12-03
### Changed

View File

@@ -62,11 +62,11 @@ composer require guzzlehttp/guzzle
| Version | Status | Packagist | Namespace | Repo | Docs | PSR-7 | PHP Version |
|---------|---------------------|---------------------|--------------|---------------------|---------------------|-------|--------------|
| 3.x | EOL | `guzzle/guzzle` | `Guzzle` | [v3][guzzle-3-repo] | [v3][guzzle-3-docs] | No | >=5.3.3,<7.0 |
| 4.x | EOL | `guzzlehttp/guzzle` | `GuzzleHttp` | [v4][guzzle-4-repo] | N/A | No | >=5.4,<7.0 |
| 5.x | EOL | `guzzlehttp/guzzle` | `GuzzleHttp` | [v5][guzzle-5-repo] | [v5][guzzle-5-docs] | No | >=5.4,<7.4 |
| 6.x | Security fixes only | `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.4 |
| 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.5 |
[guzzle-3-repo]: https://github.com/guzzle/guzzle3
[guzzle-4-repo]: https://github.com/guzzle/guzzle/tree/4.x

View File

@@ -50,11 +50,39 @@
"homepage": "https://github.com/Tobion"
}
],
"repositories": [
{
"type": "package",
"package": {
"name": "guzzle/client-integration-tests",
"version": "v3.0.2",
"dist": {
"url": "https://codeload.github.com/guzzle/client-integration-tests/zip/2c025848417c1135031fdf9c728ee53d0a7ceaee",
"type": "zip"
},
"require": {
"php": "^7.2.5 || ^8.0",
"phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.11",
"php-http/message": "^1.0 || ^2.0",
"guzzlehttp/psr7": "^1.7 || ^2.0",
"th3n3rd/cartesian-product": "^0.3"
},
"autoload": {
"psr-4": {
"Http\\Client\\Tests\\": "src/"
}
},
"bin": [
"bin/http_test_server"
]
}
}
],
"require": {
"php": "^7.2.5 || ^8.0",
"ext-json": "*",
"guzzlehttp/promises": "^1.5.3 || ^2.0.1",
"guzzlehttp/psr7": "^1.9.1 || ^2.5.1",
"guzzlehttp/promises": "^1.5.3 || ^2.0.3",
"guzzlehttp/psr7": "^2.7.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
},
@@ -64,9 +92,9 @@
"require-dev": {
"ext-curl": "*",
"bamarni/composer-bin-plugin": "^1.8.2",
"php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999",
"guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.36 || ^9.6.15",
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {

View File

@@ -11,7 +11,7 @@ final class BodySummarizer implements BodySummarizerInterface
*/
private $truncateAt;
public function __construct(int $truncateAt = null)
public function __construct(?int $truncateAt = null)
{
$this->truncateAt = $truncateAt;
}
@@ -22,7 +22,7 @@ final class BodySummarizer implements BodySummarizerInterface
public function summarize(MessageInterface $message): ?string
{
return $this->truncateAt === null
? \GuzzleHttp\Psr7\Message::bodySummary($message)
: \GuzzleHttp\Psr7\Message::bodySummary($message, $this->truncateAt);
? Psr7\Message::bodySummary($message)
: Psr7\Message::bodySummary($message, $this->truncateAt);
}
}

View File

@@ -52,7 +52,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*
* @param array $config Client configuration settings.
*
* @see \GuzzleHttp\RequestOptions for a list of available request options.
* @see RequestOptions for a list of available request options.
*/
public function __construct(array $config = [])
{
@@ -202,7 +202,7 @@ class Client implements ClientInterface, \Psr\Http\Client\ClientInterface
*
* @deprecated Client::getConfig will be removed in guzzlehttp/guzzle:8.0.
*/
public function getConfig(string $option = null)
public function getConfig(?string $option = null)
{
return $option === null
? $this->config

View File

@@ -80,5 +80,5 @@ interface ClientInterface
*
* @deprecated ClientInterface::getConfig will be removed in guzzlehttp/guzzle:8.0.
*/
public function getConfig(string $option = null);
public function getConfig(?string $option = null);
}

View File

@@ -103,7 +103,7 @@ class CookieJar implements CookieJarInterface
}, $this->getIterator()->getArrayCopy());
}
public function clear(string $domain = null, string $path = null, string $name = null): void
public function clear(?string $domain = null, ?string $path = null, ?string $name = null): void
{
if (!$domain) {
$this->cookies = [];

View File

@@ -62,7 +62,7 @@ interface CookieJarInterface extends \Countable, \IteratorAggregate
* @param string|null $path Clears cookies matching a domain and path
* @param string|null $name Clears cookies matching a domain, path, and name
*/
public function clear(string $domain = null, string $path = null, string $name = null): void;
public function clear(?string $domain = null, ?string $path = null, ?string $name = null): void;
/**
* Discard all sessions cookies.

View File

@@ -62,6 +62,10 @@ class SetCookie
if (is_numeric($value)) {
$data[$search] = (int) $value;
}
} elseif ($search === 'Secure' || $search === 'Discard' || $search === 'HttpOnly') {
if ($value) {
$data[$search] = true;
}
} else {
$data[$search] = $value;
}

View File

@@ -14,7 +14,7 @@ class BadResponseException extends RequestException
string $message,
RequestInterface $request,
ResponseInterface $response,
\Throwable $previous = null,
?\Throwable $previous = null,
array $handlerContext = []
) {
parent::__construct($message, $request, $response, $previous, $handlerContext);

View File

@@ -25,7 +25,7 @@ class ConnectException extends TransferException implements NetworkExceptionInte
public function __construct(
string $message,
RequestInterface $request,
\Throwable $previous = null,
?\Throwable $previous = null,
array $handlerContext = []
) {
parent::__construct($message, 0, $previous);

View File

@@ -7,7 +7,6 @@ use GuzzleHttp\BodySummarizerInterface;
use Psr\Http\Client\RequestExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
/**
* HTTP Request exception
@@ -32,8 +31,8 @@ class RequestException extends TransferException implements RequestExceptionInte
public function __construct(
string $message,
RequestInterface $request,
ResponseInterface $response = null,
\Throwable $previous = null,
?ResponseInterface $response = null,
?\Throwable $previous = null,
array $handlerContext = []
) {
// Set the code of the exception if the response is set and not future.
@@ -63,10 +62,10 @@ class RequestException extends TransferException implements RequestExceptionInte
*/
public static function create(
RequestInterface $request,
ResponseInterface $response = null,
\Throwable $previous = null,
?ResponseInterface $response = null,
?\Throwable $previous = null,
array $handlerContext = [],
BodySummarizerInterface $bodySummarizer = null
?BodySummarizerInterface $bodySummarizer = null
): self {
if (!$response) {
return new self(
@@ -90,8 +89,7 @@ class RequestException extends TransferException implements RequestExceptionInte
$className = __CLASS__;
}
$uri = $request->getUri();
$uri = static::obfuscateUri($uri);
$uri = \GuzzleHttp\Psr7\Utils::redactUserInfo($request->getUri());
// Client Error: `GET /` resulted in a `404 Not Found` response:
// <html> ... (truncated)
@@ -113,20 +111,6 @@ class RequestException extends TransferException implements RequestExceptionInte
return new $className($message, $request, $response, $previous, $handlerContext);
}
/**
* Obfuscates URI if there is a username and a password present
*/
private static function obfuscateUri(UriInterface $uri): UriInterface
{
$userInfo = $uri->getUserInfo();
if (false !== ($pos = \strpos($userInfo, ':'))) {
return $uri->withUserInfo(\substr($userInfo, 0, $pos), '***');
}
return $uri;
}
/**
* Get the request that caused the exception
*/

View File

@@ -11,6 +11,7 @@ use GuzzleHttp\Psr7\LazyOpenStream;
use GuzzleHttp\TransferStats;
use GuzzleHttp\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\UriInterface;
/**
* Creates curl resources from a request
@@ -46,6 +47,16 @@ class CurlFactory implements CurlFactoryInterface
public function create(RequestInterface $request, array $options): EasyHandle
{
$protocolVersion = $request->getProtocolVersion();
if ('2' === $protocolVersion || '2.0' === $protocolVersion) {
if (!self::supportsHttp2()) {
throw new ConnectException('HTTP/2 is supported by the cURL handler, however libcurl is built without HTTP/2 support.', $request);
}
} elseif ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) {
throw new ConnectException(sprintf('HTTP/%s is not supported by the cURL handler.', $protocolVersion), $request);
}
if (isset($options['curl']['body_as_string'])) {
$options['_body_as_string'] = $options['curl']['body_as_string'];
unset($options['curl']['body_as_string']);
@@ -72,6 +83,42 @@ class CurlFactory implements CurlFactoryInterface
return $easy;
}
private static function supportsHttp2(): bool
{
static $supportsHttp2 = null;
if (null === $supportsHttp2) {
$supportsHttp2 = self::supportsTls12()
&& defined('CURL_VERSION_HTTP2')
&& (\CURL_VERSION_HTTP2 & \curl_version()['features']);
}
return $supportsHttp2;
}
private static function supportsTls12(): bool
{
static $supportsTls12 = null;
if (null === $supportsTls12) {
$supportsTls12 = \CURL_SSLVERSION_TLSv1_2 & \curl_version()['features'];
}
return $supportsTls12;
}
private static function supportsTls13(): bool
{
static $supportsTls13 = null;
if (null === $supportsTls13) {
$supportsTls13 = defined('CURL_SSLVERSION_TLSv1_3')
&& (\CURL_SSLVERSION_TLSv1_3 & \curl_version()['features']);
}
return $supportsTls13;
}
public function release(EasyHandle $easy): void
{
$resource = $easy->handle;
@@ -147,7 +194,7 @@ class CurlFactory implements CurlFactoryInterface
'error' => \curl_error($easy->handle),
'appconnect_time' => \curl_getinfo($easy->handle, \CURLINFO_APPCONNECT_TIME),
] + \curl_getinfo($easy->handle);
$ctx[self::CURL_VERSION_STR] = \curl_version()['version'];
$ctx[self::CURL_VERSION_STR] = self::getCurlVersion();
$factory->release($easy);
// Retry when nothing is present or when curl failed to rewind.
@@ -158,6 +205,17 @@ class CurlFactory implements CurlFactoryInterface
return self::createRejection($easy, $ctx);
}
private static function getCurlVersion(): string
{
static $curlVersion = null;
if (null === $curlVersion) {
$curlVersion = \curl_version()['version'];
}
return $curlVersion;
}
private static function createRejection(EasyHandle $easy, array $ctx): PromiseInterface
{
static $connectionErrors = [
@@ -194,15 +252,22 @@ class CurlFactory implements CurlFactoryInterface
);
}
$uri = $easy->request->getUri();
$sanitizedError = self::sanitizeCurlError($ctx['error'] ?? '', $uri);
$message = \sprintf(
'cURL error %s: %s (%s)',
$ctx['errno'],
$ctx['error'],
$sanitizedError,
'see https://curl.haxx.se/libcurl/c/libcurl-errors.html'
);
$uriString = (string) $easy->request->getUri();
if ($uriString !== '' && false === \strpos($ctx['error'], $uriString)) {
$message .= \sprintf(' for %s', $uriString);
if ('' !== $sanitizedError) {
$redactedUriString = \GuzzleHttp\Psr7\Utils::redactUserInfo($uri)->__toString();
if ($redactedUriString !== '' && false === \strpos($sanitizedError, $redactedUriString)) {
$message .= \sprintf(' for %s', $redactedUriString);
}
}
// Create a connection exception if it was a specific error code.
@@ -213,6 +278,24 @@ class CurlFactory implements CurlFactoryInterface
return P\Create::rejectionFor($error);
}
private static function sanitizeCurlError(string $error, UriInterface $uri): string
{
if ('' === $error) {
return $error;
}
$baseUri = $uri->withQuery('')->withFragment('');
$baseUriString = $baseUri->__toString();
if ('' === $baseUriString) {
return $error;
}
$redactedUriString = \GuzzleHttp\Psr7\Utils::redactUserInfo($baseUri)->__toString();
return str_replace($baseUriString, $redactedUriString, $error);
}
/**
* @return array<int|string, mixed>
*/
@@ -232,10 +315,11 @@ class CurlFactory implements CurlFactoryInterface
}
$version = $easy->request->getProtocolVersion();
if ($version == 1.1) {
$conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
} elseif ($version == 2.0) {
if ('2' === $version || '2.0' === $version) {
$conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0;
} elseif ('1.1' === $version) {
$conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
} else {
$conf[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0;
}
@@ -390,8 +474,10 @@ class CurlFactory implements CurlFactoryInterface
// The empty string enables all available decoders and implicitly
// sets a matching 'Accept-Encoding' header.
$conf[\CURLOPT_ENCODING] = '';
// But as the user did not specify any acceptable encodings we need
// to overwrite this implicit header with an empty one.
// But as the user did not specify any encoding preference,
// let's leave it up to server by preventing curl from sending
// the header, which will be interpreted as 'Accept-Encoding: *'.
// https://www.rfc-editor.org/rfc/rfc9110#field.accept-encoding
$conf[\CURLOPT_HTTPHEADER][] = 'Accept-Encoding:';
}
}
@@ -455,23 +541,35 @@ class CurlFactory implements CurlFactoryInterface
}
if (isset($options['crypto_method'])) {
if (\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT === $options['crypto_method']) {
if (!defined('CURL_SSLVERSION_TLSv1_0')) {
throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.0 not supported by your version of cURL');
$protocolVersion = $easy->request->getProtocolVersion();
// If HTTP/2, upgrade TLS 1.0 and 1.1 to 1.2
if ('2' === $protocolVersion || '2.0' === $protocolVersion) {
if (
\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT === $options['crypto_method']
|| \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT === $options['crypto_method']
|| \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT === $options['crypto_method']
) {
$conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_2;
} elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT === $options['crypto_method']) {
if (!self::supportsTls13()) {
throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.3 not supported by your version of cURL');
}
$conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_3;
} else {
throw new \InvalidArgumentException('Invalid crypto_method request option: unknown version provided');
}
} elseif (\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT === $options['crypto_method']) {
$conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_0;
} elseif (\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT === $options['crypto_method']) {
if (!defined('CURL_SSLVERSION_TLSv1_1')) {
throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.1 not supported by your version of cURL');
}
$conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_1;
} elseif (\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT === $options['crypto_method']) {
if (!defined('CURL_SSLVERSION_TLSv1_2')) {
if (!self::supportsTls12()) {
throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.2 not supported by your version of cURL');
}
$conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_2;
} elseif (defined('STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT') && \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT === $options['crypto_method']) {
if (!defined('CURL_SSLVERSION_TLSv1_3')) {
if (!self::supportsTls13()) {
throw new \InvalidArgumentException('Invalid crypto_method request option: TLS 1.3 not supported by your version of cURL');
}
$conf[\CURLOPT_SSLVERSION] = \CURL_SSLVERSION_TLSv1_3;

View File

@@ -2,6 +2,7 @@
namespace GuzzleHttp\Handler;
use Closure;
use GuzzleHttp\Promise as P;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\PromiseInterface;
@@ -159,6 +160,9 @@ class CurlMultiHandler
}
}
// Run curl_multi_exec in the queue to enable other async tasks to run
P\Utils::queue()->add(Closure::fromCallable([$this, 'tickInQueue']));
// Step through the task queue which may add additional requests.
P\Utils::queue()->run();
@@ -169,11 +173,24 @@ class CurlMultiHandler
}
while (\curl_multi_exec($this->_mh, $this->active) === \CURLM_CALL_MULTI_PERFORM) {
// Prevent busy looping for slow HTTP requests.
\curl_multi_select($this->_mh, $this->selectTimeout);
}
$this->processMessages();
}
/**
* Runs \curl_multi_exec() inside the event loop, to prevent busy looping
*/
private function tickInQueue(): void
{
if (\curl_multi_exec($this->_mh, $this->active) === \CURLM_CALL_MULTI_PERFORM) {
\curl_multi_select($this->_mh, 0);
P\Utils::queue()->add(Closure::fromCallable([$this, 'tickInQueue']));
}
}
/**
* Runs until all outstanding connections have completed.
*/

View File

@@ -52,21 +52,21 @@ class MockHandler implements \Countable
* @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.
*/
public static function createWithMiddleware(array $queue = null, callable $onFulfilled = null, callable $onRejected = null): HandlerStack
public static function createWithMiddleware(?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 \Psr\Http\Message\ResponseInterface} objects, Exceptions,
* {@see ResponseInterface} objects, Exceptions,
* 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.
*/
public function __construct(array $queue = null, callable $onFulfilled = null, callable $onRejected = null)
public function __construct(?array $queue = null, ?callable $onFulfilled = null, ?callable $onRejected = null)
{
$this->onFulfilled = $onFulfilled;
$this->onRejected = $onRejected;
@@ -200,7 +200,7 @@ class MockHandler implements \Countable
private function invokeStats(
RequestInterface $request,
array $options,
ResponseInterface $response = null,
?ResponseInterface $response = null,
$reason = null
): void {
if (isset($options['on_stats'])) {

View File

@@ -17,10 +17,10 @@ class Proxy
* Sends synchronous requests to a specific handler while sending all other
* requests to another handler.
*
* @param callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface $default Handler used for normal responses
* @param callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface $sync Handler used for synchronous responses.
* @param callable(RequestInterface, array): PromiseInterface $default Handler used for normal responses
* @param callable(RequestInterface, array): PromiseInterface $sync Handler used for synchronous responses.
*
* @return callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface Returns the composed handler.
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
*/
public static function wrapSync(callable $default, callable $sync): callable
{
@@ -37,10 +37,10 @@ class Proxy
* performance benefits of curl while still supporting true streaming
* through the StreamHandler.
*
* @param callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface $default Handler used for non-streaming responses
* @param callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface $streaming Handler used for streaming responses
* @param callable(RequestInterface, array): PromiseInterface $default Handler used for non-streaming responses
* @param callable(RequestInterface, array): PromiseInterface $streaming Handler used for streaming responses
*
* @return callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface Returns the composed handler.
* @return callable(RequestInterface, array): PromiseInterface Returns the composed handler.
*/
public static function wrapStreaming(callable $default, callable $streaming): callable
{

View File

@@ -40,6 +40,12 @@ class StreamHandler
\usleep($options['delay'] * 1000);
}
$protocolVersion = $request->getProtocolVersion();
if ('1.0' !== $protocolVersion && '1.1' !== $protocolVersion) {
throw new ConnectException(sprintf('HTTP/%s is not supported by the stream handler.', $protocolVersion), $request);
}
$startTime = isset($options['on_stats']) ? Utils::currentTime() : null;
try {
@@ -47,8 +53,14 @@ class StreamHandler
$request = $request->withoutHeader('Expect');
// Append a content-length header if body size is zero to match
// cURL's behavior.
if (0 === $request->getBody()->getSize()) {
// the behavior of `CurlHandler`
if (
(
0 === \strcasecmp('PUT', $request->getMethod())
|| 0 === \strcasecmp('POST', $request->getMethod())
)
&& 0 === $request->getBody()->getSize()
) {
$request = $request->withHeader('Content-Length', '0');
}
@@ -83,8 +95,8 @@ class StreamHandler
array $options,
RequestInterface $request,
?float $startTime,
ResponseInterface $response = null,
\Throwable $error = null
?ResponseInterface $response = null,
?\Throwable $error = null
): void {
if (isset($options['on_stats'])) {
$stats = new TransferStats($request, $response, Utils::currentTime() - $startTime, $error, []);
@@ -273,7 +285,7 @@ class StreamHandler
// HTTP/1.1 streams using the PHP stream wrapper require a
// Connection: close header
if ($request->getProtocolVersion() == '1.1'
if ($request->getProtocolVersion() === '1.1'
&& !$request->hasHeader('Connection')
) {
$request = $request->withHeader('Connection', 'close');

View File

@@ -44,7 +44,7 @@ class HandlerStack
* handler is provided, the best handler for your
* system will be utilized.
*/
public static function create(callable $handler = null): self
public static function create(?callable $handler = null): self
{
$stack = new self($handler ?: Utils::chooseHandler());
$stack->push(Middleware::httpErrors(), 'http_errors');
@@ -58,7 +58,7 @@ class HandlerStack
/**
* @param (callable(RequestInterface, array): PromiseInterface)|null $handler Underlying HTTP handler.
*/
public function __construct(callable $handler = null)
public function __construct(?callable $handler = null)
{
$this->handler = $handler;
}
@@ -131,7 +131,7 @@ class HandlerStack
* @param callable(callable): callable $middleware Middleware function
* @param string $name Name to register for this middleware.
*/
public function unshift(callable $middleware, string $name = null): void
public function unshift(callable $middleware, ?string $name = null): void
{
\array_unshift($this->stack, [$middleware, $name]);
$this->cached = null;

View File

@@ -68,7 +68,7 @@ class MessageFormatter implements MessageFormatterInterface
* @param ResponseInterface|null $response Response that was received
* @param \Throwable|null $error Exception that was received
*/
public function format(RequestInterface $request, ResponseInterface $response = null, \Throwable $error = null): string
public function format(RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null): string
{
$cache = [];

View File

@@ -14,5 +14,5 @@ interface MessageFormatterInterface
* @param ResponseInterface|null $response Response that was received
* @param \Throwable|null $error Exception that was received
*/
public function format(RequestInterface $request, ResponseInterface $response = null, \Throwable $error = null): string;
public function format(RequestInterface $request, ?ResponseInterface $response = null, ?\Throwable $error = null): string;
}

View File

@@ -55,7 +55,7 @@ final class Middleware
*
* @return callable(callable): callable Returns a function that accepts the next handler.
*/
public static function httpErrors(BodySummarizerInterface $bodySummarizer = null): callable
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) {
@@ -132,7 +132,7 @@ final class Middleware
*
* @return callable Returns a function that accepts the next handler.
*/
public static function tap(callable $before = null, callable $after = null): callable
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) {
@@ -176,7 +176,7 @@ final class Middleware
*
* @return callable Returns a function that accepts the next handler.
*/
public static function retry(callable $decider, callable $delay = null): callable
public static function retry(callable $decider, ?callable $delay = null): callable
{
return static function (callable $handler) use ($decider, $delay): RetryMiddleware {
return new RetryMiddleware($decider, $handler, $delay);

View File

@@ -86,7 +86,7 @@ class Pool implements PromisorInterface
* @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 \GuzzleHttp\Pool::__construct}
* {@see Pool::__construct}
*
* @return array Returns an array containing the response or an exception
* in the same order that the requests were sent.

View File

@@ -76,8 +76,8 @@ class PrepareBodyMiddleware
$expect = $options['expect'] ?? null;
// Return if disabled or if you're not using HTTP/1.1 or HTTP/2.0
if ($expect === false || $request->getProtocolVersion() < 1.1) {
// Return if disabled or using HTTP/1.0
if ($expect === false || $request->getProtocolVersion() === '1.0') {
return;
}

View File

@@ -61,7 +61,7 @@ final class RequestOptions
* Specifies whether or not cookies are used in a request or what cookie
* jar to use or what cookies to send. This option only works if your
* handler has the `cookie` middleware. Valid values are `false` and
* an instance of {@see \GuzzleHttp\Cookie\CookieJarInterface}.
* an instance of {@see Cookie\CookieJarInterface}.
*/
public const COOKIES = 'cookies';

View File

@@ -40,7 +40,7 @@ class RetryMiddleware
* and returns the number of
* milliseconds to delay.
*/
public function __construct(callable $decider, callable $nextHandler, callable $delay = null)
public function __construct(callable $decider, callable $nextHandler, ?callable $delay = null)
{
$this->decider = $decider;
$this->nextHandler = $nextHandler;
@@ -110,7 +110,7 @@ class RetryMiddleware
};
}
private function doRetry(RequestInterface $request, array $options, ResponseInterface $response = null): PromiseInterface
private function doRetry(RequestInterface $request, array $options, ?ResponseInterface $response = null): PromiseInterface
{
$options['delay'] = ($this->delay)(++$options['retries'], $response, $request);

View File

@@ -46,8 +46,8 @@ final class TransferStats
*/
public function __construct(
RequestInterface $request,
ResponseInterface $response = null,
float $transferTime = null,
?ResponseInterface $response = null,
?float $transferTime = null,
$handlerErrorData = null,
array $handlerStats = []
) {

View File

@@ -71,7 +71,7 @@ final class Utils
return \STDOUT;
}
return \GuzzleHttp\Psr7\Utils::tryFopen('php://output', 'w');
return Psr7\Utils::tryFopen('php://output', 'w');
}
/**
@@ -79,7 +79,7 @@ final class Utils
*
* The returned handler is not wrapped by any default middlewares.
*
* @return callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface Returns the best handler for the given system.
* @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.
*/
@@ -87,7 +87,7 @@ final class Utils
{
$handler = null;
if (\defined('CURLOPT_CUSTOMREQUEST')) {
if (\defined('CURLOPT_CUSTOMREQUEST') && \function_exists('curl_version') && version_compare(curl_version()['version'], '7.21.2') >= 0) {
if (\function_exists('curl_multi_exec') && \function_exists('curl_exec')) {
$handler = Proxy::wrapSync(new CurlMultiHandler(), new CurlHandler());
} elseif (\function_exists('curl_exec')) {

View File

@@ -50,7 +50,7 @@ function debug_resource($value = null)
*
* The returned handler is not wrapped by any default middlewares.
*
* @return callable(\Psr\Http\Message\RequestInterface, array): \GuzzleHttp\Promise\PromiseInterface Returns the best handler for the given system.
* @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.
*

View File

@@ -1,6 +1,34 @@
# CHANGELOG
## 2.2.0 - 2025-03-27
### Fixed
- Revert "Allow an empty EachPromise to be resolved by running the queue"
## 2.1.0 - 2025-03-27
### Added
- Allow an empty EachPromise to be resolved by running the queue
## 2.0.4 - 2024-10-17
### Fixed
- Once settled, don't allow further rejection of additional promises
## 2.0.3 - 2024-07-18
### Changed
- PHP 8.4 support
## 2.0.2 - 2023-12-03
### Changed

View File

@@ -38,10 +38,10 @@ composer require guzzlehttp/promises
## Version Guidance
| Version | Status | PHP Version |
|---------|------------------------|--------------|
| 1.x | Bug and security fixes | >=5.5,<8.3 |
| 2.x | Latest | >=7.2.5,<8.4 |
| Version | Status | PHP Version |
|---------|---------------------|--------------|
| 1.x | Security fixes only | >=5.5,<8.3 |
| 2.x | Latest | >=7.2.5,<8.5 |
## Quick Start

View File

@@ -30,7 +30,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.36 || ^9.6.15"
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"autoload": {
"psr-4": {

View File

@@ -84,8 +84,8 @@ final class Coroutine implements PromiseInterface
}
public function then(
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
return $this->result->then($onFulfilled, $onRejected);
}

View File

@@ -23,8 +23,8 @@ final class Each
*/
public static function of(
$iterable,
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
return (new EachPromise($iterable, [
'fulfilled' => $onFulfilled,
@@ -46,8 +46,8 @@ final class Each
public static function ofLimit(
$iterable,
$concurrency,
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
return (new EachPromise($iterable, [
'fulfilled' => $onFulfilled,
@@ -67,7 +67,7 @@ final class Each
public static function ofLimitAll(
$iterable,
$concurrency,
callable $onFulfilled = null
?callable $onFulfilled = null
): PromiseInterface {
return self::ofLimit(
$iterable,

View File

@@ -31,8 +31,8 @@ class FulfilledPromise implements PromiseInterface
}
public function then(
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
// Return itself if there is no onFulfilled function.
if (!$onFulfilled) {

View File

@@ -25,16 +25,16 @@ class Promise implements PromiseInterface
* @param callable $cancelFn Fn that when invoked cancels the promise.
*/
public function __construct(
callable $waitFn = null,
callable $cancelFn = null
?callable $waitFn = null,
?callable $cancelFn = null
) {
$this->waitFn = $waitFn;
$this->cancelFn = $cancelFn;
}
public function then(
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
if ($this->state === self::PENDING) {
$p = new Promise(null, [$this, 'cancel']);

View File

@@ -27,8 +27,8 @@ interface PromiseInterface
* @param callable $onRejected Invoked when the promise is rejected.
*/
public function then(
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface;
/**

View File

@@ -31,8 +31,8 @@ class RejectedPromise implements PromiseInterface
}
public function then(
callable $onFulfilled = null,
callable $onRejected = null
?callable $onFulfilled = null,
?callable $onRejected = null
): PromiseInterface {
// If there's no onRejected callback then just return self.
if (!$onRejected) {

View File

@@ -18,7 +18,7 @@ class RejectionException extends \RuntimeException
* @param mixed $reason Rejection reason.
* @param string|null $description Optional description.
*/
public function __construct($reason, string $description = null)
public function __construct($reason, ?string $description = null)
{
$this->reason = $reason;

View File

@@ -21,7 +21,7 @@ final class Utils
*
* @param TaskQueueInterface|null $assign Optionally specify a new queue instance.
*/
public static function queue(TaskQueueInterface $assign = null): TaskQueueInterface
public static function queue(?TaskQueueInterface $assign = null): TaskQueueInterface
{
static $queue;
@@ -144,7 +144,9 @@ final class Utils
$results[$idx] = $value;
},
function ($reason, $idx, Promise $aggregate): void {
$aggregate->reject($reason);
if (Is::pending($aggregate)) {
$aggregate->reject($reason);
}
}
)->then(function () use (&$results) {
ksort($results);

View File

@@ -5,6 +5,33 @@ 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).
## 2.7.1 - 2025-03-27
### Fixed
- Fixed uppercase IPv6 addresses in URI
### Changed
- Improve uploaded file error message
## 2.7.0 - 2024-07-18
### Added
- Add `Utils::redactUserInfo()` method
- Add ability to encode bools as ints in `Query::build`
## 2.6.3 - 2024-07-18
### Fixed
- Make `StreamWrapper::stream_stat()` return `false` if inner stream's size is `null`
### Changed
- PHP 8.4 support
## 2.6.2 - 2023-12-03
### Fixed

View File

@@ -24,8 +24,8 @@ composer require guzzlehttp/psr7
| Version | Status | PHP Version |
|---------|---------------------|--------------|
| 1.x | Security fixes only | >=5.4,<8.1 |
| 2.x | Latest | >=7.2.5,<8.4 |
| 1.x | EOL (2024-06-30) | >=5.4,<8.2 |
| 2.x | Latest | >=7.2.5,<8.5 |
## AppendStream
@@ -436,7 +436,7 @@ 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): string`
`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.
@@ -498,11 +498,18 @@ a message.
## `GuzzleHttp\Psr7\Utils::readLine`
`public static function readLine(StreamInterface $stream, int $maxLength = null): string`
`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`
@@ -674,7 +681,7 @@ termed a relative-path reference.
### `GuzzleHttp\Psr7\Uri::isSameDocumentReference`
`public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool`
`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

View File

@@ -61,8 +61,8 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "^0.9",
"phpunit/phpunit": "^8.5.36 || ^9.6.15"
"http-interop/http-factory-tests": "0.9.0",
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"

View File

@@ -33,7 +33,7 @@ final class CachingStream implements StreamInterface
*/
public function __construct(
StreamInterface $stream,
StreamInterface $target = null
?StreamInterface $target = null
) {
$this->remoteStream = $stream;
$this->stream = $target ?: new Stream(Utils::tryFopen('php://temp', 'r+'));

View File

@@ -27,10 +27,10 @@ final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInter
{
public function createUploadedFile(
StreamInterface $stream,
int $size = null,
?int $size = null,
int $error = \UPLOAD_ERR_OK,
string $clientFilename = null,
string $clientMediaType = null
?string $clientFilename = null,
?string $clientMediaType = null
): UploadedFileInterface {
if ($size === null) {
$size = $stream->getSize();

View File

@@ -32,7 +32,7 @@ final class MultipartStream implements StreamInterface
*
* @throws \InvalidArgumentException
*/
public function __construct(array $elements = [], string $boundary = null)
public function __construct(array $elements = [], ?string $boundary = null)
{
$this->boundary = $boundary ?: bin2hex(random_bytes(20));
$this->stream = $this->createStream($elements);

View File

@@ -63,12 +63,15 @@ final class Query
* string. This function does not modify the provided keys when an array is
* encountered (like `http_build_query()` would).
*
* @param array $params Query string parameters.
* @param int|false $encoding Set to false to not encode, PHP_QUERY_RFC3986
* to encode using RFC3986, or PHP_QUERY_RFC1738
* to encode using RFC1738.
* @param array $params Query string parameters.
* @param int|false $encoding Set to false to not encode,
* PHP_QUERY_RFC3986 to encode using
* RFC3986, or PHP_QUERY_RFC1738 to
* encode using RFC1738.
* @param bool $treatBoolsAsInts Set to true to encode as 0/1, and
* false as false/true.
*/
public static function build(array $params, $encoding = PHP_QUERY_RFC3986): string
public static function build(array $params, $encoding = PHP_QUERY_RFC3986, bool $treatBoolsAsInts = true): string
{
if (!$params) {
return '';
@@ -86,12 +89,14 @@ final class Query
throw new \InvalidArgumentException('Invalid type');
}
$castBool = $treatBoolsAsInts ? static function ($v) { return (int) $v; } : static function ($v) { return $v ? 'true' : 'false'; };
$qs = '';
foreach ($params as $k => $v) {
$k = $encoder((string) $k);
if (!is_array($v)) {
$qs .= $k;
$v = is_bool($v) ? (int) $v : $v;
$v = is_bool($v) ? $castBool($v) : $v;
if ($v !== null) {
$qs .= '='.$encoder((string) $v);
}
@@ -99,7 +104,7 @@ final class Query
} else {
foreach ($v as $vv) {
$qs .= $k;
$vv = is_bool($vv) ? (int) $vv : $vv;
$vv = is_bool($vv) ? $castBool($vv) : $vv;
if ($vv !== null) {
$qs .= '='.$encoder((string) $vv);
}

View File

@@ -96,7 +96,7 @@ class Response implements ResponseInterface
array $headers = [],
$body = null,
string $version = '1.1',
string $reason = null
?string $reason = null
) {
$this->assertStatusCodeRange($status);

View File

@@ -69,7 +69,7 @@ final class StreamWrapper
}
}
public function stream_open(string $path, string $mode, int $options, string &$opened_path = null): bool
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path = null): bool
{
$options = stream_context_get_options($this->context);
@@ -136,10 +136,14 @@ final class StreamWrapper
* ctime: int,
* blksize: int,
* blocks: int
* }
* }|false
*/
public function stream_stat(): array
public function stream_stat()
{
if ($this->stream->getSize() === null) {
return false;
}
static $modeMap = [
'r' => 33060,
'rb' => 33060,

View File

@@ -11,15 +11,15 @@ use RuntimeException;
class UploadedFile implements UploadedFileInterface
{
private const ERRORS = [
UPLOAD_ERR_OK,
UPLOAD_ERR_INI_SIZE,
UPLOAD_ERR_FORM_SIZE,
UPLOAD_ERR_PARTIAL,
UPLOAD_ERR_NO_FILE,
UPLOAD_ERR_NO_TMP_DIR,
UPLOAD_ERR_CANT_WRITE,
UPLOAD_ERR_EXTENSION,
private const ERROR_MAP = [
UPLOAD_ERR_OK => 'UPLOAD_ERR_OK',
UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE',
UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE',
UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL',
UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE',
UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR',
UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE',
UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION',
];
/**
@@ -64,8 +64,8 @@ class UploadedFile implements UploadedFileInterface
$streamOrFile,
?int $size,
int $errorStatus,
string $clientFilename = null,
string $clientMediaType = null
?string $clientFilename = null,
?string $clientMediaType = null
) {
$this->setError($errorStatus);
$this->size = $size;
@@ -104,7 +104,7 @@ class UploadedFile implements UploadedFileInterface
*/
private function setError(int $error): void
{
if (false === in_array($error, UploadedFile::ERRORS, true)) {
if (!isset(UploadedFile::ERROR_MAP[$error])) {
throw new InvalidArgumentException(
'Invalid error status for UploadedFile'
);
@@ -137,7 +137,7 @@ class UploadedFile implements UploadedFileInterface
private function validateActive(): void
{
if (false === $this->isOk()) {
throw new RuntimeException('Cannot retrieve stream due to upload error');
throw new RuntimeException(\sprintf('Cannot retrieve stream due to upload error (%s)', self::ERROR_MAP[$this->error]));
}
if ($this->isMoved()) {

View File

@@ -107,7 +107,7 @@ class Uri implements UriInterface, \JsonSerializable
{
// If IPv6
$prefix = '';
if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) {
if (preg_match('%^(.*://\[[0-9:a-fA-F]+\])(.*?)$%', $url, $matches)) {
/** @var array{0:string, 1:string, 2:string} $matches */
$prefix = $matches[1];
$url = $matches[2];
@@ -279,7 +279,7 @@ class Uri implements UriInterface, \JsonSerializable
*
* @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.4
*/
public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool
public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool
{
if ($base !== null) {
$uri = UriResolver::resolve($base, $uri);

View File

@@ -231,7 +231,7 @@ final class Utils
* @param StreamInterface $stream Stream to read from
* @param int|null $maxLength Maximum buffer length
*/
public static function readLine(StreamInterface $stream, int $maxLength = null): string
public static function readLine(StreamInterface $stream, ?int $maxLength = null): string
{
$buffer = '';
$size = 0;
@@ -250,6 +250,20 @@ final class Utils
return $buffer;
}
/**
* Redact the password in the user info part of a URI.
*/
public static function redactUserInfo(UriInterface $uri): UriInterface
{
$userInfo = $uri->getUserInfo();
if (false !== ($pos = \strpos($userInfo, ':'))) {
return $uri->withUserInfo(\substr($userInfo, 0, $pos), '***');
}
return $uri;
}
/**
* Create a new stream based on the input type.
*

View File

@@ -4,6 +4,16 @@ All notable changes to `uri-template` will be documented in this file.
Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles.
## v1.0.4 - 2025-02-03
### Changed
- Officially support PHP 8.4
## v1.0.3 - 2023-12-03
### Changed
- Updated link to RFC 6570
## v1.0.2 - 2023-08-27
### Changed