Update website

This commit is contained in:
Guilhem Lavaux 2024-11-19 08:02:04 +01:00
parent 4413528994
commit 1d90fbf296
6865 changed files with 1091082 additions and 0 deletions

View file

@ -0,0 +1,181 @@
<?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 Amp\Promise;
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:^4.2.1".');
}
if (!interface_exists(Promise::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the installed "amphp/http-client" is not compatible with this version of "symfony/http-client". Try downgrading "amphp/http-client" to "^4.2.1".');
}
/**
* 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;
private $defaultOptions = self::OPTIONS_DEFAULTS;
private static $emptyDefaults = self::OPTIONS_DEFAULTS;
/** @var AmpClientState */
private $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'] = $this->defaultOptions['buffer'] ?? \Closure::fromCallable([__CLASS__, '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
*
* {@inheritdoc}
*/
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 (0 === strpos($options['bindto'], 'if!')) {
throw new TransportException(__CLASS__.' cannot bind to network interfaces, use e.g. CurlHttpClient instead.');
}
if (0 === strpos($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);
if ($options['http_version']) {
switch ((float) $options['http_version']) {
case 1.0: $request->setProtocolVersions(['1.0']); break;
case 1.1: $request->setProtocolVersions(['1.1', '1.0']); break;
default: $request->setProtocolVersions(['2', '1.1', '1.0']); break;
}
}
foreach ($options['headers'] as $v) {
$h = explode(': ', $v, 2);
$request->addHeader($h[0], $h[1]);
}
$request->setTcpConnectTimeout(1000 * $options['timeout']);
$request->setTlsHandshakeTimeout(1000 * $options['timeout']);
$request->setTransferTimeout(1000 * $options['max_duration']);
if (method_exists($request, 'setInactivityTimeout')) {
$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);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof AmpResponse) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of AmpResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
return new ResponseStream(AmpResponse::stream($responses, $timeout));
}
public function reset()
{
$this->multi->dnsCache = [];
foreach ($this->multi->pushedResponses as $authority => $pushedResponses) {
foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) {
$pushDeferred->fail(new CancelledException());
if ($this->logger) {
$this->logger->debug(sprintf('Unused pushed response: "%s"', $pushedUrl));
}
}
}
$this->multi->pushedResponses = [];
}
}

View file

@ -0,0 +1,48 @@
<?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;
/**
* {@inheritdoc}
*
* @return AsyncResponse
*/
abstract public function request(string $method, string $url, array $options = []): ResponseInterface;
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof AsyncResponse) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of AsyncResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
return new ResponseStream(AsyncResponse::stream($responses, $timeout, static::class));
}
}

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

@ -0,0 +1,54 @@
CHANGELOG
=========
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,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;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\StoreInterface;
use Symfony\Component\HttpKernel\HttpClientKernel;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Adds caching on top of an HTTP client.
*
* The implementation buffers responses in memory and doesn't stream directly from the network.
* You can disable/enable this layer by setting option "no_cache" under "extra" to true/false.
* By default, caching is enabled unless the "buffer" option is set to false.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class CachingHttpClient implements HttpClientInterface, ResetInterface
{
use HttpClientTrait;
private $client;
private $cache;
private $defaultOptions = self::OPTIONS_DEFAULTS;
public function __construct(HttpClientInterface $client, StoreInterface $store, array $defaultOptions = [])
{
if (!class_exists(HttpClientKernel::class)) {
throw new \LogicException(sprintf('Using "%s" requires that the HttpKernel component version 4.3 or higher is installed, try running "composer require symfony/http-kernel:^5.4".', __CLASS__));
}
$this->client = $client;
$kernel = new HttpClientKernel($client);
$this->cache = new HttpCache($kernel, $store, null, $defaultOptions);
unset($defaultOptions['debug']);
unset($defaultOptions['default_ttl']);
unset($defaultOptions['private_headers']);
unset($defaultOptions['allow_reload']);
unset($defaultOptions['allow_revalidate']);
unset($defaultOptions['stale_while_revalidate']);
unset($defaultOptions['stale_if_error']);
unset($defaultOptions['trace_level']);
unset($defaultOptions['trace_header']);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
}
/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = $this->prepareRequest($method, $url, $options, $this->defaultOptions, true);
$url = implode('', $url);
if (!empty($options['body']) || !empty($options['extra']['no_cache']) || !\in_array($method, ['GET', 'HEAD', 'OPTIONS'])) {
return $this->client->request($method, $url, $options);
}
$request = Request::create($url, $method);
$request->attributes->set('http_client_options', $options);
foreach ($options['normalized_headers'] as $name => $values) {
if ('cookie' !== $name) {
foreach ($values as $value) {
$request->headers->set($name, substr($value, 2 + \strlen($name)), false);
}
continue;
}
foreach ($values as $cookies) {
foreach (explode('; ', substr($cookies, \strlen('Cookie: '))) as $cookie) {
if ('' !== $cookie) {
$cookie = explode('=', $cookie, 2);
$request->cookies->set($cookie[0], $cookie[1] ?? '');
}
}
}
}
$response = $this->cache->handle($request);
$response = new MockResponse($response->getContent(), [
'http_code' => $response->getStatusCode(),
'response_headers' => $response->headers->allPreserveCase(),
]);
return MockResponse::fromRequest($method, $url, $options, $response);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of ResponseInterface objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
$mockResponses = [];
$clientResponses = [];
foreach ($responses as $response) {
if ($response instanceof MockResponse) {
$mockResponses[] = $response;
} else {
$clientResponses[] = $response;
}
}
if (!$mockResponses) {
return $this->client->stream($clientResponses, $timeout);
}
if (!$clientResponses) {
return new ResponseStream(MockResponse::stream($mockResponses, $timeout));
}
return new ResponseStream((function () use ($mockResponses, $clientResponses, $timeout) {
yield from MockResponse::stream($mockResponses, $timeout);
yield $this->client->stream($clientResponses, $timeout);
})());
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View file

@ -0,0 +1,87 @@
<?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
{
private $offset = 0;
private $content = '';
public function __construct(int $offset = 0, string $content = '')
{
$this->offset = $offset;
$this->content = $content;
}
/**
* {@inheritdoc}
*/
public function isTimeout(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
return false;
}
/**
* {@inheritdoc}
*/
public function getInformationalStatus(): ?array
{
return null;
}
/**
* {@inheritdoc}
*/
public function getContent(): string
{
return $this->content;
}
/**
* {@inheritdoc}
*/
public function getOffset(): int
{
return $this->offset;
}
/**
* {@inheritdoc}
*/
public function getError(): ?string
{
return null;
}
}

View file

@ -0,0 +1,140 @@
<?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 $didThrow = false;
private $offset;
private $errorMessage;
private $error;
/**
* @param \Throwable|string $error
*/
public function __construct(int $offset, $error)
{
$this->offset = $offset;
if (\is_string($error)) {
$this->errorMessage = $error;
} else {
$this->error = $error;
$this->errorMessage = $error->getMessage();
}
}
/**
* {@inheritdoc}
*/
public function isTimeout(): bool
{
$this->didThrow = true;
if (null !== $this->error) {
throw new TransportException($this->errorMessage, 0, $this->error);
}
return true;
}
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
/**
* {@inheritdoc}
*/
public function getInformationalStatus(): ?array
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
/**
* {@inheritdoc}
*/
public function getContent(): string
{
$this->didThrow = true;
throw null !== $this->error ? new TransportException($this->errorMessage, 0, $this->error) : new TimeoutException($this->errorMessage);
}
/**
* {@inheritdoc}
*/
public function getOffset(): int
{
return $this->offset;
}
/**
* {@inheritdoc}
*/
public function getError(): ?string
{
return $this->errorMessage;
}
/**
* @return bool Whether the wrapped error has been thrown or not
*/
public function didThrow(?bool $didThrow = null): bool
{
if (null !== $didThrow && $this->didThrow !== $didThrow) {
return !$this->didThrow = $didThrow;
}
return $this->didThrow;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
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,28 @@
<?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
{
/**
* {@inheritdoc}
*/
public function isFirst(): bool
{
return true;
}
}

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\Chunk;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class InformationalChunk extends DataChunk
{
private $status;
public function __construct(int $statusCode, array $headers)
{
$this->status = [$statusCode, $headers];
}
/**
* {@inheritdoc}
*/
public function getInformationalStatus(): ?array
{
return $this->status;
}
}

View file

@ -0,0 +1,28 @@
<?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
{
/**
* {@inheritdoc}
*/
public function isLast(): bool
{
return true;
}
}

View file

@ -0,0 +1,79 @@
<?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 Antoine Bluchet <soyuka@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
final class ServerSentEvent extends DataChunk implements ChunkInterface
{
private $data = '';
private $id = '';
private $type = 'message';
private $retry = 0;
public function __construct(string $content)
{
parent::__construct(-1, $content);
// remove BOM
if (0 === strpos($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;
}
}

View file

@ -0,0 +1,575 @@
<?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;
private $defaultOptions = self::OPTIONS_DEFAULTS + [
'auth_ntlm' => null, // 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
'extra' => [
'curl' => [], // A list of extra curl options indexed by their corresponding CURLOPT_*
],
];
private static $emptyDefaults = self::OPTIONS_DEFAULTS + ['auth_ntlm' => null];
/**
* @var LoggerInterface|null
*/
private $logger;
private $maxHostConnections;
private $maxPendingPushes;
/**
* An internal object to share state between the client and its responses.
*
* @var CurlClientState
*/
private $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->maxHostConnections = $maxHostConnections;
$this->maxPendingPushes = $maxPendingPushes;
$this->defaultOptions['buffer'] = $this->defaultOptions['buffer'] ?? \Closure::fromCallable([__CLASS__, 'shouldBuffer']);
if ($defaultOptions) {
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
}
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
if (isset($this->multi)) {
$this->multi->logger = $logger;
}
}
/**
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
*
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$multi = $this->ensureState();
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
$scheme = $url['scheme'];
$authority = $url['authority'];
$host = parse_url($authority, \PHP_URL_HOST);
$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 => 0 < $options['max_redirects'] ? $options['max_redirects'] : 0,
\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'],
];
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;
}
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'];
}
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($multi->dnsCache->hostnames[$host])) {
$options['resolve'] += [$host => $multi->dnsCache->hostnames[$host]];
}
if ($options['resolve'] || $multi->dnsCache->evictions) {
// First reset any old DNS cache entries then add the new ones
$resolve = $multi->dnsCache->evictions;
$multi->dnsCache->evictions = [];
$port = parse_url($authority, \PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443);
if ($resolve && 0x072A00 > CurlClientState::$curlVersion['version_number']) {
// DNS cache removals require curl 7.42 or higher
$multi->reset();
}
foreach ($options['resolve'] as $resolveHost => $ip) {
$resolve[] = null === $ip ? "-$resolveHost:$port" : "$resolveHost:$port:$ip";
$multi->dnsCache->hostnames[$resolveHost] = $ip;
$multi->dnsCache->removals["-$resolveHost:$port"] = "-$resolveHost:$port";
}
$curlopts[\CURLOPT_RESOLVE] = $resolve;
}
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;
} else {
$curlopts[\CURLOPT_CUSTOMREQUEST] = $method;
}
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 (\is_resource($body)) {
$curlopts[\CURLOPT_INFILE] = $body;
} else {
$eof = false;
$buffer = '';
$curlopts[\CURLOPT_READFUNCTION] = static function ($ch, $fd, $length) use ($body, &$buffer, &$eof) {
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] = $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 (!empty($options['extra']['curl']) && \is_array($options['extra']['curl'])) {
$this->validateExtraCurlOptions($options['extra']['curl']);
$curlopts += $options['extra']['curl'];
}
if ($pushedResponse = $multi->pushedResponses[$url] ?? null) {
unset($multi->pushedResponses[$url]);
if (self::acceptPushForRequest($method, $options, $pushedResponse)) {
$this->logger && $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($multi, $url, $options, $this->logger);
} else {
$this->logger && $this->logger->debug(sprintf('Rejecting pushed response: "%s"', $url));
$pushedResponse = null;
}
}
if (!$pushedResponse) {
$ch = curl_init();
$this->logger && $this->logger->info(sprintf('Request: "%s %s"', $method, $url));
$curlopts += [\CURLOPT_SHARE => $multi->share];
}
foreach ($curlopts as $opt => $value) {
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($multi, $ch, $options, $this->logger, $method, self::createRedirectResolver($options, $host), CurlClientState::$curlVersion['version_number']);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof CurlResponse) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of CurlResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
$multi = $this->ensureState();
if (\is_resource($multi->handle) || $multi->handle instanceof \CurlMultiHandle) {
$active = 0;
while (\CURLM_CALL_MULTI_PERFORM === curl_multi_exec($multi->handle, $active)) {
}
}
return new ResponseStream(CurlResponse::stream($responses, $timeout));
}
public function reset()
{
if (isset($this->multi)) {
$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;
}
}
return true;
}
/**
* Wraps the request's body callback to allow it to return strings longer than curl requested.
*/
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 $host): \Closure
{
$redirectHeaders = [];
if (0 < $options['max_redirects']) {
$redirectHeaders['host'] = $host;
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
return 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 function ($h) {
return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:');
});
}
}
return static function ($ch, string $location, bool $noContent) use (&$redirectHeaders, $options) {
try {
$location = self::parseUrl($location);
} catch (InvalidArgumentException $e) {
return null;
}
if ($noContent && $redirectHeaders) {
$filterContentHeaders = static function ($h) {
return 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 && $host = parse_url('http:'.$location['authority'], \PHP_URL_HOST)) {
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
curl_setopt($ch, \CURLOPT_HTTPHEADER, $requestHeaders);
} elseif ($noContent && $redirectHeaders) {
curl_setopt($ch, \CURLOPT_HTTPHEADER, $redirectHeaders['with_auth']);
}
$url = self::parseUrl(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL));
$url = self::resolveUrl($location, $url);
curl_setopt($ch, \CURLOPT_PROXY, self::getProxyUrl($options['proxy'], $url));
return implode('', $url);
};
}
private function ensureState(): CurlClientState
{
if (!isset($this->multi)) {
$this->multi = new CurlClientState($this->maxHostConnections, $this->maxPendingPushes);
$this->multi->logger = $this->logger;
}
return $this->multi;
}
private function findConstantName(int $opt): ?string
{
$constants = array_filter(get_defined_constants(), static function ($v, $k) use ($opt) {
return $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_INFILE => '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_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_CONNECTTIMEOUT,
\CURLOPT_CONNECTTIMEOUT_MS,
\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;
}
$methodOpts = [
\CURLOPT_POST,
\CURLOPT_PUT,
\CURLOPT_CUSTOMREQUEST,
\CURLOPT_HTTPGET,
\CURLOPT_NOBODY,
];
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, $methodOpts)) {
throw new InvalidArgumentException('The HTTP method cannot be overridden using "extra.curl".');
}
if (\in_array($opt, $curloptsToCheck)) {
$constName = $this->findConstantName($opt) ?? $opt;
throw new InvalidArgumentException(sprintf('Cannot set "%s" with "extra.curl".', $constName));
}
}
}
}

