refactor: Cleanup git state - commit all staged changes

Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
This commit is contained in:
Dwindi Ramadhana
2026-06-17 05:27:58 +07:00
parent d3f142222c
commit 690991c526
7963 changed files with 941566 additions and 67372 deletions

View File

@@ -0,0 +1,168 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Amp\CancelledException;
use Amp\Http\Client\DelegateHttpClient;
use Amp\Http\Client\InterceptedHttpClient;
use Amp\Http\Client\PooledHttpClient;
use Amp\Http\Client\Request;
use Amp\Http\Tunnel\Http1TunnelConnector;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\AmpClientState;
use Symfony\Component\HttpClient\Response\AmpResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
if (!interface_exists(DelegateHttpClient::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the "amphp/http-client" package is not installed. Try running "composer require amphp/http-client:^5".');
}
/**
* A portable implementation of the HttpClientInterface contracts based on Amp's HTTP client.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
use LoggerAwareTrait;
public const OPTIONS_DEFAULTS = HttpClientInterface::OPTIONS_DEFAULTS + [
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
];
private array $defaultOptions = self::OPTIONS_DEFAULTS;
private static array $emptyDefaults = self::OPTIONS_DEFAULTS;
private AmpClientState $multi;
/**
* @param array $defaultOptions Default requests' options
* @param callable|null $clientConfigurator A callable that builds a {@see DelegateHttpClient} from a {@see PooledHttpClient};
* passing null builds an {@see InterceptedHttpClient} with 2 retries on failures
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct(array $defaultOptions = [], ?callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50)
{
$this->defaultOptions['buffer'] ??= self::shouldBuffer(...);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->multi = new AmpClientState($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger);
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
$options['proxy'] = self::getProxy($options['proxy'], $url, $options['no_proxy']);
if (null !== $options['proxy'] && !class_exists(Http1TunnelConnector::class)) {
throw new \LogicException('You cannot use the "proxy" option as the "amphp/http-tunnel" package is not installed. Try running "composer require amphp/http-tunnel".');
}
if ($options['bindto']) {
if (str_starts_with($options['bindto'], 'if!')) {
throw new TransportException(__CLASS__.' cannot bind to network interfaces, use e.g. CurlHttpClient instead.');
}
if (str_starts_with($options['bindto'], 'host!')) {
$options['bindto'] = substr($options['bindto'], 5);
}
}
if (('' !== $options['body'] || 'POST' === $method || isset($options['normalized_headers']['content-length'])) && !isset($options['normalized_headers']['content-type'])) {
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
}
if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: Symfony HttpClient (Amp)';
}
if (0 < $options['max_duration']) {
$options['timeout'] = min($options['max_duration'], $options['timeout']);
}
if ($options['resolve']) {
$this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache;
}
if ($options['peer_fingerprint'] && !isset($options['peer_fingerprint']['pin-sha256'])) {
throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.');
}
$request = new Request(implode('', $url), $method);
$request->setBodySizeLimit(0);
if ($options['http_version']) {
$request->setProtocolVersions(match ((float) $options['http_version']) {
1.0 => ['1.0'],
1.1 => ['1.1', '1.0'],
default => ['2', '1.1', '1.0'],
});
}
foreach ($options['headers'] as $v) {
$h = explode(': ', $v, 2);
$request->addHeader($h[0], $h[1]);
}
$request->setTcpConnectTimeout($options['timeout']);
$request->setTlsHandshakeTimeout($options['timeout']);
$request->setTransferTimeout($options['max_duration']);
$request->setInactivityTimeout(0);
if ('' !== $request->getUri()->getUserInfo() && !$request->hasHeader('authorization')) {
$auth = explode(':', $request->getUri()->getUserInfo(), 2);
$auth = array_map('rawurldecode', $auth) + [1 => ''];
$request->setHeader('Authorization', 'Basic '.base64_encode(implode(':', $auth)));
}
return new AmpResponse($this->multi, $request, $options, $this->logger);
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof AmpResponse) {
$responses = [$responses];
}
return new ResponseStream(AmpResponse::stream($responses, $timeout));
}
public function reset(): void
{
$this->multi->dnsCache = [];
foreach ($this->multi->pushedResponses as $pushedResponses) {
foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) {
$pushDeferred->error(new CancelledException());
$this->logger?->debug(\sprintf('Unused pushed response: "%s"', $pushedUrl));
}
}
$this->multi->pushedResponses = [];
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* Eases with processing responses while streaming them.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
trait AsyncDecoratorTrait
{
use DecoratorTrait;
/**
* @return AsyncResponse
*/
abstract public function request(string $method, string $url, array $options = []): ResponseInterface;
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof AsyncResponse) {
$responses = [$responses];
}
return new ResponseStream(AsyncResponse::stream($responses, $timeout, static::class));
}
}

138
vendor/symfony/http-client/CHANGELOG.md vendored Normal file
View File

