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:
Fabien Potencier 2020-03-02 15:21:41 +01:00
commit f632b76824
19 changed files with 1413 additions and 188 deletions

View File

@ -59,7 +59,10 @@ test_script:
- SET SYMFONY_PHPUNIT_SKIPPED_TESTS=phpunit.skipped
- copy /Y c:\php\php.ini-min c:\php\php.ini
- 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\Component\HttpClient || SET X=!errorlevel!
- 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\Component\HttpClient || SET X=!errorlevel!
- exit %X%

View File

@ -99,6 +99,8 @@
"symfony/yaml": "self.version"
},
"require-dev": {
"amphp/http-client": "^4.2",
"amphp/http-tunnel": "^1.0",
"cache/integration-tests": "dev-master",
"doctrine/annotations": "~1.0",
"doctrine/cache": "~1.6",

View 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 = [];
}
}

View File

@ -4,8 +4,9 @@ CHANGELOG
5.1.0
-----
* added `NoPrivateNetworkHttpClient` decorator
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
* added `NoPrivateNetworkHttpClient` decorator
* added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
4.4.0
-----

View File

@ -12,6 +12,7 @@
namespace Symfony\Component\HttpClient;
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
use Symfony\Component\HttpClient\Exception\TransportException;
/**
* Provides the common logic from writing HttpClientInterface implementations.
@ -554,6 +555,48 @@ trait HttpClientTrait
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
{
if (null === $contentType = $headers['content-type'][0] ?? null) {

View 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);
}
}

View 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();
}
}

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

@ -219,13 +219,10 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
],
];
$proxy = self::getProxy($options['proxy'], $url);
$noProxy = $options['no_proxy'] ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
$noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
$resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $noProxy, $info, $onProgress);
$proxy = self::getProxy($options['proxy'], $url, $options['no_proxy']);
$resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $info, $onProgress);
$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);
}
@ -267,44 +264,6 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
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.
*/
@ -347,7 +306,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac
/**
* 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 = [];
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']) {
$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
$requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth'];
$requestHeaders[] = 'Host: '.$host.$port;
self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, $noProxy);
self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy);
}
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) {
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
foreach ($noProxy as $rule) {
foreach ($proxy['no_proxy'] as $rule) {
$dotRule = '.'.ltrim($rule, '.');
if ('*' === $rule || $host === $rule || substr($host, -\strlen($dotRule)) === $dotRule) {

View 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;
}
}
}

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\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.');
}
}

View File

@ -11,155 +11,26 @@
namespace Symfony\Component\HttpClient\Tests;
use Psr\Log\AbstractLogger;
use Symfony\Component\HttpClient\CurlHttpClient;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
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
*/
class CurlHttpClientTest extends HttpClientTestCase
{
private static $vulcainStarted = false;
protected function getHttpClient(string $testCase): HttpClientInterface
{
return new CurlHttpClient();
}
if (false !== strpos($testCase, 'Push')) {
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');
}
/**
* @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;
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');
}
}
$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) {
$this->markTestSkipped('PHP 7.3.0 to 7.3.3 don\'t support HTTP/2 PUSH');
}
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');
}
$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']) {
$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;
return new CurlHttpClient(['verify_peer' => false, 'verify_host' => false]);
}
}

View File

@ -20,7 +20,7 @@ class HttpClientTest extends TestCase
{
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());
} else {
$this->assertInstanceOf(NativeHttpClient::class, HttpClient::create());

View File

@ -13,10 +13,21 @@ namespace Symfony\Component\HttpClient\Tests;
use Symfony\Component\HttpClient\Exception\ClientException;
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;
/*
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
{
private static $vulcainStarted = false;
public function testAcceptHeader()
{
$client = $this->getHttpClient(__FUNCTION__);
@ -128,4 +139,110 @@ abstract class HttpClientTestCase extends BaseHttpClientTestCase
rewind($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;
}
}

View File

@ -312,4 +312,14 @@ class MockHttpClientTest extends HttpClientTestCase
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.');
}
}

View File

@ -25,4 +25,14 @@ class NativeHttpClientTest extends HttpClientTestCase
{
$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.');
}
}

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\Tests;
use Psr\Log\AbstractLogger;
class TestLogger extends AbstractLogger
{
public $logs = [];
public function log($level, $message, array $context = []): void
{
$this->logs[] = $message;
}
}

View File

@ -28,6 +28,9 @@
"symfony/service-contracts": "^1.0|^2"
},
"require-dev": {
"amphp/http-client": "^4.2",
"amphp/http-tunnel": "^1.0",
"amphp/socket": "^1.1",
"guzzlehttp/promises": "^1.3.1",
"nyholm/psr7": "^1.0",
"php-http/httplug": "^1.0|^2.0",