View file

@ -0,0 +1,176 @@
<?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\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\VarDumper\Caster\ImgStub;
/**
* @author Jérémy Romey <jeremy@free-agent.fr>
*/
final class HttpClientDataCollector extends DataCollector implements LateDataCollectorInterface
{
/**
* @var TraceableHttpClient[]
*/
private $clients = [];
public function registerClient(string $name, TraceableHttpClient $client)
{
$this->clients[$name] = $client;
}
/**
* {@inheritdoc}
*/
public function collect(Request $request, Response $response, ?\Throwable $exception = null)
{
$this->lateCollect();
}
public function lateCollect()
{
$this->data['request_count'] = $this->data['request_count'] ?? 0;
$this->data['error_count'] = $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;
$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;
}
/**
* {@inheritdoc}
*/
public function getName(): string
{
return 'http_client';
}
public function reset()
{
$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 (0 === strpos($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']);
}
return [$errorCount, $traces];
}
}

View file

@ -0,0 +1,66 @@
<?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 $client;
public function __construct(?HttpClientInterface $client = null)
{
$this->client = $client ?? HttpClient::create();
}
/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
return $this->client->request($method, $url, $options);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
/**
* {@inheritdoc}
*/
public function withOptions(array $options): self
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

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\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
{
private $clientTag;
public function __construct(string $clientTag = 'http_client.client')
{
if (0 < \func_num_args()) {
trigger_deprecation('symfony/http-client', '5.3', 'Configuring "%s" is deprecated.', __CLASS__);
}
$this->clientTag = $clientTag;
}
/**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container)
{
if (!$container->hasDefinition('data_collector.http_client')) {
return;
}
foreach ($container->findTaggedServiceIds($this->clientTag) as $id => $tags) {
$container->register('.debug.'.$id, TraceableHttpClient::class)
->setArguments([new Reference('.debug.'.$id.'.inner'), new Reference('debug.stopwatch', ContainerInterface::IGNORE_ON_INVALID_REFERENCE)])
->addTag('kernel.reset', ['method' => 'reset'])
->setDecoratedService($id, null, 5);
$container->getDefinition('data_collector.http_client')
->addMethodCall('registerClient', [$id, new Reference('.debug.'.$id)]);
}
}
}

View file

@ -0,0 +1,162 @@
<?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;
}
private $reconnectionTime;
public function __construct(?HttpClientInterface $client = null, float $reconnectionTime = 10.0)
{
$this->client = $client ?? HttpClient::create();
$this->reconnectionTime = $reconnectionTime;
}
public function connect(string $url, array $options = []): ResponseInterface
{
return $this->request('GET', $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 $buffer = null;
public $lastEventId = null;
public $reconnectionTime;
public $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 $e) {
$state->lastError = $lastError ?? microtime(true);
if (null === $state->buffer || ($isTimeout && microtime(true) - $state->lastError < $state->reconnectionTime)) {
yield $chunk;
} else {
$options['headers']['Last-Event-ID'] = $state->lastEventId;
$state->buffer = '';
$state->lastError = microtime(true);
$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();
}
if (null === $lastError) {
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,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,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\Exception;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait HttpExceptionTrait
{
private $response;
public function __construct(ResponseInterface $response)
{
$this->response = $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,79 @@
<?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\Connection\ConnectionLimitingPool;
use Amp\Promise;
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(ConnectionLimitingPool::class) && interface_exists(Promise::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 (\PHP_VERSION_ID < 70217 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304) || !\defined('CURLMOPT_PUSHFUNCTION')) {
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
}
static $curlVersion = null;
$curlVersion = $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:^4.2.1" 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,719 @@
<?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;
/**
* 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 $CHUNK_SIZE = 16372;
/**
* {@inheritdoc}
*/
public function withOptions(array $options): self
{
$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']);
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']);
}
// 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($options['base_uri'])) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
}
// Validate and resolve URL
$url = self::parseUrl($url, $options['query']);
$url = self::resolveUrl($url, $options['base_uri'], $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['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) {
$options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v;
}
}
// Option "query" is never inherited from defaults
$options['query'] = $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) {
$options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $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));
}
$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 (\is_object($values) && method_exists($values, '__toString')) {
$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)
{
if (\is_array($body)) {
array_walk_recursive($body, $caster = static function (&$v) use (&$caster) {
if (\is_object($v)) {
if ($vars = get_object_vars($v)) {
array_walk_recursive($vars, $caster);
$v = $vars;
} elseif (method_exists($v, '__toString')) {
$v = (string) $v;
}
}
});
return http_build_query($body, '', '&');
}
if (\is_string($body)) {
return $body;
}
$generatorToCallable = static function (\Generator $body): \Closure {
return 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;
}
/**
* @param string|string[] $fingerprint
*
* @throws InvalidArgumentException When an invalid fingerprint is passed
*/
private static function normalizePeerFingerprint($fingerprint): array
{
if (\is_string($fingerprint)) {
switch (\strlen($fingerprint = str_replace(':', '', $fingerprint))) {
case 32: $fingerprint = ['md5' => $fingerprint]; break;
case 40: $fingerprint = ['sha1' => $fingerprint]; break;
case 44: $fingerprint = ['pin-sha256' => [$fingerprint]]; break;
case 64: $fingerprint = ['sha256' => $fingerprint]; break;
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;
}
/**
* @param mixed $value
*
* @throws InvalidArgumentException When the value cannot be json-encoded
*/
private static function jsonEncode($value, ?int $flags = null, int $maxDepth = 512): string
{
$flags = $flags ?? (\JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_AMP | \JSON_HEX_QUOT | \JSON_PRESERVE_ZERO_FRACTION);
try {
$value = json_encode($value, $flags | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0), $maxDepth);
} catch (\JsonException $e) {
throw new InvalidArgumentException('Invalid value for "json" option: '.$e->getMessage());
}
if (\PHP_VERSION_ID < 70300 && \JSON_ERROR_NONE !== json_last_error() && (false === $value || !($flags & \JSON_PARTIAL_OUTPUT_ON_ERROR))) {
throw new InvalidArgumentException('Invalid value for "json" option: '.json_last_error_msg());
}
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'] = $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 === $parts = parse_url($url)) {
if ('/' !== ($url[0] ?? '') || false === $parts = parse_url($url.'#')) {
throw new InvalidArgumentException(sprintf('Malformed URL "%s".', $url));
}
unset($parts['fragment']);
}
if ($query) {
$parts['query'] = self::mergeQueryString($parts['query'] ?? null, $query, true);
}
$port = $parts['port'] ?? 0;
if (null !== $scheme = $parts['scheme'] ?? null) {
if (!isset($allowedSchemes[$scheme = strtolower($scheme)])) {
throw new InvalidArgumentException(sprintf('Unsupported scheme in "%s".', $url));
}
$port = $allowedSchemes[$scheme] === $port ? 0 : $port;
$scheme .= ':';
}
if (null !== $host = $parts['host'] ?? null) {
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', function ($m) { return rawurldecode($m[0]); }, $parts[$part]);
}
// https://tools.ietf.org/html/rfc3986#section-3.3
$parts[$part] = preg_replace_callback("#[^-A-Za-z0-9._~!$&/'()[\]*+,;=:@{}%]++#", function ($m) { return 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']) ? '#'.$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)
{
$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 = $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,331 @@
<?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 $options = [];
public function toArray(): array
{
return $this->options;
}
/**
* @return $this
*/
public function setAuthBasic(string $user, string $password = '')
{
$this->options['auth_basic'] = $user;
if ('' !== $password) {
$this->options['auth_basic'] .= ':'.$password;
}
return $this;
}
/**
* @return $this
*/
public function setAuthBearer(string $token)
{
$this->options['auth_bearer'] = $token;
return $this;
}
/**
* @return $this
*/
public function setQuery(array $query)
{
$this->options['query'] = $query;
return $this;
}
/**
* @return $this
*/
public function setHeaders(iterable $headers)
{
$this->options['headers'] = $headers;
return $this;
}
/**
* @param array|string|resource|\Traversable|\Closure $body
*
* @return $this
*/
public function setBody($body)
{
$this->options['body'] = $body;
return $this;
}
/**
* @param mixed $json
*
* @return $this
*/
public function setJson($json)
{
$this->options['json'] = $json;
return $this;
}
/**
* @return $this
*/
public function setUserData($data)
{
$this->options['user_data'] = $data;
return $this;
}
/**
* @return $this
*/
public function setMaxRedirects(int $max)
{
$this->options['max_redirects'] = $max;
return $this;
}
/**
* @return $this
*/
public function setHttpVersion(string $version)
{
$this->options['http_version'] = $version;
return $this;
}
/**
* @return $this
*/
public function setBaseUri(string $uri)
{
$this->options['base_uri'] = $uri;
return $this;
}
/**
* @return $this
*/
public function buffer(bool $buffer)
{
$this->options['buffer'] = $buffer;
return $this;
}
/**
* @return $this
*/
public function setOnProgress(callable $callback)
{
$this->options['on_progress'] = $callback;
return $this;
}
/**
* @return $this
*/
public function resolve(array $hostIps)
{
$this->options['resolve'] = $hostIps;
return $this;
}
/**
* @return $this
*/
public function setProxy(string $proxy)
{
$this->options['proxy'] = $proxy;
return $this;
}
/**
* @return $this
*/
public function setNoProxy(string $noProxy)
{
$this->options['no_proxy'] = $noProxy;
return $this;
}
/**
* @return $this
*/
public function setTimeout(float $timeout)
{
$this->options['timeout'] = $timeout;
return $this;
}
/**
* @return $this
*/
public function setMaxDuration(float $maxDuration)
{
$this->options['max_duration'] = $maxDuration;
return $this;
}
/**
* @return $this
*/
public function bindTo(string $bindto)
{
$this->options['bindto'] = $bindto;
return $this;
}
/**
* @return $this
*/
public function verifyPeer(bool $verify)
{
$this->options['verify_peer'] = $verify;
return $this;
}
/**
* @return $this
*/
public function verifyHost(bool $verify)
{
$this->options['verify_host'] = $verify;
return $this;
}
/**
* @return $this
*/
public function setCaFile(string $cafile)
{
$this->options['cafile'] = $cafile;
return $this;
}
/**
* @return $this
*/
public function setCaPath(string $capath)
{
$this->options['capath'] = $capath;
return $this;
}
/**
* @return $this
*/
public function setLocalCert(string $cert)
{
$this->options['local_cert'] = $cert;
return $this;
}
/**
* @return $this
*/
public function setLocalPk(string $pk)
{
$this->options['local_pk'] = $pk;
return $this;
}
/**
* @return $this
*/
public function setPassphrase(string $passphrase)
{
$this->options['passphrase'] = $passphrase;
return $this;
}
/**
* @return $this
*/
public function setCiphers(string $ciphers)
{
$this->options['ciphers'] = $ciphers;
return $this;
}
/**
* @param string|array $fingerprint
*
* @return $this
*/
public function setPeerFingerprint($fingerprint)
{
$this->options['peer_fingerprint'] = $fingerprint;
return $this;
}
/**
* @return $this
*/
public function capturePeerCertChain(bool $capture)
{
$this->options['capture_peer_cert_chain'] = $capture;
return $this;
}
/**
* @return $this
*/
public function setExtra(string $name, $value)
{
$this->options['extra'][$name] = $value;
return $this;
}
}

View file

@ -0,0 +1,276 @@
<?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\Client\HttpClient as HttplugInterface;
use Http\Discovery\Exception\NotFoundException;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Message\RequestFactory;
use Http\Message\StreamFactory;
use Http\Message\UriFactory;
use Http\Promise\Promise;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Request;
use Nyholm\Psr7\Uri;
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(HttplugInterface::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/httplug".');
}
if (!interface_exists(RequestFactory::class)) {
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\HttplugClient" as the "php-http/message-factory" package is not installed. Try running "composer require php-http/message-factory".');
}
/**
* An adapter to turn a Symfony HttpClientInterface into an Httplug client.
*
* Run "composer require nyholm/psr7" to install an efficient implementation of response
* and stream factories with flex-provided autowiring aliases.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class HttplugClient implements HttplugInterface, HttpAsyncClient, RequestFactory, StreamFactory, UriFactory, ResetInterface
{
private $client;
private $responseFactory;
private $streamFactory;
/**
* @var \SplObjectStorage<ResponseInterface, array{RequestInterface, Promise}>|null
*/
private $promisePool;
private $waitLoop;
public function __construct(?HttpClientInterface $client = null, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?? HttpClient::create();
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory ?? ($responseFactory instanceof StreamFactoryInterface ? $responseFactory : null);
$this->promisePool = class_exists(Utils::class) ? new \SplObjectStorage() : null;
if (null === $this->responseFactory || null === $this->streamFactory) {
if (!class_exists(Psr17Factory::class) && !class_exists(Psr17FactoryDiscovery::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been provided. Try running "composer require nyholm/psr7".');
}
try {
$psr17Factory = class_exists(Psr17Factory::class, false) ? new Psr17Factory() : null;
$this->responseFactory = $this->responseFactory ?? $psr17Factory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $this->streamFactory ?? $psr17Factory ?? Psr17FactoryDiscovery::findStreamFactory();
} catch (NotFoundException $e) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been found. Try running "composer require nyholm/psr7".', 0, $e);
}
}
$this->waitLoop = new HttplugWaitLoop($this->client, $this->promisePool, $this->responseFactory, $this->streamFactory);
}
/**
* {@inheritdoc}
*/
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);
}
}
/**
* {@inheritdoc}
*
* @return HttplugPromise
*/
public function sendAsyncRequest(RequestInterface $request): Promise
{
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);
}
/**
* {@inheritdoc}
*/
public function createRequest($method, $uri, array $headers = [], $body = null, $protocolVersion = '1.1'): RequestInterface
{
if ($this->responseFactory instanceof RequestFactoryInterface) {
$request = $this->responseFactory->createRequest($method, $uri);
} elseif (class_exists(Request::class)) {
$request = new Request($method, $uri);
} elseif (class_exists(Psr17FactoryDiscovery::class)) {
$request = Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri);
} else {
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
$request = $request
->withProtocolVersion($protocolVersion)
->withBody($this->createStream($body))
;
foreach ($headers as $name => $value) {
$request = $request->withAddedHeader($name, $value);
}
return $request;
}
/**
* {@inheritdoc}
*/
public function createStream($body = null): StreamInterface
{
if ($body instanceof StreamInterface) {
return $body;
}
if (\is_string($body ?? '')) {
$stream = $this->streamFactory->createStream($body ?? '');
} elseif (\is_resource($body)) {
$stream = $this->streamFactory->createStreamFromResource($body);
} else {
throw new \InvalidArgumentException(sprintf('"%s()" expects string, resource or StreamInterface, "%s" given.', __METHOD__, get_debug_type($body)));
}
if ($stream->isSeekable()) {
$stream->seek(0);
}
return $stream;
}
/**
* {@inheritdoc}
*/
public function createUri($uri): UriInterface
{
if ($uri instanceof UriInterface) {
return $uri;
}
if ($this->responseFactory instanceof UriFactoryInterface) {
return $this->responseFactory->createUri($uri);
}
if (class_exists(Uri::class)) {
return new Uri($uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findUrlFactory()->createUri($uri);
}
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
$this->wait();
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
private function sendPsr7Request(RequestInterface $request, ?bool $buffer = null): ResponseInterface
{
try {
$body = $request->getBody();
if ($body->isSeekable()) {
$body->seek(0);
}
$options = [
'headers' => $request->getHeaders(),
'body' => $body->getContents(),
'buffer' => $buffer,
];
if ('1.0' === $request->getProtocolVersion()) {
$options['http_version'] = '1.0';
}
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,142 @@
<?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\InputStream;
use Amp\ByteStream\ResourceInputStream;
use Amp\Http\Client\RequestBody;
use Amp\Promise;
use Amp\Success;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpBody implements RequestBody, InputStream
{
private $body;
private $info;
private $onProgress;
private $offset = 0;
private $length = -1;
private $uploaded;
public function __construct($body, &$info, \Closure $onProgress)
{
$this->body = $body;
$this->info = &$info;
$this->onProgress = $onProgress;
if (\is_resource($body)) {
$this->offset = ftell($body);
$this->length = fstat($body)['size'];
$this->body = new ResourceInputStream($body);
} elseif (\is_string($body)) {
$this->length = \strlen($body);
}
}
public function createBodyStream(): InputStream
{
if (null !== $this->uploaded) {
$this->uploaded = null;
if (\is_string($this->body)) {
$this->offset = 0;
} elseif ($this->body instanceof ResourceInputStream) {
fseek($this->body->getResource(), $this->offset);
}
}
return $this;
}
public function getHeaders(): Promise
{
return new Success([]);
}
public function getBodyLength(): Promise
{
return new Success($this->length - $this->offset);
}
public function read(): Promise
{
$this->info['size_upload'] += $this->uploaded;
$this->uploaded = 0;
($this->onProgress)();
$chunk = $this->doRead();
$chunk->onResolve(function ($e, $data) {
if (null !== $data) {
$this->uploaded = \strlen($data);
} else {
$this->info['upload_content_length'] = $this->info['size_upload'];
}
});
return $chunk;
}
public static function rewind(RequestBody $body): RequestBody
{
if (!$body instanceof self) {
return $body;
}
$body->uploaded = null;
if ($body->body instanceof ResourceInputStream) {
fseek($body->body->getResource(), $body->offset);
return new $body($body->body, $body->info, $body->onProgress);
}
if (\is_string($body->body)) {
$body->offset = 0;
}
return $body;
}
private function doRead(): Promise
{
if ($this->body instanceof ResourceInputStream) {
return $this->body->read();
}
if (null === $this->offset || !$this->length) {
return new Success();
}
if (\is_string($this->body)) {
$this->offset = null;
return new Success($this->body);
}
if ('' === $data = ($this->body)(16372)) {
$this->offset = null;
return new Success();
}
if (!\is_string($data)) {
throw new TransportException(sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
}
return new Success($data);
}
}

View file

@ -0,0 +1,217 @@
<?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\CancellationToken;
use Amp\Deferred;
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\Promise;
use Amp\Socket\Certificate;
use Amp\Socket\ClientTlsContext;
use Amp\Socket\ConnectContext;
use Amp\Socket\Connector;
use Amp\Socket\DnsConnector;
use Amp\Socket\SocketAddress;
use Amp\Success;
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 $dnsCache = [];
public $responseCount = 0;
public $pushedResponses = [];
private $clients = [];
private $clientConfigurator;
private $maxHostConnections;
private $maxPendingPushes;
private $logger;
public function __construct(?callable $clientConfigurator, int $maxHostConnections, int $maxPendingPushes, ?LoggerInterface &$logger)
{
$this->clientConfigurator = $clientConfigurator ?? static function (PooledHttpClient $client) {
return new InterceptedHttpClient($client, new RetryRequests(2));
};
$this->maxHostConnections = $maxHostConnections;
$this->maxPendingPushes = $maxPendingPushes;
$this->logger = &$logger;
}
/**
* @return Promise<Response>
*/
public function request(array $options, Request $request, CancellationToken $cancellation, array &$info, \Closure $onProgress, &$handle): Promise
{
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 || substr($host, -\strlen($dotRule)) === $dotRule) {
$options['proxy'] = null;
break;
}
}
}
$request = clone $request;
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));
$request->setPushHandler(function ($request, $response) use ($options): Promise {
return $this->handlePush($request, $response, $options);
});
($request->hasHeader('content-length') ? new Success((int) $request->getHeader('content-length')) : $request->getBody()->getBodyLength())
->onResolve(static function ($e, $bodySize) use (&$info) {
if (null !== $bodySize && 0 <= $bodySize) {
$info['upload_content_length'] = ((1 + $info['upload_content_length']) ?? 1) - 1 + $bodySize;
}
});
[$client, $connector] = $this->getClient($options);
$response = $client->request($request, $cancellation);
$response->onResolve(static function ($e) use ($connector, &$handle) {
if (null === $e) {
$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'],
];
$key = md5(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();
$connector = $handleConnector = new class() implements Connector {
public $connector;
public $uri;
public $handle;
public function connect(string $uri, ?ConnectContext $context = null, ?CancellationToken $token = null): Promise
{
$result = $this->connector->connect($this->uri ?? $uri, $context, $token);
$result->onResolve(function ($e, $socket) {
$this->handle = null !== $socket ? $socket->getResource() : false;
});
return $result;
}
};
$connector->connector = new DnsConnector(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 SocketAddress($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, Promise $response, array $options): Promise
{
$deferred = new Deferred();
$authority = $request->getUri()->getAuthority();
if ($this->maxPendingPushes <= \count($this->pushedResponses[$authority] ?? [])) {
$fifoUrl = key($this->pushedResponses[$authority]);
unset($this->pushedResponses[$authority][$fifoUrl]);
$this->logger && $this->logger->debug(sprintf('Evicting oldest pushed response: "%s"', $fifoUrl));
}
$url = (string) $request->getUri();
$this->logger && $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'],
]];
return $deferred->promise();
}
}

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 Amp\Http\Client\Connection\Stream;
use Amp\Http\Client\EventListener;
use Amp\Http\Client\Request;
use Amp\Promise;
use Amp\Success;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpListener implements EventListener
{
private $info;
private $pinSha256;
private $onProgress;
private $handle;
public function __construct(array &$info, array $pinSha256, \Closure $onProgress, &$handle)
{
$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;
$this->pinSha256 = $pinSha256;
$this->onProgress = $onProgress;
$this->handle = &$handle;
}
public function startRequest(Request $request): Promise
{
$this->info['start_time'] = $this->info['start_time'] ?? microtime(true);
($this->onProgress)();
return new Success();
}
public function startDnsResolution(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function startConnectionCreation(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function startTlsNegotiation(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function startSendingRequest(Request $request, Stream $stream): Promise
{
$host = $stream->getRemoteAddress()->getHost();
if (false !== strpos($host, ':')) {
$host = '['.$host.']';
}
$this->info['primary_ip'] = $host;
$this->info['primary_port'] = $stream->getRemoteAddress()->getPort();
$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->getRawHeaders() as [$name, $value]) {
$this->info['debug'] .= $name.': '.$value."\r\n";
}
$this->info['debug'] .= "\r\n";
return new Success();
}
public function completeSendingRequest(Request $request, Stream $stream): Promise
{
($this->onProgress)();
return new Success();
}
public function startReceivingResponse(Request $request, Stream $stream): Promise
{
$this->info['starttransfer_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
return new Success();
}
public function completeReceivingResponse(Request $request, Stream $stream): Promise
{
$this->handle = null;
($this->onProgress)();
return new Success();
}
public function completeDnsResolution(Request $request): Promise
{
$this->info['namelookup_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
return new Success();
}
public function completeConnectionCreation(Request $request): Promise
{
$this->info['connect_time'] = microtime(true) - $this->info['start_time'];
($this->onProgress)();
return new Success();
}
public function completeTlsNegotiation(Request $request): Promise
{
($this->onProgress)();
return new Success();
}
public function abort(Request $request, \Throwable $cause): Promise
{
return new Success();
}
}

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\Internal;
use Amp\Dns;
use Amp\Dns\Record;
use Amp\Promise;
use Amp\Success;
/**
* Handles local overrides for the DNS resolver.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
class AmpResolver implements Dns\Resolver
{
private $dnsMap;
public function __construct(array &$dnsMap)
{
$this->dnsMap = &$dnsMap;
}
public function resolve(string $name, ?int $typeRestriction = null): Promise
{
if (!isset($this->dnsMap[$name]) || !\in_array($typeRestriction, [Record::A, null], true)) {
return Dns\resolver()->resolve($name, $typeRestriction);
}
return new Success([new Record($this->dnsMap[$name], Record::A, null)]);
}
public function query(string $name, int $type): Promise
{
if (!isset($this->dnsMap[$name]) || Record::A !== $type) {
return Dns\resolver()->query($name, $type);
}
return new Success([new Record($this->dnsMap[$name], Record::A, null)]);
}
}

View file

@ -0,0 +1,40 @@
<?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
{
private $canceller;
public function __construct(\Closure $canceller)
{
$this->canceller = $canceller;
}
public function cancel()
{
if (($canceller = $this->canceller) instanceof \Closure) {
$this->canceller = null;
$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 $handlesActivity = [];
public $openHandles = [];
public $lastTimeout;
}

View file

@ -0,0 +1,149 @@
<?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
{
/** @var \CurlMultiHandle|resource|null */
public $handle;
/** @var \CurlShareHandle|resource|null */
public $share;
/** @var PushedResponse[] */
public $pushedResponses = [];
/** @var DnsCache */
public $dnsCache;
/** @var float[] */
public $pauseExpiries = [];
public $execCounter = \PHP_INT_MIN;
/** @var LoggerInterface|null */
public $logger;
public $performing = false;
public static $curlVersion;
public function __construct(int $maxHostConnections, int $maxPendingPushes)
{
self::$curlVersion = self::$curlVersion ?? curl_version();
$this->handle = curl_multi_init();
$this->dnsCache = new DnsCache();
$this->reset();
// 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);
}
if (\defined('CURLMOPT_MAX_HOST_CONNECTIONS') && 0 < $maxHostConnections) {
$maxHostConnections = curl_multi_setopt($this->handle, \CURLMOPT_MAX_HOST_CONNECTIONS, $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 >= $maxPendingPushes || \PHP_VERSION_ID < 70217 || (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304)) {
return;
}
// HTTP/2 push crashes before curl 7.61
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073D00 > self::$curlVersion['version_number'] || !(\CURL_VERSION_HTTP2 & self::$curlVersion['features'])) {
return;
}
// Clone to prevent a circular reference
$multi = clone $this;
$multi->handle = null;
$multi->share = null;
$multi->pushedResponses = &$this->pushedResponses;
$multi->logger = &$this->logger;
$multi->handlesActivity = &$this->handlesActivity;
$multi->openHandles = &$this->openHandles;
curl_multi_setopt($this->handle, \CURLMOPT_PUSHFUNCTION, static function ($parent, $pushed, array $requestHeaders) use ($multi, $maxPendingPushes) {
return $multi->handlePush($parent, $pushed, $requestHeaders, $maxPendingPushes);
});
}
public function reset()
{
foreach ($this->pushedResponses as $url => $response) {
$this->logger && $this->logger->debug(sprintf('Unused pushed response: "%s"', $url));
curl_multi_remove_handle($this->handle, $response->handle);
curl_close($response->handle);
}
$this->pushedResponses = [];
$this->dnsCache->evictions = $this->dnsCache->evictions ?: $this->dnsCache->removals;
$this->dnsCache->removals = $this->dnsCache->hostnames = [];
$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);
if (\defined('CURL_LOCK_DATA_CONNECT') && \PHP_VERSION_ID >= 80000) {
curl_share_setopt($this->share, \CURLSHOPT_SHARE, \CURL_LOCK_DATA_CONNECT);
}
}
private function handlePush($parent, $pushed, array $requestHeaders, int $maxPendingPushes): 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 && $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 && $this->logger->debug(sprintf('Rejecting pushed response from "%s": server is not authoritative for "%s"', $origin, $url));
return \CURL_PUSH_DENY;
}
if ($maxPendingPushes <= \count($this->pushedResponses)) {
$fifoUrl = key($this->pushedResponses);
unset($this->pushedResponses[$fifoUrl]);
$this->logger && $this->logger->debug(sprintf('Evicting oldest pushed response: "%s"', $fifoUrl));
}
$url .= $headers[':path'][0];
$this->logger && $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 $hostnames = [];
/**
* @var string[]
*/
public $removals = [];
/**
* @var string[]
*/
public $evictions = [];
}

View file

@ -0,0 +1,153 @@
<?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
{
private $client;
private $promisePool;
private $responseFactory;
private $streamFactory;
/**
* @param \SplObjectStorage<ResponseInterface, array{Psr7RequestInterface, Promise}>|null $promisePool
*/
public function __construct(HttpClientInterface $client, ?\SplObjectStorage $promisePool, ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory)
{
$this->client = $client;
$this->promisePool = $promisePool;
$this->responseFactory = $responseFactory;
$this->streamFactory = $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 = microtime(true);
$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 - microtime(true) + $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()) {
$body->seek(0);
}
return $psrResponse->withBody($body);
}
}

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\Internal;
/**
* Internal representation of the native client's state.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class NativeClientState extends ClientState
{
/** @var int */
public $id;
/** @var int */
public $maxHostConnections = \PHP_INT_MAX;
/** @var int */
public $responseCount = 0;
/** @var string[] */
public $dnsCache = [];
/** @var bool */
public $sleep = false;
/** @var int[] */
public $hosts = [];
public function __construct()
{
$this->id = random_int(\PHP_INT_MIN, \PHP_INT_MAX);
}
public function reset()
{
$this->responseCount = 0;
$this->dnsCache = [];
$this->hosts = [];
}
}

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\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 $response;
/** @var string[] */
public $requestHeaders;
public $parentOptions = [];
public $handle;
public function __construct(CurlResponse $response, array $requestHeaders, array $parentOptions, $handle)
{
$this->response = $response;
$this->requestHeaders = $requestHeaders;
$this->parentOptions = $parentOptions;
$this->handle = $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,124 @@
<?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 $responseFactory;
private $requestsCount = 0;
private $defaultOptions = [];
/**
* @param callable|callable[]|ResponseInterface|ResponseInterface[]|iterable|null $responseFactory
*/
public function __construct($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 = $responseFactory;
}
/**
* {@inheritdoc}
*/
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('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.', \is_object($response) ? \get_class($response) : \gettype($response)));
}
return MockResponse::fromRequest($method, $url, $options, $response);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of MockResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
return new ResponseStream(MockResponse::stream($responses, $timeout));
}
public function getRequestsCount(): int
{
return $this->requestsCount;
}
/**
* {@inheritdoc}
*/
public function withOptions(array $options): self
{
$clone = clone $this;
$clone->defaultOptions = self::mergeDefaultOptions($options, $this->defaultOptions, true);
return $clone;
}
public function reset()
{
$this->requestsCount = 0;
}
}

