8000 [HttpClient] Add portable HTTP/2 implementation based on Amp's HTTP c… · symfony/symfony@96b3fc5 · GitHub
[go: up one dir, main page]

Skip to content 8000

Commit 96b3fc5

Browse files
[HttpClient] Add portable HTTP/2 implementation based on Amp's HTTP client
1 parent 392d0b0 commit 96b3fc5

File tree

9 files changed

+797
-11
lines changed

9 files changed

+797
-11
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"symfony/yaml": "self.version"
100100
},
101101
"require-dev": {
102+
"amphp/http-client": "^4.0",
102103
"cache/integration-tests": "dev-master",
103104
"doctrine/annotations": "~1.0",
104105
"doctrine/cache": "~1.6",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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\Http\Client\HttpClientBuilder;
15+
use Amp\Http\Client\Request;
16+
use Psr\Log\LoggerAwareInterface;
17+
use Psr\Log\LoggerAwareTrait;
18+
use Symfony\Component\HttpClient\Exception\TransportException;
19+
use Symfony\Component\HttpClient\Internal\AmpClientState;
20+
use Symfony\Component\HttpClient\Response\AmpResponse;
21+
use Symfony\Component\HttpClient\Response\ResponseStream;
22+
use Symfony\Contracts\HttpClient\HttpClientInterface;
23+
use Symfony\Contracts\HttpClient\ResponseInterface;
24+
use Symfony\Contracts\HttpClient\ResponseStreamInterface;
25+
use Symfony\Contracts\Service\ResetInterface;
26+
27+
if (!class_exists(HttpClientBuilder::class)) {
28+
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".');
29+
}
30+
31+
/**
32+
* A portable implementation of the HttpClientInterface contracts based on Amp's HTTP client.
33+
*
34+
* @author Nicolas Grekas <p@tchwork.com>
35+
*/
36+
final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface, ResetInterface
37+
{
38+
use HttpClientTrait;
39+
use LoggerAwareTrait;
40+
41+
private $defaultOptions = self::OPTIONS_DEFAULTS;
42+
43+
/** @var AmpClientState */
44+
private $multi;
45+
46+
/**
47+
* @param array $defaultOptions Default requests' options
48+
* @param int $maxHostConnections The maximum number of connections to open
49+
*
50+
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
51+
*/
52+
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, HttpClientBuilder $builder = null)
53+
{
54+
$this->defaultOptions['buffer'] = $this->defaultOptions['buffer'] ?? \Closure::fromCallable([__CLASS__, 'shouldBuffer']);
55+
56+
if ($defaultOptions) {
57+
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
58+
}
59+
60+
$this->multi = new AmpClientState($builder, $maxHostConnections);
61+
}
62+
63+
/**
64+
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
65+
*
66+
* {@inheritdoc}
67+
*/
68+
public function request(string $method, string $url, array $options = []): ResponseInterface
69+
{
70+
[$url, $options] = self::prepareRequest($method, $url, $options, $this->defaultOptions);
71+
72+
// TODO handle missing options:
73+
// - on_progress
74+
// - resolve
75+
// - proxy
76+
// - no_proxy
77+
// - verify_host
78+
// - passphrase
79+
// - peer_fingerprint
80+
// - capture_peer_cert_chain
81+
82+
// TODO stream the body upload when possible
83+
$options['body'] = self::getBodyAsString($options['body']);
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+
$this->logger && $this->logger->info(sprintf('Request: %s %s', $method, implode('', $url)));
90+
91+
if (!isset($options['normalized_headers']['user-agent'])) {
92+
$options['headers'][] = 'User-Agent: Symfony HttpClient/Amp';
93+
}
94+
95+
if (0 < $options['max_duration']) {
96+
$options['timeout'] = min($options['max_duration'], $options['timeout']);
97+
}
98+
99+
$request = new Request(implode('', $url), $method);
100+
101+
if ($options['http_version']) {
102+
switch ((float) $options['http_version']) {
103+
case 1.0: $request->setProtocolVersions(['1.0']); break;
104+
case 1.1: $request->setProtocolVersions(['1.1', '1.0']); break;
105+
default: $request->setProtocolVersions(['2', '1.1', '1.0']); break;
106+
}
107+
}
108+
109+
foreach ($options['headers'] as $v) {
110+
$h = explode(': ', $v, 2);
111+
$request->addHeader($h[0], $h[1]);
112+
}
113+
114+
$request->setTcpConnectTimeout(1000 * $options['timeout']);
115+
$request->setTlsHandshakeTimeout(1000 * $options['timeout']);
116+
$request->setTransferTimeout(1000 * $options['max_duration']);
117+
$request->setBody($options['body'] ?? '');
118+
119+
return new AmpResponse($this->multi, $request, $options, $this->logger);
120+
}
121+
122+
/**
123+
* {@inheritdoc}
124+
*/
125+
public function stream($responses, float $timeout = null): ResponseStreamInterface
126+
{
127+
if ($responses instanceof AmpResponse) {
128+
$responses = [$responses];
129+
} elseif (!is_iterable($responses)) {
130+
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)));
131+
}
132+
133+
return new ResponseStream(AmpResponse::stream($responses, $timeout));
134+
}
135+
136+
public function reset()
137+
{
138+
}
139+
140+
private static function getBodyAsString($body): string
141+
{
142+
if (\is_resource($body)) {
143+
return stream_get_contents($body);
144+
}
145+
146+
if (!$body instanceof \Closure) {
147+
return $body;
148+
}
149+
150+
$result = '';
151+
152+
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
153+
if (!\is_string($data)) {
154+
throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data)));
155+
}
156+
157+
$result .= $data;
158+
}
159+
160+
return $result;
161+
}
162+
}