@@ -0,0 +1,138 @@
CHANGELOG
=========
8.1
---
* Add support for the `max_connect_duration` option
* Add option `extra.use_persistent_connections` to `CurlHttpClient` to control the use of persistent connections introduced in PHP 8.5
* Add `GuzzleHttpHandler` that allows using Symfony HttpClient as a Guzzle handler
* Add `$allowList` argument to `NoPrivateNetworkHttpClient` to allow specific hosts (e.g. a local proxy) to bypass the private-network filter
* Add `DnsResolvingHttpClient` decorator to resolve host names using a custom resolver, including on redirects
* Change `$maxTtl` argument of `CachingHttpClient` to default to `86400` (24h) instead of `null`
* Deprecate passing `null` as `$maxTtl` to `CachingHttpClient`, pass a positive integer instead
* Make `CachingHttpClient` implement `Psr\Log\LoggerAwareInterface` to log when a stale cached response is served because the upstream call failed (`stale-if-error` fallback)
8.0
---
* Remove support for passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor, use a `TagAwareCacheInterface` instead
* Remove support for amphp/http-client < 5
* Remove setLogger() methods on decorators; configure the logger on the wrapped client directly instead
7.4
---
* Add RFC 9111based caching support to `CachingHttpClient`
* Add option `auto_upgrade_http_version` to control how the request HTTP version is handled in `HttplugClient` and `Psr18Client`
* Add QUERY to the list of retriable HTTP methods
* Deprecate using amphp/http-client < 5
* Deprecate passing an instance of `StoreInterface` as `$cache` argument to `CachingHttpClient` constructor
7.3
---
* Add IPv6 support to `NativeHttpClient`
* Allow using HTTP/3 with the `CurlHttpClient`
7.2
---
* Add support for amphp/http-client v5 on PHP 8.4+
7.1
---
* Add `HttpOptions::setHeader()` to add or replace a single header
* Allow mocking `start_time` info in `MockResponse`
* Add `MockResponse::fromFile()` and `JsonMockResponse::fromFile()` methods to help using fixtures files
* Add `ThrottlingHttpClient` to enable limiting the number of requests within a certain period
* Deprecate the `setLogger()` methods of the `NoPrivateNetworkHttpClient`, `TraceableHttpClient` and `ScopingHttpClient` classes, configure the logger of the wrapped clients directly instead
7.0
---
* Remove implementing `Http\Message\RequestFactory` from `HttplugClient`
6.4
---
* Add `HarFileResponseFactory` testing utility, allow to replay responses from `.har` files
* Add `max_retries` option to `RetryableHttpClient` to adjust the retry logic on a per request level
* Add `PingWehookMessage` and `PingWebhookMessageHandler`
* Enable using EventSourceHttpClient::connect() for both GET and POST
6.3
---
* Add option `crypto_method` to set the minimum TLS version and make it default to v1.2
* Add `UriTemplateHttpClient` to use URI templates as specified in the RFC 6570
* Add `ServerSentEvent::getArrayData()` to get the Server-Sent Event's data decoded as an array when it's a JSON payload
* Allow array of urls as `base_uri` option value in `RetryableHttpClient` to retry on a new url each time
* Add `JsonMockResponse`, a `MockResponse` shortcut that automatically encodes the passed body to JSON and sets the content type to `application/json` by default
* Support file uploads by nesting resource streams in option "body"
6.2
---
* Make `HttplugClient` implement `Psr\Http\Message\RequestFactoryInterface`, `StreamFactoryInterface` and `UriFactoryInterface`
* Deprecate implementing `Http\Message\RequestFactory`, `StreamFactory` and `UriFactory` on `HttplugClient`
* Add `withOptions()` to `HttplugClient` and `Psr18Client`
6.1
---
* Allow yielding `Exception` from MockResponse's `$body` to mock transport errors
* Remove credentials from requests redirected to same host but different port
5.4
---
* Add `MockHttpClient::setResponseFactory()` method to be able to set response factory after client creating
5.3
---
* Implement `HttpClientInterface::withOptions()` from `symfony/contracts` v2.4
* Add `DecoratorTrait` to ease writing simple decorators
5.2.0
-----
* added `AsyncDecoratorTrait` to ease processing responses without breaking async
* added support for pausing responses with a new `pause_handler` callable exposed as an info item
* added `StreamableInterface` to ease turning responses into PHP streams
* added `MockResponse::getRequestMethod()` and `getRequestUrl()` to allow inspecting which request has been sent
* added `EventSourceHttpClient` a Server-Sent events stream implementing the [EventSource specification](https://www.w3.org/TR/eventsource/#eventsource)
* added option "extra.curl" to allow setting additional curl options in `CurlHttpClient`
* added `RetryableHttpClient` to automatically retry failed HTTP requests.
* added `extra.trace_content` option to `TraceableHttpClient` to prevent it from keeping the content in memory
5.1.0
-----
* added `NoPrivateNetworkHttpClient` decorator
* added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
* made `HttpClient::create()` return an `AmpHttpClient` when `amphp/http-client` is found but curl is not or too old
4.4.0
-----
* added `canceled` to `ResponseInterface::getInfo()`
* added `HttpClient::createForBaseUri()`
* added `HttplugClient` with support for sync and async requests
* added `max_duration` option
* added support for NTLM authentication
* added `StreamWrapper` to cast any `ResponseInterface` instances to PHP streams.
* added `$response->toStream()` to cast responses to regular PHP streams
* made `Psr18Client` implement relevant PSR-17 factories and have streaming responses
* added `TraceableHttpClient`, `HttpClientDataCollector` and `HttpClientPass` to integrate with the web profiler
* allow enabling buffering conditionally with a Closure
* allow option "buffer" to be a stream resource
* allow arbitrary values for the "json" option
4.3.0
-----
* added the component

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Caching;
/**
* @internal
*/
enum Freshness
{
/**
* The cached response is fresh and can be used without revalidation.
*/
case Fresh;
/**
* The cached response is stale and must be revalidated before use.
*/
case MustRevalidate;
/**
* The cached response is stale and should not be used.
*/
case Stale;
/**
* The cached response is stale but may be used as a fallback in case of errors.
*/
case StaleButUsable;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class DataChunk implements ChunkInterface
{
public function __construct(
private int $offset = 0,
private string $content = '',
) {
}
public function isTimeout(): bool
{
return false;
}
public function isFirst(): bool
{
return false;
}
public function isLast(): bool
{
return false;
}
public function getInformationalStatus(): ?array
{
return null;
}
public function getContent(): string
{
return $this->content;
}
public function getOffset(): int
{
return $this->offset;
}
public function getError(): ?string
{
return null;
}
}

View File

@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
use Symfony\Component\HttpClient\Exception\TimeoutException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class ErrorChunk implements ChunkInterface
{
private bool $didThrow = false;
private string $errorMessage;
private ?\Throwable $error = null;
public function __construct(
private int $offset,
\Throwable|string $error,
) {
if (\is_string($error)) {
$this->errorMessage = $error;
} else {
$this->error = $error;
$this->errorMessage = $error->getMessage();
}
}
public function isTimeout(): bool
{
$this->didThrow = true;
if (null !== $this->error) {
throw new TransportException($this->errorMessage, 0, $this->error);
}
return true;
}
public function isFirst(): bool
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function isLast(): bool
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function getInformationalStatus(): ?array
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function getContent(): string
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
public function getOffset(): int
{
return $this->offset;
}
public function getError(): ?string
{
return $this->errorMessage;
}
public function didThrow(?bool $didThrow = null): bool
{
if (null !== $didThrow && $this->didThrow !== $didThrow) {
return !$this->didThrow = $didThrow;
}
return $this->didThrow;
}
public function __serialize(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __unserialize(array $data): void
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
if (!$this->didThrow) {
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class FirstChunk extends DataChunk
{
public function isFirst(): bool
{
return true;
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class InformationalChunk extends DataChunk
{
private array $status;
public function __construct(int $statusCode, array $headers)
{
$this->status = [$statusCode, $headers];
parent::__construct();
}
public function getInformationalStatus(): ?array
{
return $this->status;
}
}

View File

@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class LastChunk extends DataChunk
{
public function isLast(): bool
{
return true;
}
}

View File

@@ -0,0 +1,114 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Chunk;
use Symfony\Component\HttpClient\Exception\JsonException;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* @author Antoine Bluchet <soyuka@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ServerSentEvent extends DataChunk implements ChunkInterface
{
private string $data = '';
private string $id = '';
private string $type = 'message';
private float $retry = 0;
private ?array $jsonData = null;
public function __construct(string $content)
{
parent::__construct(-1, $content);
// remove BOM
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
}
foreach (preg_split("/(?:\r\n|[\r\n])/", $content) as $line) {
if (0 === $i = strpos($line, ':')) {
continue;
}
$i = false === $i ? \strlen($line) : $i;
$field = substr($line, 0, $i);
$i += 1 + (' ' === ($line[1 + $i] ?? ''));
switch ($field) {
case 'id':
$this->id = substr($line, $i);
break;
case 'event':
$this->type = substr($line, $i);
break;
case 'data':
$this->data .= ('' === $this->data ? '' : "\n").substr($line, $i);
break;
case 'retry':
$retry = substr($line, $i);
if ('' !== $retry && \strlen($retry) === strspn($retry, '0123456789')) {
$this->retry = $retry / 1000.0;
}
break;
}
}
}
public function getId(): string
{
return $this->id;
}
public function getType(): string
{
return $this->type;
}
public function getData(): string
{
return $this->data;
}
public function getRetry(): float
{
return $this->retry;
}
/**
* Gets the SSE data decoded as an array when it's a JSON payload.
*/
public function getArrayData(): array
{
if (null !== $this->jsonData) {
return $this->jsonData;
}
if ('' === $this->data) {
throw new JsonException(\sprintf('Server-Sent Event%s data is empty.', '' !== $this->id ? \sprintf(' "%s"', $this->id) : ''));
}
try {
$jsonData = json_decode($this->data, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new JsonException(\sprintf('Decoding Server-Sent Event%s failed: ', '' !== $this->id ? \sprintf(' "%s"', $this->id) : '').$e->getMessage(), $e->getCode());
}
if (!\is_array($jsonData)) {
throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned in Server-Sent Event%s.', get_debug_type($jsonData), '' !== $this->id ? \sprintf(' "%s"', $this->id) : ''));
}
return $this->jsonData = $jsonData;
}
}

View File

@@ -0,0 +1,587 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Component\HttpClient\Internal\PushedResponse;
use Symfony\Component\HttpClient\Response\CurlResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* A performant implementation of the HttpClientInterface contracts based on the curl extension.
*
* This provides fully concurrent HTTP requests, with transparent
* HTTP/2 push when a curl version that supports it is installed.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
public const OPTIONS_DEFAULTS = HttpClientInterface::OPTIONS_DEFAULTS + [
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
];
private array $defaultOptions = self::OPTIONS_DEFAULTS + [
// array|string - an array containing the username as first value, and optionally the
// password as the second one; or string like username:password - enabling NTLM auth
'auth_ntlm' => null,
'extra' => [
'use_persistent_connections' => false,
// A list of extra curl options indexed by their corresponding CURLOPT_*
'curl' => [],
],
];
private static array $emptyDefaults = self::OPTIONS_DEFAULTS + ['auth_ntlm' => null];
private ?LoggerInterface $logger = null;
/**
* An internal object to share state between the client and its responses.
*/
private CurlClientState $multi;
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 0)
{
if (!\extension_loaded('curl')) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.');
}
$this->defaultOptions['buffer'] ??= self::shouldBuffer(...);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->multi = new CurlClientState($maxHostConnections, $maxPendingPushes);
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $this->multi->logger = $logger;
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
$scheme = $url['scheme'];
$authority = $url['authority'];
$host = parse_url($authority, \PHP_URL_HOST);
$port = parse_url($authority, \PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443);
$proxy = self::getProxyUrl($options['proxy'], $url);
$url = implode('', $url);
if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: Symfony HttpClient (Curl)';
}
$curlopts = [
\CURLOPT_URL => $url,
\CURLOPT_TCP_NODELAY => true,
\CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
\CURLOPT_REDIR_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS,
\CURLOPT_FOLLOWLOCATION => true,
\CURLOPT_MAXREDIRS => max(0, $options['max_redirects']),
\CURLOPT_COOKIEFILE => '', // Keep track of cookies during redirects
\CURLOPT_TIMEOUT => 0,
\CURLOPT_PROXY => $proxy,
\CURLOPT_NOPROXY => $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '',
\CURLOPT_SSL_VERIFYPEER => $options['verify_peer'],
\CURLOPT_SSL_VERIFYHOST => $options['verify_host'] ? 2 : 0,
\CURLOPT_CAINFO => $options['cafile'],
\CURLOPT_CAPATH => $options['capath'],
\CURLOPT_SSL_CIPHER_LIST => $options['ciphers'],
\CURLOPT_SSLCERT => $options['local_cert'],
\CURLOPT_SSLKEY => $options['local_pk'],
\CURLOPT_KEYPASSWD => $options['passphrase'],
\CURLOPT_CERTINFO => $options['capture_peer_cert_chain'],
\CURLOPT_SSLVERSION => match ($options['crypto_method']) {
\STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT => \CURL_SSLVERSION_TLSv1_3,
\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT => \CURL_SSLVERSION_TLSv1_2,
\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT => \CURL_SSLVERSION_TLSv1_1,
\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT => \CURL_SSLVERSION_TLSv1_0,
},
];
if (1.0 === (float) $options['http_version']) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_0;
} elseif (1.1 === (float) $options['http_version']) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
} elseif (\defined('CURL_VERSION_HTTP2') && (\CURL_VERSION_HTTP2 & CurlClientState::$curlVersion['features']) && ('https:' === $scheme || 2.0 === (float) $options['http_version'])) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_2_0;
} elseif (\defined('CURL_VERSION_HTTP3') && (\CURL_VERSION_HTTP3 & CurlClientState::$curlVersion['features']) && 3.0 === (float) $options['http_version'] && !self::willUseProxy($proxy, $curlopts[\CURLOPT_NOPROXY], $host)) {
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_3;
}
$ntlmOriginKey = null;
if (isset($options['auth_ntlm'])) {
$curlopts[\CURLOPT_HTTPAUTH] = \CURLAUTH_NTLM;
$curlopts[\CURLOPT_HTTP_VERSION] = \CURL_HTTP_VERSION_1_1;
if (\is_array($options['auth_ntlm'])) {
$count = \count($options['auth_ntlm']);
if ($count <= 0 || $count > 2) {
throw new InvalidArgumentException(\sprintf('Option "auth_ntlm" must contain 1 or 2 elements, %d given.', $count));
}
$options['auth_ntlm'] = implode(':', $options['auth_ntlm']);
}
if (!\is_string($options['auth_ntlm'])) {
throw new InvalidArgumentException(\sprintf('Option "auth_ntlm" must be a string or an array, "%s" given.', get_debug_type($options['auth_ntlm'])));
}
$curlopts[\CURLOPT_USERPWD] = $options['auth_ntlm'];
$ntlmOriginKey = CurlClientState::originKey($scheme, $host, $port);
if (isset($this->multi->ntlmRequiresFreshConnection[$ntlmOriginKey])) {
$curlopts[\CURLOPT_FRESH_CONNECT] = true;
$curlopts[\CURLOPT_FORBID_REUSE] = true;
}
}
if (!\ZEND_THREAD_SAFE) {
$curlopts[\CURLOPT_DNS_USE_GLOBAL_CACHE] = false;
}
if (\defined('CURLOPT_HEADEROPT') && \defined('CURLHEADER_SEPARATE')) {
$curlopts[\CURLOPT_HEADEROPT] = \CURLHEADER_SEPARATE;
}
// curl's resolve feature varies by host:port but ours varies by host only, let's handle this with our own DNS map
if (isset($this->multi->dnsCache->hostnames[$host])) {
$options['resolve'] += [$host => $this->multi->dnsCache->hostnames[$host]];
}
if ($options['resolve'] || $this->multi->dnsCache->evictions) {
// First reset any old DNS cache entries then add the new ones
$resolve = $this->multi->dnsCache->evictions;
$this->multi->dnsCache->evictions = [];
if ($resolve && 0x072A00 > CurlClientState::$curlVersion['version_number']) {
// DNS cache removals require curl 7.42 or higher
$this->multi->reset();
}
foreach ($options['resolve'] as $resolveHost => $ip) {
$resolve[] = null === $ip ? "-$resolveHost:$port" : "$resolveHost:$port:$ip";
$this->multi->dnsCache->hostnames[$resolveHost] = $ip;
$this->multi->dnsCache->removals["-$resolveHost:$port"] = "-$resolveHost:$port";
}
$curlopts[\CURLOPT_RESOLVE] = $resolve;
}
$curlopts[\CURLOPT_CUSTOMREQUEST] = $method;
if ('POST' === $method) {
// Use CURLOPT_POST to have browser-like POST-to-GET redirects for 301, 302 and 303
$curlopts[\CURLOPT_POST] = true;
} elseif ('HEAD' === $method) {
$curlopts[\CURLOPT_NOBODY] = true;
}
if ('\\' !== \DIRECTORY_SEPARATOR && $options['timeout'] < 1) {
$curlopts[\CURLOPT_NOSIGNAL] = true;
}
if (\extension_loaded('zlib') && !isset($options['normalized_headers']['accept-encoding'])) {
$options['headers'][] = 'Accept-Encoding: gzip'; // Expose only one encoding, some servers mess up when more are provided
}
$body = $options['body'];
foreach ($options['headers'] as $i => $header) {
if (\is_string($body) && '' !== $body && 0 === stripos($header, 'Content-Length: ')) {
// Let curl handle Content-Length headers
unset($options['headers'][$i]);
continue;
}
if (':' === $header[-2] && \strlen($header) - 2 === strpos($header, ': ')) {
// curl requires a special syntax to send empty headers
$curlopts[\CURLOPT_HTTPHEADER][] = substr_replace($header, ';', -2);
} else {
$curlopts[\CURLOPT_HTTPHEADER][] = $header;
}
}
// Prevent curl from sending its default Accept and Expect headers
foreach (['accept', 'expect'] as $header) {
if (!isset($options['normalized_headers'][$header][0])) {
$curlopts[\CURLOPT_HTTPHEADER][] = $header.':';
}
}
if (!\is_string($body)) {
if (isset($options['auth_ntlm'])) {
$curlopts[\CURLOPT_FORBID_REUSE] = true; // Reusing NTLM connections requires seeking capability, which only string bodies support
}
if (\is_resource($body)) {
$curlopts[\CURLOPT_READDATA] = $body;
} else {
$curlopts[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body) {
static $eof = false;
static $buffer = '';
return self::readRequestBody($length, $body, $buffer, $eof);
};
}
if (isset($options['normalized_headers']['content-length'][0])) {
$curlopts[\CURLOPT_INFILESIZE] = (int) substr($options['normalized_headers']['content-length'][0], \strlen('Content-Length: '));
}
if (!isset($options['normalized_headers']['transfer-encoding'])) {
$curlopts[\CURLOPT_HTTPHEADER][] = 'Transfer-Encoding:'.(isset($curlopts[\CURLOPT_INFILESIZE]) ? '' : ' chunked');
}
if ('POST' !== $method) {
$curlopts[\CURLOPT_UPLOAD] = true;
if (!isset($options['normalized_headers']['content-type']) && 0 !== ($curlopts[\CURLOPT_INFILESIZE] ?? null)) {
$curlopts[\CURLOPT_HTTPHEADER][] = 'Content-Type: application/x-www-form-urlencoded';
}
}
} elseif ('' !== $body || 'POST' === $method) {
$curlopts[\CURLOPT_POSTFIELDS] = $body;
}
if ($options['peer_fingerprint']) {
if (!isset($options['peer_fingerprint']['pin-sha256'])) {
throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.');
}
$curlopts[\CURLOPT_PINNEDPUBLICKEY] = 'sha256//'.implode(';sha256//', $options['peer_fingerprint']['pin-sha256']);
}
if ($options['bindto']) {
if (file_exists($options['bindto'])) {
$curlopts[\CURLOPT_UNIX_SOCKET_PATH] = $options['bindto'];
} elseif (!str_starts_with($options['bindto'], 'if!') && preg_match('/^(.*):(\d+)$/', $options['bindto'], $matches)) {
$curlopts[\CURLOPT_INTERFACE] = trim($matches[1], '[]');
$curlopts[\CURLOPT_LOCALPORT] = $matches[2];
} else {
$curlopts[\CURLOPT_INTERFACE] = $options['bindto'];
}
}
if (0 < $options['max_duration']) {
$curlopts[\CURLOPT_TIMEOUT_MS] = 1000 * $options['max_duration'];
}
if (0 < $options['max_connect_duration']) {
$curlopts[\CURLOPT_CONNECTTIMEOUT_MS] = ceil(1000 * $options['max_connect_duration']);
}
if (!empty($options['extra']['curl']) && \is_array($options['extra']['curl'])) {
$this->validateExtraCurlOptions($options['extra']['curl']);
$curlopts += $options['extra']['curl'];
}
if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
unset($this->multi->pushedResponses[$url]);
if (self::acceptPushForRequest($method, $options, $pushedResponse)) {
$this->logger?->debug(\sprintf('Accepting pushed response: "%s %s"', $method, $url));
// Reinitialize the pushed response with request's options
$ch = $pushedResponse->handle;
$pushedResponse = $pushedResponse->response;
$pushedResponse->__construct($this->multi, $url, $options, $this->logger);
} else {
$this->logger?->debug(\sprintf('Rejecting pushed response: "%s"', $url));
$pushedResponse = null;
}
}
if (!$pushedResponse) {
$ch = curl_init();
$this->logger?->info(\sprintf('Request: "%s %s"', $method, $url));
$curlopts += [\CURLOPT_SHARE => ($options['extra']['use_persistent_connections'] ?? false) ? $this->multi->persistentShare : $this->multi->share];
}
foreach ($curlopts as $opt => $value) {
if (\PHP_INT_SIZE === 8 && \defined('CURLOPT_INFILESIZE_LARGE') && \CURLOPT_INFILESIZE === $opt && $value >= 1 << 31) {
$opt = \CURLOPT_INFILESIZE_LARGE;
}
if (null !== $value && !curl_setopt($ch, $opt, $value) && \CURLOPT_CERTINFO !== $opt && (!\defined('CURLOPT_HEADEROPT') || \CURLOPT_HEADEROPT !== $opt)) {
$constantName = $this->findConstantName($opt);
throw new TransportException(\sprintf('Curl option "%s" is not supported.', $constantName ?? $opt));
}
}
return $pushedResponse ?? new CurlResponse($this->multi, $ch, $options, $this->logger, $method, self::createRedirectResolver($options, $authority), CurlClientState::$curlVersion['version_number'], $url, $ntlmOriginKey);
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof CurlResponse) {
$responses = [$responses];
}
if ($this->multi->handle instanceof \CurlMultiHandle) {
$active = 0;
while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active)) {
}
}
return new ResponseStream(CurlResponse::stream($responses, $timeout));
}
public function reset(): void
{
$this->multi->reset();
}
/**
* Accepts pushed responses only if their headers related to authentication match the request.
*/
private static function acceptPushForRequest(string $method, array $options, PushedResponse $pushedResponse): bool
{
if ('' !== $options['body'] || $method !== $pushedResponse->requestHeaders[':method'][0]) {
return false;
}
foreach (['proxy', 'no_proxy', 'bindto', 'local_cert', 'local_pk'] as $k) {
if ($options[$k] !== $pushedResponse->parentOptions[$k]) {
return false;
}
}
foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
$normalizedHeaders = $options['normalized_headers'][$k] ?? [];
foreach ($normalizedHeaders as $i => $v) {
$normalizedHeaders[$i] = substr($v, \strlen($k) + 2);
}
if (($pushedResponse->requestHeaders[$k] ?? []) !== $normalizedHeaders) {
return false;
}
}
$statusCode = $pushedResponse->response->getInfo('http_code') ?: 200;
return $statusCode < 300 || 400 <= $statusCode;
}
/**
* Wraps the request's body callback to allow it to return strings longer than curl requested.
*
* @param-immediately-invoked-callable $body
*/
private static function readRequestBody(int $length, \Closure $body, string &$buffer, bool &$eof): string
{
if (!$eof && \strlen($buffer) < $length) {
if (!\is_string($data = $body($length))) {
throw new TransportException(\sprintf('The return value of the "body" option callback must be a string, "%s" returned.', get_debug_type($data)));
}
$buffer .= $data;
$eof = '' === $data;
}
$data = substr($buffer, 0, $length);
$buffer = substr($buffer, $length);
return $data;
}
/**
* Resolves relative URLs on redirects and deals with authentication headers.
*
* Work around CVE-2018-1000007: Authorization and Cookie headers should not follow redirects - fixed in Curl 7.64
*/
private static function createRedirectResolver(array $options, string $authority): \Closure
{
$redirectHeaders = [];
if (0 < $options['max_redirects']) {
$redirectHeaders['authority'] = $authority;
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static fn ($h) => 0 !== stripos($h, 'Host:'));
if (isset($options['normalized_headers']['authorization'][0]) || isset($options['normalized_headers']['cookie'][0])) {
$redirectHeaders['no_auth'] = array_filter($options['headers'], static fn ($h) => 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'));
}
}
return static function ($ch, string $location, bool $noContent) use (&$redirectHeaders, $options) {
try {
$location = self::parseUrl($location);
$url = self::parseUrl(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL));
$url = self::resolveUrl($location, $url);
} catch (InvalidArgumentException) {
return null;
}
if ($noContent && $redirectHeaders) {
$filterContentHeaders = static fn ($h) => 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:');
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders);
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
}
if ($redirectHeaders && isset($location['authority'])) {
$requestHeaders = $location['authority'] === $redirectHeaders['authority'] ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
curl_setopt($ch, \CURLOPT_HTTPHEADER, $requestHeaders);
} elseif ($noContent && $redirectHeaders) {
curl_setopt($ch, \CURLOPT_HTTPHEADER, $redirectHeaders['with_auth']);
}
$proxy = self::getProxyUrl($options['proxy'], $url);
curl_setopt($ch, \CURLOPT_PROXY, $proxy);
if (\defined('CURL_HTTP_VERSION_3') && \CURL_HTTP_VERSION_3 === curl_getinfo($ch, \CURLINFO_HTTP_VERSION) && self::willUseProxy($proxy, $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '', parse_url($url['authority'], \PHP_URL_HOST))) {
curl_setopt($ch, \CURLOPT_HTTP_VERSION, \defined('CURL_HTTP_VERSION_2_0') ? \CURL_HTTP_VERSION_2_0 : \CURL_HTTP_VERSION_1_1);
}
return implode('', $url);
};
}
private function findConstantName(int $opt): ?string
{
$constants = array_filter(get_defined_constants(), static fn ($v, $k) => $v === $opt && 'C' === $k[0] && (str_starts_with($k, 'CURLOPT_') || str_starts_with($k, 'CURLINFO_')), \ARRAY_FILTER_USE_BOTH);
return key($constants);
}
/**
* Prevents overriding options that are set internally throughout the request.
*/
private function validateExtraCurlOptions(array $options): void
{
$curloptsToConfig = [
// options used in CurlHttpClient
\CURLOPT_HTTPAUTH => 'auth_ntlm',
\CURLOPT_USERPWD => 'auth_ntlm',
\CURLOPT_RESOLVE => 'resolve',
\CURLOPT_NOSIGNAL => 'timeout',
\CURLOPT_HTTPHEADER => 'headers',
\CURLOPT_READDATA => 'body',
\CURLOPT_READFUNCTION => 'body',
\CURLOPT_INFILESIZE => 'body',
\CURLOPT_POSTFIELDS => 'body',
\CURLOPT_UPLOAD => 'body',
\CURLOPT_INTERFACE => 'bindto',
\CURLOPT_TIMEOUT_MS => 'max_duration',
\CURLOPT_TIMEOUT => 'max_duration',
\CURLOPT_CONNECTTIMEOUT_MS => 'max_connect_duration',
\CURLOPT_CONNECTTIMEOUT => 'max_connect_duration',
\CURLOPT_MAXREDIRS => 'max_redirects',
\CURLOPT_POSTREDIR => 'max_redirects',
\CURLOPT_PROXY => 'proxy',
\CURLOPT_NOPROXY => 'no_proxy',
\CURLOPT_SSL_VERIFYPEER => 'verify_peer',
\CURLOPT_SSL_VERIFYHOST => 'verify_host',
\CURLOPT_CAINFO => 'cafile',
\CURLOPT_CAPATH => 'capath',
\CURLOPT_SSL_CIPHER_LIST => 'ciphers',
\CURLOPT_SSLCERT => 'local_cert',
\CURLOPT_SSLKEY => 'local_pk',
\CURLOPT_KEYPASSWD => 'passphrase',
\CURLOPT_CERTINFO => 'capture_peer_cert_chain',
\CURLOPT_USERAGENT => 'normalized_headers',
\CURLOPT_REFERER => 'headers',
// options used in CurlResponse
\CURLOPT_NOPROGRESS => 'on_progress',
\CURLOPT_PROGRESSFUNCTION => 'on_progress',
];
if (\defined('CURLOPT_UNIX_SOCKET_PATH')) {
$curloptsToConfig[\CURLOPT_UNIX_SOCKET_PATH] = 'bindto';
}
if (\defined('CURLOPT_PINNEDPUBLICKEY')) {
$curloptsToConfig[\CURLOPT_PINNEDPUBLICKEY] = 'peer_fingerprint';
}
$curloptsToCheck = [
\CURLOPT_PRIVATE,
\CURLOPT_HEADERFUNCTION,
\CURLOPT_WRITEFUNCTION,
\CURLOPT_VERBOSE,
\CURLOPT_STDERR,
\CURLOPT_RETURNTRANSFER,
\CURLOPT_URL,
\CURLOPT_FOLLOWLOCATION,
\CURLOPT_HEADER,
\CURLOPT_HTTP_VERSION,
\CURLOPT_PORT,
\CURLOPT_DNS_USE_GLOBAL_CACHE,
\CURLOPT_PROTOCOLS,
\CURLOPT_REDIR_PROTOCOLS,
\CURLOPT_COOKIEFILE,
\CURLINFO_REDIRECT_COUNT,
];
if (\defined('CURLOPT_HTTP09_ALLOWED')) {
$curloptsToCheck[] = \CURLOPT_HTTP09_ALLOWED;
}
if (\defined('CURLOPT_HEADEROPT')) {
$curloptsToCheck[] = \CURLOPT_HEADEROPT;
}
foreach ($options as $opt => $optValue) {
if (isset($curloptsToConfig[$opt])) {
$constName = $this->findConstantName($opt) ?? $opt;
throw new InvalidArgumentException(\sprintf('Cannot set "%s" with "extra.curl", use option "%s" instead.', $constName, $curloptsToConfig[$opt]));
}
if (\in_array($opt, [\CURLOPT_POST, \CURLOPT_PUT, \CURLOPT_CUSTOMREQUEST, \CURLOPT_HTTPGET, \CURLOPT_NOBODY], true)) {
throw new InvalidArgumentException('The HTTP method cannot be overridden using "extra.curl".');
}
if (\in_array($opt, $curloptsToCheck, true)) {
$constName = $this->findConstantName($opt) ?? $opt;
throw new InvalidArgumentException(\sprintf('Cannot set "%s" with "extra.curl".', $constName));
}
}
}
private static function willUseProxy(?string $proxy, string $noProxy, string $host): bool
{
if (null === $proxy) {
return false;
}
if ('' === $noProxy) {
return true;
}
foreach (preg_split('/[\s,]+/', $noProxy) as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,270 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\DataCollector;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\TraceableHttpClient;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\Process\Process;
use Symfony\Component\VarDumper\Caster\ImgStub;
/**
* @author Jérémy Romey <jeremy@free-agent.fr>
*/
final class HttpClientDataCollector extends DataCollector implements LateDataCollectorInterface
{
use HttpClientTrait;
/**
* @var TraceableHttpClient[]
*/
private array $clients = [];
public function registerClient(string $name, TraceableHttpClient $client): void
{
$this->clients[$name] = $client;
}
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
{
$this->lateCollect();
}
public function lateCollect(): void
{
$this->data['request_count'] ??= 0;
$this->data['error_count'] ??= 0;
$this->data += ['clients' => []];
foreach ($this->clients as $name => $client) {
[$errorCount, $traces] = $this->collectOnClient($client);
$this->data['clients'] += [
$name => [
'traces' => [],
'error_count' => 0,
],
];
$this->data['clients'][$name]['traces'] = array_merge($this->data['clients'][$name]['traces'], $traces);
$this->data['request_count'] += \count($traces);
$this->data['error_count'] += $errorCount;
$this->data['clients'][$name]['error_count'] += $errorCount;
if ($traces) {
$client->reset();
}
}
}
public function getClients(): array
{
return $this->data['clients'] ?? [];
}
public function getRequestCount(): int
{
return $this->data['request_count'] ?? 0;
}
public function getErrorCount(): int
{
return $this->data['error_count'] ?? 0;
}
public function getName(): string
{
return 'http_client';
}
public function reset(): void
{
$this->data = [
'clients' => [],
'request_count' => 0,
'error_count' => 0,
];
}
private function collectOnClient(TraceableHttpClient $client): array
{
$traces = $client->getTracedRequests();
$errorCount = 0;
$baseInfo = [
'response_headers' => 1,
'retry_count' => 1,
'redirect_count' => 1,
'redirect_url' => 1,
'user_data' => 1,
'error' => 1,
'url' => 1,
];
foreach ($traces as $i => $trace) {
if (400 <= ($trace['info']['http_code'] ?? 0)) {
++$errorCount;
}
$info = $trace['info'];
$traces[$i]['http_code'] = $info['http_code'] ?? 0;
unset($info['filetime'], $info['http_code'], $info['ssl_verify_result'], $info['content_type']);
if (($info['http_method'] ?? null) === $trace['method']) {
unset($info['http_method']);
}
if (($info['url'] ?? null) === $trace['url']) {
unset($info['url']);
}
foreach ($info as $k => $v) {
if (!$v || (is_numeric($v) && 0 > $v)) {
unset($info[$k]);
}
}
if (\is_string($content = $trace['content'])) {
$contentType = 'application/octet-stream';
foreach ($info['response_headers'] ?? [] as $h) {
if (0 === stripos($h, 'content-type: ')) {
$contentType = substr($h, \strlen('content-type: '));
break;
}
}
if (str_starts_with($contentType, 'image/') && class_exists(ImgStub::class)) {
$content = new ImgStub($content, $contentType, '');
} else {
$content = [$content];
}
$content = ['response_content' => $content];
} elseif (\is_array($content)) {
$content = ['response_json' => $content];
} else {
$content = [];
}
if (isset($info['retry_count'])) {
$content['retries'] = $info['previous_info'];
unset($info['previous_info']);
}
$debugInfo = array_diff_key($info, $baseInfo);
$info = ['info' => $debugInfo] + array_diff_key($info, $debugInfo) + $content;
unset($traces[$i]['info']); // break PHP reference used by TraceableHttpClient
$traces[$i]['info'] = $this->cloneVar($info);
$traces[$i]['options'] = $this->cloneVar($trace['options']);
$traces[$i]['curlCommand'] = $this->getCurlCommand($trace);
}
return [$errorCount, $traces];
}
private function getCurlCommand(array $trace): ?string
{
if (!isset($trace['info']['debug'])) {
return null;
}
$url = $trace['info']['original_url'] ?? $trace['info']['url'] ?? $trace['url'];
$command = ['curl', '--compressed'];
if (isset($trace['options']['resolve'])) {
$port = parse_url($url, \PHP_URL_PORT) ?: (str_starts_with('http:', $url) ? 80 : 443);
foreach ($trace['options']['resolve'] as $host => $ip) {
if (null !== $ip) {
$command[] = '--resolve '.escapeshellarg("$host:$port:$ip");
}
}
}
$dataArg = [];
if ($json = $trace['options']['json'] ?? null) {
$dataArg[] = '--data-raw '.$this->escapePayload(self::jsonEncode($json));
} elseif ($body = $trace['options']['body'] ?? null) {
if (\is_string($body)) {
$dataArg[] = '--data-raw '.$this->escapePayload($body);
} elseif (\is_array($body)) {
try {
$body = self::normalizeBody($body);
} catch (TransportException) {
return null;
}
if (!\is_string($body)) {
return null;
}
foreach (explode('&', $body) as $value) {
$dataArg[] = '--data-raw '.$this->escapePayload(urldecode($value));
}
} else {
return null;
}
}
$dataArg = $dataArg ? implode(' ', $dataArg) : null;
foreach (explode("\n", $trace['info']['debug']) as $line) {
$line = substr($line, 0, -1);
if (str_starts_with('< ', $line)) {
// End of the request, beginning of the response. Stop parsing.
break;
}
if (str_starts_with('Due to a bug in curl ', $line)) {
// When the curl client disables debug info due to a curl bug, we cannot build the command.
return null;
}
if ('' === $line || preg_match('/^[*<]|(Host: )/', $line)) {
continue;
}
if (preg_match('/^> ([A-Z]+)/', $line, $match)) {
$command[] = \sprintf('--request %s', $match[1]);
$command[] = \sprintf('--url %s', escapeshellarg($url));
continue;
}
$command[] = '--header '.escapeshellarg($line);
}
if (null !== $dataArg) {
$command[] = $dataArg;
}
return implode(" \\\n ", $command);
}
private function escapePayload(string $payload): string
{
static $useProcess;
if ($useProcess ??= \function_exists('proc_open') && class_exists(Process::class)) {
return substr((new Process(['', $payload]))->getCommandLine(), 3);
}
if ('\\' === \DIRECTORY_SEPARATOR) {
return '"'.str_replace('"', '""', $payload).'"';
}
return "'".str_replace("'", "'\\''", $payload)."'";
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Eases with writing decorators.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
trait DecoratorTrait
{
private HttpClientInterface $client;
public function __construct(?HttpClientInterface $client = null)
{
$this->client = $client ?? HttpClient::create();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
return $this->client->request($method, $url, $options);
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
public function reset(): void
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpClient\TraceableHttpClient;
final class HttpClientPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('data_collector.http_client')) {
return;
}
foreach ($container->findTaggedServiceIds('http_client.client') as $id => $tags) {
$container->register('.debug.'.$id, TraceableHttpClient::class)
->setDecoratedService($id, null, 100)
->setArguments([new Reference('.inner'), new Reference('debug.stopwatch', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), new Reference('profiler.is_disabled_state_checker', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)])
->addTag('kernel.reset', ['method' => 'reset']);
$container->getDefinition('data_collector.http_client')
->addMethodCall('registerClient', [$id, new Reference('.debug.'.$id)]);
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Internal\FollowRedirectsTrait;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Decorator that resolves host names using a custom resolver before delegating to the transport.
*
* The resolver is called for the requested host and for the host of every followed redirect. When it
* returns an IP address, the result is injected into the "resolve" option so that the transport connects
* to that IP without performing its own DNS resolution. When it returns null, the transport's default
* DNS resolution is used. Hosts that are already in the "resolve" option or that are IP addresses are
* not passed to it.
*
* Note that using this decorator opts out of the asynchronous and cached DNS resolution that the curl
* and amphp transports provide; the resolver is responsible for any caching it needs.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class DnsResolvingHttpClient implements HttpClientInterface, ResetInterface
{
use AsyncDecoratorTrait;
use FollowRedirectsTrait;
use HttpClientTrait;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
/** @var callable(string): ?string */
private $resolver;
/**
* @param callable(string $host): ?string $resolver Returns the IP address the given host name should resolve to,
* or null to let the transport perform its default DNS resolution
*/
public function __construct(
private HttpClientInterface $client,
callable $resolver,
) {
$this->resolver = $resolver;
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions, true);
$host = parse_url($url['authority'], \PHP_URL_HOST);
$url = implode('', $url);
$resolver = $this->resolver;
$resolve = static function (string $host, string $url, array &$options) use ($resolver): void {
if (isset($options['resolve'][$host]) || false !== filter_var(trim($host, '[]'), \FILTER_VALIDATE_IP)) {
return;
}
if (null !== $ip = $resolver($host)) {
$options['resolve'][$host] = $ip;
}
};
$resolve($host, $url, $options);
return $this->followRedirects($method, $url, $host, $options, $resolve);
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
return $clone;
}
public function reset(): void
{
if ($this->resolver instanceof ResetInterface) {
$this->resolver->reset();
}
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View File

@@ -0,0 +1,159 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Chunk\DataChunk;
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\Exception\EventSourceException;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* @author Antoine Bluchet <soyuka@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
final class EventSourceHttpClient implements HttpClientInterface, ResetInterface
{
use AsyncDecoratorTrait, HttpClientTrait {
AsyncDecoratorTrait::withOptions insteadof HttpClientTrait;
}
public function __construct(
?HttpClientInterface $client = null,
private float $reconnectionTime = 10.0,
) {
$this->client = $client ?? HttpClient::create();
}
public function connect(string $url, array $options = [], string $method = 'GET'): ResponseInterface
{
return $this->request($method, $url, self::mergeDefaultOptions($options, [
'buffer' => false,
'headers' => [
'Accept' => 'text/event-stream',
'Cache-Control' => 'no-cache',
],
], true));
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$state = new class {
public ?string $buffer = null;
public ?string $lastEventId = null;
public float $reconnectionTime;
public ?float $lastError = null;
};
$state->reconnectionTime = $this->reconnectionTime;
if ($accept = self::normalizeHeaders($options['headers'] ?? [])['accept'] ?? []) {
$state->buffer = \in_array($accept, [['Accept: text/event-stream'], ['accept: text/event-stream']], true) ? '' : null;
if (null !== $state->buffer) {
$options['extra']['trace_content'] = false;
}
}
return new AsyncResponse($this->client, $method, $url, $options, static function (ChunkInterface $chunk, AsyncContext $context) use ($state, $method, $url, $options) {
if (null !== $state->buffer) {
$context->setInfo('reconnection_time', $state->reconnectionTime);
$isTimeout = false;
}
$lastError = $state->lastError;
$state->lastError = null;
try {
$isTimeout = $chunk->isTimeout();
if (null !== $chunk->getInformationalStatus() || $context->getInfo('canceled')) {
yield $chunk;
return;
}
} catch (TransportExceptionInterface) {
$state->lastError = $lastError ?? hrtime(true) / 1E9;
if (null === $state->buffer || ($isTimeout && hrtime(true) / 1E9 - $state->lastError < $state->reconnectionTime)) {
yield $chunk;
} else {
$options['headers']['Last-Event-ID'] = $state->lastEventId;
$state->buffer = '';
$state->lastError = hrtime(true) / 1E9;
$context->getResponse()->cancel();
$context->replaceRequest($method, $url, $options);
if ($isTimeout) {
yield $chunk;
} else {
$context->pause($state->reconnectionTime);
}
}
return;
}
if ($chunk->isFirst()) {
if (preg_match('/^text\/event-stream(;|$)/i', $context->getHeaders()['content-type'][0] ?? '')) {
$state->buffer = '';
} elseif (null !== $lastError || (null !== $state->buffer && 200 === $context->getStatusCode())) {
throw new EventSourceException(\sprintf('Response content-type is "%s" while "text/event-stream" was expected for "%s".', $context->getHeaders()['content-type'][0] ?? '', $context->getInfo('url')));
} else {
$context->passthru();
}
yield $chunk;
return;
}
if ($chunk->isLast()) {
if ('' !== $content = $state->buffer) {
$state->buffer = '';
yield new DataChunk(-1, $content);
}
yield $chunk;
return;
}
$content = $state->buffer.$chunk->getContent();
$events = preg_split('/((?:\r\n){2,}|\r{2,}|\n{2,})/', $content, -1, \PREG_SPLIT_DELIM_CAPTURE);
$state->buffer = array_pop($events);
for ($i = 0; isset($events[$i]); $i += 2) {
$content = $events[$i].$events[1 + $i];
if (!preg_match('/(?:^|\r\n|[\r\n])[^:\r\n]/', $content)) {
yield new DataChunk(-1, $content);
continue;
}
$event = new ServerSentEvent($content);
if ('' !== $event->getId()) {
$context->setInfo('last_event_id', $state->lastEventId = $event->getId());
}
if ($event->getRetry()) {
$context->setInfo('reconnection_time', $state->reconnectionTime = $event->getRetry());
}
yield $event;
}
});
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
final class ChunkCacheItemNotFoundException extends TransportException
{
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
/**
* Represents a 4xx response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ClientException extends \RuntimeException implements ClientExceptionInterface
{
use HttpExceptionTrait;
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class EventSourceException extends \RuntimeException implements DecodingExceptionInterface
{
}

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait HttpExceptionTrait
{
public function __construct(
private ResponseInterface $response,
) {
$code = $response->getInfo('http_code');
$url = $response->getInfo('url');
$message = \sprintf('HTTP %d returned for "%s".', $code, $url);
$httpCodeFound = false;
$isJson = false;
foreach (array_reverse($response->getInfo('response_headers')) as $h) {
if (str_starts_with($h, 'HTTP/')) {
if ($httpCodeFound) {
break;
}
$message = \sprintf('%s returned for "%s".', $h, $url);
$httpCodeFound = true;
}
if (0 === stripos($h, 'content-type:')) {
if (preg_match('/\bjson\b/i', $h)) {
$isJson = true;
}
if ($httpCodeFound) {
break;
}
}
}
// Try to guess a better error message using common API error formats
// The MIME type isn't explicitly checked because some formats inherit from others
// Ex: JSON:API follows RFC 7807 semantics, Hydra can be used in any JSON-LD-compatible format
if ($isJson && $body = json_decode($response->getContent(false), true)) {
if (isset($body['hydra:title']) || isset($body['hydra:description'])) {
// see http://www.hydra-cg.com/spec/latest/core/#description-of-http-status-codes-and-errors
$separator = isset($body['hydra:title'], $body['hydra:description']) ? "\n\n" : '';
$message = ($body['hydra:title'] ?? '').$separator.($body['hydra:description'] ?? '');
} elseif ((isset($body['title']) || isset($body['detail']))
&& (\is_scalar($body['title'] ?? '') && \is_scalar($body['detail'] ?? ''))) {
// see RFC 7807 and https://jsonapi.org/format/#error-objects
$separator = isset($body['title'], $body['detail']) ? "\n\n" : '';
$message = ($body['title'] ?? '').$separator.($body['detail'] ?? '');
}
}
parent::__construct($message, $code);
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class InvalidArgumentException extends \InvalidArgumentException implements TransportExceptionInterface
{
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
/**
* Thrown by responses' toArray() method when their content cannot be JSON-decoded.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class JsonException extends \JsonException implements DecodingExceptionInterface
{
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
/**
* Represents a 3xx response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class RedirectionException extends \RuntimeException implements RedirectionExceptionInterface
{
use HttpExceptionTrait;
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
/**
* Represents a 5xx response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ServerException extends \RuntimeException implements ServerExceptionInterface
{
use HttpExceptionTrait;
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\TimeoutExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class TimeoutException extends TransportException implements TimeoutExceptionInterface
{
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Exception;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class TransportException extends \RuntimeException implements TransportExceptionInterface
{
}

View File

@@ -0,0 +1,639 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Promise\Promise;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Utils as PromiseUtils;
use GuzzleHttp\Psr7\Response as GuzzleResponse;
use GuzzleHttp\Psr7\Utils as Psr7Utils;
use GuzzleHttp\TransferStats;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyResponseInterface;
/**
* A Guzzle handler that uses Symfony's HttpClientInterface as its transport.
*
* This lets SDKs tightly coupled to Guzzle benefit from Symfony HttpClient's
* features (e.g. retry logic, tracing, scoping, mocking) by plugging this
* handler into a Guzzle client:
*
* $handler = new GuzzleHttpHandler(HttpClient::create());
* $guzzle = new \GuzzleHttp\Client(['handler' => $handler]);
*
* The handler is truly asynchronous: __invoke() returns a *pending* Promise
* immediately without performing any I/O. The actual work is driven by
* Symfony's HttpClientInterface::stream(), which multiplexes all in-flight
* requests together - the same approach CurlMultiHandler takes with
* curl_multi_*. Waiting on any single promise drives the whole pool so
* concurrent requests benefit from parallelism automatically.
*
* Guzzle request options are mapped to their Symfony equivalents as faithfully
* as possible; unsupported options are silently ignored so that existing SDK
* option sets do not cause errors.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class GuzzleHttpHandler
{
private readonly HttpClientInterface $client;
/**
* Maps each Symfony response (key) to a 3-tuple:
* [Psr7 RequestInterface, Guzzle options array, Guzzle Promise]
*
* @var \SplObjectStorage<SymfonyResponseInterface, array{0: RequestInterface, 1: array, 2: Promise}>
*/
private readonly \SplObjectStorage $pending;
/**
* PSR-7 response created eagerly on the first chunk so that the same
* instance is passed to on_headers and later resolved by the promise.
*
* @var \SplObjectStorage<SymfonyResponseInterface, ResponseInterface>
*/
private readonly \SplObjectStorage $psr7Responses;
private readonly bool $autoUpgradeHttpVersion;
public function __construct(?HttpClientInterface $client = null, bool $autoUpgradeHttpVersion = true)
{
$this->client = $client ?? HttpClient::create();
$this->autoUpgradeHttpVersion = $autoUpgradeHttpVersion;
$this->pending = new \SplObjectStorage();
$this->psr7Responses = new \SplObjectStorage();
}
/**
* Returns a *pending* Promise - no I/O is performed here.
*
* The wait function passed to the Promise drives Symfony's stream() loop,
* which resolves all currently queued requests concurrently.
*/
public function __invoke(RequestInterface $request, array $options): PromiseInterface
{
$symfonyOptions = $this->buildSymfonyOptions($request, $options);
try {
$symfonyResponse = $this->client->request($request->getMethod(), (string) $request->getUri(), $symfonyOptions);
} catch (\Exception $e) {
// Option validation errors surface here synchronously.
$p = new Promise();
$p->reject($e);
return $p;
}
$promise = new Promise(
function () use ($symfonyResponse): void {
$this->streamPending(null, $symfonyResponse);
},
function () use ($symfonyResponse): void {
unset($this->pending[$symfonyResponse], $this->psr7Responses[$symfonyResponse]);
$symfonyResponse->cancel();
},
);
$this->pending[$symfonyResponse] = [$request, $options, $promise];
if (isset($options['delay'])) {
$pause = $symfonyResponse->getInfo('pause_handler');
if (\is_callable($pause)) {
$pause($options['delay'] / 1000.0);
} else {
usleep((int) ($options['delay'] * 1000));
}
}
return $promise;
}
/**
* Ticks the event loop: processes available I/O and runs queued tasks.
*
* @param float $timeout Maximum time in seconds to wait for network activity (0 = non-blocking)
*/
public function tick(float $timeout = 1.0): void
{
$queue = PromiseUtils::queue();
// Push streaming work onto the Guzzle task queue so that .then()
// callbacks and other queued tasks get cooperative scheduling.
$queue->add(fn () => $this->streamPending($timeout, true));
$queue->run();
}
/**
* Runs until all outstanding connections have completed.
*/
public function execute(): void
{
while ($this->pending->count()) {
$this->streamPending(null, false);
}
}
/**
* Performs one pass of streaming I/O over all pending responses.
*
* @param float|null $timeout Idle timeout passed to stream(); 0 for non-blocking, null for default
*/
private function streamPending(?float $timeout, bool|SymfonyResponseInterface $breakAfter): void
{
if (!$this->pending->count()) {
return;
}
$queue = PromiseUtils::queue();
$responses = [];
foreach ($this->pending as $r) {
$responses[] = $r;
}
foreach ($this->client->stream($responses, $timeout) as $response => $chunk) {
try {
if ($chunk->isTimeout()) {
continue;
}
if ($chunk->isFirst()) {
// Deactivate 4xx/5xx exception throwing for this response;
// Guzzle's http_errors middleware handles that layer.
$response->getStatusCode();
[, $guzzleOpts] = $this->pending[$response] ?? [null, []];
$sink = $guzzleOpts['sink'] ?? null;
$body = Psr7Utils::streamFor(\is_string($sink) ? fopen($sink, 'w+') : ($sink ?? fopen('php://temp', 'r+')));
if (600 <= $response->getStatusCode()) {
$psrResponse = new GuzzleResponse(567, $response->getHeaders(false), $body);
(new \ReflectionProperty($psrResponse, 'statusCode'))->setValue($psrResponse, $response->getStatusCode());
} else {
$psrResponse = new GuzzleResponse($response->getStatusCode(), $response->getHeaders(false), $body);
}
$this->psr7Responses[$response] = $psrResponse;
if (isset($guzzleOpts['on_headers'])) {
try {
($guzzleOpts['on_headers'])($psrResponse);
} catch (\Throwable $e) {
[$guzzleRequest, , $promise] = $this->pending[$response];
unset($this->pending[$response], $this->psr7Responses[$response]);
$this->fireOnStats($guzzleOpts, $guzzleRequest, $psrResponse, $e, $response);
$promise->reject(new RequestException($e->getMessage(), $guzzleRequest, $psrResponse, $e));
$response->cancel();
}
}
}
$content = $chunk->getContent();
if ('' !== $content && isset($this->psr7Responses[$response])) {
$this->psr7Responses[$response]->getBody()->write($content);
}
if (!$chunk->isLast()) {
if (true === $breakAfter) {
break;
}
continue;
}
if (!isset($this->pending[$response])) {
unset($this->psr7Responses[$response]);
} else {
$this->resolveResponse($response);
}
if (\in_array($breakAfter, [true, $response], true)) {
break;
}
} catch (TransportExceptionInterface $e) {
if (isset($this->pending[$response])) {
$this->rejectResponse($response, $e);
} else {
unset($this->psr7Responses[$response]);
}
if (\in_array($breakAfter, [true, $response], true)) {
break;
}
} finally {
// Run .then() callbacks; they may add new entries to $this->pending.
$queue->run();
}
}
}
private function resolveResponse(SymfonyResponseInterface $response): void
{
[$guzzleRequest, $options, $promise] = $this->pending[$response];
$psrResponse = $this->psr7Responses[$response];
unset($this->pending[$response], $this->psr7Responses[$response]);
$body = $psrResponse->getBody();
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
$this->fireOnStats($options, $guzzleRequest, $psrResponse, null, $response);
$promise->resolve($psrResponse);
}
private function rejectResponse(SymfonyResponseInterface $response, TransportExceptionInterface $e): void
{
[$guzzleRequest, $options, $promise] = $this->pending[$response];
$psrResponse = $this->psr7Responses[$response] ?? null;
unset($this->pending[$response], $this->psr7Responses[$response]);
if ($body = $psrResponse?->getBody()) {
// Headers were already received: use RequestException so Guzzle middleware (e.g. retry)
// can distinguish a mid-stream failure from a connection-level one.
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
$this->fireOnStats($options, $guzzleRequest, $psrResponse, $e, $response);
$promise->reject(new RequestException($e->getMessage(), $guzzleRequest, $psrResponse, $e));
} else {
// No headers received: connection-level failure.
$this->fireOnStats($options, $guzzleRequest, null, $e, $response);
$promise->reject(new ConnectException($e->getMessage(), $guzzleRequest, null, [], $e));
}
}
private function fireOnStats(array $options, RequestInterface $request, ?ResponseInterface $psrResponse, ?\Throwable $error, SymfonyResponseInterface $symfonyResponse): void
{
if (!isset($options['on_stats'])) {
return;
}
$handlerStats = $symfonyResponse->getInfo();
($options['on_stats'])(new TransferStats($request, $psrResponse, $handlerStats['total_time'] ?? 0.0, $error, $handlerStats));
}
private function buildSymfonyOptions(RequestInterface $request, array $guzzleOptions): array
{
$options = [];
$options['headers'] = $this->extractHeaders($request, $guzzleOptions);
$this->applyBody($request, $options);
$this->applyAuth($guzzleOptions, $options);
$this->applyTimeouts($guzzleOptions, $options);
$this->applySsl($guzzleOptions, $options);
$this->applyProxy($request, $guzzleOptions, $options);
$this->applyRedirects($guzzleOptions, $options);
$this->applyMisc($request, $guzzleOptions, $options);
$this->applyDecodeContent($guzzleOptions, $options);
if (\extension_loaded('curl') && isset($guzzleOptions['curl'])) {
$this->applyCurlOptions($guzzleOptions['curl'], $options);
}
return $options;
}
/**
* Merges headers from the PSR-7 request with any headers supplied via the
* Guzzle 'headers' option (Guzzle option takes precedence).
*
* @return array<string, string[]>
*/
private function extractHeaders(RequestInterface $request, array $guzzleOptions): array
{
$headers = $request->getHeaders();
foreach ($guzzleOptions['headers'] ?? [] as $name => $value) {
$headers[$name] = (array) $value;
}
return $headers;
}
private function applyBody(RequestInterface $request, array &$options): void
{
$key = 'content-length';
$body = $request->getBody();
if (!$size = $options['headers'][$key][0] ?? $options['headers'][$key = 'Content-Length'][0] ?? $body->getSize() ?? -1) {
return;
}
if ($size < 0 || 1 << 21 < $size) {
$options['body'] = static function (int $size) use ($body) {
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
while (!$body->eof()) {
yield $body->read($size);
}
};
} else {
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
$options['body'] = $body->getContents();
}
if (0 < $size) {
$options['headers'][$key] = [$size];
}
}
/**
* Maps Guzzle's 'auth' option.
*
* Supported forms:
* ['user', 'pass'] -> auth_basic
* ['user', 'pass', 'basic'] -> auth_basic
* ['token', '', 'bearer'] -> auth_bearer
* ['token', '', 'token'] -> auth_bearer (alias)
*/
private function applyAuth(array $guzzleOptions, array &$options): void
{
if (!isset($guzzleOptions['auth'])) {
return;
}
$auth = $guzzleOptions['auth'];
$type = strtolower($auth[2] ?? 'basic');
if ('bearer' === $type || 'token' === $type) {
$options['auth_bearer'] = $auth[0];
} elseif ('ntlm' === $type) {
array_pop($auth);
$options['auth_ntlm'] = $auth;
} else {
$options['auth_basic'] = [$auth[0], $auth[1] ?? ''];
}
}
private function applyTimeouts(array $guzzleOptions, array &$options): void
{
if (0 < ($guzzleOptions['timeout'] ?? 0)) {
$options['max_duration'] = (float) $guzzleOptions['timeout'];
}
if (0 < ($guzzleOptions['read_timeout'] ?? 0)) {
$options['timeout'] = (float) $guzzleOptions['read_timeout'];
}
if (0 < ($guzzleOptions['connect_timeout'] ?? 0)) {
$options['max_connect_duration'] = (float) $guzzleOptions['connect_timeout'];
}
}
/**
* Maps SSL/TLS related options.
*
* Guzzle 'verify' (bool|string) -> Symfony verify_peer / verify_host / cafile / capath
* Guzzle 'cert' (string|array) -> Symfony local_cert [+ passphrase]
* Guzzle 'ssl_key'(string|array) -> Symfony local_pk [+ passphrase]
* Guzzle 'crypto_method' -> Symfony crypto_method (same PHP stream constants)
*/
private function applySsl(array $guzzleOptions, array &$options): void
{
if (isset($guzzleOptions['verify'])) {
if (false === $guzzleOptions['verify']) {
$options['verify_peer'] = false;
$options['verify_host'] = false;
} elseif (\is_string($guzzleOptions['verify'])) {
if (is_dir($guzzleOptions['verify'])) {
$options['capath'] = $guzzleOptions['verify'];
} else {
$options['cafile'] = $guzzleOptions['verify'];
}
}
}
if (isset($guzzleOptions['cert'])) {
$cert = $guzzleOptions['cert'];
if (\is_array($cert)) {
[$certPath, $certPass] = $cert;
$options['local_cert'] = $certPath;
$options['passphrase'] = $certPass;
} else {
$options['local_cert'] = $cert;
}
}
if (isset($guzzleOptions['ssl_key'])) {
$key = $guzzleOptions['ssl_key'];
if (\is_array($key)) {
[$keyPath, $keyPass] = $key;
$options['local_pk'] = $keyPath;
// Do not clobber a passphrase already set by 'cert'.
$options['passphrase'] ??= $keyPass;
} else {
$options['local_pk'] = $key;
}
}
if (isset($guzzleOptions['crypto_method'])) {
$options['crypto_method'] = $guzzleOptions['crypto_method'];
}
}
/**
* Maps Guzzle's 'proxy' option.
*
* String form -> proxy
* Array form -> selects proxy by URI scheme; 'no' key maps to no_proxy
*/
private function applyProxy(RequestInterface $request, array $guzzleOptions, array &$options): void
{
if (!isset($guzzleOptions['proxy'])) {
return;
}
if (\is_string($proxy = $guzzleOptions['proxy'])) {
$options['proxy'] = $proxy;
return;
}
$scheme = $request->getUri()->getScheme();
if (isset($proxy[$scheme])) {
$options['proxy'] = $proxy[$scheme];
}
if (isset($proxy['no'])) {
$options['no_proxy'] = implode(',', (array) $proxy['no']);
}
}
/**
* Maps Guzzle's 'allow_redirects' to Symfony's 'max_redirects'.
*
* false -> 0 (disable redirects)
* true -> (no override; Symfony defaults apply)
* ['max' => N, ...] -> N
*/
private function applyRedirects(array $guzzleOptions, array &$options): void
{
if (!isset($guzzleOptions['allow_redirects'])) {
return;
}
if (!$ar = $guzzleOptions['allow_redirects']) {
$options['max_redirects'] = 0;
} elseif (\is_array($ar)) {
// 5 matches Guzzle's own default for the 'max' sub-key.
$options['max_redirects'] = $ar['max'] ?? 5;
}
}
/**
* Miscellaneous options that do not fit a dedicated category.
*/
private function applyMisc(RequestInterface $request, array $guzzleOptions, array &$options): void
{
// We always drive I/O via stream(), so tell Symfony not to build its
// own internal buffer - chunks are written directly to the PSR-7 response body stream.
$options['buffer'] = false;
if (!$this->autoUpgradeHttpVersion || '1.0' === $request->getProtocolVersion()) {
$options['http_version'] = $request->getProtocolVersion();
}
// progress callback: (dlTotal, dlNow, ulTotal, ulNow) in Guzzle
// on_progress: (dlNow, dlTotal, info) in Symfony
if (isset($guzzleOptions['progress'])) {
$guzzleProgress = $guzzleOptions['progress'];
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use ($guzzleProgress): void {
$guzzleProgress($dlSize, $dlNow, max(0, (int) ($info['upload_content_length'] ?? 0)), (int) ($info['size_upload'] ?? 0));
};
}
}
/**
* Maps Guzzle's 'decode_content' option.
*
* true/string -> remove any explicit Accept-Encoding the caller set, so
* Symfony's HttpClient manages the header and auto-decodes
* false -> ensure an Accept-Encoding header is sent to disable
* Symfony's auto-decode behavior
*/
private function applyDecodeContent(array $guzzleOptions, array &$options): void
{
if ($guzzleOptions['decode_content'] ?? true) {
unset($options['headers']['Accept-Encoding'], $options['headers']['accept-encoding']);
} elseif (!isset($options['headers']['Accept-Encoding']) && !isset($options['headers']['accept-encoding'])) {
$options['headers']['Accept-Encoding'] = ['identity'];
}
}
/**
* Maps raw cURL options from Guzzle's 'curl' option bag to Symfony options.
*
* Constants that have a direct named Symfony equivalent are translated;
* everything else is forwarded verbatim via CurlHttpClient's 'extra.curl'
* pass-through so that no option is silently dropped when the underlying
* transport happens to be CurlHttpClient.
*
* Options managed internally by CurlHttpClient (or Symfony's other
* mechanisms) are silently dropped to avoid the "Cannot set X with
* extra.curl" exception that CurlHttpClient::validateExtraCurlOptions()
* throws for those constants.
*/
private function applyCurlOptions(array $curlOptions, array &$options): void
{
// Build a set of constants that CurlHttpClient rejects in extra.curl
// together with options whose Symfony equivalents are already applied
// via the PSR-7 request or other Guzzle option mappings.
static $blocked;
$blocked ??= array_flip(array_filter([
// Auth - handled by applyAuth() / requires NTLM-specific logic.
\CURLOPT_HTTPAUTH, \CURLOPT_USERPWD,
// Body - set from the PSR-7 request body by applyBody().
\CURLOPT_READDATA, \CURLOPT_READFUNCTION, \CURLOPT_INFILESIZE,
\CURLOPT_POSTFIELDS, \CURLOPT_UPLOAD,
// HTTP method - taken from the PSR-7 request.
\CURLOPT_POST, \CURLOPT_PUT, \CURLOPT_CUSTOMREQUEST,
\CURLOPT_HTTPGET, \CURLOPT_NOBODY,
// Headers - merged by extractHeaders().
\CURLOPT_HTTPHEADER,
// Internal curl signal / redirect-type flags with no Symfony equiv.
\CURLOPT_NOSIGNAL, \CURLOPT_POSTREDIR,
// Progress - handled by applyMisc() via Guzzle's 'progress' option.
\CURLOPT_NOPROGRESS, \CURLOPT_PROGRESSFUNCTION,
// Blocked by CurlHttpClient::validateExtraCurlOptions().
\CURLOPT_PRIVATE, \CURLOPT_HEADERFUNCTION, \CURLOPT_WRITEFUNCTION,
\CURLOPT_VERBOSE, \CURLOPT_STDERR, \CURLOPT_RETURNTRANSFER,
\CURLOPT_URL, \CURLOPT_FOLLOWLOCATION, \CURLOPT_HEADER,
\CURLOPT_HTTP_VERSION, \CURLOPT_PORT, \CURLOPT_DNS_USE_GLOBAL_CACHE,
\CURLOPT_PROTOCOLS, \CURLOPT_REDIR_PROTOCOLS, \CURLOPT_COOKIEFILE,
\CURLINFO_REDIRECT_COUNT,
\defined('CURLOPT_HTTP09_ALLOWED') ? \CURLOPT_HTTP09_ALLOWED : null,
\defined('CURLOPT_HEADEROPT') ? \CURLOPT_HEADEROPT : null,
// Pinned public key: curl uses "sha256//base64" which is
// incompatible with Symfony's peer_fingerprint array format.
\defined('CURLOPT_PINNEDPUBLICKEY') ? \CURLOPT_PINNEDPUBLICKEY : null,
]));
foreach ($curlOptions as $opt => $value) {
if (isset($blocked[$opt])) {
continue;
}
// CURLOPT_UNIX_SOCKET_PATH is conditionally defined; maps to bindto.
if (\defined('CURLOPT_UNIX_SOCKET_PATH') && \CURLOPT_UNIX_SOCKET_PATH === $opt) {
$options['bindto'] = $value;
continue;
}
match ($opt) {
\CURLOPT_CAINFO => $options['cafile'] = $value,
\CURLOPT_CAPATH => $options['capath'] = $value,
\CURLOPT_SSLCERT => $options['local_cert'] = $value,
\CURLOPT_SSLKEY => $options['local_pk'] = $value,
\CURLOPT_SSLCERTPASSWD,
\CURLOPT_SSLKEYPASSWD => $options['passphrase'] = $value,
\CURLOPT_SSL_CIPHER_LIST => $options['ciphers'] = $value,
\CURLOPT_CERTINFO => $options['capture_peer_cert_chain'] = (bool) $value,
\CURLOPT_PROXY => $options['proxy'] = $value,
\CURLOPT_NOPROXY => $options['no_proxy'] = $value,
\CURLOPT_USERAGENT => $options['headers']['User-Agent'] = [$value],
\CURLOPT_REFERER => $options['headers']['Referer'] = [$value],
\CURLOPT_INTERFACE => $options['bindto'] = $value,
\CURLOPT_SSL_VERIFYPEER => $options['verify_peer'] = (bool) $value,
\CURLOPT_SSL_VERIFYHOST => $options['verify_host'] = $value > 0,
\CURLOPT_MAXREDIRS => $options['max_redirects'] = $value,
\CURLOPT_TIMEOUT => $options['max_duration'] = (float) $value,
\CURLOPT_TIMEOUT_MS => $options['max_duration'] = $value / 1000.0,
\CURLOPT_CONNECTTIMEOUT => $options['max_connect_duration'] = (float) $value,
\CURLOPT_CONNECTTIMEOUT_MS => $options['max_connect_duration'] = $value / 1000.0,
default => $options['extra']['curl'][$opt] = $value,
};
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Amp\Http\Client\Request as AmpRequest;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* A factory to instantiate the best possible HTTP client for the runtime.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class HttpClient
{
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to a single host
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public static function create(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface
{
if ($amp = class_exists(AmpRequest::class)) {
if (!\extension_loaded('curl')) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
// Skip curl when HTTP/2 push is unsupported or buggy, see https://bugs.php.net/77535
if (!\defined('CURLMOPT_PUSHFUNCTION')) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
static $curlVersion = null;
$curlVersion ??= curl_version();
// HTTP/2 push crashes before curl 7.61
if (0x073D00 > $curlVersion['version_number'] || !(\CURL_VERSION_HTTP2 & $curlVersion['features'])) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
}
if (\extension_loaded('curl')) {
if ('\\' !== \DIRECTORY_SEPARATOR || isset($defaultOptions['cafile']) || isset($defaultOptions['capath']) || \ini_get('curl.cainfo') || \ini_get('openssl.cafile') || \ini_get('openssl.capath')) {
return new CurlHttpClient($defaultOptions, $maxHostConnections, $maxPendingPushes);
}
@trigger_error('Configure the "curl.cainfo", "openssl.cafile" or "openssl.capath" php.ini setting to enable the CurlHttpClient', \E_USER_WARNING);
}
if ($amp) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
@trigger_error((\extension_loaded('curl') ? 'Upgrade' : 'Install').' the curl extension or run "composer require amphp/http-client:^5" to perform async HTTP operations, including full HTTP/2 support', \E_USER_NOTICE);
return new NativeHttpClient($defaultOptions, $maxHostConnections);
}
/**
* Creates a client that adds options (e.g. authentication headers) only when the request URL matches the provided base URI.
*/
public static function createForBaseUri(string $baseUri, array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface
{
$client = self::create([], $maxHostConnections, $maxPendingPushes);
return ScopingHttpClient::forBaseUri($client, $baseUri, $defaultOptions);
}
}

View File

@@ -0,0 +1,865 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\StreamableInterface;
use Symfony\Component\HttpClient\Response\StreamWrapper;
use Symfony\Component\Mime\MimeTypes;
/**
* Provides the common logic from writing HttpClientInterface implementations.
*
* All private methods are static to prevent implementers from creating memory leaks via circular references.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
trait HttpClientTrait
{
private static int $CHUNK_SIZE = 16372;
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
return $clone;
}
/**
* Validates and normalizes method, URL and options, and merges them with defaults.
*
* @throws InvalidArgumentException When a not-supported option is found
*/
private static function prepareRequest(?string $method, ?string $url, array $options, array $defaultOptions = [], bool $allowExtraOptions = false): array
{
if (null !== $method) {
if (\strlen($method) !== strspn($method, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')) {
throw new InvalidArgumentException(\sprintf('Invalid HTTP method "%s", only uppercase letters are accepted.', $method));
}
if (!$method) {
throw new InvalidArgumentException('The HTTP method cannot be empty.');
}
}
$options = self::mergeDefaultOptions($options, $defaultOptions, $allowExtraOptions);
$buffer = $options['buffer'] ?? true;
if ($buffer instanceof \Closure) {
$options['buffer'] = static function (array $headers) use ($buffer) {
if (!\is_bool($buffer = $buffer($headers))) {
if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
throw new \LogicException(\sprintf('The closure passed as option "buffer" must return bool or stream resource, got "%s".', get_debug_type($buffer)));
}
if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
throw new \LogicException(\sprintf('The stream returned by the closure passed as option "buffer" must be writeable, got mode "%s".', $bufferInfo['mode']));
}
}
return $buffer;
};
} elseif (!\is_bool($buffer)) {
if (!\is_array($bufferInfo = @stream_get_meta_data($buffer))) {
throw new InvalidArgumentException(\sprintf('Option "buffer" must be bool, stream resource or Closure, "%s" given.', get_debug_type($buffer)));
}
if (false === strpbrk($bufferInfo['mode'], 'acew+')) {
throw new InvalidArgumentException(\sprintf('The stream in option "buffer" must be writeable, mode "%s" given.', $bufferInfo['mode']));
}
}
if (isset($options['json'])) {
if (isset($options['body']) && '' !== $options['body']) {
throw new InvalidArgumentException('Define either the "json" or the "body" option, setting both is not supported.');
}
$options['body'] = self::jsonEncode($options['json']);
unset($options['json']);
if (!isset($options['normalized_headers']['content-type'])) {
$options['normalized_headers']['content-type'] = ['Content-Type: application/json'];
}
}
if (!isset($options['normalized_headers']['accept'])) {
$options['normalized_headers']['accept'] = ['Accept: */*'];
}
if (isset($options['body'])) {
$options['body'] = self::normalizeBody($options['body'], $options['normalized_headers']);
if (\is_string($options['body'])
&& (string) \strlen($options['body']) !== substr($h = $options['normalized_headers']['content-length'][0] ?? '', 16)
&& ('' !== $h || '' !== $options['body'])
) {
if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) {
unset($options['normalized_headers']['transfer-encoding']);
$options['body'] = self::dechunk($options['body']);
}
$options['normalized_headers']['content-length'] = [substr_replace($h ?: 'Content-Length: ', \strlen($options['body']), 16)];
}
}
if (isset($options['peer_fingerprint'])) {
$options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']);
}
if (isset($options['crypto_method']) && !\in_array($options['crypto_method'], [
\STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT,
\STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT,
\STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
\STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT,
], true)) {
throw new InvalidArgumentException('Option "crypto_method" must be one of "STREAM_CRYPTO_METHOD_TLSv1_*_CLIENT".');
}
// Validate on_progress
if (isset($options['on_progress']) && !\is_callable($onProgress = $options['on_progress'])) {
throw new InvalidArgumentException(\sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
}
if (\is_array($options['auth_basic'] ?? null)) {
$count = \count($options['auth_basic']);
if ($count <= 0 || $count > 2) {
throw new InvalidArgumentException(\sprintf('Option "auth_basic" must contain 1 or 2 elements, "%s" given.', $count));
}
$options['auth_basic'] = implode(':', $options['auth_basic']);
}
if (!\is_string($options['auth_basic'] ?? '')) {
throw new InvalidArgumentException(\sprintf('Option "auth_basic" must be string or an array, "%s" given.', get_debug_type($options['auth_basic'])));
}
if (isset($options['auth_bearer'])) {
if (!\is_string($options['auth_bearer'])) {
throw new InvalidArgumentException(\sprintf('Option "auth_bearer" must be a string, "%s" given.', get_debug_type($options['auth_bearer'])));
}
if (preg_match('{[^\x21-\x7E]}', $options['auth_bearer'])) {
throw new InvalidArgumentException('Invalid character found in option "auth_bearer": '.json_encode($options['auth_bearer']).'.');
}
}
if (isset($options['auth_basic'], $options['auth_bearer'])) {
throw new InvalidArgumentException('Define either the "auth_basic" or the "auth_bearer" option, setting both is not supported.');
}
if (null !== $url) {
// Merge auth with headers
if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
$options['normalized_headers']['authorization'] = ['Authorization: Basic '.base64_encode($options['auth_basic'])];
}
// Merge bearer with headers
if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) {
$options['normalized_headers']['authorization'] = ['Authorization: Bearer '.$options['auth_bearer']];
}
unset($options['auth_basic'], $options['auth_bearer']);
// Parse base URI
if (\is_string($baseUri = $options['base_uri'] ?? null)) {
$baseUri = self::parseUrl($baseUri);
}
unset($options['base_uri']);
// Validate and resolve URL
$url = self::parseUrl($url, $options['query']);
$url = self::resolveUrl($url, $baseUri, $defaultOptions['query'] ?? []);
}
// Finalize normalization of options
$options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
if (0 > $options['timeout'] = (float) ($options['timeout'] ?? \ini_get('default_socket_timeout'))) {
$options['timeout'] = 172800.0; // 2 days
}
$options['max_duration'] = isset($options['max_duration']) ? (float) $options['max_duration'] : 0;
$options['max_connect_duration'] = isset($options['max_connect_duration']) ? (float) $options['max_connect_duration'] : 0;
$options['headers'] = array_merge(...array_values($options['normalized_headers']));
return [$url, $options];
}
/**
* @throws InvalidArgumentException When an invalid option is found
*/
private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array
{
$options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []);
if ($defaultOptions['headers'] ?? false) {
$options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']);
}
$options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]);
if ($resolve = $options['resolve'] ?? false) {
$options['resolve'] = [];
foreach ($resolve as $k => $v) {
if ('' === $v = (string) $v) {
$v = null;
} elseif ('[' === $v[0] && ']' === substr($v, -1) && str_contains($v, ':')) {
$v = substr($v, 1, -1);
}
$options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = $v;
}
}
// Option "query" is never inherited from defaults
$options['query'] ??= [];
$options += $defaultOptions;
if (isset(self::$emptyDefaults)) {
foreach (self::$emptyDefaults as $k => $v) {
if (!isset($options[$k])) {
$options[$k] = $v;
}
}
}
if (isset($defaultOptions['extra'])) {
$options['extra'] += $defaultOptions['extra'];
}
if ($resolve = $defaultOptions['resolve'] ?? false) {
foreach ($resolve as $k => $v) {
if ('' === $v = (string) $v) {
$v = null;
} elseif ('[' === $v[0] && ']' === substr($v, -1) && str_contains($v, ':')) {
$v = substr($v, 1, -1);
}
$options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => $v];
}
}
if ($allowExtraOptions || !$defaultOptions) {
return $options;
}
// Look for unsupported options
foreach ($options as $name => $v) {
if (\array_key_exists($name, $defaultOptions) || 'normalized_headers' === $name) {
continue;
}
if ('auth_ntlm' === $name) {
if (!\extension_loaded('curl')) {
$msg = 'try installing the "curl" extension to use "%s" instead.';
} else {
$msg = 'try using "%s" instead.';
}
throw new InvalidArgumentException(\sprintf('Option "auth_ntlm" is not supported by "%s", '.$msg, __CLASS__, CurlHttpClient::class));
}
if ('vars' === $name) {
throw new InvalidArgumentException(\sprintf('Option "vars" is not supported by "%s", try using "%s" instead.', __CLASS__, UriTemplateHttpClient::class));
}
$alternatives = [];
foreach ($defaultOptions as $k => $v) {
if (levenshtein($name, $k) <= \strlen($name) / 3 || str_contains($k, $name)) {
$alternatives[] = $k;
}
}
throw new InvalidArgumentException(\sprintf('Unsupported option "%s" passed to "%s", did you mean "%s"?', $name, __CLASS__, implode('", "', $alternatives ?: array_keys($defaultOptions))));
}
return $options;
}
/**
* @return string[][]
*
* @throws InvalidArgumentException When an invalid header is found
*/
private static function normalizeHeaders(array $headers): array
{
$normalizedHeaders = [];
foreach ($headers as $name => $values) {
if ($values instanceof \Stringable) {
$values = (string) $values;
}
if (\is_int($name)) {
if (!\is_string($values)) {
throw new InvalidArgumentException(\sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
}
[$name, $values] = explode(':', $values, 2);
$values = [ltrim($values)];
} elseif (!is_iterable($values)) {
if (\is_object($values)) {
throw new InvalidArgumentException(\sprintf('Invalid value for header "%s": expected string, "%s" given.', $name, get_debug_type($values)));
}
$values = (array) $values;
}
$lcName = strtolower($name);
$normalizedHeaders[$lcName] = [];
foreach ($values as $value) {
$normalizedHeaders[$lcName][] = $value = $name.': '.$value;
if (\strlen($value) !== strcspn($value, "\r\n\0")) {
throw new InvalidArgumentException(\sprintf('Invalid header: CR/LF/NUL found in "%s".', $value));
}
}
}
return $normalizedHeaders;
}
/**
* @param array|string|resource|\Traversable|\Closure $body
*
* @return string|resource|\Closure
*
* @throws InvalidArgumentException When an invalid body is passed
*/
private static function normalizeBody($body, array &$normalizedHeaders = [])
{
if (\is_array($body)) {
static $cookie;
$streams = [];
array_walk_recursive($body, $caster = static function (&$v) use (&$caster, &$streams, &$cookie) {
if (\is_resource($v) || $v instanceof StreamableInterface) {
$cookie = hash('xxh128', $cookie ??= random_bytes(8), true);
$k = substr(strtr(base64_encode($cookie), '+/', '-_'), 0, -2);
$streams[$k] = $v instanceof StreamableInterface ? $v->toStream(false) : $v;
$v = $k;
} elseif (\is_object($v)) {
if ($vars = get_object_vars($v)) {
array_walk_recursive($vars, $caster);
$v = $vars;
} elseif ($v instanceof \Stringable) {
$v = (string) $v;
}
}
});
$caster = null;
if ('' === $body = http_build_query($body, '', '&')) {
return '';
}
if (!$streams && !str_contains($normalizedHeaders['content-type'][0] ?? '', 'multipart/form-data')) {
if (!str_contains($normalizedHeaders['content-type'][0] ?? '', 'application/x-www-form-urlencoded')) {
$normalizedHeaders['content-type'] = ['Content-Type: application/x-www-form-urlencoded'];
}
return $body;
}
if (preg_match('{multipart/form-data; boundary=(?|"([^"\r\n]++)"|([-!#$%&\'*+.^_`|~_A-Za-z0-9]++))}', $normalizedHeaders['content-type'][0] ?? '', $boundary)) {
$boundary = $boundary[1];
} else {
$boundary = substr(strtr(base64_encode($cookie ??= random_bytes(8)), '+/', '-_'), 0, -2);
$normalizedHeaders['content-type'] = ['Content-Type: multipart/form-data; boundary='.$boundary];
}
$body = explode('&', $body);
$contentLength = 0;
foreach ($body as $i => $part) {
[$k, $v] = explode('=', $part, 2);
$part = ($i ? "\r\n" : '')."--{$boundary}\r\n";
$k = str_replace(['"', "\r", "\n"], ['%22', '%0D', '%0A'], urldecode($k)); // see WHATWG HTML living standard
if (!isset($streams[$v])) {
$part .= "Content-Disposition: form-data; name=\"{$k}\"\r\n\r\n".urldecode($v);
$contentLength += 0 <= $contentLength ? \strlen($part) : 0;
$body[$i] = [$k, $part, null];
continue;
}
$v = $streams[$v];
if (!\is_array($m = @stream_get_meta_data($v))) {
throw new TransportException(\sprintf('Invalid "%s" resource found in body part "%s".', get_resource_type($v), $k));
}
if (feof($v)) {
throw new TransportException(\sprintf('Uploaded stream ended for body part "%s".', $k));
}
$m += stream_context_get_options($v)['http'] ?? [];
$filename = basename($m['filename'] ?? $m['uri'] ?? 'unknown');
$filename = str_replace(['"', "\r", "\n"], ['%22', '%0D', '%0A'], $filename);
$contentType = $m['content_type'] ?? null;
if (($headers = $m['wrapper_data'] ?? []) instanceof StreamWrapper) {
$hasContentLength = false;
$headers = $headers->getResponse()->getInfo('response_headers');
} elseif ($hasContentLength = 0 < $h = fstat($v)['size'] ?? 0) {
$contentLength += 0 <= $contentLength ? $h : 0;
}
foreach (\is_array($headers) ? $headers : [] as $h) {
if (\is_string($h) && 0 === stripos($h, 'Content-Type: ')) {
$contentType ??= substr($h, 14);
} elseif (!$hasContentLength && \is_string($h) && 0 === stripos($h, 'Content-Length: ')) {
$hasContentLength = true;
$contentLength += 0 <= $contentLength ? substr($h, 16) : 0;
} elseif (\is_string($h) && 0 === stripos($h, 'Content-Encoding: ')) {
$contentLength = -1;
}
}
if (!$hasContentLength) {
$contentLength = -1;
}
if (null === $contentType && 'plainfile' === ($m['wrapper_type'] ?? null) && isset($m['uri'])) {
$mimeTypes = class_exists(MimeTypes::class) ? MimeTypes::getDefault() : false;
$contentType = $mimeTypes ? $mimeTypes->guessMimeType($m['uri']) : null;
}
$contentType ??= 'application/octet-stream';
$part .= "Content-Disposition: form-data; name=\"{$k}\"; filename=\"{$filename}\"\r\n";
$part .= "Content-Type: {$contentType}\r\n\r\n";
$contentLength += 0 <= $contentLength ? \strlen($part) : 0;
$body[$i] = [$k, $part, $v];
}
$body[++$i] = ['', "\r\n--{$boundary}--\r\n", null];
if (0 < $contentLength) {
$normalizedHeaders['content-length'] = ['Content-Length: '.($contentLength += \strlen($body[$i][1]))];
}
$body = static function ($size) use ($body) {
foreach ($body as $i => [$k, $part, $h]) {
unset($body[$i]);
yield $part;
while (null !== $h && !feof($h)) {
if (false === $part = fread($h, $size)) {
throw new TransportException(\sprintf('Error while reading uploaded stream for body part "%s".', $k));
}
yield $part;
}
}
$h = null;
};
}
if (\is_string($body)) {
return $body;
}
$generatorToCallable = static fn (\Generator $body): \Closure => static function () use ($body) {
while ($body->valid()) {
$chunk = $body->current();
$body->next();
if ('' !== $chunk) {
return $chunk;
}
}
return '';
};
if ($body instanceof \Generator) {
return $generatorToCallable($body);
}
if ($body instanceof \Traversable) {
return $generatorToCallable((static function ($body) { yield from $body; })($body));
}
if ($body instanceof \Closure) {
$r = new \ReflectionFunction($body);
$body = $r->getClosure();
if ($r->isGenerator()) {
$body = $body(self::$CHUNK_SIZE);
return $generatorToCallable($body);
}
return $body;
}
if (!\is_array(@stream_get_meta_data($body))) {
throw new InvalidArgumentException(\sprintf('Option "body" must be string, stream resource, iterable or callable, "%s" given.', get_debug_type($body)));
}
return $body;
}
private static function dechunk(string $body): string
{
$h = fopen('php://temp', 'w+');
stream_filter_append($h, 'dechunk', \STREAM_FILTER_WRITE);
fwrite($h, $body);
$body = stream_get_contents($h, -1, 0);
rewind($h);
ftruncate($h, 0);
if (fwrite($h, '-') && '' !== stream_get_contents($h, -1, 0)) {
throw new TransportException('Request body has broken chunked encoding.');
}
return $body;
}
/**
* @throws InvalidArgumentException When an invalid fingerprint is passed
*/
private static function normalizePeerFingerprint(mixed $fingerprint): array
{
if (\is_string($fingerprint)) {
$fingerprint = match (\strlen($fingerprint = str_replace(':', '', $fingerprint))) {
32 => ['md5' => $fingerprint],
40 => ['sha1' => $fingerprint],
44 => ['pin-sha256' => [$fingerprint]],
64 => ['sha256' => $fingerprint],
default => throw new InvalidArgumentException(\sprintf('Cannot auto-detect fingerprint algorithm for "%s".', $fingerprint)),
};
} elseif (\is_array($fingerprint)) {
foreach ($fingerprint as $algo => $hash) {
$fingerprint[$algo] = 'pin-sha256' === $algo ? (array) $hash : str_replace(':', '', $hash);
}
} else {
throw new InvalidArgumentException(\sprintf('Option "peer_fingerprint" must be string or array, "%s" given.', get_debug_type($fingerprint)));
}
return $fingerprint;
}
/**
* @throws InvalidArgumentException When the value cannot be json-encoded
*/
private static function jsonEncode(mixed $value, ?int $flags = null, int $maxDepth = 512): string
{
$flags ??= \JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_PRESERVE_ZERO_FRACTION;
try {
$value = json_encode($value, $flags | \JSON_THROW_ON_ERROR, $maxDepth);
} catch (\JsonException $e) {
throw new InvalidArgumentException('Invalid value for "json" option: '.$e->getMessage());
}
return $value;
}
/**
* Resolves a URL against a base URI.
*
* @see https://tools.ietf.org/html/rfc3986#section-5.2.2
*
* @throws InvalidArgumentException When an invalid URL is passed
*/
private static function resolveUrl(array $url, ?array $base, array $queryDefaults = []): array
{
$givenUrl = $url;
if (null !== $base && '' === ($base['scheme'] ?? '').($base['authority'] ?? '')) {
throw new InvalidArgumentException(\sprintf('Invalid "base_uri" option: host or scheme is missing in "%s".', implode('', $base)));
}
if (null === $url['scheme'] && (null === $base || null === $base['scheme'])) {
throw new InvalidArgumentException(\sprintf('Invalid URL: scheme is missing in "%s". Did you forget to add "http(s)://"?', implode('', $base ?? $url)));
}
if (null === $base && '' === $url['scheme'].$url['authority']) {
throw new InvalidArgumentException(\sprintf('Invalid URL: no "base_uri" option was provided and host or scheme is missing in "%s".', implode('', $url)));
}
if (null !== $url['scheme']) {
$url['path'] = self::removeDotSegments($url['path'] ?? '');
} else {
if (null !== $url['authority']) {
$url['path'] = self::removeDotSegments($url['path'] ?? '');
} else {
if (null === $url['path']) {
$url['path'] = $base['path'];
$url['query'] ??= $base['query'];
} else {
if ('/' !== $url['path'][0]) {
if (null === $base['path']) {
$url['path'] = '/'.$url['path'];
} else {
$segments = explode('/', $base['path']);
array_splice($segments, -1, 1, [$url['path']]);
$url['path'] = implode('/', $segments);
}
}
$url['path'] = self::removeDotSegments($url['path']);
}
$url['authority'] = $base['authority'];
if ($queryDefaults) {
$url['query'] = '?'.self::mergeQueryString(substr($url['query'] ?? '', 1), $queryDefaults, false);
}
}
$url['scheme'] = $base['scheme'];
}
if ('' === ($url['path'] ?? '')) {
$url['path'] = '/';
}
if ('?' === ($url['query'] ?? '')) {
$url['query'] = null;
}
if (null !== $url['scheme'] && null === $url['authority']) {
throw new InvalidArgumentException(\sprintf('Invalid URL: host is missing in "%s".', implode('', $givenUrl)));
}
return $url;
}
/**
* Parses a URL and fixes its encoding if needed.
*
* @throws InvalidArgumentException When an invalid URL is passed
*/
private static function parseUrl(string $url, array $query = [], array $allowedSchemes = ['http' => 80, 'https' => 443]): array
{
if (false !== ($i = strpos($url, '\\')) && $i < strcspn($url, '?#')) {
throw new InvalidArgumentException(\sprintf('Malformed URL "%s": backslashes are not allowed.', $url));
}
if (\strlen($url) !== strcspn($url, "\r\n\t")) {
throw new InvalidArgumentException(\sprintf('Malformed URL "%s": CR/LF/TAB characters are not allowed.', $url));
}
if ('' !== $url && (\ord($url[0]) <= 32 || \ord($url[-1]) <= 32)) {
throw new InvalidArgumentException(\sprintf('Malformed URL "%s": leading/trailing ASCII control characters or spaces are not allowed.', $url));
}
$tail = '';
if (false === $parts = parse_url(\strlen($url) !== strcspn($url, '?#') ? $url : $url.$tail = '#')) {
throw new InvalidArgumentException(\sprintf('Malformed URL "%s".', $url));
}
if ($query) {
$parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true);
}
$scheme = $parts['scheme'] ?? null;
$host = $parts['host'] ?? null;
if (!$scheme && $host && !str_starts_with($url, '//')) {
$parts = parse_url(':/'.$url.$tail);
$parts['path'] = substr($parts['path'], 2);
$scheme = $host = null;
}
$port = $parts['port'] ?? 0;
if (null !== $scheme) {
if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) {
throw new InvalidArgumentException(\sprintf('Unsupported scheme in "%s": "%s" expected.', $url, implode('" or "', array_keys($allowedSchemes))));
}
$port = $allowedSchemes[$scheme] === $port ? 0 : $port;
$scheme .= ':';
}
if (null !== $host) {
if (!\defined('INTL_IDNA_VARIANT_UTS46') && preg_match('/[\x80-\xFF]/', $host)) {
throw new InvalidArgumentException(\sprintf('Unsupported IDN "%s", try enabling the "intl" PHP extension or running "composer require symfony/polyfill-intl-idn".', $host));
}
$host = \defined('INTL_IDNA_VARIANT_UTS46') ? idn_to_ascii($host, \IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46) ?: strtolower($host) : strtolower($host);
$host .= $port ? ':'.$port : '';
}
foreach (['user', 'pass', 'path', 'query', 'fragment'] as $part) {
if (!isset($parts[$part])) {
continue;
}
if (str_contains($parts[$part], '%')) {
// https://tools.ietf.org/html/rfc3986#section-2.3
$parts[$part] = preg_replace_callback('/%(?:2[DE]|3[0-9]|[46][1-9A-F]|5F|[57][0-9A]|7E)++/i', static fn ($m) => rawurldecode($m[0]), $parts[$part]);
}
// https://tools.ietf.org/html/rfc3986#section-3.3
$parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()[\]*+,;=:@{}%]++#", static fn ($m) => rawurlencode($m[0]), $parts[$part]);
}
return [
'scheme' => $scheme,
'authority' => null !== $host ? '//'.(isset($parts['user']) ? $parts['user'].(isset($parts['pass']) ? ':'.$parts['pass'] : '').'@' : '').$host : null,
'path' => isset($parts['path'][0]) ? $parts['path'] : null,
'query' => isset($parts['query']) ? '?'.$parts['query'] : null,
'fragment' => isset($parts['fragment']) && !$tail ? '#'.$parts['fragment'] : null,
];
}
/**
* Removes dot-segments from a path.
*
* @see https://tools.ietf.org/html/rfc3986#section-5.2.4
*/
private static function removeDotSegments(string $path): string
{
$result = '';
while (!\in_array($path, ['', '.', '..'], true)) {
if ('.' === $path[0] && (str_starts_with($path, $p = '../') || str_starts_with($path, $p = './'))) {
$path = substr($path, \strlen($p));
} elseif ('/.' === $path || str_starts_with($path, '/./')) {
$path = substr_replace($path, '/', 0, 3);
} elseif ('/..' === $path || str_starts_with($path, '/../')) {
$i = strrpos($result, '/');
$result = $i ? substr($result, 0, $i) : '';
$path = substr_replace($path, '/', 0, 4);
} else {
$i = strpos($path, '/', 1) ?: \strlen($path);
$result .= substr($path, 0, $i);
$path = substr($path, $i);
}
}
return $result;
}
/**
* Merges and encodes a query array with a query string.
*
* @throws InvalidArgumentException When an invalid query-string value is passed
*/
private static function mergeQueryString(?string $queryString, array $queryArray, bool $replace): ?string
{
if (!$queryArray) {
return $queryString;
}
$query = [];
if (null !== $queryString) {
foreach (explode('&', $queryString) as $v) {
if ('' !== $v) {
$k = urldecode(explode('=', $v, 2)[0]);
$query[$k] = (isset($query[$k]) ? $query[$k].'&' : '').$v;
}
}
}
if ($replace) {
foreach ($queryArray as $k => $v) {
if (null === $v) {
unset($query[$k]);
}
}
}
$queryString = http_build_query($queryArray, '', '&', \PHP_QUERY_RFC3986);
$queryArray = [];
if ($queryString) {
if (str_contains($queryString, '%')) {
// https://tools.ietf.org/html/rfc3986#section-2.3 + some chars not encoded by browsers
$queryString = strtr($queryString, [
'%21' => '!',
'%24' => '$',
'%28' => '(',
'%29' => ')',
'%2A' => '*',
'%2F' => '/',
'%3A' => ':',
'%3B' => ';',
'%40' => '@',
'%5B' => '[',
'%5D' => ']',
]);
}
foreach (explode('&', $queryString) as $v) {
$queryArray[rawurldecode(explode('=', $v, 2)[0])] = $v;
}
}
return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
}
/**
* Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
*/
private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array
{
if (null === $proxy = self::getProxyUrl($proxy, $url)) {
return null;
}
$proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
if (!isset($proxy['host'])) {
throw new TransportException('Invalid HTTP proxy: host is missing.');
}
if ('http' === $proxy['scheme']) {
$proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
} elseif ('https' === $proxy['scheme']) {
$proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
} else {
throw new TransportException(\sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme']));
}
$noProxy ??= $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
$noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
return [
'url' => $proxyUrl,
'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
'no_proxy' => $noProxy,
];
}
private static function getProxyUrl(?string $proxy, array $url): ?string
{
if (null !== $proxy) {
return $proxy;
}
// Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
$proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
if ('https:' === $url['scheme']) {
$proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
}
return $proxy;
}
private static function shouldBuffer(array $headers): bool
{
if (null === $contentType = $headers['content-type'][0] ?? null) {
return false;
}
if (false !== $i = strpos($contentType, ';')) {
$contentType = substr($contentType, 0, $i);
}
return $contentType && preg_match('#^(?:text/|application/(?:.+\+)?(?:json|xml)$)#i', $contentType);
}
}

View File

@@ -0,0 +1,359 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* A helper providing autocompletion for available options.
*
* @see HttpClientInterface for a description of each options.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class HttpOptions
{
private array $options = [];
public function toArray(): array
{
return $this->options;
}
/**
* @return $this
*/
public function setAuthBasic(string $user, #[\SensitiveParameter] string $password = ''): static
{
$this->options['auth_basic'] = $user;
if ('' !== $password) {
$this->options['auth_basic'] .= ':'.$password;
}
return $this;
}
/**
* @return $this
*/
public function setAuthBearer(#[\SensitiveParameter] string $token): static
{
$this->options['auth_bearer'] = $token;
return $this;
}
/**
* @return $this
*/
public function setQuery(array $query): static
{
$this->options['query'] = $query;
return $this;
}
/**
* @return $this
*/
public function setHeader(string $key, string $value): static
{
$this->options['headers'][$key] = $value;
return $this;
}
/**
* @return $this
*/
public function setHeaders(iterable $headers): static
{
$this->options['headers'] = $headers;
return $this;
}
/**
* @param array|string|resource|\Traversable|\Closure $body
*
* @return $this
*/
public function setBody(mixed $body): static
{
$this->options['body'] = $body;
return $this;
}
/**
* @return $this
*/
public function setJson(mixed $json): static
{
$this->options['json'] = $json;
return $this;
}
/**
* @return $this
*/
public function setUserData(mixed $data): static
{
$this->options['user_data'] = $data;
return $this;
}
/**
* @return $this
*/
public function setMaxRedirects(int $max): static
{
$this->options['max_redirects'] = $max;
return $this;
}
/**
* @return $this
*/
public function setHttpVersion(string $version): static
{
$this->options['http_version'] = $version;
return $this;
}
/**
* @return $this
*/
public function setBaseUri(string $uri): static
{
$this->options['base_uri'] = $uri;
return $this;
}
/**
* @return $this
*/
public function setVars(array $vars): static
{
$this->options['vars'] = $vars;
return $this;
}
/**
* @return $this
*/
public function buffer(bool $buffer): static
{
$this->options['buffer'] = $buffer;
return $this;
}
/**
* @param callable(int, int, array, \Closure|null=):void $callback
*
* @return $this
*/
public function setOnProgress(callable $callback): static
{
$this->options['on_progress'] = $callback;
return $this;
}
/**
* @return $this
*/
public function resolve(array $hostIps): static
{
$this->options['resolve'] = $hostIps;
return $this;
}
/**
* @return $this
*/
public function setProxy(string $proxy): static
{
$this->options['proxy'] = $proxy;
return $this;
}
/**
* @return $this
*/
public function setNoProxy(string $noProxy): static
{
$this->options['no_proxy'] = $noProxy;
return $this;
}
/**
* @return $this
*/
public function setTimeout(float $timeout): static
{
$this->options['timeout'] = $timeout;
return $this;
}
/**
* @return $this
*/
public function setMaxDuration(float $maxDuration): static
{
$this->options['max_duration'] = $maxDuration;
return $this;
}
/**
* @return $this
*/
public function setMaxConnectDuration(float $maxConnectDuration): static
{
$this->options['max_connect_duration'] = $maxConnectDuration;
return $this;
}
/**
* @return $this
*/
public function bindTo(string $bindto): static
{
$this->options['bindto'] = $bindto;
return $this;
}
/**
* @return $this
*/
public function verifyPeer(bool $verify): static
{
$this->options['verify_peer'] = $verify;
return $this;
}
/**
* @return $this
*/
public function verifyHost(bool $verify): static
{
$this->options['verify_host'] = $verify;
return $this;
}
/**
* @return $this
*/
public function setCaFile(string $cafile): static
{
$this->options['cafile'] = $cafile;
return $this;
}
/**
* @return $this
*/
public function setCaPath(string $capath): static
{
$this->options['capath'] = $capath;
return $this;
}
/**
* @return $this
*/
public function setLocalCert(string $cert): static
{
$this->options['local_cert'] = $cert;
return $this;
}
/**
* @return $this
*/
public function setLocalPk(string $pk): static
{
$this->options['local_pk'] = $pk;
return $this;
}
/**
* @return $this
*/
public function setPassphrase(string $passphrase): static
{
$this->options['passphrase'] = $passphrase;
return $this;
}
/**
* @return $this
*/
public function setCiphers(string $ciphers): static
{
$this->options['ciphers'] = $ciphers;
return $this;
}
/**
* @return $this
*/
public function setPeerFingerprint(string|array $fingerprint): static
{
$this->options['peer_fingerprint'] = $fingerprint;
return $this;
}
/**
* @return $this
*/
public function capturePeerCertChain(bool $capture): static
{
$this->options['capture_peer_cert_chain'] = $capture;
return $this;
}
/**
* @return $this
*/
public function setExtra(string $name, mixed $value): static
{
$this->options['extra'][$name] = $value;
return $this;
}
}

View File

@@ -0,0 +1,284 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use GuzzleHttp\Promise\Promise as GuzzlePromise;
use GuzzleHttp\Promise\RejectedPromise;
use GuzzleHttp\Promise\Utils;
use Http\Client\Exception\NetworkException;
use Http\Client\Exception\RequestException;
use Http\Client\HttpAsyncClient;
use Http\Discovery\Psr17Factory;
use Http\Discovery\Psr17FactoryDiscovery;
use Nyholm\Psr7\Factory\Psr17Factory as NyholmPsr17Factory;
use Nyholm\Psr7\Request;
use Nyholm\Psr7\Uri;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UriFactoryInterface;
use Psr\Http\Message\UriInterface;
use Symfony\Component\HttpClient\Internal\HttplugWaitLoop;
use Symfony\Component\HttpClient\Response\HttplugPromise;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
if (!interface_exists(HttpAsyncClient::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\HttplugClient" as the "php-http/httplug" package is not installed. Try running "composer require php-http/discovery php-http/async-client-implementation:*".');
}
if (!interface_exists(RequestFactoryInterface::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as the "psr/http-factory" package is not installed. Try running "composer require php-http/discovery psr/http-factory-implementation:*".');
}
/**
* An adapter to turn a Symfony HttpClientInterface into an Httplug client.
*
* In comparison to Psr18Client, this client supports asynchronous requests.
*
* Run "composer require php-http/discovery php-http/async-client-implementation:*"
* to get the required dependencies.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class HttplugClient implements ClientInterface, HttpAsyncClient, RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface, ResetInterface
{
private HttpClientInterface $client;
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
private bool $autoUpgradeHttpVersion = true;
/**
* @var \SplObjectStorage<ResponseInterface, array{RequestInterface, Promise}>|null
*/
private ?\SplObjectStorage $promisePool;
private HttplugWaitLoop $waitLoop;
public function __construct(?HttpClientInterface $client = null, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?? HttpClient::create();
$streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null;
$this->promisePool = class_exists(Utils::class) ? new \SplObjectStorage() : null;
if (null === $responseFactory || null === $streamFactory) {
if (class_exists(Psr17Factory::class)) {
$psr17Factory = new Psr17Factory();
} elseif (class_exists(NyholmPsr17Factory::class)) {
$psr17Factory = new NyholmPsr17Factory();
} else {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been provided. Try running "composer require php-http/discovery psr/http-factory-implementation:*".');
}
$responseFactory ??= $psr17Factory;
$streamFactory ??= $psr17Factory;
}
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
$this->waitLoop = new HttplugWaitLoop($this->client, $this->promisePool, $this->responseFactory, $this->streamFactory);
}
public function withOptions(array $options): static
{
$clone = clone $this;
if (\array_key_exists('auto_upgrade_http_version', $options)) {
$clone->autoUpgradeHttpVersion = $options['auto_upgrade_http_version'];
unset($options['auto_upgrade_http_version']);
}
$clone->client = $clone->client->withOptions($options);
return $clone;
}
public function sendRequest(RequestInterface $request): Psr7ResponseInterface
{
try {
return HttplugWaitLoop::createPsr7Response($this->responseFactory, $this->streamFactory, $this->client, $this->sendPsr7Request($request), true);
} catch (TransportExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $request, $e);
}
}
public function sendAsyncRequest(RequestInterface $request): HttplugPromise
{
if (!$promisePool = $this->promisePool) {
throw new \LogicException(\sprintf('You cannot use "%s()" as the "guzzlehttp/promises" package is not installed. Try running "composer require guzzlehttp/promises".', __METHOD__));
}
try {
$response = $this->sendPsr7Request($request, true);
} catch (NetworkException $e) {
return new HttplugPromise(new RejectedPromise($e));
}
$waitLoop = $this->waitLoop;
$promise = new GuzzlePromise(static function () use ($response, $waitLoop) {
$waitLoop->wait($response);
}, static function () use ($response, $promisePool) {
$response->cancel();
unset($promisePool[$response]);
});
$promisePool[$response] = [$request, $promise];
return new HttplugPromise($promise);
}
/**
* Resolves pending promises that complete before the timeouts are reached.
*
* When $maxDuration is null and $idleTimeout is reached, promises are rejected.
*
* @return int The number of remaining pending promises
*/
public function wait(?float $maxDuration = null, ?float $idleTimeout = null): int
{
return $this->waitLoop->wait(null, $maxDuration, $idleTimeout);
}
/**
* @param UriInterface|string $uri
*/
public function createRequest(string $method, $uri = ''): RequestInterface
{
if ($this->responseFactory instanceof RequestFactoryInterface) {
$request = $this->responseFactory->createRequest($method, $uri);
} elseif (class_exists(Psr17FactoryDiscovery::class)) {
$request = Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri);
} elseif (class_exists(Request::class)) {
$request = new Request($method, $uri);
} else {
throw new \LogicException(\sprintf('You cannot use "%s()" as no PSR-17 factories have been found. Try running "composer require php-http/discovery psr/http-factory-implementation:*".', __METHOD__));
}
return $request;
}
public function createStream(string $content = ''): StreamInterface
{
return $this->streamFactory->createStream($content);
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
return $this->streamFactory->createStreamFromFile($filename, $mode);
}
public function createStreamFromResource($resource): StreamInterface
{
return $this->streamFactory->createStreamFromResource($resource);
}
public function createUri(string $uri = ''): UriInterface
{
if ($this->responseFactory instanceof UriFactoryInterface) {
return $this->responseFactory->createUri($uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findUriFactory()->createUri($uri);
}
if (class_exists(Uri::class)) {
return new Uri($uri);
}
throw new \LogicException(\sprintf('You cannot use "%s()" as no PSR-17 factories have been found. Try running "composer require php-http/discovery psr/http-factory-implementation:*".', __METHOD__));
}
public function __serialize(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __unserialize(array $data): void
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
$this->wait();
}
public function reset(): void
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
private function sendPsr7Request(RequestInterface $request, ?bool $buffer = null): ResponseInterface
{
try {
$body = $request->getBody();
$headers = $request->getHeaders();
$size = $request->getHeader('content-length')[0] ?? -1;
if (0 > $size && 0 < $size = $body->getSize() ?? -1) {
$headers['Content-Length'] = [$size];
}
if (0 === $size) {
$body = '';
} elseif (0 < $size && $size < 1 << 21) {
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
$body = $body->getContents();
} else {
$body = static function (int $size) use ($body) {
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
while (!$body->eof()) {
yield $body->read($size);
}
};
}
$options = [
'headers' => $headers,
'body' => $body,
'buffer' => $buffer,
];
if (!$this->autoUpgradeHttpVersion || '1.0' === $request->getProtocolVersion()) {
$options['http_version'] = $request->getProtocolVersion();
}
return $this->client->request($request->getMethod(), (string) $request->getUri(), $options);
} catch (\InvalidArgumentException $e) {
throw new RequestException($e->getMessage(), $request, $e);
} catch (TransportExceptionInterface $e) {
throw new NetworkException($e->getMessage(), $request, $e);
}
}
}

View File

@@ -0,0 +1,150 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Amp\ByteStream\ReadableBuffer;
use Amp\ByteStream\ReadableIterableStream;
use Amp\ByteStream\ReadableResourceStream;
use Amp\ByteStream\ReadableStream;
use Amp\Cancellation;
use Amp\Http\Client\HttpContent;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpBody implements HttpContent, ReadableStream, \IteratorAggregate
{
private ReadableStream $body;
private ?string $content;
private array $info;
private ?int $offset = 0;
private int $length = -1;
private ?int $uploaded = null;
/**
* @param \Closure|resource|string $body
*/
public function __construct(
$body,
&$info,
private \Closure $onProgress,
) {
$this->info = &$info;
if (\is_resource($body)) {
$this->offset = ftell($body);
$this->length = fstat($body)['size'];
$this->body = new ReadableResourceStream($body);
} elseif (\is_string($body)) {
$this->length = \strlen($body);
$this->body = new ReadableBuffer($body);
$this->content = $body;
} else {
$this->body = new ReadableIterableStream((static function () use ($body) {
while ('' !== $data = ($body)(16372)) {
if (!\is_string($data)) {
throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
yield $data;
}
})());
}
}
public function getContent(): ReadableStream
{
if (null !== $this->uploaded) {
$this->uploaded = null;
if (\is_string($this->body)) {
$this->offset = 0;
} elseif ($this->body instanceof ReadableResourceStream) {
fseek($this->body->getResource(), $this->offset);
}
}
return $this;
}
public function getContentType(): ?string
{
return null;
}
public function getContentLength(): ?int
{
return 0 <= $this->length ? $this->length - $this->offset : null;
}
public function read(?Cancellation $cancellation = null): ?string
{
$this->info['size_upload'] += $this->uploaded;
$this->uploaded = 0;
($this->onProgress)();
if (null !== $data = $this->body->read($cancellation)) {
$this->uploaded = \strlen($data);
} else {
$this->info['upload_content_length'] = $this->info['size_upload'];
}
return $data;
}
public function isReadable(): bool
{
return $this->body->isReadable();
}
public function close(): void
{
$this->body->close();
}
public function isClosed(): bool
{
return $this->body->isClosed();
}
public function onClose(\Closure $onClose): void
{
$this->body->onClose($onClose);
}
public function getIterator(): \Traversable
{
return $this->body;
}
public static function rewind(HttpContent $body): HttpContent
{
if (!$body instanceof self) {
return $body;
}
$body->uploaded = null;
if ($body->body instanceof ReadableResourceStream && !$body->body->isClosed()) {
fseek($body->body->getResource(), $body->offset);
}
if ($body->body instanceof ReadableBuffer) {
return new $body($body->content, $body->info, $body->onProgress);
}
return $body;
}
}

View File

@@ -0,0 +1,204 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Amp\ByteStream\ResourceStream;
use Amp\Cancellation;
use Amp\DeferredCancellation;
use Amp\DeferredFuture;
use Amp\Future;
use Amp\Http\Client\Connection\ConnectionLimitingPool;
use Amp\Http\Client\Connection\DefaultConnectionFactory;
use Amp\Http\Client\InterceptedHttpClient;
use Amp\Http\Client\Interceptor\RetryRequests;
use Amp\Http\Client\PooledHttpClient;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Amp\Http\Tunnel\Http1TunnelConnector;
use Amp\Http\Tunnel\Https1TunnelConnector;
use Amp\Socket\Certificate;
use Amp\Socket\ClientTlsContext;
use Amp\Socket\ConnectContext;
use Amp\Socket\DnsSocketConnector;
use Amp\Socket\InternetAddress;
use Amp\Socket\Socket;
use Amp\Socket\SocketAddress;
use Amp\Socket\SocketConnector;
use Psr\Log\LoggerInterface;
/**
* Internal representation of the Amp client's state.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class AmpClientState extends ClientState
{
public array $dnsCache = [];
public int $responseCount = 0;
public array $pushedResponses = [];
private array $clients = [];
private \Closure $clientConfigurator;
public function __construct(
?callable $clientConfigurator,
private int $maxHostConnections,
private int $maxPendingPushes,
private ?LoggerInterface &$logger,
) {
$clientConfigurator ??= static fn (PooledHttpClient $client) => new InterceptedHttpClient($client, new RetryRequests(2), []);
$this->clientConfigurator = $clientConfigurator(...);
}
public function request(array $options, Request $request, DeferredCancellation $canceller, array &$info, \Closure $onProgress, &$handle): Response
{
if ($options['proxy']) {
if ($request->hasHeader('proxy-authorization')) {
$options['proxy']['auth'] = $request->getHeader('proxy-authorization');
}
// Matching "no_proxy" should follow the behavior of curl
$host = $request->getUri()->getHost();
foreach ($options['proxy']['no_proxy'] as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) {
$options['proxy'] = null;
break;
}
}
}
if ($request->hasHeader('proxy-authorization')) {
$request->removeHeader('proxy-authorization');
}
if ($options['capture_peer_cert_chain']) {
$info['peer_certificate_chain'] = [];
}
$request->addEventListener(new AmpListener($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle, $options['max_connect_duration'], $canceller));
$request->setPushHandler(fn ($request, $response) => $this->handlePush($request, $response, $options));
if (0 <= $bodySize = $request->hasHeader('content-length') ? (int) $request->getHeader('content-length') : $request->getBody()->getContentLength() ?? -1) {
$info['upload_content_length'] = ((1 + $info['upload_content_length']) ?? 1) - 1 + $bodySize;
}
[$client, $connector] = $this->getClient($options);
$response = $client->request($request, $canceller->getCancellation());
$handle = $connector->handle;
return $response;
}
private function getClient(array $options): array
{
$options = [
'bindto' => $options['bindto'] ?: '0',
'verify_peer' => $options['verify_peer'],
'capath' => $options['capath'],
'cafile' => $options['cafile'],
'local_cert' => $options['local_cert'],
'local_pk' => $options['local_pk'],
'ciphers' => $options['ciphers'],
'capture_peer_cert_chain' => $options['capture_peer_cert_chain'] || $options['peer_fingerprint'],
'proxy' => $options['proxy'],
'crypto_method' => $options['crypto_method'],
];
$key = hash('xxh128', serialize($options));
if (isset($this->clients[$key])) {
return $this->clients[$key];
}
$context = new ClientTlsContext('');
$options['verify_peer'] || $context = $context->withoutPeerVerification();
$options['cafile'] && $context = $context->withCaFile($options['cafile']);
$options['capath'] && $context = $context->withCaPath($options['capath']);
$options['local_cert'] && $context = $context->withCertificate(new Certificate($options['local_cert'], $options['local_pk']));
$options['ciphers'] && $context = $context->withCiphers($options['ciphers']);
$options['capture_peer_cert_chain'] && $context = $context->withPeerCapturing();
$options['crypto_method'] && $context = $context->withMinimumVersion($options['crypto_method']);
$connector = $handleConnector = new class implements SocketConnector {
public DnsSocketConnector $connector;
public string $uri;
/** @var resource|null */
public $handle;
public function connect(SocketAddress|string $uri, ?ConnectContext $context = null, ?Cancellation $cancellation = null): Socket
{
$socket = $this->connector->connect($this->uri ?? $uri, $context, $cancellation);
$this->handle = $socket instanceof ResourceStream ? $socket->getResource() : false;
return $socket;
}
};
$connector->connector = new DnsSocketConnector(new AmpResolver($this->dnsCache));
$context = (new ConnectContext())
->withTcpNoDelay()
->withTlsContext($context);
if ($options['bindto']) {
if (file_exists($options['bindto'])) {
$connector->uri = 'unix://'.$options['bindto'];
} else {
$context = $context->withBindTo($options['bindto']);
}
}
if ($options['proxy']) {
$proxyUrl = parse_url($options['proxy']['url']);
$proxySocket = new InternetAddress($proxyUrl['host'], $proxyUrl['port']);
$proxyHeaders = $options['proxy']['auth'] ? ['Proxy-Authorization' => $options['proxy']['auth']] : [];
if ('ssl' === $proxyUrl['scheme']) {
$connector = new Https1TunnelConnector($proxySocket, $context->getTlsContext(), $proxyHeaders, $connector);
} else {
$connector = new Http1TunnelConnector($proxySocket, $proxyHeaders, $connector);
}
}
$maxHostConnections = 0 < $this->maxHostConnections ? $this->maxHostConnections : \PHP_INT_MAX;
$pool = new DefaultConnectionFactory($connector, $context);
$pool = ConnectionLimitingPool::byAuthority($maxHostConnections, $pool);
return $this->clients[$key] = [($this->clientConfigurator)(new PooledHttpClient($pool)), $handleConnector];
}
private function handlePush(Request $request, Future $response, array $options): void
{
$deferred = new DeferredFuture();
$authority = $request->getUri()->getAuthority();
if ($this->maxPendingPushes <= \count($this->pushedResponses[$authority] ?? [])) {
$fifoUrl = key($this->pushedResponses[$authority]);
unset($this->pushedResponses[$authority][$fifoUrl]);
$this->logger?->debug(\sprintf('Evicting oldest pushed response: "%s"', $fifoUrl));
}
$url = (string) $request->getUri();
$this->logger?->debug(\sprintf('Queueing pushed response: "%s"', $url));
$this->pushedResponses[$authority][] = [$url, $deferred, $request, $response, [
'proxy' => $options['proxy'],
'bindto' => $options['bindto'],
'local_cert' => $options['local_cert'],
'local_pk' => $options['local_pk'],
]];
$deferred->getFuture()->await();
}
}

View File

@@ -0,0 +1,234 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Amp\DeferredCancellation;
use Amp\Http\Client\ApplicationInterceptor;
use Amp\Http\Client\Connection\Connection;
use Amp\Http\Client\Connection\Stream;
use Amp\Http\Client\EventListener;
use Amp\Http\Client\NetworkInterceptor;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Amp\Socket\InternetAddress;
use Revolt\EventLoop;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpListener implements EventListener
{
private array $info;
private ?string $connectTimerId = null;
/**
* @param resource|null $handle
*/
public function __construct(
array &$info,
private array $pinSha256,
private \Closure $onProgress,
private &$handle,
private float $maxConnectDuration,
private DeferredCancellation $canceller,
) {
$info += [
'connect_time' => 0.0,
'pretransfer_time' => 0.0,
'starttransfer_time' => 0.0,
'total_time' => 0.0,
'namelookup_time' => 0.0,
'primary_ip' => '',
'primary_port' => 0,
];
$this->info = &$info;
}
public function requestStart(Request $request): void
{
$this->info['start_time'] ??= microtime(true);
if (0 < $this->maxConnectDuration) {
$this->connectTimerId = EventLoop::delay($this->maxConnectDuration, function (): void {
$this->canceller->cancel(new TransportException(\sprintf('Max connect duration was reached for "%s".', $this->info['url'])));
});
}
($this->onProgress)();
}
public function connectionAcquired(Request $request, Connection $connection, int $streamCount): void
{
if (null !== $this->connectTimerId) {
EventLoop::cancel($this->connectTimerId);
$this->connectTimerId = null;
}
$this->info['namelookup_time'] = microtime(true) - $this->info['start_time']; // see https://github.com/amphp/socket/issues/114
$this->info['connect_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
}
public function requestHeaderStart(Request $request, Stream $stream): void
{
$host = $stream->getRemoteAddress()->toString();
if ($stream->getRemoteAddress() instanceof InternetAddress) {
$host = $stream->getRemoteAddress()->getAddress();
$this->info['primary_port'] = $stream->getRemoteAddress()->getPort();
}
$this->info['primary_ip'] = $host;
if (str_contains($host, ':')) {
$host = '['.$host.']';
}
$this->info['pretransfer_time'] = microtime(true) - $this->info['start_time'];
$this->info['debug'] .= \sprintf("* Connected to %s (%s) port %d\n", $request->getUri()->getHost(), $host, $this->info['primary_port']);
if ((isset($this->info['peer_certificate_chain']) || $this->pinSha256) && null !== $tlsInfo = $stream->getTlsInfo()) {
foreach ($tlsInfo->getPeerCertificates() as $cert) {
$this->info['peer_certificate_chain'][] = openssl_x509_read($cert->toPem());
}
if ($this->pinSha256) {
$pin = openssl_pkey_get_public($this->info['peer_certificate_chain'][0]);
$pin = openssl_pkey_get_details($pin)['key'];
$pin = \array_slice(explode("\n", $pin), 1, -2);
$pin = base64_decode(implode('', $pin));
$pin = base64_encode(hash('sha256', $pin, true));
if (!\in_array($pin, $this->pinSha256, true)) {
throw new TransportException(\sprintf('SSL public key does not match pinned public key for "%s".', $this->info['url']));
}
}
}
($this->onProgress)();
$uri = $request->getUri();
$requestUri = $uri->getPath() ?: '/';
if ('' !== $query = $uri->getQuery()) {
$requestUri .= '?'.$query;
}
if ('CONNECT' === $method = $request->getMethod()) {
$requestUri = $uri->getHost().': '.($uri->getPort() ?? ('https' === $uri->getScheme() ? 443 : 80));
}
$this->info['debug'] .= \sprintf("> %s %s HTTP/%s \r\n", $method, $requestUri, $request->getProtocolVersions()[0]);
foreach ($request->getHeaderPairs() as [$name, $value]) {
$this->info['debug'] .= $name.': '.$value."\r\n";
}
$this->info['debug'] .= "\r\n";
}
public function requestBodyEnd(Request $request, Stream $stream): void
{
($this->onProgress)();
}
public function responseHeaderStart(Request $request, Stream $stream): void
{
($this->onProgress)();
}
public function requestEnd(Request $request, Response $response): void
{
($this->onProgress)();
}
public function requestFailed(Request $request, \Throwable $exception): void
{
if (null !== $this->connectTimerId) {
EventLoop::cancel($this->connectTimerId);
$this->connectTimerId = null;
}
$this->handle = null;
($this->onProgress)();
}
public function requestHeaderEnd(Request $request, Stream $stream): void
{
($this->onProgress)();
}
public function requestBodyStart(Request $request, Stream $stream): void
{
($this->onProgress)();
}
public function requestBodyProgress(Request $request, Stream $stream): void
{
($this->onProgress)();
}
public function responseHeaderEnd(Request $request, Stream $stream, Response $response): void
{
($this->onProgress)();
}
public function responseBodyStart(Request $request, Stream $stream, Response $response): void
{
$this->info['starttransfer_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
}
public function responseBodyProgress(Request $request, Stream $stream, Response $response): void
{
($this->onProgress)();
}
public function responseBodyEnd(Request $request, Stream $stream, Response $response): void
{
$this->handle = null;
($this->onProgress)();
}
public function applicationInterceptorStart(Request $request, ApplicationInterceptor $interceptor): void
{
}
public function applicationInterceptorEnd(Request $request, ApplicationInterceptor $interceptor, Response $response): void
{
}
public function networkInterceptorStart(Request $request, NetworkInterceptor $interceptor): void
{
}
public function networkInterceptorEnd(Request $request, NetworkInterceptor $interceptor, Response $response): void
{
}
public function push(Request $request): void
{
($this->onProgress)();
}
public function requestRejected(Request $request): void
{
if (null !== $this->connectTimerId) {
EventLoop::cancel($this->connectTimerId);
$this->connectTimerId = null;
}
$this->handle = null;
($this->onProgress)();
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Amp\Cancellation;
use Amp\Dns;
use Amp\Dns\DnsRecord;
use Amp\Dns\DnsResolver;
/**
* Handles local overrides for the DNS resolver.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpResolver implements DnsResolver
{
public function __construct(
private array &$dnsMap,
) {
}
public function resolve(string $name, ?int $typeRestriction = null, ?Cancellation $cancellation = null): array
{
$recordType = DnsRecord::A;
$ip = $this->dnsMap[$name] ?? null;
if (null !== $ip && str_contains($ip, ':')) {
$recordType = DnsRecord::AAAA;
}
if (null === $ip || $recordType !== ($typeRestriction ?? $recordType)) {
return Dns\resolve($name, $typeRestriction, $cancellation);
}
return [new DnsRecord($ip, $recordType, null)];
}
public function query(string $name, int $type, ?Cancellation $cancellation = null): array
{
$recordType = DnsRecord::A;
$ip = $this->dnsMap[$name] ?? null;
if (null !== $ip && str_contains($ip, ':')) {
$recordType = DnsRecord::AAAA;
}
if (null !== $ip || $recordType !== $type) {
return Dns\resolve($name, $type, $cancellation);
}
return [new DnsRecord($ip, $recordType, null)];
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class Canary
{
public function __construct(
private \Closure $canceller,
) {
}
public function cancel(): void
{
if (isset($this->canceller)) {
$canceller = $this->canceller;
unset($this->canceller);
$canceller();
}
}
public function __destruct()
{
$this->cancel();
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* Internal representation of the client state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
class ClientState
{
public array $handlesActivity = [];
public array $openHandles = [];
public ?float $lastTimeout = null;
}

View File

@@ -0,0 +1,183 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Response\CurlResponse;
/**
* Internal representation of the cURL client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class CurlClientState extends ClientState
{
public ?\CurlMultiHandle $handle;
public ?\CurlShareHandle $share;
public \CurlShareHandle|\CurlSharePersistentHandle|null $persistentShare;
public bool $performing = false;
/** @var PushedResponse[] */
public array $pushedResponses = [];
public DnsCache $dnsCache;
/** @var float[] */
public array $pauseExpiries = [];
public int $execCounter = \PHP_INT_MIN;
public ?LoggerInterface $logger = null;
/** @var array<string, true> Indexed by self::originKey() */
public array $ntlmRequiresFreshConnection = [];
public static array $curlVersion;
public function __construct(
private int $maxHostConnections,
private int $maxPendingPushes,
) {
self::$curlVersion ??= curl_version();
$this->dnsCache = new DnsCache();
// handle, share and persistentShare are initialized lazily in __get()
unset($this->handle, $this->share, $this->persistentShare);
}
public static function originKey(string $scheme, string $host, ?int $port = null): string
{
$scheme = strtolower(rtrim($scheme, ':'));
$port ??= 'https' === $scheme ? 443 : 80;
return $scheme.'://'.strtolower($host).':'.$port;
}
public function reset(): void
{
foreach ($this->pushedResponses as $url => $response) {
$this->logger?->debug(\sprintf('Unused pushed response: "%s"', $url));
curl_multi_remove_handle($this->handle, $response->handle);
unset($this->handlesActivity[(int) $response->handle]);
}
$this->pushedResponses = [];
$this->dnsCache->evictions = $this->dnsCache->evictions ?: $this->dnsCache->removals;
$this->dnsCache->removals = $this->dnsCache->hostnames = [];
$this->ntlmRequiresFreshConnection = [];
unset($this->share);
}
public function __get(string $name): mixed
{
if ('persistentShare' === $name) {
if (\PHP_VERSION_ID < 80500) {
return $this->persistentShare = $this->share;
}
return $this->persistentShare = curl_share_init_persistent([
\CURL_LOCK_DATA_DNS,
\CURL_LOCK_DATA_SSL_SESSION,
\CURL_LOCK_DATA_CONNECT,
]);
}
if ('share' === $name) {
$this->share = curl_share_init();
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_DNS);
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_SSL_SESSION);
// Don't share CURL_LOCK_DATA_CONNECT: easy handles attached to the same multi handle
// already share the connection cache, and adding it here creates a second pool that
// bypasses CURLMOPT_MAX_HOST_CONNECTIONS.
// See https://curl.se/libcurl/c/CURLSHOPT_SHARE.html#CURLLOCKDATACONNECT
return $this->share;
}
if ('handle' === $name) {
$this->handle = curl_multi_init();
// Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order
if (\defined('CURLPIPE_MULTIPLEX')) {
curl_multi_setopt($this->handle, \CURLMOPT_PIPELINING, \CURLPIPE_MULTIPLEX);
}
$maxHostConnections = $this->maxHostConnections;
if (\defined('CURLMOPT_MAX_HOST_CONNECTIONS') && 0 < $maxHostConnections) {
$maxHostConnections = curl_multi_setopt($this->handle, \CURLMOPT_MAX_HOST_CONNECTIONS, $maxHostConnections) ? min(50 * $maxHostConnections, 4294967295) : $maxHostConnections;
}
if (\defined('CURLMOPT_MAXCONNECTS') && 0 < $maxHostConnections) {
curl_multi_setopt($this->handle, \CURLMOPT_MAXCONNECTS, $maxHostConnections);
}
// Skip configuring HTTP/2 push when it's unsupported or buggy, see https://bugs.php.net/77535
if (0 < $this->maxPendingPushes && (\defined('CURLMOPT_PUSHFUNCTION') && 0x073D00 <= self::$curlVersion['version_number'] && (\CURL_VERSION_HTTP2 & self::$curlVersion['features']))) {
// Clone to prevent a circular reference
$multi = clone $this;
$multi->handle = null;
$multi->share = null;
$multi->persistentShare = null;
$multi->pushedResponses = &$this->pushedResponses;
$multi->logger = &$this->logger;
$multi->handlesActivity = &$this->handlesActivity;
$multi->openHandles = &$this->openHandles;
curl_multi_setopt($this->handle, \CURLMOPT_PUSHFUNCTION, $multi->handlePush(...));
}
return $this->handle;
}
throw new \LogicException(\sprintf('Unknown property "%s" on "%s".', $name, self::class));
}
private function handlePush($parent, $pushed, array $requestHeaders): int
{
$headers = [];
$origin = curl_getinfo($parent, \CURLINFO_EFFECTIVE_URL);
foreach ($requestHeaders as $h) {
if (false !== $i = strpos($h, ':', 1)) {
$headers[substr($h, 0, $i)][] = substr($h, 1 + $i);
}
}
if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path'])) {
$this->logger?->debug(\sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin));
return \CURL_PUSH_DENY;
}
$url = $headers[':scheme'][0].'://'.$headers[':authority'][0];
// curl before 7.65 doesn't validate the pushed ":authority" header,
// but this is a MUST in the HTTP/2 RFC; let's restrict pushes to the original host,
// ignoring domains mentioned as alt-name in the certificate for now (same as curl).
if (!str_starts_with($origin, $url.'/')) {
$this->logger?->debug(\sprintf('Rejecting pushed response from "%s": server is not authoritative for "%s"', $origin, $url));
return \CURL_PUSH_DENY;
}
if ($this->maxPendingPushes <= \count($this->pushedResponses)) {
$fifoUrl = key($this->pushedResponses);
unset($this->pushedResponses[$fifoUrl]);
$this->logger?->debug(\sprintf('Evicting oldest pushed response: "%s"', $fifoUrl));
}
$url .= $headers[':path'][0];
$this->logger?->debug(\sprintf('Queueing pushed response: "%s"', $url));
$this->pushedResponses[$url] = new PushedResponse(new CurlResponse($this, $pushed), $headers, $this->openHandles[(int) $parent][1] ?? [], $pushed);
return \CURL_PUSH_OK;
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* Cache for resolved DNS queries.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class DnsCache
{
/**
* Resolved hostnames (hostname => IP address).
*
* @var string[]
*/
public array $hostnames = [];
/**
* @var string[]
*/
public array $removals = [];
/**
* @var string[]
*/
public array $evictions = [];
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Contracts\HttpClient\ChunkInterface;
/**
* Follows redirections in userland so that decorators can inspect each hop.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait FollowRedirectsTrait
{
/**
* @param string $url An already prepared, absolute URL
* @param string $host The host name found in $url
* @param \Closure(string $host, string $url, array &$options): void $onRedirect Called before each followed redirect
*/
private function followRedirects(string $method, string $url, string $host, array $options, \Closure $onRedirect): AsyncResponse
{
if (0 >= $maxRedirects = $options['max_redirects']) {
return new AsyncResponse($this->client, $method, $url, $options);
}
$options['max_redirects'] = 0;
$redirectHeaders = [
'host' => $host,
'port' => parse_url($url, \PHP_URL_PORT),
'with_auth' => $options['headers'],
'no_auth' => $options['headers'],
];
if (isset($options['normalized_headers']['host']) || isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], static fn ($h) => 0 !== stripos($h, 'Host:') && 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'));
}
return new AsyncResponse($this->client, $method, $url, $options, static function (ChunkInterface $chunk, AsyncContext $context) use (&$method, &$options, $maxRedirects, &$redirectHeaders, $onRedirect): \Generator {
if (null !== $chunk->getError() || $chunk->isTimeout() || !$chunk->isFirst()) {
yield $chunk;
return;
}
$statusCode = $context->getStatusCode();
if ($statusCode < 300 || 400 <= $statusCode || null === $url = $context->getInfo('redirect_url')) {
$context->passthru();
yield $chunk;
return;
}
$host = parse_url($url, \PHP_URL_HOST);
$onRedirect($host, $url, $options);
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
if (303 === $statusCode || 'POST' === $method && \in_array($statusCode, [301, 302], true)) {
$method = 'HEAD' === $method ? 'HEAD' : 'GET';
unset($options['body'], $options['json']);
if (isset($options['normalized_headers']['content-length']) || isset($options['normalized_headers']['content-type']) || isset($options['normalized_headers']['transfer-encoding'])) {
$filterContentHeaders = static fn ($h) => 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:');
$options['headers'] = array_filter($options['headers'], $filterContentHeaders);
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders);
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
}
}
// Authorization and Cookie headers MUST NOT follow except for the initial host name
$port = parse_url($url, \PHP_URL_PORT);
$options['headers'] = $redirectHeaders['host'] === $host && ($redirectHeaders['port'] ?? null) === $port ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
static $redirectCount = 0;
$context->setInfo('redirect_count', ++$redirectCount);
$context->replaceRequest($method, $url, $options);
if ($redirectCount >= $maxRedirects) {
$context->passthru();
}
});
}
}

View File

@@ -0,0 +1,152 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Http\Client\Exception\NetworkException;
use Http\Promise\Promise;
use Psr\Http\Message\RequestInterface as Psr7RequestInterface;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Symfony\Component\HttpClient\Response\StreamableInterface;
use Symfony\Component\HttpClient\Response\StreamWrapper;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class HttplugWaitLoop
{
/**
* @param \SplObjectStorage<ResponseInterface, array{Psr7RequestInterface, Promise}>|null $promisePool
*/
public function __construct(
private HttpClientInterface $client,
private ?\SplObjectStorage $promisePool,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
) {
}
public function wait(?ResponseInterface $pendingResponse, ?float $maxDuration = null, ?float $idleTimeout = null): int
{
if (!$this->promisePool) {
return 0;
}
$guzzleQueue = \GuzzleHttp\Promise\Utils::queue();
if (0.0 === $remainingDuration = $maxDuration) {
$idleTimeout = 0.0;
} elseif (null !== $maxDuration) {
$startTime = hrtime(true) / 1E9;
$idleTimeout = max(0.0, min($maxDuration / 5, $idleTimeout ?? $maxDuration));
}
do {
foreach ($this->client->stream($this->promisePool, $idleTimeout) as $response => $chunk) {
try {
if (null !== $maxDuration && $chunk->isTimeout()) {
goto check_duration;
}
if ($chunk->isFirst()) {
// Deactivate throwing on 3/4/5xx
$response->getStatusCode();
}
if (!$chunk->isLast()) {
goto check_duration;
}
if ([, $promise] = $this->promisePool[$response] ?? null) {
unset($this->promisePool[$response]);
$promise->resolve(self::createPsr7Response($this->responseFactory, $this->streamFactory, $this->client, $response, true));
}
} catch (\Exception $e) {
if ([$request, $promise] = $this->promisePool[$response] ?? null) {
unset($this->promisePool[$response]);
if ($e instanceof TransportExceptionInterface) {
$e = new NetworkException($e->getMessage(), $request, $e);
}
$promise->reject($e);
}
}
$guzzleQueue->run();
if ($pendingResponse === $response) {
return $this->promisePool->count();
}
check_duration:
if (null !== $maxDuration && $idleTimeout && $idleTimeout > $remainingDuration = max(0.0, $maxDuration - hrtime(true) / 1E9 + $startTime)) {
$idleTimeout = $remainingDuration / 5;
break;
}
}
if (!$count = $this->promisePool->count()) {
return 0;
}
} while (null === $maxDuration || 0 < $remainingDuration);
return $count;
}
public static function createPsr7Response(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory, HttpClientInterface $client, ResponseInterface $response, bool $buffer): Psr7ResponseInterface
{
$responseParameters = [$response->getStatusCode()];
foreach ($response->getInfo('response_headers') as $h) {
if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? (?:\d\d\d) (.+)#', $h, $m)) {
$responseParameters[1] = $m[1];
}
}
$psrResponse = $responseFactory->createResponse(...$responseParameters);
foreach ($response->getHeaders(false) as $name => $values) {
foreach ($values as $value) {
try {
$psrResponse = $psrResponse->withAddedHeader($name, $value);
} catch (\InvalidArgumentException $e) {
// ignore invalid header
}
}
}
if ($response instanceof StreamableInterface) {
$body = $streamFactory->createStreamFromResource($response->toStream(false));
} elseif (!$buffer) {
$body = $streamFactory->createStreamFromResource(StreamWrapper::createResource($response, $client));
} else {
$body = $streamFactory->createStream($response->getContent(false));
}
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
return $psrResponse->withBody($body);
}
}

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
/**
* Internal representation of the native client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class NativeClientState extends ClientState
{
public int $id;
public int $maxHostConnections = \PHP_INT_MAX;
public int $responseCount = 0;
/** @var string[] */
public array $dnsCache = [];
public bool $sleep = false;
/** @var int[] */
public array $hosts = [];
public function __construct()
{
$this->id = random_int(\PHP_INT_MIN, \PHP_INT_MAX);
}
public function reset(): void
{
$this->responseCount = 0;
$this->dnsCache = [];
$this->hosts = [];
}
}

View File

@@ -0,0 +1,32 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Internal;
use Symfony\Component\HttpClient\Response\CurlResponse;
/**
* A pushed response with its request headers.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class PushedResponse
{
public function __construct(
public CurlResponse $response,
public array $requestHeaders,
public array $parentOptions,
public \CurlHandle $handle,
) {
}
}

19
vendor/symfony/http-client/LICENSE vendored Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2018-present Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Messenger;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
final class PingWebhookMessage implements \Stringable
{
public function __construct(
public readonly string $method,
public readonly string $url,
public readonly array $options = [],
public readonly bool $throw = true,
) {
}
public function __toString(): string
{
return "[{$this->method}] {$this->url}";
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Messenger;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Kevin Bond <kevinbond@gmail.com>
*/
class PingWebhookMessageHandler
{
public function __construct(
private readonly HttpClientInterface $httpClient,
) {
}
public function __invoke(PingWebhookMessage $message): ResponseInterface
{
$response = $this->httpClient->request($message->method, $message->url, $message->options);
$response->getHeaders($message->throw);
return $response;
}
}

View File

@@ -0,0 +1,113 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* A test-friendly HttpClient that doesn't make actual HTTP requests.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class MockHttpClient implements HttpClientInterface, ResetInterface
{
use HttpClientTrait;
private ResponseInterface|\Closure|iterable|null $responseFactory;
private int $requestsCount = 0;
private array $defaultOptions = [];
/**
* @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory
*/
public function __construct(callable|iterable|ResponseInterface|null $responseFactory = null, ?string $baseUri = 'https://example.com')
{
$this->setResponseFactory($responseFactory);
$this->defaultOptions['base_uri'] = $baseUri;
}
/**
* @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory
*/
public function setResponseFactory($responseFactory): void
{
if ($responseFactory instanceof ResponseInterface) {
$responseFactory = [$responseFactory];
}
if (!$responseFactory instanceof \Iterator && null !== $responseFactory && !\is_callable($responseFactory)) {
$responseFactory = (static function () use ($responseFactory) {
yield from $responseFactory;
})();
}
$this->responseFactory = !\is_callable($responseFactory) ? $responseFactory : $responseFactory(...);
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
$url = implode('', $url);
if (null === $this->responseFactory) {
$response = new MockResponse();
} elseif (\is_callable($this->responseFactory)) {
$response = ($this->responseFactory)($method, $url, $options);
} elseif (!$this->responseFactory->valid()) {
throw new TransportException($this->requestsCount ? 'No more response left in the response factory iterator passed to MockHttpClient: the number of requests exceeds the number of responses.' : 'The response factory iterator passed to MockHttpClient is empty.');
} else {
$responseFactory = $this->responseFactory->current();
$response = \is_callable($responseFactory) ? $responseFactory($method, $url, $options) : $responseFactory;
$this->responseFactory->next();
}
++$this->requestsCount;
if (!$response instanceof ResponseInterface) {
throw new TransportException(\sprintf('The response factory passed to MockHttpClient must return/yield an instance of ResponseInterface, "%s" given.', get_debug_type($response)));
}
return MockResponse::fromRequest($method, $url, $options, $response);
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
}
return new ResponseStream(MockResponse::stream($responses, $timeout));
}
public function getRequestsCount(): int
{
return $this->requestsCount;
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions, true);
return $clone;
}
public function reset(): void
{
$this->requestsCount = 0;
}
}

