Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit f632b76

Browse filesBrowse 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
Copy full SHA for f632b76

19 files changed

+1413
-188
lines changed

‎.appveyor.yml

Copy file name to clipboardExpand all lines: .appveyor.yml
+3Lines 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

Copy file name to clipboardExpand all lines: composer.json
+2Lines 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",
+163Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/CHANGELOG.md
+3-2Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpClient/HttpClientTrait.php
+43Lines 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) {
+141Lines 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)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.