src/Symfony/Component/HttpClient/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
5.1.0
5+
-----
6+
7+
* added `AmpHttpClient`, a portable HTTP/2 implementation based on Amp
8+
49
4.4.0
510
-----
611

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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\Http\Client\Connection\DefaultConnectionFactory;
15+
use Amp\Http\Client\Connection\LimitedConnectionPool;
16+
use Amp\Http\Client\Connection\UnlimitedConnectionPool;
17+
use Amp\Http\Client\HttpClient;
18+
use Amp\Http\Client\HttpClientBuilder;
19+
use Amp\Socket\Certificate;
20+
use Amp\Socket\ClientTlsContext;
21+
use Amp\Socket\ConnectContext;
22+
use Amp\Socket\DnsConnector;
23+
use Amp\Socket\StaticConnector;
24+
use Amp\Sync\LocalKeyedSemaphore;
25+
26+
/**
27+
* Internal representation of the Amp client's state.
28+
*
29+
* @author Nicolas Grekas <p@tchwork.com>
30+
*
31+
* @internal
32+
*/
33+
final class AmpClientState extends ClientState
34+
{
35+
/** @var HttpClientBuilder[] */
36+
private $clients = [];
37+
private $maxHostConnections = 0;
38+
private $builder;
39+
40+
public function __construct(?HttpClientBuilder $builder, int $maxHostConnections)
41+
{
42+
$this->builder = ($builder ?? (new HttpClientBuilder())->allowDeprecatedUriUserInfo())->followRedirects(0);
43+
$this->maxHostConnections = $maxHostConnections;
44+
}
45+
46+
public function getClient(array $options): HttpClient
47+
{
48+
$options = [
49+
'bindto' => $options['bindto'] ?: '0',
50+
'verify_peer' => $options['verify_peer'],
51+
'capath' => $options['capath'],
52+
'cafile' => $options['cafile'],
53+
'local_cert' => $options['local_cert'],
54+
'local_pk' => $options['local_pk'],
55+
'ciphers' => $options['ciphers'],
56+
'capture_peer_cert_chain' => $options['capture_peer_cert_chain'] || $options['peer_fingerprint'],
57+
];
58+
59+
$key = implode("\0", $options);
60+
61+
if (isset($this->clients[$key])) {
62+
return $this->clients[$key];
63+
}
64+
65+
$context = new ClientTlsContext('');
66+
$options['verify_peer'] || $context = $context->withoutPeerVerification();
67+
$options['cafile'] && $context = $context->withCaFile($options['cafile']);
68+
$options['capath'] && $context = $context->withCaPath($options['capath']);
69+
$options['local_cert'] && $context = $context->withCertificate(new Certificate($options['local_cert'], $options['local_pk']));
70+
$options['ciphers'] && $context = $context->withCiphers($options['ciphers']);
71+
$options['capture_peer_cert_chain'] && $context = $context->withPeerCapturing();
72+
73+
if ($options['bindto']) {
74+
$connector = (file_exists($options['bindto']) ? 'unix://' : 'tcp://').$options['bindto'];
75+
$connector = new StaticConnector($connector, new DnsConnector());
76+
} else {
77+
$connector = null;
78+
}
79+
80+
$pool = new UnlimitedConnectionPool(new DefaultConnectionFactory($connector, (new ConnectContext())->withTlsContext($context)));
81+
82+
if (0 < $this->maxHostConnections) {
83+
$pool = LimitedConnectionPool::byHost($pool, new LocalKeyedSemaphore($this->maxHostConnections));
84+
}
85+
86+
return $this->clients[$key] = $this->builder->usingPool($pool)->build();
87+
}
88+
}

0 commit comments

Comments
 (0)
0