View File

@@ -0,0 +1,496 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\NativeClientState;
use Symfony\Component\HttpClient\Response\NativeResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* A portable implementation of the HttpClientInterface contracts based on PHP stream wrappers.
*
* PHP stream wrappers are able to fetch response bodies concurrently,
* but each request is opened synchronously.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
use LoggerAwareTrait;
public const OPTIONS_DEFAULTS = HttpClientInterface::OPTIONS_DEFAULTS + [
'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT,
];
private array $defaultOptions = self::OPTIONS_DEFAULTS;
private static array $emptyDefaults = self::OPTIONS_DEFAULTS;
private NativeClientState $multi;
/**
* @param array $defaultOptions Default request's options
* @param int $maxHostConnections The maximum number of connections to open
*
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6)
{
$this->defaultOptions['buffer'] ??= self::shouldBuffer(...);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
$this->multi = new NativeClientState();
$this->multi->maxHostConnections = 0 < $maxHostConnections ? $maxHostConnections : \PHP_INT_MAX;
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
if ($options['bindto']) {
if (file_exists($options['bindto'])) {
throw new TransportException(__CLASS__.' cannot bind to local Unix sockets, use e.g. CurlHttpClient instead.');
}
if (str_starts_with($options['bindto'], 'if!')) {
throw new TransportException(__CLASS__.' cannot bind to network interfaces, use e.g. CurlHttpClient instead.');
}
if (str_starts_with($options['bindto'], 'host!')) {
$options['bindto'] = substr($options['bindto'], 5);
}
}
$hasContentLength = isset($options['normalized_headers']['content-length']);
$hasBody = '' !== $options['body'] || 'POST' === $method || $hasContentLength;
$options['body'] = self::getBodyAsString($options['body']);
if ('chunked' === substr($options['normalized_headers']['transfer-encoding'][0] ?? '', \strlen('Transfer-Encoding: '))) {
unset($options['normalized_headers']['transfer-encoding']);
$options['headers'] = array_merge(...array_values($options['normalized_headers']));
$options['body'] = self::dechunk($options['body']);
}
if ('' === $options['body'] && $hasBody && !$hasContentLength) {
$options['headers'][] = 'Content-Length: 0';
}
if ($hasBody && !isset($options['normalized_headers']['content-type'])) {
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
}
if (\extension_loaded('zlib') && !isset($options['normalized_headers']['accept-encoding'])) {
// gzip is the most widely available algo, no need to deal with deflate
$options['headers'][] = 'Accept-Encoding: gzip';
}
if ($options['peer_fingerprint']) {
if (isset($options['peer_fingerprint']['pin-sha256']) && 1 === \count($options['peer_fingerprint'])) {
throw new TransportException(__CLASS__.' cannot verify "pin-sha256" fingerprints, please provide a "sha256" one.');
}
unset($options['peer_fingerprint']['pin-sha256']);
}
$info = [
'response_headers' => [],
'url' => $url,
'error' => null,
'canceled' => false,
'http_method' => $method,
'http_code' => 0,
'redirect_count' => 0,
'start_time' => 0.0,
'connect_time' => 0.0,
'redirect_time' => 0.0,
'pretransfer_time' => 0.0,
'starttransfer_time' => 0.0,
'total_time' => 0.0,
'namelookup_time' => 0.0,
'size_upload' => 0,
'size_download' => 0,
'size_body' => \strlen($options['body']),
'primary_ip' => '',
'primary_port' => 'http:' === $url['scheme'] ? 80 : 443,
'debug' => \extension_loaded('curl') ? '' : "* Enable the curl extension for better performance\n",
];
if ($onProgress = $options['on_progress']) {
$maxDuration = 0 < $options['max_duration'] ? $options['max_duration'] : \INF;
$onProgress = static function (...$progress) use ($onProgress, &$info, $maxDuration) {
if ($info['total_time'] >= $maxDuration) {
throw new TransportException(\sprintf('Max duration was reached for "%s".', implode('', $info['url'])));
}
$progressInfo = $info;
$progressInfo['url'] = implode('', $info['url']);
unset($progressInfo['size_body']);
// Memoize the last progress to ease calling the callback periodically when no network transfer happens
static $lastProgress = [0, 0];
if ($progress && -1 === $progress[0]) {
// Response completed
$lastProgress[0] = max($lastProgress);
} else {
$lastProgress = $progress ?: $lastProgress;
}
$onProgress($lastProgress[0], $lastProgress[1], $progressInfo);
};
} elseif (0 < $options['max_duration']) {
$maxDuration = $options['max_duration'];
$onProgress = static function () use (&$info, $maxDuration): void {
if ($info['total_time'] >= $maxDuration) {
throw new TransportException(\sprintf('Max duration was reached for "%s".', implode('', $info['url'])));
}
};
}
// Always register a notification callback to compute live stats about the response
$notification = static function (int $code, int $severity, ?string $msg, int $msgCode, int $dlNow, int $dlSize) use ($onProgress, &$info) {
$info['total_time'] = microtime(true) - $info['start_time'];
if (\STREAM_NOTIFY_PROGRESS === $code) {
$info['starttransfer_time'] = $info['starttransfer_time'] ?: $info['total_time'];
$info['size_upload'] += $dlNow ? 0 : $info['size_body'];
$info['size_download'] = $dlNow;
} elseif (\STREAM_NOTIFY_CONNECT === $code) {
$info['connect_time'] = $info['total_time'];
$info['debug'] .= $info['request_header'];
unset($info['request_header']);
} else {
return;
}
if ($onProgress) {
$onProgress($dlNow, $dlSize);
}
};
if ($options['resolve']) {
$this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache;
}
$this->logger?->info(\sprintf('Request: "%s %s"', $method, implode('', $url)));
if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: Symfony HttpClient (Native)';
}
if (0 < $options['max_duration']) {
$options['timeout'] = min($options['max_duration'], $options['timeout']);
}
if (\PHP_INT_SIZE === 4 && 2147 < $options['timeout']) {
$options['timeout'] = 2147; // fopen() on x86 doesn't support longer timeouts
}
switch ($cryptoMethod = $options['crypto_method']) {
case \STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT:
$cryptoMethod |= \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT;
// no break
case \STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT:
$cryptoMethod |= \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT;
// no break
case \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT:
$cryptoMethod |= \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
}
$context = [
'http' => [
'protocol_version' => min($options['http_version'] ?: '1.1', '1.1'),
'method' => $method,
'content' => $options['body'],
'ignore_errors' => true,
'curl_verify_ssl_peer' => $options['verify_peer'],
'curl_verify_ssl_host' => $options['verify_host'],
'auto_decode' => false, // Disable dechunk filter, it's incompatible with stream_select()
// PHP's stream context "timeout" is a read timeout, not a connect-only deadline; on this backend
// "max_connect_duration" is therefore best-effort and also caps subsequent socket reads at that
// duration. The curl and amp backends enforce the connect phase precisely.
'timeout' => 0 < $options['max_connect_duration'] ? min($options['timeout'], $options['max_connect_duration']) : $options['timeout'],
'follow_location' => false, // We follow redirects ourselves - the native logic is too limited
],
'ssl' => array_filter([
'verify_peer' => $options['verify_peer'],
'verify_peer_name' => $options['verify_host'],
'cafile' => $options['cafile'],
'capath' => $options['capath'],
'local_cert' => $options['local_cert'],
'local_pk' => $options['local_pk'],
'passphrase' => $options['passphrase'],
'ciphers' => $options['ciphers'],
'peer_fingerprint' => $options['peer_fingerprint'],
'capture_peer_cert_chain' => $options['capture_peer_cert_chain'],
'allow_self_signed' => (bool) $options['peer_fingerprint'],
'SNI_enabled' => true,
'disable_compression' => true,
'crypto_method' => $cryptoMethod,
], static fn ($v) => null !== $v),
'socket' => [
'bindto' => $options['bindto'],
'tcp_nodelay' => true,
],
];
$context = stream_context_create($context, ['notification' => $notification]);
$resolver = static function ($multi) use ($context, $options, $url, &$info, $onProgress) {
$authority = $url['authority'];
[$host, $port] = self::parseHostPort($url, $info);
if (!isset($options['normalized_headers']['host'])) {
$options['headers'][] = 'Host: '.$host.$port;
}
$proxy = self::getProxy($options['proxy'], $url, $options['no_proxy']);
if (!self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy, 'https:' === $url['scheme'])) {
$ip = self::dnsResolve($host, $multi, $info, $onProgress);
$url['authority'] = substr_replace($url['authority'], $ip, -\strlen($host) - \strlen($port), \strlen($host));
}
return [self::createRedirectResolver($options, $authority, $proxy, $info, $onProgress), implode('', $url)];
};
return new NativeResponse($this->multi, $context, implode('', $url), $options, $info, $resolver, $onProgress, $this->logger);
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof NativeResponse) {
$responses = [$responses];
}
return new ResponseStream(NativeResponse::stream($responses, $timeout));
}
public function reset(): void
{
$this->multi->reset();
}
private static function getBodyAsString($body): string
{
if (\is_resource($body)) {
return stream_get_contents($body);
}
if (!$body instanceof \Closure) {
return $body;
}
$result = '';
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
if (!\is_string($data)) {
throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
$result .= $data;
}
return $result;
}
/**
* Extracts the host and the port from the URL.
*/
private static function parseHostPort(array $url, array &$info): array
{
if ($port = parse_url($url['authority'], \PHP_URL_PORT) ?: '') {
$info['primary_port'] = $port;
$port = ':'.$port;
} else {
$info['primary_port'] = 'http:' === $url['scheme'] ? 80 : 443;
}
return [parse_url($url['authority'], \PHP_URL_HOST), $port];
}
/**
* Resolves the IP of the host using the local DNS cache if possible.
*
* @param-immediately-invoked-callable $onProgress
*/
private static function dnsResolve(string $host, NativeClientState $multi, array &$info, ?\Closure $onProgress): string
{
$flag = '' !== $host && '[' === $host[0] && ']' === $host[-1] && str_contains($host, ':') ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4;
$ip = \FILTER_FLAG_IPV6 === $flag ? substr($host, 1, -1) : $host;
$now = microtime(true);
if (filter_var($ip, \FILTER_VALIDATE_IP, $flag)) {
// The host is already an IP address
} elseif (null === $ip = $multi->dnsCache[$host] ?? null) {
$info['debug'] .= "* Hostname was NOT found in DNS cache\n";
if ($ip = gethostbynamel($host)) {
$ip = $ip[0];
} elseif (!\defined('STREAM_PF_INET6')) {
throw new TransportException(\sprintf('Could not resolve host "%s".', $host));
} elseif ($ip = dns_get_record($host, \DNS_AAAA)) {
$ip = $ip[0]['ipv6'];
} elseif (\extension_loaded('sockets')) {
if (!$addrInfo = socket_addrinfo_lookup($host, 0, ['ai_socktype' => \SOCK_STREAM, 'ai_family' => \AF_INET6])) {
throw new TransportException(\sprintf('Could not resolve host "%s".', $host));
}
$ip = socket_addrinfo_explain($addrInfo[0])['ai_addr']['sin6_addr'];
} elseif ('localhost' === $host || 'localhost.' === $host) {
$ip = '::1';
} else {
throw new TransportException(\sprintf('Could not resolve host "%s".', $host));
}
$multi->dnsCache[$host] = $ip;
$info['debug'] .= "* Added {$host}:0:{$ip} to DNS cache\n";
} else {
$info['debug'] .= "* Hostname was found in DNS cache\n";
}
$host = str_contains($ip, ':') ? "[$ip]" : $ip;
$info['namelookup_time'] = microtime(true) - ($info['start_time'] ?: $now);
$info['primary_ip'] = $ip;
if ($onProgress) {
// Notify DNS resolution
$onProgress();
}
return $host;
}
/**
* Handles redirects - the native logic is too buggy to be used.
*/
private static function createRedirectResolver(array $options, string $authority, ?array $proxy, array &$info, ?\Closure $onProgress): \Closure
{
$redirectHeaders = [];
if (0 < $maxRedirects = $options['max_redirects']) {
$redirectHeaders = ['authority' => $authority];
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static fn ($h) => 0 !== stripos($h, 'Host:'));
if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], static fn ($h) => 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'));
}
}
return static function (NativeClientState $multi, ?string $location, $context) use (&$redirectHeaders, $proxy, &$info, $maxRedirects, $onProgress): ?string {
if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) {
$info['redirect_url'] = null;
return null;
}
try {
$url = self::parseUrl($location);
$locationHasHost = isset($url['authority']);
$url = self::resolveUrl($url, $info['url']);
} catch (InvalidArgumentException) {
$info['redirect_url'] = null;
return null;
}
$info['redirect_url'] = implode('', $url);
if ($info['redirect_count'] >= $maxRedirects) {
return null;
}
$info['url'] = $url;
++$info['redirect_count'];
$info['redirect_time'] = microtime(true) - $info['start_time'];
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
if (\in_array($info['http_code'], [301, 302, 303], true)) {
$options = stream_context_get_options($context)['http'];
if ('POST' === $options['method'] || 303 === $info['http_code']) {
$info['http_method'] = $options['method'] = 'HEAD' === $options['method'] ? 'HEAD' : 'GET';
$options['content'] = '';
$filterContentHeaders = static fn ($h) => 0 !== stripos($h, 'Content-Length:') && 0 !== stripos($h, 'Content-Type:') && 0 !== stripos($h, 'Transfer-Encoding:');
$options['header'] = array_filter($options['header'], $filterContentHeaders);
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], $filterContentHeaders);
$redirectHeaders['with_auth'] = array_filter($redirectHeaders['with_auth'], $filterContentHeaders);
stream_context_set_options($context, ['http' => $options]);
}
}
[$host, $port] = self::parseHostPort($url, $info);
if ($locationHasHost) {
// Authorization and Cookie headers MUST NOT follow except for the initial authority name
$requestHeaders = $redirectHeaders['authority'] === $url['authority'] ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
$requestHeaders[] = 'Host: '.$host.$port;
$dnsResolve = !self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, 'https:' === $url['scheme']);
} else {
$dnsResolve = isset(stream_context_get_options($context)['ssl']['peer_name']);
}
if ($dnsResolve) {
$ip = self::dnsResolve($host, $multi, $info, $onProgress);
$url['authority'] = substr_replace($url['authority'], $ip, -\strlen($host) - \strlen($port), \strlen($host));
}
return implode('', $url);
};
}
private static function configureHeadersAndProxy($context, string $host, array $requestHeaders, ?array $proxy, bool $isSsl): bool
{
if (null === $proxy) {
stream_context_set_option($context, 'http', 'header', $requestHeaders);
stream_context_set_option($context, 'ssl', 'peer_name', $host);
return false;
}
// Matching "no_proxy" should follow the behavior of curl
foreach ($proxy['no_proxy'] as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) {
stream_context_set_option($context, 'http', 'proxy', null);
stream_context_set_option($context, 'http', 'request_fulluri', false);
stream_context_set_option($context, 'http', 'header', $requestHeaders);
stream_context_set_option($context, 'ssl', 'peer_name', $host);
return false;
}
}
if (null !== $proxy['auth']) {
$requestHeaders[] = 'Proxy-Authorization: '.$proxy['auth'];
}
stream_context_set_option($context, 'http', 'proxy', $proxy['url']);
stream_context_set_option($context, 'http', 'request_fulluri', !$isSsl);
stream_context_set_option($context, 'http', 'header', $requestHeaders);
stream_context_set_option($context, 'ssl', 'peer_name', null);
return true;
}
}