View file

@ -0,0 +1,472 @@
<?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;
private $defaultOptions = self::OPTIONS_DEFAULTS;
private static $emptyDefaults = self::OPTIONS_DEFAULTS;
/** @var NativeClientState */
private $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'] = $this->defaultOptions['buffer'] ?? \Closure::fromCallable([__CLASS__, '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
*
* {@inheritdoc}
*/
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']) {
// Memoize the last progress to ease calling the callback periodically when no network transfer happens
$lastProgress = [0, 0];
$maxDuration = 0 < $options['max_duration'] ? $options['max_duration'] : \INF;
$onProgress = static function (...$progress) use ($onProgress, &$lastProgress, &$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']);
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 && $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']);
}
$bindto = $options['bindto'];
if (!$bindto && (70322 === \PHP_VERSION_ID || 70410 === \PHP_VERSION_ID)) {
$bindto = '0:0';
}
$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()
'timeout' => $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,
], static function ($v) { return null !== $v; }),
'socket' => [
'bindto' => $bindto,
'tcp_nodelay' => true,
],
];
$context = stream_context_create($context, ['notification' => $notification]);
$resolver = static function ($multi) use ($context, $options, $url, &$info, $onProgress) {
[$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, $host, $proxy, $info, $onProgress), implode('', $url)];
};
return new NativeResponse($this->multi, $context, implode('', $url), $options, $info, $resolver, $onProgress, $this->logger);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof NativeResponse) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of NativeResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
return new ResponseStream(NativeResponse::stream($responses, $timeout));
}
public function reset()
{
$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.
*/
private static function dnsResolve($host, NativeClientState $multi, array &$info, ?\Closure $onProgress): string
{
if (null === $ip = $multi->dnsCache[$host] ?? null) {
$info['debug'] .= "* Hostname was NOT found in DNS cache\n";
$now = microtime(true);
if (!$ip = gethostbynamel($host)) {
throw new TransportException(sprintf('Could not resolve host "%s".', $host));
}
$info['namelookup_time'] = microtime(true) - ($info['start_time'] ?: $now);
$multi->dnsCache[$host] = $ip = $ip[0];
$info['debug'] .= "* Added {$host}:0:{$ip} to DNS cache\n";
} else {
$info['debug'] .= "* Hostname was found in DNS cache\n";
}
$info['primary_ip'] = $ip;
if ($onProgress) {
// Notify DNS resolution
$onProgress();
}
return $ip;
}
/**
* Handles redirects - the native logic is too buggy to be used.
*/
private static function createRedirectResolver(array $options, string $host, ?array $proxy, array &$info, ?\Closure $onProgress): \Closure
{
$redirectHeaders = [];
if (0 < $maxRedirects = $options['max_redirects']) {
$redirectHeaders = ['host' => $host];
$redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) {
return 0 !== stripos($h, 'Host:');
});
if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) {
$redirectHeaders['no_auth'] = array_filter($redirectHeaders['no_auth'], static function ($h) {
return 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);
} catch (InvalidArgumentException $e) {
$info['redirect_url'] = null;
return null;
}
$url = self::resolveUrl($url, $info['url']);
$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 function ($h) {
return 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);
if (\PHP_VERSION_ID >= 80300) {
stream_context_set_options($context, ['http' => $options]);
} else {
stream_context_set_option($context, ['http' => $options]);
}
}
}
[$host, $port] = self::parseHostPort($url, $info);
if (false !== (parse_url($location.'#', \PHP_URL_HOST) ?? false)) {
// Authorization and Cookie headers MUST NOT follow except for the initial host name
$requestHeaders = $redirectHeaders['host'] === $host ? $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,132 @@
<?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\HttpFoundation\IpUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
use Symfony\Contracts\Service\ResetInterface;
/**
* Decorator that blocks requests to private networks by default.
*
* @author Hallison Boaventura <hallisonboaventura@gmail.com>
*/
final class NoPrivateNetworkHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
{
use HttpClientTrait;
private const PRIVATE_SUBNETS = [
'127.0.0.0/8',
'10.0.0.0/8',
'192.168.0.0/16',
'172.16.0.0/12',
'169.254.0.0/16',
'0.0.0.0/8',
'240.0.0.0/4',
'::1/128',
'fc00::/7',
'fe80::/10',
'::ffff:0:0/96',
'::/128',
];
private $client;
private $subnets;
/**
* @param string|array|null $subnets String or array of subnets using CIDR notation that will be used by IpUtils.
* If null is passed, the standard private subnets will be used.
*/
public function __construct(HttpClientInterface $client, $subnets = null)
{
if (!(\is_array($subnets) || \is_string($subnets) || null === $subnets)) {
throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be of the type array, string or null. "%s" given.', __METHOD__, get_debug_type($subnets)));
}
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__));
}
$this->client = $client;
$this->subnets = $subnets;
}
/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$onProgress = $options['on_progress'] ?? null;
if (null !== $onProgress && !\is_callable($onProgress)) {
throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, "%s" given.', get_debug_type($onProgress)));
}
$subnets = $this->subnets;
$lastPrimaryIp = '';
$options['on_progress'] = function (int $dlNow, int $dlSize, array $info) use ($onProgress, $subnets, &$lastPrimaryIp): void {
if ($info['primary_ip'] !== $lastPrimaryIp) {
if ($info['primary_ip'] && IpUtils::checkIp($info['primary_ip'], $subnets ?? self::PRIVATE_SUBNETS)) {
throw new TransportException(sprintf('IP "%s" is blocked for "%s".', $info['primary_ip'], $info['url']));
}
$lastPrimaryIp = $info['primary_ip'];
}
null !== $onProgress && $onProgress($dlNow, $dlSize, $info);
};
return $this->client->request($method, $url, $options);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
/**
* {@inheritdoc}
*/
public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}
/**
* {@inheritdoc}
*/
public function withOptions(array $options): self
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}

