8000 feature #35115 [HttpClient] Add portable HTTP/2 implementation based … · symfony/symfony@f632b76 · GitHub
[go: up one dir, main page]

Skip to content

Commit f632b76

Browse files
committed
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] ~amphp/http-client#241 - [x] extensive debug info - [x] HTTP/2 PUSH support - [x] amphp/http-client#243 - [x] amphp/http-client#242 - [x] amphp/http-client#250 - [x] amphp/http-client#239 - [x] ~kelunik/certificate#2 - [x] amphp/socket#71 - [x] amphp/http-client#252 Commits ------- ef113fe [HttpClient] Add portable HTTP/2 implementation based on Amp's HTTP client
2 parents 2265a57 + ef113fe commit f632b76

19 files changed

+1413
-188
lines changed

.appveyor.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ test_script:
5959
- SET SYMFONY_PHPUNIT_SKIPPED_TESTS=phpunit.skipped
6060
- copy /Y c:\php\php.ini-min c:\php\php.ini
6161
- IF %APPVEYOR_REPO_BRANCH% neq master (rm -Rf src\Symfony\Bridge\PhpUnit)
62+
- mv src\Symfony\Component\HttpClient\phpunit.xml.dist src\Symfony\Component\HttpClient\phpunit.xml
6263
- php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel!
64+
- php phpunit src\Symfony\Component\HttpClient || SET X=!errorlevel!
6365
- copy /Y c:\php\php.ini-max c:\php\php.ini
6466
- php phpunit src\Symfony --exclude-group tty,benchmark,intl-data || SET X=!errorlevel!
67+
- php phpunit src\Symfony\Component\HttpClient || SET X=!errorlevel!
6568
- exit %X%

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@
9999
"symfony/yaml": "self.version"
100100
},
101101
"require-dev": {
102+
"amphp/http-client": "^4.2",
103+
"amphp/http-tunnel": "^1.0",
102104
"cache/integration-tests": "dev-master",
103105
"doctrine/annotations": "~1.0",
104106
"doctrine/cache": "~1.6",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient;
13+
14+
use Amp\CancelledException;
15+
use Amp\Http\Client\DelegateHttpClient;
16+
use Amp\Http\Client\InterceptedHttpClient;
17+
use Amp\Http\Client\PooledHttpClient;
18+
use Amp\Http\Client\Request;
19+
use Amp\Http\Tunnel\Http1TunnelConnector;
20+
use Psr\Log\LoggerAwareInterface;
21+
use Psr\Log\LoggerAwareTrait;
22+
use Symfony\Component\HttpClient\Exception\TransportException;
23+
use Symfony\Component\HttpClient\Internal\AmpClientState;
24+
use Symfony\Component\HttpClient\Response\AmpResponse;
25+
use Symfony\Component\HttpClient\Response\ResponseStream;
26+
use Symfony\Contracts\HttpClient\HttpClientInterface;
27+
use Symfony\Contracts\HttpClient\ResponseInterface;
28+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
29+
use Symfony\Contracts\Service\ResetInterface;
30+
31+
if (!interface_exists(DelegateHttpClient::class)) {
32+
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".');
33+
}
34+
35+
/**
36+
* A portable implementation of the HttpClientInterface contracts based on Amp's HTTP client.
37+
*
38+
* @author Nicolas Grekas <p@tchwork.com>
39+
*/
40+
final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
41+
{
42+
use HttpClientTrait;
43+
use LoggerAwareTrait;
44+
45+
private $defaultOptions = self::OPTIONS_DEFAULTS;
46+
47+
/** @var AmpClientState */
48+
private $multi;
49+
50+
/**
51+
* @param array $defaultOptions Default requests' options
52+
* @param callable $clientConfigurator A callable that builds a {@see DelegateHttpClient} from a {@see PooledHttpClient};
53+
* passing null builds an {@see InterceptedHttpClient} with 2 retries on failures
54+
* @param int $maxHostConnections The maximum number of connections to a single host
55+
* @param int $maxPendingPushes The maximum number of pushed responses to accept in the queue
56+
*
57+
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
58+
*/
59+
public function __construct(array $defaultOptions = [], callable $clientConfigurator = null, int $maxHostConnections = 6, int $maxPendingPushes = 50)
60+
{
61+
$this->defaultOptions['buffer'] = $this->defaultOptions['buffer'] ?? \Closure::fromCallable([__CLASS__, 'shouldBuffer']);
62+
63+
if ($defaultOptions) {
64+
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
65+
}
66+
67+
$this->multi = new AmpClientState($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger);
68+
}
69+
70+
/**
71+
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
72+
*
73+
* {@inheritdoc}
74+
*/
75+
public function request(string $method, string $url, array $options = []): ResponseInterface
76+
{
77+
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
78+
79+
$options['proxy'] = self::getProxy($options['proxy'], $url, $options['no_proxy']);
80+
81+
if (null !== $options['proxy'] && !class_exists(Http1TunnelConnector::class)) {
82+
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".');
83+
}
84+
85+
if ('' !== $options['body'] && 'POST' === $method && !isset($options['normalized_headers']['content-type'])) {
86+
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
87+
}
88+
89+
if (!isset($options['normalized_headers']['user-agent'])) {
90+
$options['headers'][] = 'User-Agent: Symfony HttpClient/Amp';
91+
}
92+
93+
if (0 < $options['max_duration']) {
94+
$options['timeout'] = min($options['max_duration'], $options['timeout']);
95+
}
96+
97+
if ($options['resolve']) {
98+
$this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache;
99+
}
100+
101+
if ($options['peer_fingerprint'] && !isset($options['peer_fingerprint']['pin-sha256'])) {
102+
throw new TransportException(__CLASS__.' supports only "pin-sha256" fingerprints.');
103+
}
104+
105+
$request = new Request(implode('', $url), $method);
106+
107+
if ($options['http_version']) {
108+
switch ((float) $options['http_version']) {
109+
case 1.0: $request->setProtocolVersions(['1.0']); break;
110+
case 1.1: $request->setProtocolVersions(['1.1', '1.0']); break;
111+
default: $request->setProtocolVersions(['2', '1.1', '1.0']); break;
112+
}
113+
}
114+
115+
foreach ($options['headers'] as $v) {
116+
$h = explode(': ', $v, 2);
117+
$request->addHeader($h[0], $h[1]);
118+
}
119+
120+
$request->setTcpConnectTimeout(1000 * $options['timeout']);
121+
$request->setTlsHandshakeTimeout(1000 * $options['timeout']);
122+
$request->setTransferTimeout(1000 * $options['max_duration']);
123+
124+
if ('' !== $request->getUri()->getUserInfo() && !$request->hasHeader('authorization')) {
125+
$auth = explode(':', $request->getUri()->getUserInfo(), 2);
126+
$auth = array_map('rawurldecode', $auth) + [1 => ''];
127+
$request->setHeader('Authorization', 'Basic '.base64_encode(implode(':', $auth)));
128+
}
129+
130+
return new AmpResponse($this->multi, $request, $options, $this->logger);
131+
}
132+
133+
/**
134+
* {@inheritdoc}
135+
*/
136+
public function stream($responses, float $timeout = null): ResponseStreamInterface
137+
{
138+
if ($responses instanceof AmpResponse) {
139+
$responses = [$responses];
140+
} elseif (!is_iterable($responses)) {
141+
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)));
142+
}
143+
144+
return new ResponseStream(AmpResponse::stream($responses, $timeout));
145+
}
146+
147+
public function reset()
148+
{
149+
$this->multi->dnsCache = [];
150+
151+
foreach ($this->multi->pushedResponses as $authority => $pushedResponses) {
152+
foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) {
153+
$pushDeferred->fail(new CancelledException());
154+
155+
if ($this->logger) {
156+
$this->logger->debug(sprintf('Unused pushed response: "%s"', $pushedUrl));
157+
}
158+
}
159+
}
160+
161+
$this->multi->pushedResponses = [];
162+
}
163+
}

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ CHANGELOG
44
5.1.0
55
-----
66