View File

@@ -0,0 +1,189 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\FollowRedirectsTrait;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Decorator that blocks requests to private networks by default.
*
* @author Hallison Boaventura <hallisonboaventura@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
final class NoPrivateNetworkHttpClient implements HttpClientInterface, ResetInterface
{
use AsyncDecoratorTrait;
use FollowRedirectsTrait;
use HttpClientTrait;
private array $defaultOptions = self::OPTIONS_DEFAULTS;
private HttpClientInterface $client;
private ?array $subnets;
private array $allowList;
private int $ipFlags;
private \ArrayObject $dnsCache;
/**
* @param string|array|null $subnets String or array of subnets using CIDR notation that should be considered private.
* If null is passed, the standard private subnets will be used.
* @param string|array $allowList String or array of IPs/subnets using CIDR notation that should be allowed
* even when they would otherwise match the private subnets. Useful e.g. to allow
* reaching a local proxy or a known internal host while still blocking the rest
* of the private network.
*/
public function __construct(HttpClientInterface $client, string|array|null $subnets = null, string|array $allowList = [])
{
if (!class_exists(IpUtils::class)) {
throw new \LogicException(\sprintf('You cannot use "%s" if the HttpFoundation component is not installed. Try running "composer require symfony/http-foundation".', __CLASS__));
}
if (null === $subnets) {
$ipFlags = \FILTER_FLAG_IPV4 | \FILTER_FLAG_IPV6;
} else {
$ipFlags = 0;
foreach ((array) $subnets as $subnet) {
$ipFlags |= str_contains($subnet, ':') ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4;
}
}
foreach ((array) $allowList as $allowed) {
$ipFlags |= str_contains($allowed, ':') ? \FILTER_FLAG_IPV6 : \FILTER_FLAG_IPV4;
}
if (!\defined('STREAM_PF_INET6')) {
$ipFlags &= ~\FILTER_FLAG_IPV6;
}
$this->client = $client;
$this->subnets = null !== $subnets ? (array) $subnets : null;
$this->allowList = (array) $allowList;
$this->ipFlags = $ipFlags;
$this->dnsCache = new \ArrayObject();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions, true);
$host = parse_url($url['authority'], \PHP_URL_HOST);
$url = implode('', $url);
$dnsCache = $this->dnsCache;
$subnets = $this->subnets;
$allowList = $this->allowList;
$ipFlags = $this->ipFlags;
$checkHost = static function (string $host, string $url, array &$options) use ($dnsCache, $subnets, $allowList, $ipFlags): void {
$ip = self::dnsResolve($dnsCache, $host, $ipFlags, $options);
self::ipCheck($ip, $subnets, $allowList, $ipFlags, $host, $url);
};
$checkHost($host, $url, $options);
$onProgress = $options['on_progress'] ?? null;
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, $allowList, $ipFlags): void {
static $lastPrimaryIp = '';
if (!\in_array($info['primary_ip'] ?? '', ['', $lastPrimaryIp], true)) {
self::ipCheck($info['primary_ip'], $subnets, $allowList, $ipFlags, null, $info['url']);
$lastPrimaryIp = $info['primary_ip'];
}
null !== $onProgress && $onProgress($dlNow, $dlSize, $info);
};
return $this->followRedirects($method, $url, $host, $options, $checkHost);
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions);
return $clone;
}
public function reset(): void
{
$this->dnsCache->exchangeArray([]);
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
private static function dnsResolve(\ArrayObject $dnsCache, string $host, int $ipFlags, array &$options): string
{
if ($ip = filter_var(trim($host, '[]'), \FILTER_VALIDATE_IP) ?: $options['resolve'][$host] ?? false) {
return $ip;
}
if ($dnsCache->offsetExists($host)) {
return $dnsCache[$host];
}
if ((\FILTER_FLAG_IPV4 & $ipFlags) && $ip = gethostbynamel($host)) {
return $options['resolve'][$host] = $dnsCache[$host] = $ip[0];
}
if (!(\FILTER_FLAG_IPV6 & $ipFlags)) {
return $host;
}
if ($ip = dns_get_record($host, \DNS_AAAA)) {
$ip = $ip[0]['ipv6'];
} elseif (\extension_loaded('sockets')) {
if (!$info = socket_addrinfo_lookup($host, 0, ['ai_socktype' => \SOCK_STREAM, 'ai_family' => \AF_INET6])) {
return $host;
}
$ip = socket_addrinfo_explain($info[0])['ai_addr']['sin6_addr'];
} elseif ('localhost' === $host || 'localhost.' === $host) {
$ip = '::1';
} else {
return $host;
}
return $options['resolve'][$host] = $dnsCache[$host] = $ip;
}
private static function ipCheck(string $ip, ?array $subnets, array $allowList, int $ipFlags, ?string $host, string $url): void
{
if (null === $subnets) {
// Quick check, but not reliable enough, see https://github.com/php/php-src/issues/16944
$ipFlags |= \FILTER_FLAG_NO_PRIV_RANGE | \FILTER_FLAG_NO_RES_RANGE;
}
if (false !== filter_var($ip, \FILTER_VALIDATE_IP, $ipFlags) && !IpUtils::checkIp($ip, $subnets ?? IpUtils::PRIVATE_SUBNETS)) {
return;
}
if ($allowList && false !== filter_var($ip, \FILTER_VALIDATE_IP) && IpUtils::checkIp($ip, $allowList)) {
return;
}
if (null !== $host) {
$type = 'Host';
} else {
$host = $ip;
$type = 'IP';
}
throw new TransportException($type.\sprintf(' "%s" is blocked for "%s".', $host, $url));
}
}