View file

@ -0,0 +1,231 @@
<?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\Exception\NotFoundException;
use Http\Discovery\Psr17FactoryDiscovery;
use Nyholm\Psr7\Factory\Psr17Factory;
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\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 as HttpClientResponseInterface;
use Symfony\Contracts\Service\ResetInterface;
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 nyholm/psr7".');
}
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 psr/http-client".');
}
/**
* An adapter to turn a Symfony HttpClientInterface into a PSR-18 ClientInterface.
*
* Run "composer require psr/http-client" to install the base ClientInterface. Run
* "composer require nyholm/psr7" to install an efficient implementation of response
* and stream factories with flex-provided autowiring aliases.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
final class Psr18Client implements ClientInterface, RequestFactoryInterface, StreamFactoryInterface, UriFactoryInterface, ResetInterface
{
private $client;
private $responseFactory;
private $streamFactory;
public function __construct(?HttpClientInterface $client = null, ?ResponseFactoryInterface $responseFactory = null, ?StreamFactoryInterface $streamFactory = null)
{
$this->client = $client ?? HttpClient::create();
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory ?? ($responseFactory instanceof StreamFactoryInterface ? $responseFactory : null);
if (null !== $this->responseFactory && null !== $this->streamFactory) {
return;
}
if (!class_exists(Psr17Factory::class) && !class_exists(Psr17FactoryDiscovery::class)) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\Psr18Client" as no PSR-17 factories have been provided. Try running "composer require nyholm/psr7".');
}
try {
$psr17Factory = class_exists(Psr17Factory::class, false) ? new Psr17Factory() : null;
$this->responseFactory = $this->responseFactory ?? $psr17Factory ?? Psr17FactoryDiscovery::findResponseFactory();
$this->streamFactory = $this->streamFactory ?? $psr17Factory ?? Psr17FactoryDiscovery::findStreamFactory();
} catch (NotFoundException $e) {
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\HttplugClient" as no PSR-17 factories have been found. Try running "composer require nyholm/psr7".', 0, $e);
}
}
/**
* {@inheritdoc}
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
try {
$body = $request->getBody();
if ($body->isSeekable()) {
$body->seek(0);
}
$options = [
'headers' => $request->getHeaders(),
'body' => $body->getContents(),
];
if ('1.0' === $request->getProtocolVersion()) {
$options['http_version'] = '1.0';
}
$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);
}
}
/**
* {@inheritdoc}
*/
public function createRequest(string $method, $uri): RequestInterface
{
if ($this->responseFactory instanceof RequestFactoryInterface) {
return $this->responseFactory->createRequest($method, $uri);
}
if (class_exists(Request::class)) {
return new Request($method, $uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findRequestFactory()->createRequest($method, $uri);
}
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
/**
* {@inheritdoc}
*/
public function createStream(string $content = ''): StreamInterface
{
$stream = $this->streamFactory->createStream($content);
if ($stream->isSeekable()) {
$stream->seek(0);
}
return $stream;
}
/**
* {@inheritdoc}
*/
public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface
{
return $this->streamFactory->createStreamFromFile($filename, $mode);
}
/**
* {@inheritdoc}
*/
public function createStreamFromResource($resource): StreamInterface
{
return $this->streamFactory->createStreamFromResource($resource);
}
/**
* {@inheritdoc}
*/
public function createUri(string $uri = ''): UriInterface
{
if ($this->responseFactory instanceof UriFactoryInterface) {
return $this->responseFactory->createUri($uri);
}
if (class_exists(Uri::class)) {
return new Uri($uri);
}
if (class_exists(Psr17FactoryDiscovery::class)) {
return Psr17FactoryDiscovery::findUrlFactory()->createUri($uri);
}
throw new \LogicException(sprintf('You cannot use "%s()" as the "nyholm/psr7" package is not installed. Try running "composer require nyholm/psr7".', __METHOD__));
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
}
/**
* @internal
*/
class Psr18NetworkException extends \RuntimeException implements NetworkExceptionInterface
{
private $request;
public function __construct(TransportExceptionInterface $e, RequestInterface $request)
{
parent::__construct($e->getMessage(), 0, $e);
$this->request = $request;
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}
/**
* @internal
*/
class Psr18RequestException extends \InvalidArgumentException implements RequestExceptionInterface
{
private $request;
public function __construct(TransportExceptionInterface $e, RequestInterface $request)
{
parent::__construct($e->getMessage(), 0, $e);
$this->request = $request;
}
public function getRequest(): RequestInterface
{
return $this->request;
}
}

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

@ -0,0 +1,27 @@
HttpClient component
====================
The HttpClient component provides powerful methods to fetch HTTP resources synchronously or asynchronously.
Sponsor
-------
The Httpclient component for Symfony 5.4/6.0 is [backed][1] by [Klaxoon][2].
Klaxoon is a platform that empowers organizations to run effective and
productive workshops easily in a hybrid environment. Anytime, Anywhere.
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
[2]: https://klaxoon.com
[3]: https://symfony.com/sponsor

View file

@ -0,0 +1,460 @@
<?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\CancellationTokenSource;
use Amp\Coroutine;
use Amp\Deferred;
use Amp\Http\Client\HttpException;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Amp\Loop;
use Amp\Promise;
use Amp\Success;
use Psr\Log\LoggerInterface;
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;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
final class AmpResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
use TransportResponseTrait;
private static $nextId = 'a';
private $multi;
private $options;
private $onProgress;
private static $delay;
/**
* @internal
*/
public function __construct(AmpClientState $multi, Request $request, array $options, ?LoggerInterface $logger)
{
$this->multi = $multi;
$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 function (self $response) {
return null !== $response->options;
};
$info = &$this->info;
$headers = &$this->headers;
$canceller = new CancellationTokenSource();
$handle = &$this->handle;
$info['url'] = (string) $request->getUri();
$info['http_method'] = $request->getMethod();
$info['start_time'] = null;
$info['redirect_url'] = null;
$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['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);
};
$pauseDeferred = new Deferred();
$pause = new Success();
$throttleWatcher = null;
$this->id = $id = self::$nextId++;
Loop::defer(static function () use ($request, $multi, &$id, &$info, &$headers, $canceller, &$options, $onProgress, &$handle, $logger, &$pause) {
return new Coroutine(self::generateResponse($request, $multi, $id, $info, $headers, $canceller, $options, $onProgress, $handle, $logger, $pause));
});
$info['pause_handler'] = static function (float $duration) use (&$throttleWatcher, &$pauseDeferred, &$pause) {
if (null !== $throttleWatcher) {
Loop::cancel($throttleWatcher);
}
$pause = $pauseDeferred->promise();
if ($duration <= 0) {
$deferred = $pauseDeferred;
$pauseDeferred = new Deferred();
$deferred->resolve();
} else {
$throttleWatcher = Loop::delay(ceil(1000 * $duration), static function () use (&$pauseDeferred) {
$deferred = $pauseDeferred;
$pauseDeferred = new Deferred();
$deferred->resolve();
});
}
};
$multi->lastTimeout = null;
$multi->openHandles[$id] = $id;
++$multi->responseCount;
$this->canary = new Canary(static function () use ($canceller, $multi, $id) {
$canceller->cancel();
unset($multi->openHandles[$id], $multi->handlesActivity[$id]);
});
}
/**
* {@inheritdoc}
*/
public function getInfo(?string $type = null)
{
return null !== $type ? $this->info[$type] ?? null : $this->info;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
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 = [];
}
}
}
/**
* {@inheritdoc}
*/
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;
}
}
/**
* {@inheritdoc}
*
* @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;
}
}
}
}
/**
* {@inheritdoc}
*
* @param AmpClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
$timeout += microtime(true);
self::$delay = Loop::defer(static function () use ($timeout) {
if (0 < $timeout -= microtime(true)) {
self::$delay = Loop::delay(ceil(1000 * $timeout), [Loop::class, 'stop']);
} else {
Loop::stop();
}
});
Loop::run();
return null === self::$delay ? 1 : 0;
}
private static function generateResponse(Request $request, AmpClientState $multi, string $id, array &$info, array &$headers, CancellationTokenSource $canceller, array &$options, \Closure $onProgress, &$handle, ?LoggerInterface $logger, Promise &$pause)
{
$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());
self::stopLoop();
});
try {
/* @var Response $response */
if (null === $response = yield from self::getPushedResponse($request, $multi, $info, $headers, $options, $logger)) {
$logger && $logger->info(sprintf('Request: "%s %s"', $info['http_method'], $info['url']));
$response = yield from 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;
self::stopLoop();
return;
}
if ($response->hasHeader('content-length')) {
$info['download_content_length'] = (float) $response->getHeader('content-length');
}
$body = $response->getBody();
while (true) {
self::stopLoop();
yield $pause;
if (null === $data = yield $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'];
}
self::stopLoop();
}
private static function followRedirects(Request $originRequest, AmpClientState $multi, array &$info, array &$headers, CancellationTokenSource $canceller, array $options, \Closure $onProgress, &$handle, ?LoggerInterface $logger, Promise &$pause)
{
yield $pause;
$originRequest->setBody(new AmpBody($options['body'], $info, $onProgress));
$response = yield $multi->request($options, $originRequest, $canceller->getToken(), $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 = $previousUrl ?? $urlResolver::parseUrl($info['url']);
$location = $urlResolver::parseUrl($location);
$location = $urlResolver::resolveUrl($location, $previousUrl);
$info['redirect_url'] = implode('', $location);
} catch (InvalidArgumentException $e) {
return $response;
}
if (0 >= $options['max_redirects'] || $info['redirect_count'] >= $options['max_redirects']) {
return $response;
}
$logger && $logger->info(sprintf('Redirecting: "%s %s"', $status, $info['url']));
try {
// Discard body of redirects
while (null !== yield $response->getBody()->read()) {
}
} catch (HttpException|StreamException $e) {
// 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());
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->getRawHeaders() as [$name, $value]) {
$request->addHeader($name, $value);
}
if ($request->getUri()->getAuthority() !== $originRequest->getUri()->getAuthority()) {
$request->removeHeader('authorization');
$request->removeHeader('cookie');
$request->removeHeader('host');
}
yield $pause;
$response = yield $multi->request($options, $request, $canceller->getToken(), $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->getRawHeaders() 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, array $options, ?LoggerInterface $logger)
{
if ('' !== $options['body']) {
return null;
}
$authority = $request->getUri()->getAuthority();
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;
}
}
foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
if ($pushedRequest->getHeaderArray($k) !== $request->getHeaderArray($k)) {
continue 2;
}
}
$response = yield $pushedResponse;
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 && $logger->debug(sprintf('Skipping pushed response: "%s"', $info['url']));
continue 3;
}
}
}
$pushDeferred->resolve();
$logger && $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;
}
}
private static function stopLoop(): void
{
if (null !== self::$delay) {
Loop::cancel(self::$delay);
self::$delay = null;
}
Loop::defer([Loop::class, 'stop']);
}
}

