feature #35115 [HttpClient] Add portable HTTP/2 implementation based on Amp's HTTP client (nicolas-grekas)
This PR was merged into the 5.1-dev branch.
Discussion
----------
[HttpClient] Add portable HTTP/2 implementation based on Amp's HTTP client
| Q | A
| ------------- | ---
| Branch? | master
| Bug fix? | no
| New feature? | yes
| Deprecations? | no
| Tickets | -
| License | MIT
| Doc PR | -
This PR provides an `AmpHttpClient`, which is an adapter between [`amphp/http-client`](https://github.com/amphp/http-client) and `symfony/http-client-contracts`.
~This is an early experiment for now, but it works already on the happy path:~ I have a local h2-intensive script, and while it's slower than CurlHttpClient, this performs quite well!
This could provide a portable implementation of HTTP/2 \o/
/cc @kelunik FYI
Todo:
- [x] async request/response
- [x] streaming and multiplexing
- [x] handle all ssl options
- [x] timers info
- [x] upload/download progress info
- [x] upload/download progress callback
- [x] HTTP proxy support
- [x] streamed upload
- [x] public-key pinning
- [x] peer certificate capturing
- [x] stream casting with `$response->toStream()`
- [x] ~https://github.com/amphp/http-client/pull/241~
- [x] extensive debug info
- [x] HTTP/2 PUSH support
- [x] https://github.com/amphp/http-client/issues/243
- [x] https://github.com/amphp/http-client/issues/242
- [x] https://github.com/amphp/http-client/pull/250
- [x] https://github.com/amphp/http-client/pull/239
- [x] ~https://github.com/kelunik/certificate/pull/2~
- [x] https://github.com/amphp/socket/pull/71
- [x] https://github.com/amphp/http-client/issues/252
Commits
-------
ef113feeb3
[HttpClient] Add portable HTTP/2 implementation based on Amp's HTTP client
This commit is contained in:
commit
f632b76824
@ -59,7 +59,10 @@ test_script:
|
|||||||
- SET SYMFONY_PHPUNIT_SKIPPED_TESTS=phpunit.skipped
|
- SET SYMFONY_PHPUNIT_SKIPPED_TESTS=phpunit.skipped
|
||||||
- copy /Y c:\php\php.ini-min c:\php\php.ini
|
- copy /Y c:\php\php.ini-min c:\php\php.ini
|
||||||
- IF %APPVEYOR_REPO_BRANCH% neq master (rm -Rf src\Symfony\Bridge\PhpUnit)
|
- IF %APPVEYOR_REPO_BRANCH% neq master (rm -Rf src\Symfony\Bridge\PhpUnit)
|
||||||
|
- mv src\Symfony\Component\HttpClient\phpunit.xml.dist src\Symfony\Component\HttpClient\phpunit.xml
|
||||||
- php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel!
|
- php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel!
|
||||||
|
- php phpunit src\Symfony\Component\HttpClient || SET X=!errorlevel!
|
||||||
- copy /Y c:\php\php.ini-max c:\php\php.ini
|
- copy /Y c:\php\php.ini-max c:\php\php.ini
|
||||||
- php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel!
|
- php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel!
|
||||||
|
- php phpunit src\Symfony\Component\HttpClient || SET X=!errorlevel!
|
||||||
- exit %X%
|
- exit %X%
|
||||||
|
@ -99,6 +99,8 @@
|
|||||||
"symfony/yaml": "self.version"
|
"symfony/yaml": "self.version"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"amphp/http-client": "^4.2",
|
||||||
|
"amphp/http-tunnel": "^1.0",
|
||||||
"cache/integration-tests": "dev-master",
|
"cache/integration-tests": "dev-master",
|
||||||
"doctrine/annotations": "~1.0",
|
"doctrine/annotations": "~1.0",
|
||||||
"doctrine/cache": "~1.6",
|
"doctrine/cache": "~1.6",
|
||||||
|
163
src/Symfony/Component/HttpClient/AmpHttpClient.php
Normal file
163
src/Symfony/Component/HttpClient/AmpHttpClient.php
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Symfony\Component\HttpClient;
|
||||||
|
|
||||||
|
use Amp\CancelledException;
|
||||||
|
use Amp\Http\Client\DelegateHttpClient;
|
||||||
|
use Amp\Http\Client\InterceptedHttpClient;
|
||||||
|
use Amp\Http\Client\PooledHttpClient;
|
||||||
|
use Amp\Http\Client\Request;
|
||||||
|
use Amp\Http\Tunnel\Http1TunnelConnector;
|
||||||
|
use Psr\Log\LoggerAwareInterface;
|
||||||
|
use Psr\Log\LoggerAwareTrait;
|
||||||
|
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||||
|
use Symfony\Component\HttpClient\Internal\AmpClientState;
|
||||||
|
use Symfony\Component\HttpClient\Response\AmpResponse;
|
||||||
|
use Symfony\Component\HttpClient\Response\ResponseStream;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||||
|
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
|
||||||
|
use Symfony\Contracts\Service\ResetInterface;
|
||||||
|
|
||||||
|
if (!interface_exists(DelegateHttpClient::class)) {
|
||||||
|
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the "amphp/http-client" package is not installed. Try running "composer require amphp/http-client".');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** @var AmpClientState */
|
||||||
|
private $multi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $defaultOptions Default requests' options
|
||||||
|
* @param callable $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['body'] && 'POST' === $method && !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 ('' !== $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__, \is_object($responses) ? \get_class($responses) : \gettype($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 = [];
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ CHANGELOG
|
|||||||
-----
|
-----
|
||||||
|
|
||||||
* added `NoPrivateNetworkHttpClient` decorator
|
* added `NoPrivateNetworkHttpClient` decorator
|
||||||
|
* added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp
|
||||||
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
|
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
|
||||||
|
|
||||||
4.4.0
|
4.4.0
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
namespace Symfony\Component\HttpClient;
|
namespace Symfony\Component\HttpClient;
|
||||||
|
|
||||||
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
|
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
|
||||||
|
use Symfony\Component\HttpClient\Exception\TransportException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides the common logic from writing HttpClientInterface implementations.
|
* Provides the common logic from writing HttpClientInterface implementations.
|
||||||
@ -554,6 +555,48 @@ trait HttpClientTrait
|
|||||||
return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
|
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) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $proxy) {
|
||||||
|
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 shouldBuffer(array $headers): bool
|
private static function shouldBuffer(array $headers): bool
|
||||||
{
|
{
|
||||||
if (null === $contentType = $headers['content-type'][0] ?? null) {
|
if (null === $contentType = $headers['content-type'][0] ?? null) {
|
||||||
|
141
src/Symfony/Component/HttpClient/Internal/AmpBody.php
Normal file
141
src/Symfony/Component/HttpClient/Internal/AmpBody.php
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<?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 $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.', \gettype($data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Success($data);
|
||||||
|
}
|
||||||
|
}
|
215
src/Symfony/Component/HttpClient/Internal/AmpClientState.php
Normal file
215
src/Symfony/Component/HttpClient/Internal/AmpClientState.php
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<?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())->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();
|
||||||
|
}
|
||||||
|
}
|
183
src/Symfony/Component/HttpClient/Internal/AmpListener.php
Normal file
183
src/Symfony/Component/HttpClient/Internal/AmpListener.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
52
src/Symfony/Component/HttpClient/Internal/AmpResolver.php
Normal file
52
src/Symfony/Component/HttpClient/Internal/AmpResolver.php
Normal 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)]);
|
||||||
|
}
|
||||||
|
}
|
@ -219,13 +219,10 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$proxy = self::getProxy($options['proxy'], $url);
|
$proxy = self::getProxy($options['proxy'], $url, $options['no_proxy']);
|
||||||
$noProxy = $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
|
$resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $info, $onProgress);
|
||||||
$noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
|
|
||||||
|
|
||||||
$resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $noProxy, $info, $onProgress);
|
|
||||||
$context = stream_context_create($context, ['notification' => $notification]);
|
$context = stream_context_create($context, ['notification' => $notification]);
|
||||||
self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy, $noProxy);
|
self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy);
|
||||||
|
|
||||||
return new NativeResponse($this->multi, $context, implode('', $url), $options, $info, $resolveRedirect, $onProgress, $this->logger);
|
return new NativeResponse($this->multi, $context, implode('', $url), $options, $info, $resolveRedirect, $onProgress, $this->logger);
|
||||||
}
|
}
|
||||||
@ -267,44 +264,6 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
|
|
||||||
*/
|
|
||||||
private static function getProxy(?string $proxy, array $url): ?array
|
|
||||||
{
|
|
||||||
if (null === $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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $proxy) {
|
|
||||||
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']));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'url' => $proxyUrl,
|
|
||||||
'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the IP of the host using the local DNS cache if possible.
|
* Resolves the IP of the host using the local DNS cache if possible.
|
||||||
*/
|
*/
|
||||||
@ -347,7 +306,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
|||||||
/**
|
/**
|
||||||
* Handles redirects - the native logic is too buggy to be used.
|
* Handles redirects - the native logic is too buggy to be used.
|
||||||
*/
|
*/
|
||||||
private static function createRedirectResolver(array $options, string $host, ?array $proxy, array $noProxy, array &$info, ?\Closure $onProgress): \Closure
|
private static function createRedirectResolver(array $options, string $host, ?array $proxy, array &$info, ?\Closure $onProgress): \Closure
|
||||||
{
|
{
|
||||||
$redirectHeaders = [];
|
$redirectHeaders = [];
|
||||||
if (0 < $maxRedirects = $options['max_redirects']) {
|
if (0 < $maxRedirects = $options['max_redirects']) {
|
||||||
@ -363,7 +322,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return static function (NativeClientState $multi, ?string $location, $context) use ($redirectHeaders, $proxy, $noProxy, &$info, $maxRedirects, $onProgress): ?string {
|
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']) {
|
if (null === $location || $info['http_code'] < 300 || 400 <= $info['http_code']) {
|
||||||
$info['redirect_url'] = null;
|
$info['redirect_url'] = null;
|
||||||
|
|
||||||
@ -411,14 +370,14 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
|||||||
// Authorization and Cookie headers MUST NOT follow except for the initial host name
|
// Authorization and Cookie headers MUST NOT follow except for the initial host name
|
||||||
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
|
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
|
||||||
$requestHeaders[] = 'Host: '.$host.$port;
|
$requestHeaders[] = 'Host: '.$host.$port;
|
||||||
self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, $noProxy);
|
self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy);
|
||||||
}
|
}
|
||||||
|
|
||||||
return implode('', $url);
|
return implode('', $url);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function configureHeadersAndProxy($context, string $host, array $requestHeaders, ?array $proxy, array $noProxy): bool
|
private static function configureHeadersAndProxy($context, string $host, array $requestHeaders, ?array $proxy): bool
|
||||||
{
|
{
|
||||||
if (null === $proxy) {
|
if (null === $proxy) {
|
||||||
return stream_context_set_option($context, 'http', 'header', $requestHeaders);
|
return stream_context_set_option($context, 'http', 'header', $requestHeaders);
|
||||||
@ -426,7 +385,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
|
|||||||
|
|
||||||
// Matching "no_proxy" should follow the behavior of curl
|
// Matching "no_proxy" should follow the behavior of curl
|
||||||
|
|
||||||
foreach ($noProxy as $rule) {
|
foreach ($proxy['no_proxy'] as $rule) {
|
||||||
$dotRule = '.'.ltrim($rule, '.');
|
$dotRule = '.'.ltrim($rule, '.');
|
||||||
|
|
||||||
if ('*' === $rule || $host === $rule || substr($host, -\strlen($dotRule)) === $dotRule) {
|
if ('*' === $rule || $host === $rule || substr($host, -\strlen($dotRule)) === $dotRule) {
|
||||||
|
400
src/Symfony/Component/HttpClient/Response/AmpResponse.php
Normal file
400
src/Symfony/Component/HttpClient/Response/AmpResponse.php
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
<?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\Http\Client\HttpException;
|
||||||
|
use Amp\Http\Client\Request;
|
||||||
|
use Amp\Http\Client\Response;
|
||||||
|
use Amp\Loop;
|
||||||
|
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\Contracts\HttpClient\ResponseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Nicolas Grekas <p@tchwork.com>
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class AmpResponse implements ResponseInterface
|
||||||
|
{
|
||||||
|
use ResponseTrait;
|
||||||
|
|
||||||
|
private $multi;
|
||||||
|
private $options;
|
||||||
|
private $canceller;
|
||||||
|
private $onProgress;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 = $this->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['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);
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->id = $id = Loop::defer(static function () use ($request, $multi, &$id, &$info, &$headers, $canceller, &$options, $onProgress, &$handle, $logger) {
|
||||||
|
return self::generateResponse($request, $multi, $id, $info, $headers, $canceller, $options, $onProgress, $handle, $logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
$multi->openHandles[$id] = $id;
|
||||||
|
++$multi->responseCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getInfo(string $type = null)
|
||||||
|
{
|
||||||
|
return null !== $type ? $this->info[$type] ?? null : $this->info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->doDestruct();
|
||||||
|
} finally {
|
||||||
|
$this->close();
|
||||||
|
|
||||||
|
// Clear the DNS cache when all requests completed
|
||||||
|
if (0 >= --$this->multi->responseCount) {
|
||||||
|
$this->multi->responseCount = 0;
|
||||||
|
$this->multi->dnsCache = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
private function close(): void
|
||||||
|
{
|
||||||
|
$this->canceller->cancel();
|
||||||
|
unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@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}
|
||||||
|
*/
|
||||||
|
private static function perform(AmpClientState $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}
|
||||||
|
*/
|
||||||
|
private static function select(AmpClientState $multi, float $timeout): int
|
||||||
|
{
|
||||||
|
$selected = 1;
|
||||||
|
$delay = Loop::delay(1000 * $timeout, static function () use (&$selected) {
|
||||||
|
$selected = 0;
|
||||||
|
Loop::stop();
|
||||||
|
});
|
||||||
|
Loop::run();
|
||||||
|
|
||||||
|
if ($selected) {
|
||||||
|
Loop::cancel($delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function generateResponse(Request $request, AmpClientState $multi, string $id, array &$info, array &$headers, CancellationTokenSource $canceller, array &$options, \Closure $onProgress, &$handle, ?LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
$activity = &$multi->handlesActivity;
|
||||||
|
|
||||||
|
$request->setInformationalResponseHandler(static function (Response $response) use (&$activity, $id, &$info, &$headers) {
|
||||||
|
self::addResponseHeaders($response, $info, $headers);
|
||||||
|
$activity[$id][] = new InformationalChunk($response->getStatus(), $response->getHeaders());
|
||||||
|
Loop::defer([Loop::class, 'stop']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = null;
|
||||||
|
|
||||||
|
$activity[$id] = [new FirstChunk()];
|
||||||
|
|
||||||
|
if ('HEAD' === $response->getRequest()->getMethod() || \in_array($info['http_code'], [204, 304], true)) {
|
||||||
|
$activity[$id][] = null;
|
||||||
|
$activity[$id][] = null;
|
||||||
|
Loop::defer([Loop::class, 'stop']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->hasHeader('content-length')) {
|
||||||
|
$info['download_content_length'] = (float) $response->getHeader('content-length');
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = $response->getBody();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
Loop::defer([Loop::class, 'stop']);
|
||||||
|
|
||||||
|
if (null === $data = yield $body->read()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$info['size_download'] += \strlen($data);
|
||||||
|
$activity[$id][] = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activity[$id][] = null;
|
||||||
|
$activity[$id][] = null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$activity[$id][] = null;
|
||||||
|
$activity[$id][] = $e;
|
||||||
|
} finally {
|
||||||
|
$info['download_content_length'] = $info['size_download'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Loop::defer([Loop::class, 'stop']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function followRedirects(Request $originRequest, AmpClientState $multi, array &$info, array &$headers, CancellationTokenSource $canceller, array $options, \Closure $onProgress, &$handle, ?LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
$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->setHeader($name, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->getUri()->getAuthority() !== $originRequest->getUri()->getAuthority()) {
|
||||||
|
$request->removeHeader('authorization');
|
||||||
|
$request->removeHeader('cookie');
|
||||||
|
$request->removeHeader('host');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
src/Symfony/Component/HttpClient/Tests/AmpHttpClientTest.php
Normal file
28
src/Symfony/Component/HttpClient/Tests/AmpHttpClientTest.php
Normal 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\Tests;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpClient\AmpHttpClient;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class AmpHttpClientTest extends HttpClientTestCase
|
||||||
|
{
|
||||||
|
protected function getHttpClient(string $testCase): HttpClientInterface
|
||||||
|
{
|
||||||
|
return new AmpHttpClient(['verify_peer' => false, 'verify_host' => false, 'timeout' => 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testProxy()
|
||||||
|
{
|
||||||
|
$this->markTestSkipped('A real proxy server would be needed.');
|
||||||
|
}
|
||||||
|
}
|
@ -11,109 +11,17 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\HttpClient\Tests;
|
namespace Symfony\Component\HttpClient\Tests;
|
||||||
|
|
||||||
use Psr\Log\AbstractLogger;
|
|
||||||
use Symfony\Component\HttpClient\CurlHttpClient;
|
use Symfony\Component\HttpClient\CurlHttpClient;
|
||||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
/*
|
|
||||||
Tests for HTTP2 Push need a recent version of both PHP and curl. This docker command should run them:
|
|
||||||
docker run -it --rm -v $(pwd):/app -v /path/to/vulcain:/usr/local/bin/vulcain -w /app php:7.3-alpine ./phpunit src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php --filter testHttp2Push
|
|
||||||
The vulcain binary can be found at https://github.com/symfony/binary-utils/releases/download/v0.1/vulcain_0.1.3_Linux_x86_64.tar.gz - see https://github.com/dunglas/vulcain for source
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @requires extension curl
|
* @requires extension curl
|
||||||
*/
|
*/
|
||||||
class CurlHttpClientTest extends HttpClientTestCase
|
class CurlHttpClientTest extends HttpClientTestCase
|
||||||
{
|
{
|
||||||
private static $vulcainStarted = false;
|
|
||||||
|
|
||||||
protected function getHttpClient(string $testCase): HttpClientInterface
|
protected function getHttpClient(string $testCase): HttpClientInterface
|
||||||
{
|
{
|
||||||
return new CurlHttpClient();
|
if (false !== strpos($testCase, 'Push')) {
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @requires PHP 7.2.17
|
|
||||||
*/
|
|
||||||
public function testHttp2PushVulcain()
|
|
||||||
{
|
|
||||||
$client = $this->getVulcainClient();
|
|
||||||
$logger = new TestLogger();
|
|
||||||
$client->setLogger($logger);
|
|
||||||
|
|
||||||
$responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
|
|
||||||
'headers' => [
|
|
||||||
'Preload' => '/documents/*/id',
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
foreach ($responseAsArray['documents'] as $document) {
|
|
||||||
$client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
$client->reset();
|
|
||||||
|
|
||||||
$expected = [
|
|
||||||
'Request: "GET https://127.0.0.1:3000/json"',
|
|
||||||
'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
|
|
||||||
'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
|
|
||||||
'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json"',
|
|
||||||
'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json/1"',
|
|
||||||
'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json/2"',
|
|
||||||
'Accepting pushed response: "GET https://127.0.0.1:3000/json/3"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json/3"',
|
|
||||||
];
|
|
||||||
$this->assertSame($expected, $logger->logs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @requires PHP 7.2.17
|
|
||||||
*/
|
|
||||||
public function testHttp2PushVulcainWithUnusedResponse()
|
|
||||||
{
|
|
||||||
$client = $this->getVulcainClient();
|
|
||||||
$logger = new TestLogger();
|
|
||||||
$client->setLogger($logger);
|
|
||||||
|
|
||||||
$responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
|
|
||||||
'headers' => [
|
|
||||||
'Preload' => '/documents/*/id',
|
|
||||||
],
|
|
||||||
])->toArray();
|
|
||||||
|
|
||||||
$i = 0;
|
|
||||||
foreach ($responseAsArray['documents'] as $document) {
|
|
||||||
$client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
|
|
||||||
if (++$i >= 2) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$client->reset();
|
|
||||||
|
|
||||||
$expected = [
|
|
||||||
'Request: "GET https://127.0.0.1:3000/json"',
|
|
||||||
'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
|
|
||||||
'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
|
|
||||||
'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json"',
|
|
||||||
'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json/1"',
|
|
||||||
'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
|
|
||||||
'Response: "200 https://127.0.0.1:3000/json/2"',
|
|
||||||
'Unused pushed response: "https://127.0.0.1:3000/json/3"',
|
|
||||||
];
|
|
||||||
$this->assertSame($expected, $logger->logs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getVulcainClient(): CurlHttpClient
|
|
||||||
{
|
|
||||||
if (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304) {
|
if (\PHP_VERSION_ID >= 70300 && \PHP_VERSION_ID < 70304) {
|
||||||
$this->markTestSkipped('PHP 7.3.0 to 7.3.3 don\'t support HTTP/2 PUSH');
|
$this->markTestSkipped('PHP 7.3.0 to 7.3.3 don\'t support HTTP/2 PUSH');
|
||||||
}
|
}
|
||||||
@ -121,45 +29,8 @@ class CurlHttpClientTest extends HttpClientTestCase
|
|||||||
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > ($v = curl_version())['version_number'] || !(CURL_VERSION_HTTP2 & $v['features'])) {
|
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > ($v = curl_version())['version_number'] || !(CURL_VERSION_HTTP2 & $v['features'])) {
|
||||||
$this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH');
|
$this->markTestSkipped('curl <7.61 is used or it is not compiled with support for HTTP/2 PUSH');
|
||||||
}
|
}
|
||||||
|
|
||||||
$client = new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
|
|
||||||
|
|
||||||
if (static::$vulcainStarted) {
|
|
||||||
return $client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['application/json'] !== $client->request('GET', 'http://127.0.0.1:8057/json')->getHeaders()['content-type']) {
|
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
|
||||||
$this->markTestSkipped('symfony/http-client-contracts >= 2.0.1 required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = new Process(['vulcain'], null, [
|
|
||||||
'DEBUG' => 1,
|
|
||||||
'UPSTREAM' => 'http://127.0.0.1:8057',
|
|
||||||
'ADDR' => ':3000',
|
|
||||||
'KEY_FILE' => __DIR__.'/Fixtures/tls/server.key',
|
|
||||||
'CERT_FILE' => __DIR__.'/Fixtures/tls/server.crt',
|
|
||||||
]);
|
|
||||||
$process->start();
|
|
||||||
|
|
||||||
register_shutdown_function([$process, 'stop']);
|
|
||||||
sleep('\\' === \DIRECTORY_SEPARATOR ? 10 : 1);
|
|
||||||
|
|
||||||
if (!$process->isRunning()) {
|
|
||||||
throw new ProcessFailedException($process);
|
|
||||||
}
|
|
||||||
|
|
||||||
static::$vulcainStarted = true;
|
|
||||||
|
|
||||||
return $client;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TestLogger extends AbstractLogger
|
|
||||||
{
|
|
||||||
public $logs = [];
|
|
||||||
|
|
||||||
public function log($level, $message, array $context = []): void
|
|
||||||
{
|
|
||||||
$this->logs[] = $message;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,7 +20,7 @@ class HttpClientTest extends TestCase
|
|||||||
{
|
{
|
||||||
public function testCreateClient()
|
public function testCreateClient()
|
||||||
{
|
{
|
||||||
if (\extension_loaded('curl')) {
|
if (\extension_loaded('curl') && ('\\' !== \DIRECTORY_SEPARATOR || ini_get('curl.cainfo') || ini_get('openssl.cafile') || ini_get('openssl.capath'))) {
|
||||||
$this->assertInstanceOf(CurlHttpClient::class, HttpClient::create());
|
$this->assertInstanceOf(CurlHttpClient::class, HttpClient::create());
|
||||||
} else {
|
} else {
|
||||||
$this->assertInstanceOf(NativeHttpClient::class, HttpClient::create());
|
$this->assertInstanceOf(NativeHttpClient::class, HttpClient::create());
|
||||||
|
@ -13,10 +13,21 @@ namespace Symfony\Component\HttpClient\Tests;
|
|||||||
|
|
||||||
use Symfony\Component\HttpClient\Exception\ClientException;
|
use Symfony\Component\HttpClient\Exception\ClientException;
|
||||||
use Symfony\Component\HttpClient\Response\StreamWrapper;
|
use Symfony\Component\HttpClient\Response\StreamWrapper;
|
||||||
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase;
|
use Symfony\Contracts\HttpClient\Test\HttpClientTestCase as BaseHttpClientTestCase;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Tests for HTTP2 Push need a recent version of both PHP and curl. This docker command should run them:
|
||||||
|
docker run -it --rm -v $(pwd):/app -v /path/to/vulcain:/usr/local/bin/vulcain -w /app php:7.3-alpine ./phpunit src/Symfony/Component/HttpClient --filter Push
|
||||||
|
The vulcain binary can be found at https://github.com/symfony/binary-utils/releases/download/v0.1/vulcain_0.1.3_Linux_x86_64.tar.gz - see https://github.com/dunglas/vulcain for source
|
||||||
|
*/
|
||||||
|
|
||||||
abstract class HttpClientTestCase extends BaseHttpClientTestCase
|
abstract class HttpClientTestCase extends BaseHttpClientTestCase
|
||||||
{
|
{
|
||||||
|
private static $vulcainStarted = false;
|
||||||
|
|
||||||
public function testAcceptHeader()
|
public function testAcceptHeader()
|
||||||
{
|
{
|
||||||
$client = $this->getHttpClient(__FUNCTION__);
|
$client = $this->getHttpClient(__FUNCTION__);
|
||||||
@ -128,4 +139,110 @@ abstract class HttpClientTestCase extends BaseHttpClientTestCase
|
|||||||
rewind($stream);
|
rewind($stream);
|
||||||
$this->assertSame('Here the body', stream_get_contents($stream));
|
$this->assertSame('Here the body', stream_get_contents($stream));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testHttp2PushVulcain()
|
||||||
|
{
|
||||||
|
$client = $this->getHttpClient(__FUNCTION__);
|
||||||
|
self::startVulcain($client);
|
||||||
|
$logger = new TestLogger();
|
||||||
|
$client->setLogger($logger);
|
||||||
|
|
||||||
|
$responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
|
||||||
|
'headers' => [
|
||||||
|
'Preload' => '/documents/*/id',
|
||||||
|
],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
foreach ($responseAsArray['documents'] as $document) {
|
||||||
|
$client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->reset();
|
||||||
|
|
||||||
|
$expected = [
|
||||||
|
'Request: "GET https://127.0.0.1:3000/json"',
|
||||||
|
'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
|
||||||
|
'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
|
||||||
|
'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json"',
|
||||||
|
'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json/1"',
|
||||||
|
'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json/2"',
|
||||||
|
'Accepting pushed response: "GET https://127.0.0.1:3000/json/3"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json/3"',
|
||||||
|
];
|
||||||
|
$this->assertSame($expected, $logger->logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHttp2PushVulcainWithUnusedResponse()
|
||||||
|
{
|
||||||
|
$client = $this->getHttpClient(__FUNCTION__);
|
||||||
|
self::startVulcain($client);
|
||||||
|
$logger = new TestLogger();
|
||||||
|
$client->setLogger($logger);
|
||||||
|
|
||||||
|
$responseAsArray = $client->request('GET', 'https://127.0.0.1:3000/json', [
|
||||||
|
'headers' => [
|
||||||
|
'Preload' => '/documents/*/id',
|
||||||
|
],
|
||||||
|
])->toArray();
|
||||||
|
|
||||||
|
$i = 0;
|
||||||
|
foreach ($responseAsArray['documents'] as $document) {
|
||||||
|
$client->request('GET', 'https://127.0.0.1:3000'.$document['id'])->toArray();
|
||||||
|
if (++$i >= 2) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$client->reset();
|
||||||
|
|
||||||
|
$expected = [
|
||||||
|
'Request: "GET https://127.0.0.1:3000/json"',
|
||||||
|
'Queueing pushed response: "https://127.0.0.1:3000/json/1"',
|
||||||
|
'Queueing pushed response: "https://127.0.0.1:3000/json/2"',
|
||||||
|
'Queueing pushed response: "https://127.0.0.1:3000/json/3"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json"',
|
||||||
|
'Accepting pushed response: "GET https://127.0.0.1:3000/json/1"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json/1"',
|
||||||
|
'Accepting pushed response: "GET https://127.0.0.1:3000/json/2"',
|
||||||
|
'Response: "200 https://127.0.0.1:3000/json/2"',
|
||||||
|
'Unused pushed response: "https://127.0.0.1:3000/json/3"',
|
||||||
|
];
|
||||||
|
$this->assertSame($expected, $logger->logs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function startVulcain(HttpClientInterface $client)
|
||||||
|
{
|
||||||
|
if (self::$vulcainStarted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('\\' === \DIRECTORY_SEPARATOR) {
|
||||||
|
self::markTestSkipped('Testing with the "vulcain" is not supported on Windows.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['application/json'] !== $client->request('GET', 'http://127.0.0.1:8057/json')->getHeaders()['content-type']) {
|
||||||
|
self::markTestSkipped('symfony/http-client-contracts >= 2.0.1 required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process(['vulcain'], null, [
|
||||||
|
'DEBUG' => 1,
|
||||||
|
'UPSTREAM' => 'http://127.0.0.1:8057',
|
||||||
|
'ADDR' => ':3000',
|
||||||
|
'KEY_FILE' => __DIR__.'/Fixtures/tls/server.key',
|
||||||
|
'CERT_FILE' => __DIR__.'/Fixtures/tls/server.crt',
|
||||||
|
]);
|
||||||
|
$process->start();
|
||||||
|
|
||||||
|
register_shutdown_function([$process, 'stop']);
|
||||||
|
sleep('\\' === \DIRECTORY_SEPARATOR ? 10 : 1);
|
||||||
|
|
||||||
|
if (!$process->isRunning()) {
|
||||||
|
throw new ProcessFailedException($process);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$vulcainStarted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -312,4 +312,14 @@ class MockHttpClientTest extends HttpClientTestCase
|
|||||||
|
|
||||||
return new MockHttpClient($responses);
|
return new MockHttpClient($responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testHttp2PushVulcain()
|
||||||
|
{
|
||||||
|
$this->markTestSkipped('MockHttpClient doesn\'t support HTTP/2 PUSH.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHttp2PushVulcainWithUnusedResponse()
|
||||||
|
{
|
||||||
|
$this->markTestSkipped('MockHttpClient doesn\'t support HTTP/2 PUSH.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,4 +25,14 @@ class NativeHttpClientTest extends HttpClientTestCase
|
|||||||
{
|
{
|
||||||
$this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.');
|
$this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testHttp2PushVulcain()
|
||||||
|
{
|
||||||
|
$this->markTestSkipped('NativeHttpClient doesn\'t support HTTP/2.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHttp2PushVulcainWithUnusedResponse()
|
||||||
|
{
|
||||||
|
$this->markTestSkipped('NativeHttpClient doesn\'t support HTTP/2.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
24
src/Symfony/Component/HttpClient/Tests/TestLogger.php
Normal file
24
src/Symfony/Component/HttpClient/Tests/TestLogger.php
Normal 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\Tests;
|
||||||
|
|
||||||
|
use Psr\Log\AbstractLogger;
|
||||||
|
|
||||||
|
class TestLogger extends AbstractLogger
|
||||||
|
{
|
||||||
|
public $logs = [];
|
||||||
|
|
||||||
|
public function log($level, $message, array $context = []): void
|
||||||
|
{
|
||||||
|
$this->logs[] = $message;
|
||||||
|
}
|
||||||
|
}
|
@ -28,6 +28,9 @@
|
|||||||
"symfony/service-contracts": "^1.0|^2"
|
"symfony/service-contracts": "^1.0|^2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
"amphp/http-client": "^4.2",
|
||||||
|
"amphp/http-tunnel": "^1.0",
|
||||||
|
"amphp/socket": "^1.1",
|
||||||
"guzzlehttp/promises": "^1.3.1",
|
"guzzlehttp/promises": "^1.3.1",
|
||||||
"nyholm/psr7": "^1.0",
|
"nyholm/psr7": "^1.0",
|
||||||
"php-http/httplug": "^1.0|^2.0",
|
"php-http/httplug": "^1.0|^2.0",
|
||||||
|
Reference in New Issue
Block a user