View File

@@ -0,0 +1,253 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Http\Discovery\Psr17Factory;
use Http\Discovery\Psr17FactoryDiscovery;
use Nyholm\Psr7\Factory\Psr17Factory as NyholmPsr17Factory;
use Nyholm\Psr7\Request;
use Nyholm\Psr7\Uri;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Client\RequestExceptionInterface;
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;
use Symfony\Component\HttpClient\Internal\HttplugWaitLoop;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Service\ResetInterface;
if (!interface_exists(ClientInterface::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as the "psr/http-client" package is not installed. Try running "composer require php-http/discovery psr/http-client-implementation:*".');
}
if (!interface_exists(RequestFactoryInterface::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as the "psr/http-factory" package is not installed. Try running "composer require php-http/discovery psr/http-factory-implementation:*".');
}
/**
* An adapter to turn a Symfony HttpClientInterface into a PSR-18 ClientInterface.
*
* Run "composer require php-http/discovery psr/http-client-implementation:*"
* to get the required dependencies.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class Psr18Client implements ClientInterface, RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface, ResetInterface
{
private HttpClientInterface $client;
private ResponseFactoryInterface $responseFactory;
private StreamFactoryInterface $streamFactory;
private bool $autoUpgradeHttpVersion = true;
public function __construct(?HttpClientInterface $client = null, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?? HttpClient::create();
$streamFactory ??= $responseFactory instanceof StreamFactoryInterface ? $responseFactory : null;
if (null === $responseFactory || null === $streamFactory) {
if (class_exists(Psr17Factory::class)) {
$psr17Factory = new Psr17Factory();
} elseif (class_exists(NyholmPsr17Factory::class)) {
$psr17Factory = new NyholmPsr17Factory();
} else {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided. Try running "composer require php-http/discovery psr/http-factory-implementation:*".');
}
$responseFactory ??= $psr17Factory;
$streamFactory ??= $psr17Factory;
}
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
}
public function withOptions(array $options): static
{
$clone = clone $this;
if (\array_key_exists('auto_upgrade_http_version', $options)) {
$clone->autoUpgradeHttpVersion = $options['auto_upgrade_http_version'];
unset($options['auto_upgrade_http_version']);
}
$clone->client = $clone->client->withOptions($options);
return $clone;
}
public function sendRequest(RequestInterface $request): ResponseInterface
{
try {
$body = $request->getBody();
$headers = $request->getHeaders();
$size = $request->getHeader('content-length')[0] ?? -1;
if (0 > $size && 0 < $size = $body->getSize() ?? -1) {
$headers['Content-Length'] = [$size];
}
if (0 === $size) {
$body = '';
} elseif (0 < $size && $size < 1 << 21) {
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
$body = $body->getContents();
} else {
$body = static function (int $size) use ($body) {
if ($body->isSeekable()) {
try {
$body->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
while (!$body->eof()) {
yield $body->read($size);
}
};
}
$options = [
'headers' => $headers,
'body' => $body,
];
if (!$this->autoUpgradeHttpVersion || '1.0' === $request->getProtocolVersion()) {
$options['http_version'] = $request->getProtocolVersion();
}
$response = $this->client->request($request->getMethod(), (string) $request->getUri(), $options);
return HttplugWaitLoop::createPsr7Response($this->responseFactory, $this->streamFactory, $this->client, $response, false);
} catch (TransportExceptionInterface $e) {
if ($e instanceof \InvalidArgumentException) {
throw new Psr18RequestException($e, $request);
}
throw new Psr18NetworkException($e, $request);
}
}
public function createRequest(string $method, $uri): RequestInterface
{
if ($this->responseFactory instanceof RequestFactoryInterface) {
return $this->responseFactory->createRequest($method, $uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri);
}
if (class_exists(Request::class)) {
return new Request($method, $uri);
}
throw new \LogicException(\sprintf('You cannot use "%s()" as no PSR-17 factories have been found. Try running "composer require php-http/discovery psr/http-factory-implementation:*".', __METHOD__));
}
public function createStream(string $content = ''): StreamInterface
{
$stream = $this->streamFactory->createStream($content);
if ($stream->isSeekable()) {
try {
$stream->seek(0);
} catch (\RuntimeException) {
// ignore
}
}
return $stream;
}
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
return $this->streamFactory->createStreamFromFile($filename, $mode);
}
public function createStreamFromResource($resource): StreamInterface
{
return $this->streamFactory->createStreamFromResource($resource);
}
public function createUri(string $uri = ''): UriInterface
{
if ($this->responseFactory instanceof UriFactoryInterface) {
return $this->responseFactory->createUri($uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findUrlFactory()->createUri($uri);
}
if (class_exists(Uri::class)) {
return new Uri($uri);
}
throw new \LogicException(\sprintf('You cannot use "%s()" as no PSR-17 factories have been found. Try running "composer require php-http/discovery psr/http-factory-implementation:*".', __METHOD__));
}
public function reset(): void
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}
/**
* @internal
*/
class Psr18NetworkException extends \RuntimeException implements NetworkExceptionInterface
{
public function __construct(
TransportExceptionInterface $e,
private RequestInterface $request,
) {
parent::__construct($e->getMessage(), 0, $e);
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}
/**
* @internal
*/
class Psr18RequestException extends \InvalidArgumentException implements RequestExceptionInterface
{
public function __construct(
TransportExceptionInterface $e,
private RequestInterface $request,
) {
parent::__construct($e->getMessage(), 0, $e);
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}

23
vendor/symfony/http-client/README.md vendored Normal file
View File

@@ -0,0 +1,23 @@
HttpClient component
====================
The HttpClient component provides powerful methods to fetch HTTP resources synchronously or asynchronously.
Sponsor
-------
This package is looking for a [backer][1].
Help Symfony by [sponsoring][3] its development!
Resources
---------
* [Documentation](https://symfony.com/doc/current/components/http_client.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)
[1]: https://symfony.com/backers
[3]: https://symfony.com/sponsor

View File

@@ -0,0 +1,466 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Amp\ByteStream\StreamException;
use Amp\DeferredCancellation;
use Amp\DeferredFuture;
use Amp\Future;
use Amp\Http\Client\HttpException;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Psr\Log\LoggerInterface;
use Revolt\EventLoop;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\InformationalChunk;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\HttpClientTrait;
use Symfony\Component\HttpClient\Internal\AmpBody;
use Symfony\Component\HttpClient\Internal\AmpClientState;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
use function Amp\delay;
use function Amp\Future\awaitFirst;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class AmpResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait;
private static string $nextId = 'a';
private ?array $options;
private \Closure $onProgress;
/**
* @internal
*/
public function __construct(
private AmpClientState $multi,
Request $request,
array $options,
?LoggerInterface $logger,
) {
$this->options = &$options;
$this->logger = $logger;
$this->timeout = $options['timeout'];
$this->shouldBuffer = $options['buffer'];
if ($this->inflate = \extension_loaded('zlib') && !$request->hasHeader('accept-encoding')) {
$request->setHeader('Accept-Encoding', 'gzip');
}
$this->initializer = static fn (self $response) => null !== $response->options;
$info = &$this->info;
$headers = &$this->headers;
$canceller = new DeferredCancellation();
$handle = &$this->handle;
$info['url'] = (string) $request->getUri();
$info['http_method'] = $request->getMethod();
$info['start_time'] = null;
$info['redirect_url'] = null;
$info['original_url'] = $info['url'];
$info['redirect_time'] = 0.0;
$info['redirect_count'] = 0;
$info['size_upload'] = 0.0;
$info['size_download'] = 0.0;
$info['upload_content_length'] = -1.0;
$info['download_content_length'] = -1.0;
$info['user_data'] = $options['user_data'];
$info['max_duration'] = $options['max_duration'];
$info['max_connect_duration'] = $options['max_connect_duration'];
$info['debug'] = '';
$onProgress = $options['on_progress'] ?? static function () {};
$onProgress = $this->onProgress = static function () use (&$info, $onProgress) {
$info['total_time'] = microtime(true) - $info['start_time'];
$onProgress((int) $info['size_download'], ((int) (1 + $info['download_content_length']) ?: 1) - 1, (array) $info);
};
$pause = 0.0;
$this->id = $id = self::$nextId;
self::$nextId = str_increment(self::$nextId);
$info['pause_handler'] = static function (float $duration) use (&$pause) {
$pause = $duration;
};
$multi->lastTimeout = null;
$multi->openHandles[$id] = new DeferredFuture();
++$multi->responseCount;
$this->canary = new Canary(static function () use ($canceller, $multi, $id) {
$canceller->cancel();
$multi->openHandles[$id]?->isComplete() || $multi->openHandles[$id]?->complete();
unset($multi->openHandles[$id], $multi->handlesActivity[$id]);
});
EventLoop::queue(static function () use ($request, $multi, $id, &$info, &$headers, $canceller, &$options, $onProgress, &$handle, $logger, &$pause) {
self::generateResponse($request, $multi, $id, $info, $headers, $canceller, $options, $onProgress, $handle, $logger, $pause);
});
}
public function getInfo(?string $type = null): mixed
{
return null !== $type ? $this->info[$type] ?? null : $this->info;
}
public function __serialize(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __unserialize(array $data): void
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
try {
$this->doDestruct();
} finally {
// Clear the DNS cache when all requests completed
if (0 >= --$this->multi->responseCount) {
$this->multi->responseCount = 0;
$this->multi->dnsCache = [];
}
}
}
private static function schedule(self $response, array &$runningResponses): void
{
if (isset($runningResponses[0])) {
$runningResponses[0][1][$response->id] = $response;
} else {
$runningResponses[0] = [$response->multi, [$response->id => $response]];
}
if (!isset($response->multi->openHandles[$response->id])) {
$response->multi->handlesActivity[$response->id][] = null;
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
}
}
/**
* @param AmpClientState $multi
*/
private static function perform(ClientState $multi, ?array $responses = null): void
{
if ($responses) {
foreach ($responses as $response) {
try {
if ($response->info['start_time']) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
($response->onProgress)();
}
} catch (\Throwable $e) {
$multi->handlesActivity[$response->id][] = null;
$multi->handlesActivity[$response->id][] = $e;
}
}
}
}
/**
* @param AmpClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
$delay = new DeferredFuture();
$id = EventLoop::delay($timeout, $delay->complete(...));
awaitFirst((static function () use ($delay, $multi) {
yield $delay->getFuture();
foreach ($multi->openHandles as $deferred) {
yield $deferred->getFuture();
}
})());
if ($delay->isComplete()) {
return 0;
}
$delay->complete();
EventLoop::cancel($id);
return 1;
}
private static function generateResponse(Request $request, AmpClientState $multi, string $id, array &$info, array &$headers, DeferredCancellation $canceller, array &$options, \Closure $onProgress, &$handle, ?LoggerInterface $logger, float &$pause): void
{
$request->setInformationalResponseHandler(static function (Response $response) use ($multi, $id, &$info, &$headers) {
self::addResponseHeaders($response, $info, $headers);
$multi->handlesActivity[$id][] = new InformationalChunk($response->getStatus(), $response->getHeaders());
$multi->openHandles[$id]->complete();
$multi->openHandles[$id] = new DeferredFuture();
});
try {
/** @var Response $response */
if (null === $response = self::getPushedResponse($request, $multi, $info, $headers, $canceller, $options, $logger)) {
$logger?->info(\sprintf('Request: "%s %s"', $info['http_method'], $info['url']));
$response = self::followRedirects($request, $multi, $info, $headers, $canceller, $options, $onProgress, $handle, $logger, $pause);
}
$options = null;
$multi->handlesActivity[$id][] = new FirstChunk();
if ('HEAD' === $response->getRequest()->getMethod() || \in_array($info['http_code'], [204, 304], true)) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null;
$multi->openHandles[$id]->complete();
return;
}
if ($response->hasHeader('content-length')) {
$info['download_content_length'] = (float) $response->getHeader('content-length');
}
$body = $response->getBody();
while (true) {
if (!isset($multi->openHandles[$id])) {
return;
}
$multi->openHandles[$id]->complete();
$multi->openHandles[$id] = new DeferredFuture();
if (0 < $pause) {
delay($pause, true, $canceller->getCancellation());
}
if (null === $data = $body->read()) {
break;
}
$info['size_download'] += \strlen($data);
$multi->handlesActivity[$id][] = $data;
}
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null;
} catch (\Throwable $e) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = $e;
} finally {
$info['download_content_length'] = $info['size_download'];
}
}
private static function followRedirects(Request $originRequest, AmpClientState $multi, array &$info, array &$headers, DeferredCancellation $canceller, array $options, \Closure $onProgress, &$handle, ?LoggerInterface $logger, float &$pause): ?Response
{
if (0 < $pause) {
delay($pause, true, $canceller->getCancellation());
}
$originRequest->setBody(new AmpBody($options['body'], $info, $onProgress));
$response = $multi->request($options, $originRequest, $canceller, $info, $onProgress, $handle);
$previousUrl = null;
while (true) {
self::addResponseHeaders($response, $info, $headers);
$status = $response->getStatus();
if (!\in_array($status, [301, 302, 303, 307, 308], true) || null === $location = $response->getHeader('location')) {
return $response;
}
$urlResolver = new class {
use HttpClientTrait {
parseUrl as public;
resolveUrl as public;
}
};
try {
$previousUrl ??= $urlResolver::parseUrl($info['url']);
$location = $urlResolver::parseUrl($location);
$location = $urlResolver::resolveUrl($location, $previousUrl);
$info['redirect_url'] = implode('', $location);
} catch (InvalidArgumentException) {
return $response;
}
if (0 >= $options['max_redirects'] || $info['redirect_count'] >= $options['max_redirects']) {
return $response;
}
$logger?->info(\sprintf('Redirecting: "%s %s"', $status, $info['url']));
try {
// Discard body of redirects
$response->getBody()->close();
} catch (HttpException|StreamException) {
// Ignore streaming errors on previous responses
}
++$info['redirect_count'];
$info['url'] = $info['redirect_url'];
$info['redirect_url'] = null;
$previousUrl = $location;
$request = new Request($info['url'], $info['http_method']);
$request->setProtocolVersions($originRequest->getProtocolVersions());
$request->setTcpConnectTimeout($originRequest->getTcpConnectTimeout());
$request->setTlsHandshakeTimeout($originRequest->getTlsHandshakeTimeout());
$request->setTransferTimeout($originRequest->getTransferTimeout());
$request->setBodySizeLimit(0);
if (method_exists($request, 'setInactivityTimeout')) {
$request->setInactivityTimeout(0);
}
if (\in_array($status, [301, 302, 303], true)) {
$originRequest->removeHeader('transfer-encoding');
$originRequest->removeHeader('content-length');
$originRequest->removeHeader('content-type');
// Do like curl and browsers: turn POST to GET on 301, 302 and 303
if ('POST' === $response->getRequest()->getMethod() || 303 === $status) {
$info['http_method'] = 'HEAD' === $response->getRequest()->getMethod() ? 'HEAD' : 'GET';
$request->setMethod($info['http_method']);
}
} else {
$request->setBody(AmpBody::rewind($response->getRequest()->getBody()));
}
foreach ($originRequest->getHeaderPairs() as [$name, $value]) {
$request->addHeader($name, $value);
}
if ($request->getUri()->getAuthority() !== $originRequest->getUri()->getAuthority()) {
$request->removeHeader('authorization');
$request->removeHeader('cookie');
$request->removeHeader('host');
}
if (0 < $pause) {
delay($pause, true, $canceller->getCancellation());
}
$response = $multi->request($options, $request, $canceller, $info, $onProgress, $handle);
$info['redirect_time'] = microtime(true) - $info['start_time'];
}
}
private static function addResponseHeaders(Response $response, array &$info, array &$headers): void
{
$info['http_code'] = $response->getStatus();
if ($headers) {
$info['debug'] .= "< \r\n";
$headers = [];
}
$h = \sprintf('HTTP/%s %s %s', $response->getProtocolVersion(), $response->getStatus(), $response->getReason());
$info['debug'] .= "< {$h}\r\n";
$info['response_headers'][] = $h;
foreach ($response->getHeaderPairs() as [$name, $value]) {
$headers[strtolower($name)][] = $value;
$h = $name.': '.$value;
$info['debug'] .= "< {$h}\r\n";
$info['response_headers'][] = $h;
}
$info['debug'] .= "< \r\n";
}
/**
* Accepts pushed responses only if their headers related to authentication match the request.
*/
private static function getPushedResponse(Request $request, AmpClientState $multi, array &$info, array &$headers, DeferredCancellation $canceller, array $options, ?LoggerInterface $logger): ?Response
{
if ('' !== $options['body']) {
return null;
}
$authority = $request->getUri()->getAuthority();
$cancellation = $canceller->getCancellation();
foreach ($multi->pushedResponses[$authority] ?? [] as $i => [$pushedUrl, $pushDeferred, $pushedRequest, $pushedResponse, $parentOptions]) {
if ($info['url'] !== $pushedUrl || $info['http_method'] !== $pushedRequest->getMethod()) {
continue;
}
foreach ($parentOptions as $k => $v) {
if ($options[$k] !== $v) {
continue 2;
}
}
/** @var DeferredFuture $pushDeferred */
$id = $cancellation->subscribe(static fn ($e) => $pushDeferred->error($e));
try {
/** @var Future $pushedResponse */
$response = $pushedResponse->await($cancellation);
} finally {
$cancellation->unsubscribe($id);
}
foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
if ($response->getHeaderArray($k) !== $request->getHeaderArray($k)) {
continue 2;
}
}
foreach ($response->getHeaderArray('vary') as $vary) {
foreach (preg_split('/\s*+,\s*+/', $vary) as $v) {
if ('*' === $v || ($pushedRequest->getHeaderArray($v) !== $request->getHeaderArray($v) && 'accept-encoding' !== strtolower($v))) {
$logger?->debug(\sprintf('Skipping pushed response: "%s"', $info['url']));
continue 3;
}
}
}
$info += [
'connect_time' => 0.0,
'pretransfer_time' => 0.0,
'starttransfer_time' => 0.0,
'total_time' => 0.0,
'namelookup_time' => 0.0,
'primary_ip' => '',
'primary_port' => 0,
'start_time' => microtime(true),
];
$pushDeferred->complete();
$logger?->debug(\sprintf('Accepting pushed response: "%s %s"', $info['http_method'], $info['url']));
self::addResponseHeaders($response, $info, $headers);
unset($multi->pushedResponses[$authority][$i]);
if (!$multi->pushedResponses[$authority]) {
unset($multi->pushedResponses[$authority]);
}
return $response;
}
return null;
}
}