View file

@ -0,0 +1,195 @@
<?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
{
private $passthru;
private $client;
private $response;
private $info = [];
private $content;
private $offset;
public function __construct(&$passthru, HttpClientInterface $client, ResponseInterface &$response, array &$info, $content, int $offset)
{
$this->passthru = &$passthru;
$this->client = $client;
$this->response = &$response;
$this->info = &$info;
$this->content = $content;
$this->offset = $offset;
}
/**
* 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)
{
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, $value): self
{
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,473 @@
<?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\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>
*/
final class AsyncResponse implements ResponseInterface, StreamableInterface
{
use CommonResponseTrait;
private const FIRST_CHUNK_YIELDED = 1;
private const LAST_CHUNK_YIELDED = 2;
private $client;
private $response;
private $info = ['canceled' => false];
private $passthru;
private $stream;
private $yieldedState;
/**
* @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) {
// 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)
{
if (null !== $type) {
return $this->info[$type] ?? $this->response->getInfo($type);
}
return $this->info + $this->response->getInfo();
}
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 $e) {
// ignore any errors when canceling
}
}
public function __destruct()
{
$httpException = null;
if ($this->initializer && null === $this->getInfo('error')) {
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 $e) {
// 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, new FirstChunk(), $asyncMap);
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;
}
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+');
}
}
if (!$r->passthru) {
if (null !== $chunk->getError() || $chunk->isLast()) {
unset($asyncMap[$response]);
} elseif (null !== $r->content && '' !== ($content = $chunk->getContent()) && \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();
}
yield $r => $chunk;
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));
}
foreach (self::passthru($r->client, $r, $chunk, $asyncMap) as $chunk) {
yield $r => $chunk;
}
if ($r->response !== $response && isset($asyncMap[$response])) {
break;
}
}
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[] = $asyncMap[$response];
}
}
}
}
/**
* @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, null, $asyncMap);
}
/**
* @param \SplObjectStorage<ResponseInterface, AsyncResponse>|null $asyncMap
*/
private static function passthruStream(ResponseInterface $response, self $r, ?ChunkInterface $chunk, ?\SplObjectStorage $asyncMap): \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);
}
}
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,185 @@
<?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;
private $shouldBuffer;
private $content;
private $offset = 0;
private $jsonData;
/**
* {@inheritdoc}
*/
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);
}
/**
* {@inheritdoc}
*/
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 | (\PHP_VERSION_ID >= 70300 ? \JSON_THROW_ON_ERROR : 0));
} catch (\JsonException $e) {
throw new JsonException($e->getMessage().sprintf(' for "%s".', $this->getInfo('url')), $e->getCode());
}
if (\PHP_VERSION_ID < 70300 && \JSON_ERROR_NONE !== json_last_error()) {
throw new JsonException(json_last_error_msg().sprintf(' for "%s".', $this->getInfo('url')), json_last_error());
}
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;
}
/**
* {@inheritdoc}
*/
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 __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
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()
{
$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,472 @@
<?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;
private $multi;
private $debugBuffer;
/**
* @param \CurlHandle|resource|string $ch
*
* @internal
*/
public function __construct(CurlClientState $multi, $ch, ?array $options = null, ?LoggerInterface $logger = null, string $method = 'GET', ?callable $resolveRedirect = null, ?int $curlVersion = null)
{
$this->multi = $multi;
if (\is_resource($ch) || $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['start_time'] = $this->info['start_time'] ?? microtime(true);
$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'], 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 += microtime(true);
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 {
rewind($debugBuffer);
$debug = ['debug' => stream_get_contents($debugBuffer)];
$onProgress($dlNow, $dlSize, $url + curl_getinfo($ch) + $info + $debug);
} 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($ch = $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 = [];
}
});
}
/**
* {@inheritdoc}
*/
public function getInfo(?string $type = null)
{
if (!$info = $this->finalInfo) {
$info = array_merge($this->info, curl_getinfo($this->handle));
$info['url'] = $this->info['url'] ?? $info['url'];
$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;
}
rewind($this->debugBuffer);
$info['debug'] = stream_get_contents($this->debugBuffer);
$waitFor = curl_getinfo($this->handle, \CURLINFO_PRIVATE);
if ('H' !== $waitFor[0] && 'C' !== $waitFor[0]) {
curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
rewind($this->debugBuffer);
ftruncate($this->debugBuffer, 0);
$this->finalInfo = $info;
}
}
return null !== $type ? $info[$type] ?? null : $info;
}
/**
* {@inheritdoc}
*/
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 (\is_resource($this->handle) || $this->handle instanceof \CurlHandle) {
curl_setopt($this->handle, \CURLOPT_VERBOSE, false);
}
}
}
/**
* {@inheritdoc}
*/
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($ch = $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;
}
}
/**
* {@inheritdoc}
*
* @param CurlClientState $multi
*/
private static function perform(ClientState $multi, ?array &$responses = null): void
{
if ($multi->performing) {
if ($responses) {
$response = current($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 (\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();
}
$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) ? null : new TransportException(ucfirst(curl_error($ch) ?: curl_strerror($result)).sprintf(' for "%s".', curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL)));
}
} finally {
$multi->performing = false;
}
}
/**
* {@inheritdoc}
*
* @param CurlClientState $multi
*/
private static function select(ClientState $multi, float $timeout): int
{
if (\PHP_VERSION_ID < 70211) {
// workaround https://bugs.php.net/76480
$timeout = min($timeout, 0.01);
}
if ($multi->pauseExpiries) {
$now = microtime(true);
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 -= microtime(true) - $now) {
usleep((int) (1E6 * $timeout));
}
return 0;
}
/**
* Parses header lines as curl yields them to us.
*/
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) {
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) {
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']);
} else {
$url = parse_url($location ?? ':');
if (isset($url['host']) && null !== $ip = $multi->dnsCache->hostnames[$url['host'] = strtolower($url['host'])] ?? null) {
// Populate DNS cache for redirects if needed
$port = $url['port'] ?? ('http' === ($url['scheme'] ?? parse_url(curl_getinfo($ch, \CURLINFO_EFFECTIVE_URL), \PHP_URL_SCHEME)) ? 80 : 443);
curl_setopt($ch, \CURLOPT_RESOLVE, ["{$url['host']}:$port:$ip"]);
$multi->dnsCache->removals["-{$url['host']}:$port"] = "-{$url['host']}:$port";
}
}
}
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 || 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);
}
}