7-
* added `NoPrivateNetworkHttpClient` decorator
8-
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
7+
* added `NoPrivateNetworkHttpClient` decorator
8+
* added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp
9+
* added `LoggerAwareInterface` to `ScopingHttpClient` and `TraceableHttpClient`
910

1011
4.4.0
1112
-----

src/Symfony/Component/HttpClient/HttpClientTrait.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\HttpClient;
1313

1414
use Symfony\Component\HttpClient\Exception\InvalidArgumentException;
15+
use Symfony\Component\HttpClient\Exception\TransportException;
1516

1617
/**
1718
* Provides the common logic from writing HttpClientInterface implementations.
@@ -554,6 +555,48 @@ private static function mergeQueryString(?string $queryString, array $queryArray
554555
return implode('&', $replace ? array_replace($query, $queryArray) : ($query + $queryArray));
555556
}
556557

558+
/**
559+
* Loads proxy configuration from the same environment variables as curl when no proxy is explicitly set.
560+
*/
561+
private static function getProxy(?string $proxy, array $url, ?string $noProxy): ?array
562+
{
563+
if (null === $proxy) {
564+
// Ignore HTTP_PROXY except on the CLI to work around httpoxy set of vulnerabilities
565+
$proxy = $_SERVER['http_proxy'] ?? (\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) ? $_SERVER['HTTP_PROXY'] ?? null : null) ?? $_SERVER['all_proxy'] ?? $_SERVER['ALL_PROXY'] ?? null;
566+
567+
if ('https:' === $url['scheme']) {
568+
$proxy = $_SERVER['https_proxy'] ?? $_SERVER['HTTPS_PROXY'] ?? $proxy;
569+
}
570+
}
571+
572+
if (null === $proxy) {
573+
return null;
574+
}
575+
576+
$proxy = (parse_url($proxy) ?: []) + ['scheme' => 'http'];
577+
578+
if (!isset($proxy['host'])) {
579+
throw new TransportException('Invalid HTTP proxy: host is missing.');
580+
}
581+
582+
if ('http' === $proxy['scheme']) {
583+
$proxyUrl = 'tcp://'.$proxy['host'].':'.($proxy['port'] ?? '80');
584+
} elseif ('https' === $proxy['scheme']) {
585+
$proxyUrl = 'ssl://'.$proxy['host'].':'.($proxy['port'] ?? '443');
586+
} else {
587+
throw new TransportException(sprintf('Unsupported proxy scheme "%s": "http" or "https" expected.', $proxy['scheme']));
588+
}
589+
590+
$noProxy = $noProxy ?? $_SERVER['no_proxy'] ?? $_SERVER['NO_PROXY'] ?? '';
591+
$noProxy = $noProxy ? preg_split('/[\s,]+/', $noProxy) : [];
592+
593+
return [
594+
'url' => $proxyUrl,
595+
'auth' => isset($proxy['user']) ? 'Basic '.base64_encode(rawurldecode($proxy['user']).':'.rawurldecode($proxy['pass'] ?? '')) : null,
596+
'no_proxy' => $noProxy,
597+
];
598+
}
599+
557600
private static function shouldBuffer(array $headers): bool
558601
{
559602
if (null === $contentType = $headers['content-type'][0] ?? null) {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpClient\Internal;
13+
14+
use Amp\ByteStream\InputStream;
15+
use Amp\ByteStream\ResourceInputStream;
16+
use Amp\Http\Client\RequestBody;
17+
use Amp\Promise;
18+
use Amp\Success;
19+
use Symfony\Component\HttpClient\Exception\TransportException;
20+
21+
/**
22+
* @author Nicolas Grekas <p@tchwork.com>
23+
*
24+
* @internal
25+
*/
26+
class AmpBody implements RequestBody, InputStream
27+
{
28+
private $body;
29+
private $onProgress;
30+
private $offset = 0;
31+
private $length = -1;
32+
private $uploaded;
33+
34+
public function __construct($body, &$info, \Closure $onProgress)
35+
{
36+
$this->body = $body;
37+
$this->info = &$info;
38+
$this->onProgress = $onProgress;
39+
40+
if (\is_resource($body)) {
41+
$this->offset = ftell($body);
42+
$this->length = fstat($body)['size'];
43+
$this->body = new ResourceInputStream($body);
44+
} elseif (\is_string($body)) {
45+
$this->length = \strlen($body);
46+
}
47+
}
48+
49+
public function createBodyStream(): InputStream
50+
{
51+
if (null !== $this->uploaded) {
52+
$this->uploaded = null;
53+
54+
if (\is_string($this->body)) {
55+
$this->offset = 0;
56+
} elseif ($this->body instanceof ResourceInputStream) {
57+
fseek($this->body->getResource(), $this->offset);
58+
}
59+
}
60+
61+
return $this;
62+
}
63+
64+
public function getHeaders(): Promise
65+
{
66+
return new Success([]);
67+
}
68+
69+
public function getBodyLength(): Promise
70+
{
71+
return new Success($this->length - $this->offset);
72+
}
73+
74+
public function read(): Promise
75+
{
76+
$this->info['size_upload'] += $this->uploaded;
77+
$this->uploaded = 0;
78+
($this->onProgress)();
79+
80+
$chunk = $this->doRead();
81+
$chunk->onResolve(function ($e, $data) {
82+
if (null !== $data) {
83+
$this->uploaded = \strlen($data);
84+
} else {
85+
$this->info['upload_content_length'] = $this->info['size_upload'];
86+
}
87+
});
88+
89+
return $chunk;
90+
}
91+
92+
public static function rewind(RequestBody $body): RequestBody
93+
{
94+
if (!$body instanceof self) {
95+
return $body;
96+
}
97+
98+
$body->uploaded = null;
99+
100+
if ($body->body instanceof ResourceInputStream) {
101+
fseek($body->body->getResource(), $body->offset);
102+
103+
return new $body($body->body, $body->info, $body->onProgress);
104+
}
105+
106+
if (\is_string($body->body)) {
107+
$body->offset = 0;
108+
}
109+
110+
return $body;
111+
}
112+
113+
private function doRead(): Promise
114+
{
115+
if ($this->body instanceof ResourceInputStream) {
116+
return $this->body->read();
117+
}
118+
119+
if (null === $this->offset || !$this->length) {
120+
return new Success();
121+
}
122+
123+
if (\is_string($this->body)) {
124+
$this->offset = null;
125+
126+
return new Success($this->body);
127+
}
128+
129+
if ('' === $data = ($this->body)(16372)) {
130+
$this->offset = null;
131+
132+
return new Success();
133+
}
134+
135+
if (!\is_string($data)) {
136+
throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data)));
137+
}
138+
139+
return new Success($data);
140+
}
141+
}

0 commit comments

Comments
 (0)
0