View File

@@ -0,0 +1,199 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Component\HttpClient\Chunk\DataChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* A DTO to work with AsyncResponse.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class AsyncContext
{
/** @var callable|null */
private $passthru;
private ResponseInterface $response;
private array $info = [];
/**
* @param resource|null $content
*/
public function __construct(
?callable &$passthru,
private HttpClientInterface $client,
ResponseInterface &$response,
array &$info,
private $content,
private int $offset,
) {
$this->passthru = &$passthru;
$this->response = &$response;
$this->info = &$info;
}
/**
* Returns the HTTP status without consuming the response.
*/
public function getStatusCode(): int
{
return $this->response->getInfo('http_code');
}
/**
* Returns the headers without consuming the response.
*/
public function getHeaders(): array
{
$headers = [];
foreach ($this->response->getInfo('response_headers') as $h) {
if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? ([123456789]\d\d)(?: |$)#', $h, $m)) {
$headers = [];
} elseif (2 === \count($m = explode(':', $h, 2))) {
$headers[strtolower($m[0])][] = ltrim($m[1]);
}
}
return $headers;
}
/**
* @return resource|null The PHP stream resource where the content is buffered, if it is
*/
public function getContent()
{
return $this->content;
}
/**
* Creates a new chunk of content.
*/
public function createChunk(string $data): ChunkInterface
{
return new DataChunk($this->offset, $data);
}
/**
* Pauses the request for the given number of seconds.
*/
public function pause(float $duration): void
{
if (\is_callable($pause = $this->response->getInfo('pause_handler'))) {
$pause($duration);
} elseif (0 < $duration) {
usleep((int) (1E6 * $duration));
}
}
/**
* Cancels the request and returns the last chunk to yield.
*/
public function cancel(): ChunkInterface
{
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
$this->response->cancel();
return new LastChunk();
}
/**
* Returns the current info of the response.
*/
public function getInfo(?string $type = null): mixed
{
if (null !== $type) {
return $this->info[$type] ?? $this->response->getInfo($type);
}
return $this->info + $this->response->getInfo();
}
/**
* Attaches an info to the response.
*
* @return $this
*/
public function setInfo(string $type, mixed $value): static
{
if ('canceled' === $type && $value !== $this->info['canceled']) {
throw new \LogicException('You cannot set the "canceled" info directly.');
}
if (null === $value) {
unset($this->info[$type]);
} else {
$this->info[$type] = $value;
}
return $this;
}
/**
* Returns the currently processed response.
*/
public function getResponse(): ResponseInterface
{
return $this->response;
}
/**
* Replaces the currently processed response by doing a new request.
*/
public function replaceRequest(string $method, string $url, array $options = []): ResponseInterface
{
$this->info['previous_info'][] = $info = $this->response->getInfo();
if (null !== $onProgress = $options['on_progress'] ?? null) {
$thisInfo = &$this->info;
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) {
$onProgress($dlNow, $dlSize, $thisInfo + $info);
};
}
if (0 < ($info['max_duration'] ?? 0) && 0 < ($info['total_time'] ?? 0)) {
if (0 >= $options['max_duration'] = $info['max_duration'] - $info['total_time']) {
throw new TransportException(\sprintf('Max duration was reached for "%s".', $info['url']));
}
}
return $this->response = $this->client->request($method, $url, ['buffer' => false] + $options);
}
/**
* Replaces the currently processed response by another one.
*/
public function replaceResponse(ResponseInterface $response): ResponseInterface
{
$this->info['previous_info'][] = $this->response->getInfo();
return $this->response = $response;
}
/**
* Replaces or removes the chunk filter iterator.
*
* @param ?callable(ChunkInterface, self): ?\Iterator $passthru
*/
public function passthru(?callable $passthru = null): void
{
$this->passthru = $passthru ?? static function ($chunk, $context) {
$context->passthru = null;
yield $chunk;
};
}
}

View File

@@ -0,0 +1,503 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Provides a single extension point to process a response's content stream.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class AsyncResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
private const FIRST_CHUNK_YIELDED = 1;
private const LAST_CHUNK_YIELDED = 2;
private ?HttpClientInterface $client;
private ResponseInterface $response;
private array $info = ['canceled' => false];
/** @var callable|null */
private $passthru;
private ?\Iterator $stream = null;
private ?int $yieldedState = null;
private bool $hasThrown = false;
/**
* @param ?callable(ChunkInterface, AsyncContext): ?\Iterator $passthru
*/
public function __construct(HttpClientInterface $client, string $method, string $url, array $options, ?callable $passthru = null)
{
$this->client = $client;
$this->shouldBuffer = $options['buffer'] ?? true;
if (null !== $onProgress = $options['on_progress'] ?? null) {
$thisInfo = &$this->info;
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$thisInfo, $onProgress) {
$onProgress($dlNow, $dlSize, $thisInfo + $info);
};
}
$this->response = $client->request($method, $url, ['buffer' => false] + $options);
$this->passthru = $passthru;
$this->initializer = static function (self $response, ?float $timeout = null) {
if (null === $response->shouldBuffer) {
return false;
}
while (true) {
foreach (self::stream([$response], $timeout) as $chunk) {
if ($chunk->isTimeout() && ($response->passthru || $response = self::findInnerPassthru($response))) {
// Timeouts thrown during initialization are transport errors
foreach (self::passthru($response->client, $response, new ErrorChunk($response->offset, new TransportException($chunk->getError()))) as $chunk) {
if ($chunk->isFirst()) {
return false;
}
}
continue 2;
}
if ($chunk->isFirst()) {
return false;
}
}
return false;
}
};
if (\array_key_exists('user_data', $options)) {
$this->info['user_data'] = $options['user_data'];
}
if (\array_key_exists('max_duration', $options)) {
$this->info['max_duration'] = $options['max_duration'];
}
}
public function getStatusCode(): int
{
if ($this->initializer) {
self::initialize($this);
}
return $this->response->getStatusCode();
}
public function getHeaders(bool $throw = true): array
{
if ($this->initializer) {
self::initialize($this);
}
$headers = $this->response->getHeaders(false);
if ($throw) {
$this->checkStatusCode();
}
return $headers;
}
public function getInfo(?string $type = null): mixed
{
if ('debug' === ($type ?? 'debug')) {
$debug = implode('', array_column($this->info['previous_info'] ?? [], 'debug'));
$debug .= $this->response->getInfo('debug');
if ('debug' === $type) {
return $debug;
}
}
if (null !== $type) {
return $this->info[$type] ?? $this->response->getInfo($type);
}
return array_merge($this->info + $this->response->getInfo(), ['debug' => $debug]);
}
/**
* @return resource
*/
public function toStream(bool $throw = true)
{
if ($throw) {
// Ensure headers arrived
$this->getHeaders(true);
}
$handle = function () {
$stream = $this->response instanceof StreamableInterface ? $this->response->toStream(false) : StreamWrapper::createResource($this->response);
return stream_get_meta_data($stream)['wrapper_data']->stream_cast(\STREAM_CAST_FOR_SELECT);
};
$stream = StreamWrapper::createResource($this);
stream_get_meta_data($stream)['wrapper_data']
->bindHandles($handle, $this->content);
return $stream;
}
public function cancel(): void
{
if ($this->info['canceled']) {
return;
}
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
$this->close();
$client = $this->client;
$this->client = null;
if (!$this->passthru) {
return;
}
try {
foreach (self::passthru($client, $this, new LastChunk()) as $chunk) {
// no-op
}
$this->passthru = null;
} catch (ExceptionInterface) {
// ignore any errors when canceling
}
}
public function __destruct()
{
$httpException = null;
if ($this->initializer && null === $this->getInfo('error') && !$this->hasThrown) {
try {
self::initialize($this, -0.0);
$this->getHeaders(true);
} catch (HttpExceptionInterface $httpException) {
// no-op
}
}
if ($this->passthru && null === $this->getInfo('error')) {
$this->info['canceled'] = true;
try {
foreach (self::passthru($this->client, $this, new LastChunk()) as $chunk) {
// no-op
}
} catch (ExceptionInterface) {
// ignore any errors when destructing
}
}
if (null !== $httpException) {
throw $httpException;
}
}
/**
* @internal
*/
public static function stream(iterable $responses, ?float $timeout = null, ?string $class = null): \Generator
{
while ($responses) {
$wrappedResponses = [];
$asyncMap = new \SplObjectStorage();
$client = null;
foreach ($responses as $r) {
if (!$r instanceof self) {
throw new \TypeError(\sprintf('"%s::stream()" expects parameter 1 to be an iterable of AsyncResponse objects, "%s" given.', $class ?? static::class, get_debug_type($r)));
}
if (null !== $e = $r->info['error'] ?? null) {
yield $r => $chunk = new ErrorChunk($r->offset, new TransportException($e));
$chunk->didThrow() ?: $chunk->getContent();
continue;
}
if (null === $client) {
$client = $r->client;
} elseif ($r->client !== $client) {
throw new TransportException('Cannot stream AsyncResponse objects with many clients.');
}
$asyncMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
if ($r->stream) {
yield from self::passthruStream($response = $r->response, $r, $asyncMap, new LastChunk());
if (!isset($asyncMap[$response])) {
array_pop($wrappedResponses);
}
if ($r->response !== $response && !isset($asyncMap[$r->response])) {
$asyncMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
}
}
}
if (!$client || !$wrappedResponses) {
return;
}
$chunk = null;
foreach ($client->stream($wrappedResponses, $timeout) as $response => $chunk) {
$r = $asyncMap[$response];
if (null === $chunk->getError()) {
if ($chunk->isFirst()) {
// Ensure no exception is thrown on destruct for the wrapped response
$r->response->getStatusCode();
} elseif (0 === $r->offset && null === $r->content && $chunk->isLast()) {
$r->content = fopen('php://memory', 'w+');
}
}
$innerR = null;
if (!$r->passthru && !$innerR = null !== $chunk->getError() ? self::findInnerPassthru($r) : null) {
$r->stream = (static fn () => yield $chunk)();
yield from self::passthruStream($response, $r, $asyncMap);
continue;
}
if (null !== $chunk->getError()) {
// no-op
} elseif ($chunk->isFirst()) {
$r->yieldedState = self::FIRST_CHUNK_YIELDED;
} elseif (self::FIRST_CHUNK_YIELDED !== $r->yieldedState && null === $chunk->getInformationalStatus()) {
throw new \LogicException(\sprintf('Instance of "%s" is already consumed and cannot be managed by "%s". A decorated client should not call any of the response\'s methods in its "request()" method.', get_debug_type($response), $class ?? static::class));
}
$innerR ??= $r;
foreach (self::passthru($innerR->client, $innerR, $chunk, $asyncMap) as $chunk) {
yield $r => $chunk;
}
if ($innerR->response !== $response && isset($asyncMap[$response])) {
break;
}
}
if (null === $chunk) {
throw new \LogicException(\sprintf('"%s" is not compliant with HttpClientInterface: its "stream()" method didn\'t yield any chunks when it should have.', get_debug_type($client)));
}
if (null === $chunk->getError() && $chunk->isLast()) {
$r->yieldedState = self::LAST_CHUNK_YIELDED;
}
if (null === $chunk->getError() && self::LAST_CHUNK_YIELDED !== $r->yieldedState && $r->response === $response && null !== $r->client) {
throw new \LogicException('A chunk passthru must yield an "isLast()" chunk before ending a stream.');
}
$responses = [];
foreach ($asyncMap as $response) {
$r = $asyncMap[$response];
if (null !== $r->client) {
$responses[] = $r;
}
}
}
}
/**
* @param \SplObjectStorage<ResponseInterface, AsyncResponse>|null $asyncMap
*/
private static function passthru(HttpClientInterface $client, self $r, ChunkInterface $chunk, ?\SplObjectStorage $asyncMap = null): \Generator
{
$r->stream = null;
$response = $r->response;
$context = new AsyncContext($r->passthru, $client, $r->response, $r->info, $r->content, $r->offset);
if (null === $stream = ($r->passthru)($chunk, $context)) {
if ($r->response === $response && (null !== $chunk->getError() || $chunk->isLast())) {
throw new \LogicException('A chunk passthru cannot swallow the last chunk.');
}
return;
}
if (!$stream instanceof \Iterator) {
throw new \LogicException(\sprintf('A chunk passthru must return an "Iterator", "%s" returned.', get_debug_type($stream)));
}
$r->stream = $stream;
yield from self::passthruStream($response, $r, $asyncMap);
}
private static function findInnerPassthru(self $response): ?self
{
$innerResponse = $response->response ?? null;
while ($innerResponse instanceof self) {
if ($innerResponse->passthru) {
return $innerResponse;
}
$innerResponse = $innerResponse->response ?? null;
}
return null;
}
/**
* @param \SplObjectStorage<ResponseInterface, AsyncResponse>|null $asyncMap
*/
private static function passthruStream(ResponseInterface $response, self $r, ?\SplObjectStorage $asyncMap, ?ChunkInterface $chunk = null): \Generator
{
while (true) {
try {
if (null !== $chunk && $r->stream) {
$r->stream->next();
}
if (!$r->stream || !$r->stream->valid() || !$r->stream) {
$r->stream = null;
break;
}
} catch (\Throwable $e) {
unset($asyncMap[$response]);
$r->stream = null;
$r->info['error'] = $e->getMessage();
$r->response->cancel();
yield $r => $chunk = new ErrorChunk($r->offset, $e);
$chunk->didThrow() ?: $chunk->getContent();
break;
}
$chunk = $r->stream->current();
if (!$chunk instanceof ChunkInterface) {
throw new \LogicException(\sprintf('A chunk passthru must yield instances of "%s", "%s" yielded.', ChunkInterface::class, get_debug_type($chunk)));
}
if (null !== $chunk->getError()) {
// no-op
} elseif ($chunk->isFirst()) {
$e = $r->openBuffer();
yield $r => $chunk;
if ($r->initializer && null === $r->getInfo('error')) {
// Ensure the HTTP status code is always checked
$r->getHeaders(true);
}
if (null === $e) {
continue;
}
$r->response->cancel();
$chunk = new ErrorChunk($r->offset, $e);
} elseif ('' !== $content = $chunk->getContent()) {
if (null !== $r->shouldBuffer) {
throw new \LogicException('A chunk passthru must yield an "isFirst()" chunk before any content chunk.');
}
if (null !== $r->content && \strlen($content) !== fwrite($r->content, $content)) {
$chunk = new ErrorChunk($r->offset, new TransportException(\sprintf('Failed writing %d bytes to the response buffer.', \strlen($content))));
$r->info['error'] = $chunk->getError();
$r->response->cancel();
}
}
if (null !== $chunk->getError() || $chunk->isLast()) {
$stream = $r->stream;
$r->stream = null;
unset($asyncMap[$response]);
}
if (null === $chunk->getError()) {
$r->offset += \strlen($content);
yield $r => $chunk;
if (!$chunk->isLast()) {
continue;
}
$stream->next();
if ($stream->valid()) {
throw new \LogicException('A chunk passthru cannot yield after an "isLast()" chunk.');
}
$r->passthru = null;
} else {
if ($chunk instanceof ErrorChunk) {
$chunk->didThrow(false);
} else {
try {
$chunk = new ErrorChunk($chunk->getOffset(), !$chunk->isTimeout() ?: $chunk->getError());
} catch (TransportExceptionInterface $e) {
$chunk = new ErrorChunk($chunk->getOffset(), $e);
}
}
$r->hasThrown = true;
yield $r => $chunk;
$chunk->didThrow() ?: $chunk->getContent();
}
break;
}
}
private function openBuffer(): ?\Throwable
{
if (null === $shouldBuffer = $this->shouldBuffer) {
throw new \LogicException('A chunk passthru cannot yield more than one "isFirst()" chunk.');
}
$e = $this->shouldBuffer = null;
if ($shouldBuffer instanceof \Closure) {
try {
$shouldBuffer = $shouldBuffer($this->getHeaders(false));
if (null !== $e = $this->response->getInfo('error')) {
throw new TransportException($e);
}
} catch (\Throwable $e) {
$this->info['error'] = $e->getMessage();
$this->response->cancel();
}
}
if (true === $shouldBuffer) {
$this->content = fopen('php://temp', 'w+');
} elseif (\is_resource($shouldBuffer)) {
$this->content = $shouldBuffer;
}
return $e;
}
private function close(): void
{
$this->response->cancel();
}
}

View File