View file

@ -0,0 +1,80 @@
<?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
{
private $promise;
public function __construct(GuzzlePromiseInterface $promise)
{
$this->promise = $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();
}
/**
* {@inheritdoc}
*/
public function getState(): string
{
return $this->promise->getState();
}
/**
* {@inheritdoc}
*
* @return Psr7ResponseInterface|mixed
*/
public function wait($unwrap = true)
{
$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 function ($value) use ($callback) {
return Create::promiseFor($callback($value));
};
}
}

View file

@ -0,0 +1,343 @@
<?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 {
doDestruct as public __destruct;
}
private $body;
private $requestOptions = [];
private $requestUrl;
private $requestMethod;
private static $mainMulti;
private static $idSequence = 0;
/**
* @param string|string[]|iterable $body The response body as a string or an iterable of strings,
* yielding an empty string simulates an idle timeout,
* throwing an exception yields an ErrorChunk
*
* @see ResponseInterface::getInfo() for possible info, e.g. "response_headers"
*/
public function __construct($body = '', array $info = [])
{
$this->body = is_iterable($body) ? $body : (string) $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);
}
/**
* 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;
}
/**
* {@inheritdoc}
*/
public function getInfo(?string $type = null)
{
return null !== $type ? $this->info[$type] ?? null : $this->info;
}
/**
* {@inheritdoc}
*/
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);
}
/**
* {@inheritdoc}
*/
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 function (self $response) {
return \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['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;
}
/**
* {@inheritdoc}
*/
protected static function schedule(self $response, array &$runningResponses): void
{
if (!$response->id) {
throw new InvalidArgumentException('MockResponse instances must be issued by MockHttpClient before processing.');
}
$multi = self::$mainMulti ?? self::$mainMulti = new ClientState();
if (!isset($runningResponses[0])) {
$runningResponses[0] = [$multi, []];
}
$runningResponses[0][1][$response->id] = $response;
}
/**
* {@inheritdoc}
*/
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();
$buffer = $response->requestOptions['buffer'] ?? null;
if ($buffer instanceof \Closure && $response->content = $buffer($response->headers) ?: null) {
$response->content = \is_resource($response->content) ? $response->content : fopen('php://temp', 'w+');
}
} 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;
}
}
}
/**
* {@inheritdoc}
*/
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)
{
$onProgress = $options['on_progress'] ?? static function () {};
$response->info += $mock->getInfo() ?: [];
// 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)
{
$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'],
'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 = (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,376 @@
<?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 $context;
private $url;
private $resolver;
private $onProgress;
private $remaining;
private $buffer;
private $multi;
private $pauseExpiry = 0;
/**
* @internal
*/
public function __construct(NativeClientState $multi, $context, string $url, array $options, array &$info, callable $resolver, ?callable $onProgress, ?LoggerInterface $logger)
{
$this->multi = $multi;
$this->id = $id = (int) $context;
$this->context = $context;
$this->url = $url;
$this->logger = $logger;
$this->timeout = $options['timeout'];
$this->info = &$info;
$this->resolver = $resolver;
$this->onProgress = $onProgress;
$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['user_data'] = $options['user_data'];
$info['max_duration'] = $options['max_duration'];
++$multi->responseCount;
$this->initializer = static function (self $response) {
return null === $response->remaining;
};
$pauseExpiry = &$this->pauseExpiry;
$info['pause_handler'] = static function (float $duration) use (&$pauseExpiry) {
$pauseExpiry = 0 < $duration ? microtime(true) + $duration : 0;
};
$this->canary = new Canary(static function () use ($multi, $id) {
if (null !== ($host = $multi->openHandles[$id][6] ?? null) && 0 >= --$multi->hosts[$host]) {
unset($multi->hosts[$host]);
}
unset($multi->openHandles[$id], $multi->handlesActivity[$id]);
});
}
/**
* {@inheritdoc}
*/
public function getInfo(?string $type = null)
{
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 && $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 && $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);
$this->context = $this->resolver = null;
// 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);
}
/**
* {@inheritdoc}
*/
private function close(): void
{
$this->canary->cancel();
$this->handle = $this->buffer = $this->inflate = $this->onProgress = null;
}
/**
* {@inheritdoc}
*/
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;
}
}
/**
* {@inheritdoc}
*
* @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 (microtime(true) < $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) && 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 && microtime(true) < $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;
}
}
}
/**
* {@inheritdoc}
*
* @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 ?? $now = microtime(true)) < $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,54 @@
<?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
{
private $generator;
public function __construct(\Generator $generator)
{
$this->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,313 @@
<?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;
/** @var HttpClientInterface */
private $client;
/** @var ResponseInterface */
private $response;
/** @var resource|string|null */
private $content;
/** @var resource|null */
private $handle;
private $blocking = true;
private $timeout;
private $eof = false;
private $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)
{
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) {
if (null === $this->content) {
$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;
}
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,221 @@
<?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
{
private $client;
private $response;
private $content;
private $event;
public function __construct(HttpClientInterface $client, ResponseInterface $response, &$content, ?StopwatchEvent $event = null)
{
$this->client = $client;
$this->response = $response;
$this->content = &$content;
$this->event = $event;
}
public function __sleep(): array
{
throw new \BadMethodCallException('Cannot serialize '.__CLASS__);
}
public function __wakeup()
{
throw new \BadMethodCallException('Cannot unserialize '.__CLASS__);
}
public function __destruct()
{
try {
if (method_exists($this->response, '__destruct')) {
$this->response->__destruct();
}
} finally {
if ($this->event && $this->event->isStarted()) {
$this->event->stop();
}
}
}
public function getStatusCode(): int
{
try {
return $this->response->getStatusCode();
} finally {
if ($this->event && $this->event->isStarted()) {
$this->event->lap();
}
}
}
public function getHeaders(bool $throw = true): array
{
try {
return $this->response->getHeaders($throw);
} finally {
if ($this->event && $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 && $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 && $this->event->isStarted()) {
$this->event->stop();
}
if ($throw) {
$this->checkStatusCode($this->response->getStatusCode());
}
}
}
public function cancel(): void
{
$this->response->cancel();
if ($this->event && $this->event->isStarted()) {
$this->event->stop();
}
}
public function getInfo(?string $type = null)
{
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)
{
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,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\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\ClientState;
/**
* Implements common logic for transport-level response classes.
*
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait TransportResponseTrait
{
private $canary;
private $headers = [];
private $info = [
'response_headers' => [],
'http_code' => 0,
'error' => null,
'canceled' => false,
];
/** @var object|resource */
private $handle;
private $id;
private $timeout = 0;
private $inflate;
private $finalInfo;
private $logger;
/**
* {@inheritdoc}
*/
public function getStatusCode(): int
{
if ($this->initializer) {
self::initialize($this);
}
return $this->info['http_code'];
}
/**
* {@inheritdoc}
*/
public function getHeaders(bool $throw = true): array
{
if ($this->initializer) {
self::initialize($this);
}
if ($throw) {
$this->checkStatusCode();
}
return $this->headers;
}
/**
* {@inheritdoc}
*/
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()
{
$this->shouldBuffer = true;
if ($this->initializer && null === $this->info['error']) {
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 = microtime(true);
$elapsedTimeout = 0;
if ($fromLastTimeout = 0.0 === $timeout && '-0' === (string) $timeout) {
$timeout = null;
} elseif ($fromLastTimeout = 0 > $timeout) {
$timeout = -$timeout;
}
while (true) {
$hasActivity = false;
$timeoutMax = 0;
$timeoutMin = $timeout ?? \INF;
/** @var ClientState $multi */
foreach ($runningResponses as $i => [$multi]) {
$responses = &$runningResponses[$i][1];
self::perform($multi, $responses);
foreach ($responses as $j => $response) {
$timeoutMax = $timeout ?? max($timeoutMax, $response->timeout);
$timeoutMin = min($timeoutMin, $response->timeout, 1);
$chunk = false;
if ($fromLastTimeout && null !== $multi->lastTimeout) {
$elapsedTimeout = microtime(true) - $multi->lastTimeout;
}
if (isset($multi->handlesActivity[$j])) {
$multi->lastTimeout = null;
} elseif (!isset($multi->openHandles[$j])) {
unset($responses[$j]);
continue;
} elseif ($elapsedTimeout >= $timeoutMax) {
$multi->handlesActivity[$j] = [new ErrorChunk($response->offset, sprintf('Idle timeout reached for "%s".', $response->getInfo('url')))];
$multi->lastTimeout ?? $multi->lastTimeout = $lastActivity;
} else {
continue;
}
while ($multi->handlesActivity[$j] ?? false) {
$hasActivity = true;
$elapsedTimeout = 0;
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]);
$elapsedTimeout = $timeoutMax;
} elseif ($chunk instanceof FirstChunk) {
if ($response->logger) {
$info = $response->getInfo();
$response->logger->info(sprintf('Response: "%s %s"', $info['http_code'], $info['url']));
}
$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();
}
}
if (!$responses) {
unset($runningResponses[$i]);
}
// Prevent memory leaks
$multi->handlesActivity = $multi->handlesActivity ?: [];
$multi->openHandles = $multi->openHandles ?: [];
}
if (!$runningResponses) {
break;
}
if ($hasActivity) {
$lastActivity = microtime(true);
continue;
}
if (-1 === self::select($multi, min($timeoutMin, $timeoutMax - $elapsedTimeout))) {
usleep((int) min(500, 1E6 * $timeoutMin));
}
$elapsedTimeout = microtime(true) - $lastActivity;
}
}
}

View file

@ -0,0 +1,115 @@
<?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'];
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,
];
private $statusCodes;
private $delayMs;
private $multiplier;
private $maxDelayMs;
private $jitter;
/**
* @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(array $statusCodes = self::DEFAULT_RETRY_STATUS_CODES, int $delayMs = 1000, float $multiplier = 2.0, int $maxDelayMs = 0, float $jitter = 0.1)
{
$this->statusCodes = $statusCodes;
if ($delayMs < 0) {
throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMs));
}
$this->delayMs = $delayMs;
if ($multiplier < 1) {
throw new InvalidArgumentException(sprintf('Multiplier must be greater than or equal to one: "%s" given.', $multiplier));
}
$this->multiplier = $multiplier;
if ($maxDelayMs < 0) {
throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMs));
}
$this->maxDelayMs = $maxDelayMs;
if ($jitter < 0 || $jitter > 1) {
throw new InvalidArgumentException(sprintf('Jitter must be between 0 and 1: "%s" given.', $jitter));
}
$this->jitter = $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 = $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,171 @@
<?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 Psr\Log\NullLogger;
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 $strategy;
private $maxRetries;
private $logger;
/**
* @param int $maxRetries The maximum number of times to retry
*/
public function __construct(HttpClientInterface $client, ?RetryStrategyInterface $strategy = null, int $maxRetries = 3, ?LoggerInterface $logger = null)
{
$this->client = $client;
$this->strategy = $strategy ?? new GenericRetryStrategy();
$this->maxRetries = $maxRetries;
$this->logger = $logger ?? new NullLogger();
}
public function request(string $method, string $url, array $options = []): ResponseInterface
{
if ($this->maxRetries <= 0) {
return new AsyncResponse($this->client, $method, $url, $options);
}
$retryCount = 0;
$content = '';
$firstChunk = null;
return new AsyncResponse($this->client, $method, $url, $options, function (ChunkInterface $chunk, AsyncContext $context) use ($method, $url, $options, &$retryCount, &$content, &$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.', \get_class($this->strategy)));
}
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.', \get_class($this->strategy)));
}
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, $options);
$context->pause($delay / 1000);
if ($retryCount >= $this->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;
}
}