@@ -0,0 +1,177 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\JsonException;
use Symfony\Component\HttpClient\Exception\RedirectionException;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* Implements common logic for response classes.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait CommonResponseTrait
{
/**
* @var callable|null A callback that tells whether we're waiting for response headers
*/
private $initializer;
/** @var bool|\Closure|resource|null */
private $shouldBuffer;
/** @var resource|null */
private $content;
private int $offset = 0;
private ?array $jsonData = null;
public function getContent(bool $throw = true): string
{
if ($this->initializer) {
self::initialize($this);
}
if ($throw) {
$this->checkStatusCode();
}
if (null === $this->content) {
$content = null;
foreach (self::stream([$this]) as $chunk) {
if (!$chunk->isLast()) {
$content .= $chunk->getContent();
}
}
if (null !== $content) {
return $content;
}
if (null === $this->content) {
throw new TransportException('Cannot get the content of the response twice: buffering is disabled.');
}
} else {
foreach (self::stream([$this]) as $chunk) {
// Chunks are buffered in $this->content already
}
}
rewind($this->content);
return stream_get_contents($this->content);
}
public function toArray(bool $throw = true): array
{
if ('' === $content = $this->getContent($throw)) {
throw new JsonException('Response body is empty.');
}
if (null !== $this->jsonData) {
return $this->jsonData;
}
try {
$content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new JsonException($e->getMessage().\sprintf(' for "%s".', $this->getInfo('url')), $e->getCode());
}
if (!\is_array($content)) {
throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned for "%s".', get_debug_type($content), $this->getInfo('url')));
}
if (null !== $this->content) {
// Option "buffer" is true
return $this->jsonData = $content;
}
return $content;
}
/**
* @return resource
*/
public function toStream(bool $throw = true)
{
if ($throw) {
// Ensure headers arrived
$this->getHeaders($throw);
}
$stream = StreamWrapper::createResource($this);
stream_get_meta_data($stream)['wrapper_data']
->bindHandles($this->handle, $this->content);
return $stream;
}
public function __serialize(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __unserialize(array $data): void
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
/**
* Closes the response and all its network handles.
*/
abstract protected function close(): void;
private static function initialize(self $response): void
{
if (null !== $response->getInfo('error')) {
throw new TransportException($response->getInfo('error'));
}
try {
if (($response->initializer)($response, -0.0)) {
foreach (self::stream([$response], -0.0) as $chunk) {
if ($chunk->isFirst()) {
break;
}
}
}
} catch (\Throwable $e) {
// Persist timeouts thrown during initialization
$response->info['error'] = $e->getMessage();
$response->close();
throw $e;
}
$response->initializer = null;
}
private function checkStatusCode(): void
{
$code = $this->getInfo('http_code');
if (500 <= $code) {
throw new ServerException($this);
}
if (400 <= $code) {
throw new ClientException($this);
}
if (300 <= $code) {
throw new RedirectionException($this);
}
}
}

View File

@@ -0,0 +1,515 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\InformationalChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Component\HttpClient\Internal\CurlClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class CurlResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait {
getContent as private doGetContent;
}
use TransportResponseTrait;
/**
* @var resource
*/
private $debugBuffer;
/**
* @internal
*/
public function __construct(
private CurlClientState $multi,
\CurlHandle|string $ch,
?array $options = null,
?LoggerInterface $logger = null,
string $method = 'GET',
?callable $resolveRedirect = null,
?int $curlVersion = null,
?string $originalUrl = null,
private ?string $ntlmOriginKey = null,
) {
if ($ch instanceof \CurlHandle) {
$this->handle = $ch;
$this->debugBuffer = fopen('php://temp', 'w+');
if (0x074000 === $curlVersion) {
fwrite($this->debugBuffer, 'Due to a bug in curl 7.64.0, the debug log is disabled; use another version to work around the issue.');
} else {
curl_setopt($ch, \CURLOPT_VERBOSE, true);
curl_setopt($ch, \CURLOPT_STDERR, $this->debugBuffer);
}
} else {
$this->info['url'] = $ch;
$ch = $this->handle;
}
$this->id = $id = (int) $ch;
$this->logger = $logger;
$this->shouldBuffer = $options['buffer'] ?? true;
$this->timeout = $options['timeout'] ?? null;
$this->info['http_method'] = $method;
$this->info['user_data'] = $options['user_data'] ?? null;
$this->info['max_duration'] = $options['max_duration'] ?? null;
$this->info['max_connect_duration'] = $options['max_connect_duration'] ?? null;
$this->info['start_time'] ??= microtime(true);
$this->info['original_url'] = $originalUrl ?? $this->info['url'] ?? curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL);
$info = &$this->info;
$headers = &$this->headers;
$debugBuffer = $this->debugBuffer;
if (!$info['response_headers']) {
// Used to keep track of what we're waiting for
curl_setopt($ch, \CURLOPT_PRIVATE, \in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY'], true) && 1.0 < (float) ($options['http_version'] ?? 1.1) ? 'H2' : 'H0'); // H = headers + retry counter
}
curl_setopt($ch, \CURLOPT_HEADERFUNCTION, static function ($ch, string $data) use (&$info, &$headers, $options, $multi, $id, &$location, $resolveRedirect, $logger): int {
return self::parseHeaderLine($ch, $data, $info, $headers, $options, $multi, $id, $location, $resolveRedirect, $logger);
});
if (null === $options) {
// Pushed response: buffer until requested
curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
$multi->handlesActivity[$id][] = $data;
curl_pause($ch, \CURLPAUSE_RECV);
return \strlen($data);
});
return;
}
$execCounter = $multi->execCounter;
$this->info['pause_handler'] = static function (float $duration) use ($ch, $multi, $execCounter) {
if (0 < $duration) {
if ($execCounter === $multi->execCounter) {
curl_multi_remove_handle($multi->handle, $ch);
}
$lastExpiry = end($multi->pauseExpiries);
$multi->pauseExpiries[(int) $ch] = $duration += hrtime(true) / 1E9;
if (false !== $lastExpiry && $lastExpiry > $duration) {
asort($multi->pauseExpiries);
}
curl_pause($ch, \CURLPAUSE_ALL);
} else {
unset($multi->pauseExpiries[(int) $ch]);
curl_pause($ch, \CURLPAUSE_CONT);
curl_multi_add_handle($multi->handle, $ch);
}
};
$this->inflate = !isset($options['normalized_headers']['accept-encoding']);
curl_pause($ch, \CURLPAUSE_CONT);
if ($onProgress = $options['on_progress']) {
$url = isset($info['url']) ? ['url' => $info['url']] : [];
curl_setopt($ch, \CURLOPT_NOPROGRESS, false);
curl_setopt($ch, \CURLOPT_PROGRESSFUNCTION, static function ($ch, $dlSize, $dlNow) use ($onProgress, &$info, $url, $multi, $debugBuffer) {
try {
$info['debug'] ??= '';
rewind($debugBuffer);
if (fstat($debugBuffer)['size']) {
$info['debug'] .= stream_get_contents($debugBuffer);
rewind($debugBuffer);
ftruncate($debugBuffer, 0);
}
$onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info);
} catch (\Throwable $e) {
$multi->handlesActivity[(int) $ch][] = null;
$multi->handlesActivity[(int) $ch][] = $e;
return 1; // Abort the request
}
return null;
});
}
curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
if ('H' === (curl_getinfo($ch, \CURLINFO_PRIVATE)[0] ?? null)) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = new TransportException(\sprintf('Unsupported protocol for "%s"', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
return 0;
}
curl_setopt($ch, \CURLOPT_WRITEFUNCTION, static function ($ch, string $data) use ($multi, $id): int {
$multi->handlesActivity[$id][] = $data;
return \strlen($data);
});
$multi->handlesActivity[$id][] = $data;
return \strlen($data);
});
$this->initializer = static function (self $response) {
$waitFor = curl_getinfo($response->handle, \CURLINFO_PRIVATE);
return 'H' === $waitFor[0];
};
// Schedule the request in a non-blocking way
$multi->lastTimeout = null;
$multi->openHandles[$id] = [$ch, $options];
curl_multi_add_handle($multi->handle, $ch);
$this->canary = new Canary(static function () use ($ch, $multi, $id) {
unset($multi->pauseExpiries[$id], $multi->openHandles[$id], $multi->handlesActivity[$id]);
curl_setopt($ch, \CURLOPT_PRIVATE, '_0');
if ($multi->performing) {
return;
}
curl_multi_remove_handle($multi->handle, $ch);
curl_setopt_array($ch, [
\CURLOPT_NOPROGRESS => true,
\CURLOPT_PROGRESSFUNCTION => null,
\CURLOPT_HEADERFUNCTION => null,
\CURLOPT_WRITEFUNCTION => null,
\CURLOPT_READFUNCTION => null,
\CURLOPT_INFILE => null,
]);
if (!$multi->openHandles) {
// Schedule DNS cache eviction for the next request
$multi->dnsCache->evictions = $multi->dnsCache->evictions ?: $multi->dnsCache->removals;
$multi->dnsCache->removals = $multi->dnsCache->hostnames = [];
}
});
}
public function getInfo(?string $type = null): mixed
{
if (!$info = $this->finalInfo) {
$info = array_merge($this->info, curl_getinfo($this->handle));
$info['redirect_url'] = $this->info['redirect_url'] ?? null;
// workaround curl not subtracting the time offset for pushed responses
if (isset($this->info['url']) && $info['start_time'] / 1000 < $info['total_time']) {
$info['total_time'] -= $info['starttransfer_time'] ?: $info['total_time'];
$info['starttransfer_time'] = 0.0;
}
$info['debug'] ??= '';
rewind($this->debugBuffer);
if (fstat($this->debugBuffer)['size']) {
$info['debug'] .= stream_get_contents($this->debugBuffer);
rewind($this->debugBuffer);
ftruncate($this->debugBuffer, 0);
}
$this->info = array_merge($this->info, $info);
$waitFor = curl_getinfo($this->handle, \CURLINFO_PRIVATE);
if ('H' !== $waitFor[0] && 'C' !== $waitFor[0]) {
curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
$this->finalInfo = $info;
}
}
return null !== $type ? $info[$type] ?? null : $info;
}
public function getContent(bool $throw = true): string
{
$performing = $this->multi->performing;
$this->multi->performing = $performing || '_0' === curl_getinfo($this->handle, \CURLINFO_PRIVATE);
try {
return $this->doGetContent($throw);
} finally {
$this->multi->performing = $performing;
}
}
public function __destruct()
{
try {
if (null === $this->timeout) {
return; // Unused pushed response
}
$this->doDestruct();
} finally {
if ($this->handle instanceof \CurlHandle) {
curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
}
}
}
private static function schedule(self $response, array &$runningResponses): void
{
if (isset($runningResponses[$i = (int) $response->multi->handle])) {
$runningResponses[$i][1][$response->id] = $response;
} else {
$runningResponses[$i] = [$response->multi, [$response->id => $response]];
}
if ('_0' === curl_getinfo($response->handle, \CURLINFO_PRIVATE)) {
// Response already completed
$response->multi->handlesActivity[$response->id][] = null;
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
}
}
/**
* @param CurlClientState $multi
*/
private static function perform(ClientState $multi, ?array $responses = null): void
{
if ($multi->performing) {
if ($responses) {
$response = $responses[array_key_first($responses)];
$multi->handlesActivity[(int) $response->handle][] = null;
$multi->handlesActivity[(int) $response->handle][] = new TransportException(\sprintf('Userland callback cannot use the client nor the response while processing "%s".', curl_getinfo($response->handle, \CURLINFO_EFFECTIVE_URL)));
}
return;
}
try {
$multi->performing = true;
++$multi->execCounter;
$active = 0;
while (\CURLM_CALL_MULTI_PERFORM === ($err = curl_multi_exec($multi->handle, $active))) {
}
if (\CURLM_OK !== $err) {
throw new TransportException(curl_multi_strerror($err));
}
while ($info = curl_multi_info_read($multi->handle)) {
if (\CURLMSG_DONE !== $info['msg']) {
continue;
}
$result = $info['result'];
$id = (int) $ch = $info['handle'];
$waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0';
if (isset($responses[$id]) && self::retryNtlmOnFreshConnection($multi, $ch, $responses[$id]->ntlmOriginKey, $result)) {
continue;
}
if (\in_array($result, [\CURLE_SEND_ERROR, \CURLE_RECV_ERROR, /* CURLE_HTTP2 */ 16, /* CURLE_HTTP2_STREAM */ 92], true) && $waitFor[1] && 'C' !== $waitFor[0]) {
curl_multi_remove_handle($multi->handle, $ch);
$waitFor[1] = (string) ((int) $waitFor[1] - 1); // decrement the retry counter
curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor);
curl_setopt($ch, \CURLOPT_FORBID_REUSE, true);
if (0 === curl_multi_add_handle($multi->handle, $ch)) {
continue;
}
}
if (\CURLE_RECV_ERROR === $result && 'H' === $waitFor[0] && 400 <= ($responses[(int) $ch]->info['http_code'] ?? 0)) {
$multi->handlesActivity[$id][] = new FirstChunk();
curl_setopt($ch, \CURLOPT_PRIVATE, 'C'.$waitFor[1]);
}
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true)
|| '_0' === $waitFor
|| curl_getinfo($ch, \CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD)
|| ('C' === $waitFor[0]
&& 'OpenSSL SSL_read: SSL_ERROR_SYSCALL, errno 0' === curl_error($ch)
&& -1.0 === curl_getinfo($ch, \CURLINFO_CONTENT_LENGTH_DOWNLOAD)
&& \in_array('close', array_map('strtolower', $responses[$id]->headers['connection'] ?? []), true)
)
? null
: new TransportException(ucfirst(curl_error($ch) ?: curl_strerror($result)).\sprintf(' for "%s".', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
}
} finally {
$multi->performing = false;
}
}
/**
* @param CurlClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
if ($multi->pauseExpiries) {
$now = hrtime(true) / 1E9;
foreach ($multi->pauseExpiries as $id => $pauseExpiry) {
if ($now < $pauseExpiry) {
$timeout = min($timeout, $pauseExpiry - $now);
break;
}
unset($multi->pauseExpiries[$id]);
curl_pause($multi->openHandles[$id][0], \CURLPAUSE_CONT);
curl_multi_add_handle($multi->handle, $multi->openHandles[$id][0]);
}
}
if (0 !== $selected = curl_multi_select($multi->handle, $timeout)) {
return $selected;
}
if ($multi->pauseExpiries && 0 < $timeout -= hrtime(true) / 1E9 - $now) {
usleep((int) (1E6 * $timeout));
}
return 0;
}
/**
* Parses header lines as curl yields them to us.
*
* @param-immediately-invoked-callable $resolveRedirect
*/
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
{
if (!str_ends_with($data, "\r\n")) {
return 0;
}
$waitFor = @curl_getinfo($ch, \CURLINFO_PRIVATE) ?: '_0';
if ('H' !== $waitFor[0]) {
return \strlen($data); // Ignore HTTP trailers
}
$statusCode = curl_getinfo($ch, \CURLINFO_RESPONSE_CODE);
if ($statusCode !== $info['http_code'] && !preg_match("#^HTTP/\d+(?:\.\d+)? {$statusCode}(?: |\r\n$)#", $data)) {
return \strlen($data); // Ignore headers from responses to CONNECT requests
}
if ("\r\n" !== $data) {
// Regular header line: add it to the list
self::addResponseHeaders([substr($data, 0, -2)], $info, $headers);
if (!str_starts_with($data, 'HTTP/')) {
if (0 === stripos($data, 'Location:')) {
$location = trim(substr($data, 9, -2));
}
return \strlen($data);
}
if (\function_exists('openssl_x509_read') && $certinfo = curl_getinfo($ch, \CURLINFO_CERTINFO)) {
$info['peer_certificate_chain'] = array_map('openssl_x509_read', array_column($certinfo, 'Cert'));
}
if (300 <= $info['http_code'] && $info['http_code'] < 400 && null !== $options) {
if (curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
} elseif (303 === $info['http_code'] || ('POST' === $info['http_method'] && \in_array($info['http_code'], [301, 302], true))) {
curl_setopt($ch, \CURLOPT_POSTFIELDS, '');
}
}
return \strlen($data);
}
// End of headers: handle informational responses, redirects, etc.
if (200 > $statusCode) {
$multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers);
$location = null;
return \strlen($data);
}
$info['redirect_url'] = null;
if (300 <= $statusCode && $statusCode < 400 && null !== $location && null !== $options) {
if ($noContent = 303 === $statusCode || ('POST' === $info['http_method'] && \in_array($statusCode, [301, 302], true))) {
$info['http_method'] = 'HEAD' === $info['http_method'] ? 'HEAD' : 'GET';
curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $info['http_method']);
}
if (null === $info['redirect_url'] = $resolveRedirect($ch, $location, $noContent)) {
$options['max_redirects'] = curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT);
curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, \CURLOPT_MAXREDIRS, $options['max_redirects']);
}
}
if (401 === $statusCode && isset($options['auth_ntlm']) && 0 === strncasecmp($headers['www-authenticate'][0] ?? '', 'NTLM ', 5)) {
// Continue with NTLM auth
} elseif ($statusCode < 300 || 400 <= $statusCode || null === $location || null === $options || curl_getinfo($ch, \CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
// Headers and redirects completed, time to get the response's content
$multi->handlesActivity[$id][] = new FirstChunk();
if ('HEAD' === $info['http_method'] || \in_array($statusCode, [204, 304], true)) {
$waitFor = '_0'; // no content expected
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null;
} else {
$waitFor[0] = 'C'; // C = content
}
curl_setopt($ch, \CURLOPT_PRIVATE, $waitFor);
} elseif (null !== $info['redirect_url'] && $logger) {
$logger->info(\sprintf('Redirecting: "%s %s"', $info['http_code'], $info['redirect_url']));
}
$location = null;
return \strlen($data);
}
/**
* Handles servers that do not persist NTLM authentication across requests on a reused
* TCP connection (e.g. IIS with authPersistNonNTLM=false). On such servers, libcurl
* still considers the pooled socket authenticated client-side and skips the handshake,
* but the server returns a fresh 401 + NTLM challenge that libcurl cannot pick up on
* the same request, so the caller sees an unauthenticated 401.
*
* The discriminator is CURLINFO_NUM_CONNECTS == 0: no new connection was opened for
* this request, so the socket came from the pool. A 401 + NTLM challenge on a brand
* new socket is the legitimate first leg of libcurl's in-request 3-way handshake and
* must NOT trigger this path.
*
* When detected, the deauthenticated socket is closed, the request is retried once on
* a fresh one, and the origin is recorded so subsequent requests to it skip the pool
* from the start.
*/
private static function retryNtlmOnFreshConnection(CurlClientState $multi, \CurlHandle $ch, ?string $originKey, int $result): bool
{
if (null === $originKey
|| \CURLE_OK !== $result
|| 401 !== curl_getinfo($ch, \CURLINFO_RESPONSE_CODE)
|| 0 === (curl_getinfo($ch, \CURLINFO_HTTPAUTH_AVAIL) & \CURLAUTH_NTLM)
|| 0 !== curl_getinfo($ch, \CURLINFO_NUM_CONNECTS)
|| isset($multi->ntlmRequiresFreshConnection[$originKey])
) {
return false;
}
$multi->ntlmRequiresFreshConnection[$originKey] = true;
$multi->logger?->info(\sprintf('Discarding NTLM-deauthenticated connection to "%s" and retrying on a fresh one', $originKey));
curl_setopt($ch, \CURLOPT_FORBID_REUSE, true);
curl_multi_remove_handle($multi->handle, $ch);
curl_setopt($ch, \CURLOPT_FRESH_CONNECT, true);
return 0 === curl_multi_add_handle($multi->handle, $ch);
}
}

View File

@@ -0,0 +1,71 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use GuzzleHttp\Promise\Create;
use GuzzleHttp\Promise\PromiseInterface as GuzzlePromiseInterface;
use Http\Promise\Promise as HttplugPromiseInterface;
use Psr\Http\Message\ResponseInterface as Psr7ResponseInterface;
/**
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
*
* @internal
*/
final class HttplugPromise implements HttplugPromiseInterface
{
public function __construct(
private GuzzlePromiseInterface $promise,
) {
}
public function then(?callable $onFulfilled = null, ?callable $onRejected = null): self
{
return new self($this->promise->then(
$this->wrapThenCallback($onFulfilled),
$this->wrapThenCallback($onRejected)
));
}
public function cancel(): void
{
$this->promise->cancel();
}
public function getState(): string
{
return $this->promise->getState();
}
/**
* @return Psr7ResponseInterface|mixed
*/
public function wait($unwrap = true): mixed
{
$result = $this->promise->wait($unwrap);
while ($result instanceof HttplugPromiseInterface || $result instanceof GuzzlePromiseInterface) {
$result = $result->wait($unwrap);
}
return $result;
}
private function wrapThenCallback(?callable $callback): ?callable
{
if (null === $callback) {
return null;
}
return static fn ($value) => Create::promiseFor($callback($value));
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
class JsonMockResponse extends MockResponse
{
/**
* @param mixed $body Any value that `json_encode()` can serialize
*/
public function __construct(mixed $body = [], array $info = [])
{
try {
$json = json_encode($body, \JSON_THROW_ON_ERROR | \JSON_PRESERVE_ZERO_FRACTION);
} catch (\JsonException $e) {
throw new InvalidArgumentException('JSON encoding failed: '.$e->getMessage(), $e->getCode(), $e);
}
$info['response_headers']['content-type'] ??= 'application/json';
parent::__construct($json, $info);
}
public static function fromFile(string $path, array $info = []): static
{
if (!is_file($path)) {
throw new InvalidArgumentException(\sprintf('File not found: "%s".', $path));
}
$json = file_get_contents($path);
if (!json_validate($json)) {
throw new \InvalidArgumentException(\sprintf('File "%s" does not contain valid JSON.', $path));
}
return new static(json_decode($json, true, flags: \JSON_THROW_ON_ERROR), $info);
}
}

View File

@@ -0,0 +1,340 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* A test-friendly response.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class MockResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait;
private string|iterable|null $body;
private array $requestOptions = [];
private string $requestUrl;
private string $requestMethod;
private static ClientState $mainMulti;
private static int $idSequence = 0;
/**
* @param string|iterable<string|\Throwable> $body The response body as a string or an iterable of strings,
* yielding an empty string simulates an idle timeout,
* throwing or yielding an exception yields an ErrorChunk
*
* @see ResponseInterface::getInfo() for possible info, e.g. "response_headers"
*/
public function __construct(string|iterable $body = '', array $info = [])
{
$this->body = $body;
$this->info = $info + ['http_code' => 200] + $this->info;
if (!isset($info['response_headers'])) {
return;
}
$responseHeaders = [];
foreach ($info['response_headers'] as $k => $v) {
foreach ((array) $v as $v) {
$responseHeaders[] = (\is_string($k) ? $k.': ' : '').$v;
}
}
$this->info['response_headers'] = [];
self::addResponseHeaders($responseHeaders, $this->info, $this->headers);
}
public static function fromFile(string $path, array $info = []): static
{
if (!is_file($path)) {
throw new \InvalidArgumentException(\sprintf('File not found: "%s".', $path));
}
return new static(file_get_contents($path), $info);
}
/**
* Returns the options used when doing the request.
*/
public function getRequestOptions(): array
{
return $this->requestOptions;
}
/**
* Returns the URL used when doing the request.
*/
public function getRequestUrl(): string
{
return $this->requestUrl;
}
/**
* Returns the method used when doing the request.
*/
public function getRequestMethod(): string
{
return $this->requestMethod;
}
public function getInfo(?string $type = null): mixed
{
return null !== $type ? $this->info[$type] ?? null : $this->info;
}
public function cancel(): void
{
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
try {
$this->body = null;
} catch (TransportException $e) {
// ignore errors when canceling
}
$onProgress = $this->requestOptions['on_progress'] ?? static function () {};
$dlSize = isset($this->headers['content-encoding']) || 'HEAD' === ($this->info['http_method'] ?? null) || \in_array($this->info['http_code'], [204, 304], true) ? 0 : (int) ($this->headers['content-length'][0] ?? 0);
$onProgress($this->offset, $dlSize, $this->info);
}
public function __destruct()
{
$this->doDestruct();
}
protected function close(): void
{
$this->inflate = null;
$this->body = [];
}
/**
* @internal
*/
public static function fromRequest(string $method, string $url, array $options, ResponseInterface $mock): self
{
$response = new self([]);
$response->requestOptions = $options;
$response->id = ++self::$idSequence;
$response->shouldBuffer = $options['buffer'] ?? true;
$response->initializer = static fn (self $response) => \is_array($response->body[0] ?? null);
$response->info['redirect_count'] = 0;
$response->info['redirect_url'] = null;
$response->info['start_time'] = microtime(true);
$response->info['http_method'] = $method;
$response->info['http_code'] = 0;
$response->info['user_data'] = $options['user_data'] ?? null;
$response->info['max_duration'] = $options['max_duration'] ?? null;
$response->info['max_connect_duration'] = $options['max_connect_duration'] ?? null;
$response->info['url'] = $url;
$response->info['original_url'] = $url;
if ($mock instanceof self) {
$mock->requestOptions = $response->requestOptions;
$mock->requestMethod = $method;
$mock->requestUrl = $url;
}
self::writeRequest($response, $options, $mock);
$response->body[] = [$options, $mock];
return $response;
}
protected static function schedule(self $response, array &$runningResponses): void
{
if (!isset($response->id)) {
throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.');
}
$multi = self::$mainMulti ??= new ClientState();
if (!isset($runningResponses[0])) {
$runningResponses[0] = [$multi, []];
}
$runningResponses[0][1][$response->id] = $response;
}
protected static function perform(ClientState $multi, array $responses): void
{
foreach ($responses as $response) {
$id = $response->id;
if (null === $response->body) {
// Canceled response
$response->body = [];
} elseif ([] === $response->body) {
// Error chunk
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
} elseif (null === $chunk = array_shift($response->body)) {
// Last chunk
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = array_shift($response->body);
} elseif (\is_array($chunk)) {
// First chunk
try {
$offset = 0;
$chunk[1]->getStatusCode();
$chunk[1]->getHeaders(false);
self::readResponse($response, $chunk[0], $chunk[1], $offset);
$multi->handlesActivity[$id][] = new FirstChunk();
} catch (\Throwable $e) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = $e;
}
} elseif ($chunk instanceof \Throwable) {
$multi->handlesActivity[$id][] = null;
$multi->handlesActivity[$id][] = $chunk;
} else {
// Data or timeout chunk
$multi->handlesActivity[$id][] = $chunk;
}
}
}
protected static function select(ClientState $multi, float $timeout): int
{
return 42;
}
/**
* Simulates sending the request.
*/
private static function writeRequest(self $response, array $options, ResponseInterface $mock): void
{
$onProgress = $options['on_progress'] ?? static function () {};
$response->info += $mock->getInfo() ?: [];
if (null !== $mock->getInfo('start_time')) {
$response->info['start_time'] = $mock->getInfo('start_time');
}
// simulate "size_upload" if it is set
if (isset($response->info['size_upload'])) {
$response->info['size_upload'] = 0.0;
}
// simulate "total_time" if it is not set
if (!isset($response->info['total_time'])) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
}
// "notify" DNS resolution
$onProgress(0, 0, $response->info);
// consume the request body
if (\is_resource($body = $options['body'] ?? '')) {
$data = stream_get_contents($body);
if (isset($response->info['size_upload'])) {
$response->info['size_upload'] += \strlen($data);
}
} elseif ($body instanceof \Closure) {
while ('' !== $data = $body(16372)) {
if (!\is_string($data)) {
throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
// "notify" upload progress
if (isset($response->info['size_upload'])) {
$response->info['size_upload'] += \strlen($data);
}
$onProgress(0, 0, $response->info);
}
}
}
/**
* Simulates reading the response.
*/
private static function readResponse(self $response, array $options, ResponseInterface $mock, int &$offset): void
{
$onProgress = $options['on_progress'] ?? static function () {};
// populate info related to headers
$info = $mock->getInfo() ?: [];
$response->info['http_code'] = ($info['http_code'] ?? 0) ?: $mock->getStatusCode() ?: 200;
$response->addResponseHeaders($info['response_headers'] ?? [], $response->info, $response->headers);
$dlSize = isset($response->headers['content-encoding']) || 'HEAD' === $response->info['http_method'] || \in_array($response->info['http_code'], [204, 304], true) ? 0 : (int) ($response->headers['content-length'][0] ?? 0);
$response->info = [
'start_time' => $response->info['start_time'],
'user_data' => $response->info['user_data'],
'max_duration' => $response->info['max_duration'],
'max_connect_duration' => $response->info['max_connect_duration'],
'http_code' => $response->info['http_code'],
] + $info + $response->info;
if (null !== $response->info['error']) {
throw new TransportException($response->info['error']);
}
if (!isset($response->info['total_time'])) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
}
// "notify" headers arrival
$onProgress(0, $dlSize, $response->info);
// cast response body to activity list
$body = $mock instanceof self ? $mock->body : $mock->getContent(false);
if (!\is_string($body)) {
try {
foreach ($body as $chunk) {
if ($chunk instanceof \Throwable) {
throw $chunk;
}
if ('' === $chunk = (string) $chunk) {
// simulate an idle timeout
$response->body[] = new ErrorChunk($offset, \sprintf('Idle timeout reached for "%s".', $response->info['url']));
} else {
$response->body[] = $chunk;
$offset += \strlen($chunk);
// "notify" download progress
$onProgress($offset, $dlSize, $response->info);
}
}
} catch (\Throwable $e) {
$response->body[] = $e;
}
} elseif ('' !== $body) {
$response->body[] = $body;
$offset = \strlen($body);
}
if (!isset($response->info['total_time'])) {
$response->info['total_time'] = microtime(true) - $response->info['start_time'];
}
// "notify" completion
$onProgress($offset, $dlSize, $response->info);
if ($dlSize && $offset !== $dlSize) {
throw new TransportException(\sprintf('Transfer closed with %d bytes remaining to read.', $dlSize - $offset));
}
}
}

View File