View file

@ -0,0 +1,131 @@
<?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\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, LoggerAwareInterface
{
use HttpClientTrait;
private $client;
private $defaultOptionsByRegexp;
private $defaultRegexp;
public function __construct(HttpClientInterface $client, array $defaultOptionsByRegexp, ?string $defaultRegexp = null)
{
$this->client = $client;
$this->defaultOptionsByRegexp = $defaultOptionsByRegexp;
$this->defaultRegexp = $defaultRegexp;
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
{
if (null === $regexp) {
$regexp = preg_quote(implode('', self::resolveUrl(self::parseUrl('.'), self::parseUrl($baseUri))));
}
$defaultOptions['base_uri'] = $baseUri;
return new self($client, [$regexp => $defaultOptions], $regexp);
}
/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$e = null;
$url = self::parseUrl($url, $options['query'] ?? []);
if (\is_string($options['base_uri'] ?? null)) {
$options['base_uri'] = self::parseUrl($options['base_uri']);
}
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']);
}
$url = implode('', self::resolveUrl($url, $options['base_uri'] ?? null, $defaultOptions['query'] ?? []));
}
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);
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
return $this->client->stream($responses, $timeout);
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
}
/**
* {@inheritdoc}
*/
public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}
/**
* {@inheritdoc}
*/
public function withOptions(array $options): self
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
}

View file

@ -0,0 +1,120 @@
<?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\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, LoggerAwareInterface
{
private $client;
private $stopwatch;
private $tracedRequests;
public function __construct(HttpClientInterface $client, ?Stopwatch $stopwatch = null)
{
$this->client = $client;
$this->stopwatch = $stopwatch;
$this->tracedRequests = new \ArrayObject();
}
/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$content = null;
$traceInfo = [];
$this->tracedRequests[] = [
'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;
}
$options['on_progress'] = 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, null === $this->stopwatch ? null : $this->stopwatch->start("$method $url", 'http_client'));
}
/**
* {@inheritdoc}
*/
public function stream($responses, ?float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof TraceableResponse) {
$responses = [$responses];
} elseif (!is_iterable($responses)) {
throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an iterable of TraceableResponse objects, "%s" given.', __METHOD__, get_debug_type($responses)));
}
return new ResponseStream(TraceableResponse::stream($this->client, $responses, $timeout));
}
public function getTracedRequests(): array
{
return $this->tracedRequests->getArrayCopy();
}
public function reset()
{
if ($this->client instanceof ResetInterface) {
$this->client->reset();
}
$this->tracedRequests->exchangeArray([]);
}
/**
* {@inheritdoc}
*/
public function setLogger(LoggerInterface $logger): void
{
if ($this->client instanceof LoggerAwareInterface) {
$this->client->setLogger($logger);
}
}
/**
* {@inheritdoc}
*/
public function withOptions(array $options): self
{
$clone = clone $this;
$clone->client = $this->client->withOptions($options);
return $clone;
}
}

View file

@ -0,0 +1,55 @@
{
"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": "2.4"
},
"require": {
"php": ">=7.2.5",
"psr/log": "^1|^2|^3",
"symfony/deprecation-contracts": "^2.1|^3",
"symfony/http-client-contracts": "^2.5.3",
"symfony/polyfill-php73": "^1.11",
"symfony/polyfill-php80": "^1.16",
"symfony/service-contracts": "^1.0|^2|^3"
},
"require-dev": {
"amphp/amp": "^2.5",
"amphp/http-client": "^4.2.1",
"amphp/http-tunnel": "^1.0",
"amphp/socket": "^1.1",
"guzzlehttp/promises": "^1.4|^2.0",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",
"php-http/message-factory": "^1.0",
"psr/http-client": "^1.0",
"symfony/dependency-injection": "^4.4|^5.0|^6.0",
"symfony/http-kernel": "^4.4.13|^5.1.5|^6.0",
"symfony/process": "^4.4|^5.0|^6.0",
"symfony/stopwatch": "^4.4|^5.0|^6.0"
},
"autoload": {
"psr-4": { "Symfony\\Component\\HttpClient\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev"
}