@@ -0,0 +1,372 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
use Symfony\Component\HttpClient\Internal\NativeClientState;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class NativeResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait;
private \Closure $resolver;
private ?\Closure $onProgress;
private ?int $remaining = null;
/**
* @var resource|null
*/
private $buffer;
private float $pauseExpiry = 0.0;
/**
* @internal
*
* @param $context resource
*/
public function __construct(
private NativeClientState $multi,
private $context,
private string $url,
array $options,
array &$info,
callable $resolver,
?callable $onProgress,
?LoggerInterface $logger,
) {
$this->id = $id = (int) $context;
$this->logger = $logger;
$this->timeout = $options['timeout'];
$this->info = &$info;
$this->resolver = $resolver(...);
$this->onProgress = $onProgress ? $onProgress(...) : null;
$this->inflate = !isset($options['normalized_headers']['accept-encoding']);
$this->shouldBuffer = $options['buffer'] ?? true;
// Temporary resource to dechunk the response stream
$this->buffer = fopen('php://temp', 'w+');
$info['original_url'] = implode('', $info['url']);
$info['user_data'] = $options['user_data'];
$info['max_duration'] = $options['max_duration'];
$info['max_connect_duration'] = $options['max_connect_duration'];
++$multi->responseCount;
$this->initializer = static fn (self $response) => null === $response->remaining;
$pauseExpiry = &$this->pauseExpiry;
$info['pause_handler'] = static function (float $duration) use (&$pauseExpiry) {
$pauseExpiry = 0 < $duration ? hrtime(true) / 1E9 + $duration : 0;
};
$this->canary = new Canary(static function () use ($multi, $id) {
if (null !== ($host = $multi->openHandles[$id][6] ?? null) && isset($multi->hosts[$host]) && 0 >= --$multi->hosts[$host]) {
unset($multi->hosts[$host]);
}
unset($multi->openHandles[$id], $multi->handlesActivity[$id]);
});
}
public function getInfo(?string $type = null): mixed
{
if (!$info = $this->finalInfo) {
$info = $this->info;
$info['url'] = implode('', $info['url']);
unset($info['size_body'], $info['request_header']);
if (null === $this->buffer) {
$this->finalInfo = $info;
}
}
return null !== $type ? $info[$type] ?? null : $info;
}
public function __destruct()
{
try {
$this->doDestruct();
} finally {
// Clear the DNS cache when all requests completed
if (0 >= --$this->multi->responseCount) {
$this->multi->responseCount = 0;
$this->multi->dnsCache = [];
}
}
}
private function open(): void
{
$url = $this->url;
set_error_handler(function ($type, $msg) use (&$url) {
if (\E_NOTICE !== $type || 'fopen(): Content-type not specified assuming application/x-www-form-urlencoded' !== $msg) {
throw new TransportException($msg);
}
$this->logger?->info(\sprintf('%s for "%s".', $msg, $url ?? $this->url));
});
try {
$this->info['start_time'] = microtime(true);
[$resolver, $url] = ($this->resolver)($this->multi);
while (true) {
$context = stream_context_get_options($this->context);
if ($proxy = $context['http']['proxy'] ?? null) {
$this->info['debug'] .= "* Establish HTTP proxy tunnel to {$proxy}\n";
$this->info['request_header'] = $url;
} else {
$this->info['debug'] .= "* Trying {$this->info['primary_ip']}...\n";
$this->info['request_header'] = $this->info['url']['path'].$this->info['url']['query'];
}
$this->info['request_header'] = \sprintf("> %s %s HTTP/%s \r\n", $context['http']['method'], $this->info['request_header'], $context['http']['protocol_version']);
$this->info['request_header'] .= implode("\r\n", $context['http']['header'])."\r\n\r\n";
if (\array_key_exists('peer_name', $context['ssl']) && null === $context['ssl']['peer_name']) {
unset($context['ssl']['peer_name']);
$this->context = stream_context_create([], ['options' => $context] + stream_context_get_params($this->context));
}
// Send request and follow redirects when needed
$this->handle = $h = fopen($url, 'r', false, $this->context);
self::addResponseHeaders(stream_get_meta_data($h)['wrapper_data'], $this->info, $this->headers, $this->info['debug']);
$url = $resolver($this->multi, $this->headers['location'][0] ?? null, $this->context);
if (null === $url) {
break;
}
$this->logger?->info(\sprintf('Redirecting: "%s %s"', $this->info['http_code'], $url ?? $this->url));
}
} catch (\Throwable $e) {
$this->close();
$this->multi->handlesActivity[$this->id][] = null;
$this->multi->handlesActivity[$this->id][] = $e;
return;
} finally {
$this->info['pretransfer_time'] = $this->info['total_time'] = microtime(true) - $this->info['start_time'];
restore_error_handler();
}
if (isset($context['ssl']['capture_peer_cert_chain']) && isset(($context = stream_context_get_options($this->context))['ssl']['peer_certificate_chain'])) {
$this->info['peer_certificate_chain'] = $context['ssl']['peer_certificate_chain'];
}
stream_set_blocking($h, false);
unset($this->context, $this->resolver);
// Create dechunk buffers
if (isset($this->headers['content-length'])) {
$this->remaining = (int) $this->headers['content-length'][0];
} elseif ('chunked' === ($this->headers['transfer-encoding'][0] ?? null)) {
stream_filter_append($this->buffer, 'dechunk', \STREAM_FILTER_WRITE);
$this->remaining = -1;
} else {
$this->remaining = -2;
}
$this->multi->handlesActivity[$this->id] = [new FirstChunk()];
if ('HEAD' === $context['http']['method'] || \in_array($this->info['http_code'], [204, 304], true)) {
$this->multi->handlesActivity[$this->id][] = null;
$this->multi->handlesActivity[$this->id][] = null;
return;
}
$host = parse_url($this->info['redirect_url'] ?? $this->url, \PHP_URL_HOST);
$this->multi->lastTimeout = null;
$this->multi->openHandles[$this->id] = [&$this->pauseExpiry, $h, $this->buffer, $this->onProgress, &$this->remaining, &$this->info, $host];
$this->multi->hosts[$host] = 1 + ($this->multi->hosts[$host] ?? 0);
}
private function close(): void
{
$this->canary->cancel();
$this->handle = $this->buffer = $this->inflate = $this->onProgress = null;
}
private static function schedule(self $response, array &$runningResponses): void
{
if (!isset($runningResponses[$i = $response->multi->id])) {
$runningResponses[$i] = [$response->multi, []];
}
$runningResponses[$i][1][$response->id] = $response;
if (null === $response->buffer) {
// Response already completed
$response->multi->handlesActivity[$response->id][] = null;
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
}
}
/**
* @param NativeClientState $multi
*/
private static function perform(ClientState $multi, ?array $responses = null): void
{
foreach ($multi->openHandles as $i => [$pauseExpiry, $h, $buffer, $onProgress]) {
if ($pauseExpiry) {
if (hrtime(true) / 1E9 < $pauseExpiry) {
continue;
}
$multi->openHandles[$i][0] = 0;
}
$hasActivity = false;
$remaining = &$multi->openHandles[$i][4];
$info = &$multi->openHandles[$i][5];
$e = null;
// Read incoming buffer and write it to the dechunk one
try {
if ($remaining && '' !== $data = (string) fread($h, 0 > $remaining ? 16372 : $remaining)) {
fwrite($buffer, $data);
$hasActivity = true;
$multi->sleep = false;
if (-1 !== $remaining) {
$remaining -= \strlen($data);
}
}
} catch (\Throwable $e) {
$hasActivity = $onProgress = false;
}
if (!$hasActivity) {
if ($onProgress) {
try {
// Notify the progress callback so that it can e.g. cancel
// the request if the stream is inactive for too long
$info['total_time'] = microtime(true) - $info['start_time'];
$onProgress();
} catch (\Throwable $e) {
// no-op
}
}
} elseif ('' !== $data = stream_get_contents($buffer, -1, 0)) {
rewind($buffer);
ftruncate($buffer, 0);
if (null === $e) {
$multi->handlesActivity[$i][] = $data;
}
}
if (null !== $e || !$remaining || feof($h)) {
// Stream completed
$info['total_time'] = microtime(true) - $info['start_time'];
$info['starttransfer_time'] = $info['starttransfer_time'] ?: $info['total_time'];
if ($onProgress) {
try {
$onProgress(-1);
} catch (\Throwable $e) {
// no-op
}
}
if (null === $e) {
if (0 < $remaining) {
$e = new TransportException(\sprintf('Transfer closed with %s bytes remaining to read.', $remaining));
} elseif (-1 === $remaining && fwrite($buffer, '-') && '' !== stream_get_contents($buffer, -1, 0)) {
$e = new TransportException('Transfer closed with outstanding data remaining from chunked response.');
}
}
$multi->handlesActivity[$i][] = null;
$multi->handlesActivity[$i][] = $e;
if (null !== ($host = $multi->openHandles[$i][6] ?? null) && isset($multi->hosts[$host]) && 0 >= --$multi->hosts[$host]) {
unset($multi->hosts[$host]);
}
unset($multi->openHandles[$i]);
$multi->sleep = false;
}
}
if (null === $responses) {
return;
}
$maxHosts = $multi->maxHostConnections;
foreach ($responses as $i => $response) {
if (null !== $response->remaining || null === $response->buffer) {
continue;
}
if ($response->pauseExpiry && hrtime(true) / 1E9 < $response->pauseExpiry) {
// Create empty open handles to tell we still have pending requests
$multi->openHandles[$i] = [\INF, null, null, null];
} elseif ($maxHosts && $maxHosts > ($multi->hosts[parse_url($response->url, \PHP_URL_HOST)] ?? 0)) {
// Open the next pending request - this is a blocking operation so we do only one of them
$response->open();
$multi->sleep = false;
self::perform($multi);
$maxHosts = 0;
}
}
}
/**
* @param NativeClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
if (!$multi->sleep = !$multi->sleep) {
return -1;
}
$_ = $handles = [];
$now = null;
foreach ($multi->openHandles as [$pauseExpiry, $h]) {
if (null === $h) {
continue;
}
if ($pauseExpiry && ($now ??= hrtime(true) / 1E9) < $pauseExpiry) {
$timeout = min($timeout, $pauseExpiry - $now);
continue;
}
$handles[] = $h;
}
if (!$handles) {
usleep((int) (1E6 * $timeout));
return 0;
}
return stream_select($handles, $_, $_, (int) $timeout, (int) (1E6 * ($timeout - (int) $timeout)));
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ResponseStream implements ResponseStreamInterface
{
public function __construct(
private \Generator $generator,
) {
}
public function key(): ResponseInterface
{
return $this->generator->key();
}
public function current(): ChunkInterface
{
return $this->generator->current();
}
public function next(): void
{
$this->generator->next();
}
public function rewind(): void
{
$this->generator->rewind();
}
public function valid(): bool
{
return $this->generator->valid();
}
}

View File

@@ -0,0 +1,312 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Allows turning ResponseInterface instances to PHP streams.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class StreamWrapper
{
/** @var resource|null */
public $context;
private HttpClientInterface|ResponseInterface $client;
private ResponseInterface $response;
/** @var resource|string|null */
private $content;
/** @var resource|callable|null */
private $handle;
private bool $blocking = true;
private ?float $timeout = null;
private bool $eof = false;
private ?int $offset = 0;
/**
* Creates a PHP stream resource from a ResponseInterface.
*
* @return resource
*/
public static function createResource(ResponseInterface $response, ?HttpClientInterface $client = null)
{
if ($response instanceof StreamableInterface) {
$stack = debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT | \DEBUG_BACKTRACE_IGNORE_ARGS, 2);
if ($response !== ($stack[1]['object'] ?? null)) {
return $response->toStream(false);
}
}
if (null === $client && !method_exists($response, 'stream')) {
throw new \InvalidArgumentException(\sprintf('Providing a client to "%s()" is required when the response doesn\'t have any "stream()" method.', __CLASS__));
}
static $registered = false;
if (!$registered = $registered || stream_wrapper_register(strtr(__CLASS__, '\\', '-'), __CLASS__)) {
throw new \RuntimeException(error_get_last()['message'] ?? 'Registering the "symfony" stream wrapper failed.');
}
$context = [
'client' => $client ?? $response,
'response' => $response,
];
return fopen(strtr(__CLASS__, '\\', '-').'://'.$response->getInfo('url'), 'r', false, stream_context_create(['symfony' => $context]));
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
/**
* @param resource|callable|null $handle The resource handle that should be monitored when
* stream_select() is used on the created stream
* @param resource|null $content The seekable resource where the response body is buffered
*/
public function bindHandles(&$handle, &$content): void
{
$this->handle = &$handle;
$this->content = &$content;
$this->offset = null;
}
public function stream_open(string $path, string $mode, int $options): bool
{
if ('r' !== $mode) {
if ($options & \STREAM_REPORT_ERRORS) {
trigger_error(\sprintf('Invalid mode "%s": only "r" is supported.', $mode), \E_USER_WARNING);
}
return false;
}
$context = stream_context_get_options($this->context)['symfony'] ?? null;
$this->client = $context['client'] ?? null;
$this->response = $context['response'] ?? null;
$this->context = null;
if (null !== $this->client && null !== $this->response) {
return true;
}
if ($options & \STREAM_REPORT_ERRORS) {
trigger_error('Missing options "client" or "response" in "symfony" stream context.', \E_USER_WARNING);
}
return false;
}
public function stream_read(int $count): string|false
{
if (\is_resource($this->content)) {
// Empty the internal activity list
foreach ($this->client->stream([$this->response], 0) as $chunk) {
try {
if (!$chunk->isTimeout() && $chunk->isFirst()) {
$this->response->getStatusCode(); // ignore 3/4/5xx
}
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
return false;
}
}
if (0 !== fseek($this->content, $this->offset ?? 0)) {
return false;
}
if ('' !== $data = fread($this->content, $count)) {
fseek($this->content, 0, \SEEK_END);
$this->offset += \strlen($data);
return $data;
}
}
if (\is_string($this->content)) {
if (\strlen($this->content) <= $count) {
$data = $this->content;
$this->content = null;
} else {
$data = substr($this->content, 0, $count);
$this->content = substr($this->content, $count);
}
$this->offset += \strlen($data);
return $data;
}
foreach ($this->client->stream([$this->response], $this->blocking ? $this->timeout : 0) as $chunk) {
try {
$this->eof = true;
$this->eof = !$chunk->isTimeout();
if (!$this->eof && !$this->blocking) {
return '';
}
$this->eof = $chunk->isLast();
if ($chunk->isFirst()) {
$this->response->getStatusCode(); // ignore 3/4/5xx
}
if ('' !== $data = $chunk->getContent()) {
if (\strlen($data) > $count) {
$this->content ??= substr($data, $count);
$data = substr($data, 0, $count);
}
$this->offset += \strlen($data);
return $data;
}
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
return false;
}
}
return '';
}
public function stream_set_option(int $option, int $arg1, ?int $arg2): bool
{
if (\STREAM_OPTION_BLOCKING === $option) {
$this->blocking = (bool) $arg1;
} elseif (\STREAM_OPTION_READ_TIMEOUT === $option) {
$this->timeout = $arg1 + $arg2 / 1e6;
} else {
return false;
}
return true;
}
public function stream_tell(): int
{
return $this->offset ?? 0;
}
public function stream_eof(): bool
{
return $this->eof && !\is_string($this->content);
}
public function stream_seek(int $offset, int $whence = \SEEK_SET): bool
{
if (null === $this->content && null === $this->offset) {
$this->response->getStatusCode();
$this->offset = 0;
}
if (!\is_resource($this->content) || 0 !== fseek($this->content, 0, \SEEK_END)) {
return false;
}
$size = ftell($this->content);
if (\SEEK_CUR === $whence) {
$offset += $this->offset ?? 0;
}
if (\SEEK_END === $whence || $size < $offset) {
foreach ($this->client->stream([$this->response]) as $chunk) {
try {
if ($chunk->isFirst()) {
$this->response->getStatusCode(); // ignore 3/4/5xx
}
// Chunks are buffered in $this->content already
$size += \strlen($chunk->getContent());
if (\SEEK_END !== $whence && $offset <= $size) {
break;
}
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
return false;
}
}
if (\SEEK_END === $whence) {
$offset += $size;
}
}
if (0 <= $offset && $offset <= $size) {
$this->eof = false;
$this->offset = $offset;
return true;
}
return false;
}
/**
* @return resource|false
*/
public function stream_cast(int $castAs)
{
if (\STREAM_CAST_FOR_SELECT === $castAs) {
$this->response->getHeaders(false);
return (\is_callable($this->handle) ? ($this->handle)() : $this->handle) ?? false;
}
return false;
}
public function stream_stat(): array
{
try {
$headers = $this->response->getHeaders(false);
} catch (ExceptionInterface $e) {
trigger_error($e->getMessage(), \E_USER_WARNING);
$headers = [];
}
return [
'dev' => 0,
'ino' => 0,
'mode' => 33060,
'nlink' => 0,
'uid' => 0,
'gid' => 0,
'rdev' => 0,
'size' => (int) ($headers['content-length'][0] ?? -1),
'atime' => 0,
'mtime' => strtotime($headers['last-modified'][0] ?? '') ?: 0,
'ctime' => 0,
'blksize' => 0,
'blocks' => 0,
];
}
private function __construct()
{
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
interface StreamableInterface
{
/**
* Casts the response to a PHP stream resource.
*
* @return resource
*
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function toStream(bool $throw = true);
}

View File

@@ -0,0 +1,216 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\Exception\RedirectionException;
use Symfony\Component\HttpClient\Exception\ServerException;
use Symfony\Component\HttpClient\TraceableHttpClient;
use Symfony\Component\Stopwatch\StopwatchEvent;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class TraceableResponse implements ResponseInterface, StreamableInterface
{
public function __construct(
private HttpClientInterface $client,
private ResponseInterface $response,
private mixed &$content = false,
private ?StopwatchEvent $event = null,
) {
}
public function __serialize(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __unserialize(array $data): void
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
try {
if (method_exists($this->response, '__destruct')) {
$this->response->__destruct();
}
} finally {
if ($this->event?->isStarted()) {
$this->event->stop();
}
}
}
public function getStatusCode(): int
{
try {
return $this->response->getStatusCode();
} finally {
if ($this->event?->isStarted()) {
$this->event->lap();
}
}
}
public function getHeaders(bool $throw = true): array
{
try {
return $this->response->getHeaders($throw);
} finally {
if ($this->event?->isStarted()) {
$this->event->lap();
}
}
}
public function getContent(bool $throw = true): string
{
try {
if (false === $this->content) {
return $this->response->getContent($throw);
}
return $this->content = $this->response->getContent(false);
} finally {
if ($this->event?->isStarted()) {
$this->event->stop();
}
if ($throw) {
$this->checkStatusCode($this->response->getStatusCode());
}
}
}
public function toArray(bool $throw = true): array
{
try {
if (false === $this->content) {
return $this->response->toArray($throw);
}
return $this->content = $this->response->toArray(false);
} finally {
if ($this->event?->isStarted()) {
$this->event->stop();
}
if ($throw) {
$this->checkStatusCode($this->response->getStatusCode());
}
}
}
public function cancel(): void
{
$this->response->cancel();
if ($this->event?->isStarted()) {
$this->event->stop();
}
}
public function getInfo(?string $type = null): mixed
{
return $this->response->getInfo($type);
}
/**
* Casts the response to a PHP stream resource.
*
* @return resource
*
* @throws TransportExceptionInterface When a network error occurs
* @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached
* @throws ClientExceptionInterface On a 4xx when $throw is true
* @throws ServerExceptionInterface On a 5xx when $throw is true
*/
public function toStream(bool $throw = true)
{
if ($throw) {
// Ensure headers arrived
$this->response->getHeaders(true);
}
if ($this->response instanceof StreamableInterface) {
return $this->response->toStream(false);
}
return StreamWrapper::createResource($this->response, $this->client);
}
/**
* @internal
*/
public static function stream(HttpClientInterface $client, iterable $responses, ?float $timeout): \Generator
{
$wrappedResponses = [];
$traceableMap = new \SplObjectStorage();
foreach ($responses as $r) {
if (!$r instanceof self) {
throw new \TypeError(\sprintf('"%s::stream()" expects parameter 1 to be an iterable of TraceableResponse objects, "%s" given.', TraceableHttpClient::class, get_debug_type($r)));
}
$traceableMap[$r->response] = $r;
$wrappedResponses[] = $r->response;
if ($r->event && !$r->event->isStarted()) {
$r->event->start();
}
}
foreach ($client->stream($wrappedResponses, $timeout) as $r => $chunk) {
if ($traceableMap[$r]->event && $traceableMap[$r]->event->isStarted()) {
try {
if ($chunk->isTimeout() || !$chunk->isLast()) {
$traceableMap[$r]->event->lap();
} else {
$traceableMap[$r]->event->stop();
}
} catch (TransportExceptionInterface $e) {
$traceableMap[$r]->event->stop();
if ($chunk instanceof ErrorChunk) {
$chunk->didThrow(false);
} else {
$chunk = new ErrorChunk($chunk->getOffset(), $e);
}
}
}
yield $traceableMap[$r] => $chunk;
}
}
private function checkStatusCode(int $code): void
{
if (500 <= $code) {
throw new ServerException($this);
}
if (400 <= $code) {
throw new ClientException($this);
}
if (300 <= $code) {
throw new RedirectionException($this);
}
}
}

View File

@@ -0,0 +1,316 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Response;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Chunk\DataChunk;
use Symfony\Component\HttpClient\Chunk\ErrorChunk;
use Symfony\Component\HttpClient\Chunk\FirstChunk;
use Symfony\Component\HttpClient\Chunk\LastChunk;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Internal\Canary;
use Symfony\Component\HttpClient\Internal\ClientState;
/**
* Implements common logic for transport-level response classes.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait TransportResponseTrait
{
private Canary $canary;
/** @var array<string, list<string>> */
private array $headers = [];
private array $info = [
'response_headers' => [],
'http_code' => 0,
'error' => null,
'canceled' => false,
];
/** @var object|resource|null */
private $handle;
private int|string $id;
private ?float $timeout = 0;
private \InflateContext|bool|null $inflate = null;
private ?array $finalInfo = null;
private ?LoggerInterface $logger = null;
private bool $didTimeout = false;
public function getStatusCode(): int
{
if ($this->initializer) {
self::initialize($this);
}
return $this->info['http_code'];
}
public function getHeaders(bool $throw = true): array
{
if ($this->initializer) {
self::initialize($this);
}
if ($throw) {
$this->checkStatusCode();
}
return $this->headers;
}
public function cancel(): void
{
$this->info['canceled'] = true;
$this->info['error'] = 'Response has been canceled.';
$this->close();
}
/**
* Closes the response and all its network handles.
*/
protected function close(): void
{
$this->canary->cancel();
$this->inflate = null;
}
/**
* Adds pending responses to the activity list.
*/
abstract protected static function schedule(self $response, array &$runningResponses): void;
/**
* Performs all pending non-blocking operations.
*/
abstract protected static function perform(ClientState $multi, array $responses): void;
/**
* Waits for network activity.
*/
abstract protected static function select(ClientState $multi, float $timeout): int;
private static function addResponseHeaders(array $responseHeaders, array &$info, array &$headers, string &$debug = ''): void
{
foreach ($responseHeaders as $h) {
if (11 <= \strlen($h) && '/' === $h[4] && preg_match('#^HTTP/\d+(?:\.\d+)? (\d\d\d)(?: |$)#', $h, $m)) {
if ($headers) {
$debug .= "< \r\n";
$headers = [];
}
$info['http_code'] = (int) $m[1];
} elseif (2 === \count($m = explode(':', $h, 2))) {
$headers[strtolower($m[0])][] = ltrim($m[1]);
}
$debug .= "< {$h}\r\n";
$info['response_headers'][] = $h;
}
$debug .= "< \r\n";
}
/**
* Ensures the request is always sent and that the response code was checked.
*/
private function doDestruct(): void
{
$this->shouldBuffer = true;
if ($this->initializer && null === $this->info['error'] && !$this->didTimeout) {
self::initialize($this);
$this->checkStatusCode();
}
}
/**
* Implements an event loop based on a buffer activity queue.
*
* @param iterable<array-key, self> $responses
*
* @internal
*/
public static function stream(iterable $responses, ?float $timeout = null): \Generator
{
$runningResponses = [];
foreach ($responses as $response) {
self::schedule($response, $runningResponses);
}
$lastActivity = hrtime(true) / 1E9;
$elapsedTimeout = 0;
if ((0.0 === $timeout && '-0' === (string) $timeout) || 0 > $timeout) {
$timeout = $timeout ? -$timeout : null;
/** @var ClientState $multi */
foreach ($runningResponses as [$multi]) {
if (null !== $multi->lastTimeout) {
$elapsedTimeout = max($elapsedTimeout, $lastActivity - $multi->lastTimeout);
}
}
}
while (true) {
$hasActivity = false;
$timeoutMax = 0;
$timeoutMin = $timeout ?? \INF;
/** @var ClientState $multi */
foreach ($runningResponses as $i => [$multi, &$responses]) {
self::perform($multi, $responses);
foreach ($responses as $j => $response) {
$timeoutMax = $timeout ?? max($timeoutMax, $response->timeout);
$timeoutMin = min($timeoutMin, $response->timeout, 1);
$chunk = false;
if (isset($multi->handlesActivity[$j])) {
$multi->lastTimeout = null;
$elapsedTimeout = 0;
} elseif (!isset($multi->openHandles[$j])) {
$hasActivity = true;
unset($responses[$j]);
continue;
} elseif ($elapsedTimeout >= $timeoutMax) {
$response->didTimeout = true;
$multi->handlesActivity[$j] = [new ErrorChunk($response->offset, \sprintf('Idle timeout reached for "%s".', $response->getInfo('url')))];
$multi->lastTimeout ??= $lastActivity;
$elapsedTimeout = $timeoutMax;
} else {
continue;
}
$lastActivity = null;
$hasActivity = true;
while ($multi->handlesActivity[$j] ?? false) {
if (\is_string($chunk = array_shift($multi->handlesActivity[$j]))) {
if (null !== $response->inflate && false === $chunk = @inflate_add($response->inflate, $chunk)) {
$multi->handlesActivity[$j] = [null, new TransportException(\sprintf('Error while processing content unencoding for "%s".', $response->getInfo('url')))];
continue;
}
if ('' !== $chunk && null !== $response->content && \strlen($chunk) !== fwrite($response->content, $chunk)) {
$multi->handlesActivity[$j] = [null, new TransportException(\sprintf('Failed writing %d bytes to the response buffer.', \strlen($chunk)))];
continue;
}
$chunkLen = \strlen($chunk);
$chunk = new DataChunk($response->offset, $chunk);
$response->offset += $chunkLen;
} elseif (null === $chunk) {
$e = $multi->handlesActivity[$j][0];
unset($responses[$j], $multi->handlesActivity[$j]);
$response->close();
if (null !== $e) {
$response->info['error'] = $e->getMessage();
if ($e instanceof \Error) {
throw $e;
}
$chunk = new ErrorChunk($response->offset, $e);
} else {
if (0 === $response->offset && null === $response->content) {
$response->content = fopen('php://memory', 'w+');
}
$chunk = new LastChunk($response->offset);
}
} elseif ($chunk instanceof ErrorChunk) {
unset($responses[$j]);
} elseif ($chunk instanceof FirstChunk) {
if ($response->logger) {
$info = $response->getInfo();
$response->logger->info('Response: "{http_code} {url}" {total_time} seconds', [
'http_code' => $info['http_code'],
'url' => $info['url'],
'total_time' => $info['total_time'],
]);
}
$response->inflate = \extension_loaded('zlib') && $response->inflate && 'gzip' === ($response->headers['content-encoding'][0] ?? null) ? inflate_init(\ZLIB_ENCODING_GZIP) : null;
if ($response->shouldBuffer instanceof \Closure) {
try {
$response->shouldBuffer = ($response->shouldBuffer)($response->headers);
if (null !== $response->info['error']) {
throw new TransportException($response->info['error']);
}
} catch (\Throwable $e) {
$response->close();
$multi->handlesActivity[$j] = [null, $e];
}
}
if (true === $response->shouldBuffer) {
$response->content = fopen('php://temp', 'w+');
} elseif (\is_resource($response->shouldBuffer)) {
$response->content = $response->shouldBuffer;
}
$response->shouldBuffer = null;
yield $response => $chunk;
if ($response->initializer && null === $response->info['error']) {
// Ensure the HTTP status code is always checked
$response->getHeaders(true);
}
continue;
}
yield $response => $chunk;
}
unset($multi->handlesActivity[$j]);
if ($chunk instanceof ErrorChunk && !$chunk->didThrow()) {
// Ensure transport exceptions are always thrown
$chunk->getContent();
throw new \LogicException('A transport exception should have been thrown.');
}
}
if (!$responses) {
$hasActivity = true;
unset($runningResponses[$i]);
}
// Prevent memory leaks
$multi->handlesActivity = $multi->handlesActivity ?: [];
$multi->openHandles = $multi->openHandles ?: [];
}
if (!$runningResponses) {
break;
}
if ($hasActivity) {
$lastActivity ??= hrtime(true) / 1E9;
continue;
}
if (-1 === self::select($multi, min($timeoutMin, max(0, $timeoutMax - $elapsedTimeout)))) {
usleep((int) min(500, 1E6 * $timeoutMin));
}
$elapsedTimeout = hrtime(true) / 1E9 - $lastActivity;
}
}
}

View File

@@ -0,0 +1,108 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Retry;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* Decides to retry the request when HTTP status codes belong to the given list of codes.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class GenericRetryStrategy implements RetryStrategyInterface
{
public const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE', 'QUERY'];
public const DEFAULT_RETRY_STATUS_CODES = [
0 => self::IDEMPOTENT_METHODS, // for transport exceptions
423,
425,
429,
500 => self::IDEMPOTENT_METHODS,
502,
503,
504 => self::IDEMPOTENT_METHODS,
507 => self::IDEMPOTENT_METHODS,
510 => self::IDEMPOTENT_METHODS,
];
/**
* @param array $statusCodes List of HTTP status codes that trigger a retry
* @param int $delayMs Amount of time to delay (or the initial value when multiplier is used)
* @param float $multiplier Multiplier to apply to the delay each time a retry occurs
* @param int $maxDelayMs Maximum delay to allow (0 means no maximum)
* @param float $jitter Probability of randomness int delay (0 = none, 1 = 100% random)
*/
public function __construct(
private array $statusCodes = self::DEFAULT_RETRY_STATUS_CODES,
private int $delayMs = 1000,
private float $multiplier = 2.0,
private int $maxDelayMs = 0,
private float $jitter = 0.1,
) {
if ($delayMs < 0) {
throw new InvalidArgumentException(\sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMs));
}
if ($multiplier < 1) {
throw new InvalidArgumentException(\sprintf('Multiplier must be greater than or equal to one: "%s" given.', $multiplier));
}
if ($maxDelayMs < 0) {
throw new InvalidArgumentException(\sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMs));
}
if ($jitter < 0 || $jitter > 1) {
throw new InvalidArgumentException(\sprintf('Jitter must be between 0 and 1: "%s" given.', $jitter));
}
}
public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool
{
$statusCode = $context->getStatusCode();
if (\in_array($statusCode, $this->statusCodes, true)) {
return true;
}
if (isset($this->statusCodes[$statusCode]) && \is_array($this->statusCodes[$statusCode])) {
return \in_array($context->getInfo('http_method'), $this->statusCodes[$statusCode], true);
}
if (null === $exception) {
return false;
}
if (\in_array(0, $this->statusCodes, true)) {
return true;
}
if (isset($this->statusCodes[0]) && \is_array($this->statusCodes[0])) {
return \in_array($context->getInfo('http_method'), $this->statusCodes[0], true);
}
return false;
}
public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int
{
$delay = $this->delayMs * $this->multiplier ** $context->getInfo('retry_count');
if ($this->jitter > 0) {
$randomness = (int) ($delay * $this->jitter);
$delay += random_int(-$randomness, +$randomness);
}
if ($delay > $this->maxDelayMs && 0 !== $this->maxDelayMs) {
return $this->maxDelayMs;
}
return (int) $delay;
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Retry;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
interface RetryStrategyInterface
{
/**
* Returns whether the request should be retried.
*
* @param ?string $responseContent Null is passed when the body did not arrive yet
*
* @return bool|null Returns null to signal that the body is required to take a decision
*/
public function shouldRetry(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): ?bool;
/**
* Returns the time to wait in milliseconds.
*/
public function getDelay(AsyncContext $context, ?string $responseContent, ?TransportExceptionInterface $exception): int;
}

View File

@@ -0,0 +1,210 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Response\AsyncContext;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
use Symfony\Component\HttpClient\Retry\RetryStrategyInterface;
use Symfony\Contracts\HttpClient\ChunkInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Automatically retries failing HTTP requests.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class RetryableHttpClient implements HttpClientInterface, ResetInterface
{
use AsyncDecoratorTrait;
private RetryStrategyInterface $strategy;
private array $baseUris = [];
/**
* @param int $maxRetries The maximum number of times to retry
*/
public function __construct(
HttpClientInterface $client,
?RetryStrategyInterface $strategy = null,
private int $maxRetries = 3,
private ?LoggerInterface $logger = null,
) {
$this->client = $client;
$this->strategy = $strategy ?? new GenericRetryStrategy();
}
public function withOptions(array $options): static
{
if (\array_key_exists('base_uri', $options)) {
if (\is_array($options['base_uri'])) {
$this->baseUris = $options['base_uri'];
unset($options['base_uri']);
} else {
$this->baseUris = [];
}
}
$clone = clone $this;
$clone->maxRetries = (int) ($options['max_retries'] ?? $this->maxRetries);
unset($options['max_retries']);
$clone->client = $this->client->withOptions($options);
return $clone;
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$baseUris = \array_key_exists('base_uri', $options) ? $options['base_uri'] : $this->baseUris;
$baseUris = \is_array($baseUris) ? $baseUris : [];
$options = self::shiftBaseUri($options, $baseUris);
$maxRetries = (int) ($options['max_retries'] ?? $this->maxRetries);
unset($options['max_retries']);
if ($maxRetries <= 0) {
return new AsyncResponse($this->client, $method, $url, $options);
}
return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, $maxRetries, &$baseUris) {
static $retryCount = 0;
static $content = '';
static $firstChunk;
$exception = null;
try {
if ($context->getInfo('canceled') || $chunk->isTimeout() || null !== $chunk->getInformationalStatus()) {
yield $chunk;
return;
}
} catch (TransportExceptionInterface $exception) {
// catch TransportExceptionInterface to send it to the strategy
}
if (null !== $exception) {
// always retry request that fail to resolve DNS
if ('' !== $context->getInfo('primary_ip')) {
$shouldRetry = $this->strategy->shouldRetry($context, null, $exception);
if (null === $shouldRetry) {
throw new \LogicException(\sprintf('The "%s::shouldRetry()" method must not return null when called with an exception.', $this->strategy::class));
}
if (false === $shouldRetry) {
yield from $this->passthru($context, $firstChunk, $content, $chunk);
return;
}
}
} elseif ($chunk->isFirst()) {
if (false === $shouldRetry = $this->strategy->shouldRetry($context, null, null)) {
yield from $this->passthru($context, $firstChunk, $content, $chunk);
return;
}
// Body is needed to decide
if (null === $shouldRetry) {
$firstChunk = $chunk;
$content = '';
return;
}
} else {
if (!$chunk->isLast()) {
$content .= $chunk->getContent();
return;
}
if (null === $shouldRetry = $this->strategy->shouldRetry($context, $content, null)) {
throw new \LogicException(\sprintf('The "%s::shouldRetry()" method must not return null when called with a body.', $this->strategy::class));
}
if (false === $shouldRetry) {
yield from $this->passthru($context, $firstChunk, $content, $chunk);
return;
}
}
$context->getResponse()->cancel();
$delay = $this->getDelayFromHeader($context->getHeaders()) ?? $this->strategy->getDelay($context, !$exception && $chunk->isLast() ? $content : null, $exception);
++$retryCount;
$content = '';
$firstChunk = null;
$this->logger?->info('Try #{count} after {delay}ms'.($exception ? ': '.$exception->getMessage() : ', status code: '.$context->getStatusCode()), [
'count' => $retryCount,
'delay' => $delay,
]);
$context->setInfo('retry_count', $retryCount);
$context->replaceRequest($method, $url, self::shiftBaseUri($options, $baseUris));
$context->pause($delay / 1000);
if ($retryCount >= $maxRetries) {
$context->passthru();
}
});
}
private function getDelayFromHeader(array $headers): ?int
{
if (null !== $after = $headers['retry-after'][0] ?? null) {
if (is_numeric($after)) {
return (int) ($after * 1000);
}
if (false !== $time = strtotime($after)) {
return max(0, $time - time()) * 1000;
}
}
return null;
}
private function passthru(AsyncContext $context, ?ChunkInterface $firstChunk, string &$content, ChunkInterface $lastChunk): \Generator
{
$context->passthru();
if (null !== $firstChunk) {
yield $firstChunk;
}
if ('' !== $content) {
$chunk = $context->createChunk($content);
$content = '';
yield $chunk;
}
yield $lastChunk;
}
private static function shiftBaseUri(array $options, array &$baseUris): array
{
if ($baseUris) {
$baseUri = 1 < \count($baseUris) ? array_shift($baseUris) : current($baseUris);
$options['base_uri'] = \is_array($baseUri) ? $baseUri[array_rand($baseUri)] : $baseUri;
} elseif (\is_array($options['base_uri'] ?? null)) {
unset($options['base_uri']);
}
return $options;
}
}

View File

@@ -0,0 +1,110 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Auto-configure the default options based on the requested URL.
*
* @author Anthony Martin <anthony.martin@sensiolabs.com>
*/
class ScopingHttpClient implements HttpClientInterface, ResetInterface
{
use HttpClientTrait;
public function __construct(
private HttpClientInterface $client,
private array $defaultOptionsByRegexp,
private ?string $defaultRegexp = null,
) {
if (null !== $defaultRegexp && !isset($defaultOptionsByRegexp[$defaultRegexp])) {
throw new InvalidArgumentException(\sprintf('No options are mapped to the provided "%s" default regexp.', $defaultRegexp));
}
}
public static function forBaseUri(HttpClientInterface $client, string $baseUri, array $defaultOptions = [], ?string $regexp = null): self
{
$regexp ??= preg_quote(implode('', self::resolveUrl(self::parseUrl('.'), self::parseUrl($baseUri))));
$defaultOptions['base_uri'] = $baseUri;
return new self($client, [$regexp => $defaultOptions], $regexp);
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$e = null;
$url = self::parseUrl($url, $options['query'] ?? []);
$resolved = false;
if (\is_string($options['base_uri'] ?? null)) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
$resolved = true;
}
try {
$url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null));
} catch (InvalidArgumentException $e) {
if (null === $this->defaultRegexp) {
throw $e;
}
$defaultOptions = $this->defaultOptionsByRegexp[$this->defaultRegexp];
$options = self::mergeDefaultOptions($options, $defaultOptions, true);
if (\is_string($options['base_uri'] ?? null)) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
$resolved = true;
}
$url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null, $defaultOptions['query'] ?? []));
}
if ($resolved) {
unset($options['base_uri']);
}
foreach ($this->defaultOptionsByRegexp as $regexp => $defaultOptions) {
if (preg_match("{{$regexp}}A", $url)) {
if (null === $e || $regexp !== $this->defaultRegexp) {
$options = self::mergeDefaultOptions($options, $defaultOptions, true);
}
break;
}
}
return $this->client->request($method, $url, $options);
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function reset(): void
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
}

View File

@@ -0,0 +1,97 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient\Test;
use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* See: https://w3c.github.io/web-performance/specs/HAR/Overview.html.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class HarFileResponseFactory
{
public function __construct(private string $archiveFile)
{
}
public function setArchiveFile(string $archiveFile): void
{
$this->archiveFile = $archiveFile;
}
public function __invoke(string $method, string $url, array $options): ResponseInterface
{
if (!is_file($this->archiveFile)) {
throw new \InvalidArgumentException(\sprintf('Invalid file path provided: "%s".', $this->archiveFile));
}
$json = json_decode(json: file_get_contents($this->archiveFile), associative: true, flags: \JSON_THROW_ON_ERROR);
foreach ($json['log']['entries'] as $entry) {
/**
* @var array{status: int, headers: array, content: array} $response
* @var array{method: string, url: string, postData: array} $request
*/
['response' => $response, 'request' => $request, 'startedDateTime' => $startedDateTime] = $entry;
$body = $this->getContent($response['content']);
$entryMethod = $request['method'];
$entryUrl = $request['url'];
$requestBody = $options['body'] ?? null;
if ($method !== $entryMethod || $url !== $entryUrl) {
continue;
}
if (null !== $requestBody && $requestBody !== $this->getContent($request['postData'] ?? [])) {
continue;
}
$info = [
'http_code' => $response['status'],
'http_method' => $entryMethod,
'response_headers' => [],
'start_time' => strtotime($startedDateTime),
'url' => $entryUrl,
];
/** @var array{name: string, value: string} $header */
foreach ($response['headers'] as $header) {
['name' => $name, 'value' => $value] = $header;
$info['response_headers'][$name][] = $value;
}
return new MockResponse($body, $info);
}
throw new TransportException(\sprintf('File "%s" does not contain a response for HTTP request "%s" "%s".', $this->archiveFile, $method, $url));
}
/**
* @param array{text: string, encoding: string} $content
*/
private function getContent(array $content): string
{
$text = $content['text'] ?? '';
$encoding = $content['encoding'] ?? null;
return match ($encoding) {
'base64' => base64_decode($text),
null => $text,
default => throw new \InvalidArgumentException(\sprintf('Unsupported encoding "%s", currently only base64 is supported.', $encoding)),
};
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Limits the number of requests within a certain period.
*/
class ThrottlingHttpClient implements HttpClientInterface, ResetInterface
{
use DecoratorTrait {
reset as private traitReset;
}
public function __construct(
HttpClientInterface $client,
private readonly LimiterInterface $rateLimiter,
) {
$this->client = $client;
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$response = $this->client->request($method, $url, $options);
if (0 < $waitDuration = $this->rateLimiter->reserve()->getWaitDuration()) {
$response->getInfo('pause_handler')($waitDuration);
}
return $response;
}
public function reset(): void
{
$this->traitReset();
$this->rateLimiter->reset();
}
}

View File

@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Component\HttpClient\Response\TraceableResponse;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* @author Jérémy Romey <jeremy@free-agent.fr>
*/
final class TraceableHttpClient implements HttpClientInterface, ResetInterface
{
private \ArrayObject $tracedRequests;
public function __construct(
private HttpClientInterface $client,
private ?Stopwatch $stopwatch = null,
private ?\Closure $disabled = null,
) {
$this->tracedRequests = new \ArrayObject();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
if ($this->disabled?->__invoke()) {
return new TraceableResponse($this->client, $this->client->request($method, $url, $options));
}
$content = null;
$traceInfo = [];
$tracedRequest = [
'method' => $method,
'url' => $url,
'options' => $options,
'info' => &$traceInfo,
'content' => &$content,
];
$onProgress = $options['on_progress'] ?? null;
if (false === ($options['extra']['trace_content'] ?? true)) {
unset($content);
$content = false;
unset($tracedRequest['options']['body'], $tracedRequest['options']['json']);
}
$this->tracedRequests[] = $tracedRequest;
$options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use (&$traceInfo, $onProgress) {
$traceInfo = $info;
if (null !== $onProgress) {
$onProgress($dlNow, $dlSize, $info);
}
};
return new TraceableResponse($this->client, $this->client->request($method, $url, $options), $content, $this->stopwatch?->start("$method $url", 'http_client'));
}
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof TraceableResponse) {
$responses = [$responses];
}
return new ResponseStream(TraceableResponse::stream($this->client, $responses, $timeout));
}
public function getTracedRequests(): array
{
return $this->tracedRequests->getArrayCopy();
}
public function reset(): void
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
$this->tracedRequests->exchangeArray([]);
}
public function withOptions(array $options): static
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
}

View File

@@ -0,0 +1,84 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
class UriTemplateHttpClient implements HttpClientInterface, ResetInterface
{
use DecoratorTrait;
/**
* @param (\Closure(string $url, array $vars): string)|null $expander
*/
public function __construct(?HttpClientInterface $client = null, private ?\Closure $expander = null, private array $defaultVars = [])
{
$this->client = $client ?? HttpClient::create();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$vars = $this->defaultVars;
if (\array_key_exists('vars', $options)) {
if (!\is_array($options['vars'])) {
throw new \InvalidArgumentException('The "vars" option must be an array.');
}
$vars = [...$vars, ...$options['vars']];
unset($options['vars']);
}
if ($vars) {
$url = ($this->expander ??= $this->createExpanderFromPopularVendors())($url, $vars);
}
return $this->client->request($method, $url, $options);
}
public function withOptions(array $options): static
{
if (!\is_array($options['vars'] ?? [])) {
throw new \InvalidArgumentException('The "vars" option must be an array.');
}
$clone = clone $this;
$clone->defaultVars = [...$clone->defaultVars, ...$options['vars'] ?? []];
unset($options['vars']);
$clone->client = $this->client->withOptions($options);
return $clone;
}
/**
* @return \Closure(string $url, array $vars): string
*/
private function createExpanderFromPopularVendors(): \Closure
{
if (class_exists(\GuzzleHttp\UriTemplate\UriTemplate::class)) {
return \GuzzleHttp\UriTemplate\UriTemplate::expand(...);
}
if (class_exists(\League\Uri\UriTemplate::class)) {
return static fn (string $url, array $vars): string => (new \League\Uri\UriTemplate($url))->expand($vars);
}
if (class_exists(\Rize\UriTemplate::class)) {
return (new \Rize\UriTemplate())->expand(...);
}
throw new \LogicException('Support for URI template requires a vendor to expand the URI. Run "composer require guzzlehttp/uri-template" or pass your own expander \Closure implementation.');
}
}

View File

@@ -0,0 +1,57 @@
{
"name": "symfony/http-client",
"type": "library",
"description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously",
"keywords": ["http"],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "1.0",
"symfony/http-client-implementation": "3.0"
},
"require": {
"php": ">=8.4.1",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/http-client-contracts": "^3.7",
"symfony/service-contracts": "^2.5|^3"
},
"require-dev": {
"amphp/http-client": "^5.3.2",
"amphp/http-tunnel": "^2.0",
"guzzlehttp/guzzle": "^7.10",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"psr/http-client": "^1.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/cache": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/rate-limiter": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0"
},
"conflict": {
"amphp/amp": "<3",
"php-http/discovery": "<1.15"
},
"autoload": {
"psr-4": { "Symfony\\Component\\HttpClient\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}