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

Skip to content

Commit 287f2ba

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

File tree

10 files changed

+857
-11
lines changed

10 files changed

+857
-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: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
// - proxy
75+
// - no_proxy
76+
// - verify_host
77+
// - passphrase
78+
// - peer_fingerprint
79+
// - capture_peer_cert_chain
80+
81+
// TODO stream the body upload when possible
82+
$options['body'] = self::getBodyAsString($options['body']);
83+
84+
if ('' !== $options['body'] && 'POST' === $method && !isset($options['normalized_headers']['content-type'])) {
85+
$options['headers'][] = 'Content-Type: application/x-www-form-urlencoded';
86+
}
87+
88+
$this->logger && $this->logger->info(sprintf('Request: %s %s', $method, implode('', $url)));
89+
90+
if (!isset($options['normalized_headers']['user-agent'])) {
91+
$options['headers'][] = 'User-Agent: Symfony HttpClient/Amp';
92+
}
93+
94+
if (0 < $options['max_duration']) {
95+
$options['timeout'] = min($options['max_duration'], $options['timeout']);
96+
}
97+
98+
if ($options['resolve']) {
99+
$this->multi->dnsCache = $options['resolve'] + $this->multi->dnsCache;
100+
}
101+
102+
$request = new Request(implode('', $url), $method);
103+
104+
if ($options['http_version']) {
105+
switch ((float) $options['http_version']) {
106+
case 1.0: $request->setProtocolVersions(['1.0']); break;
107+
case 1.1: $request->setProtocolVersions(['1.1', '1.0']); break;
108+
default: $request->setProtocolVersions(['2', '1.1', '1.0']); break;
109+
}
110+
}
111+
112+
foreach ($options['headers'] as $v) {
113+
$h = explode(': ', $v, 2);
114+
$request->addHeader($h[0], $h[1]);
115+
}
116+
117+
$request->setTcpConnectTimeout(1000 * $options['timeout']);
118+
$request->setTlsHandshakeTimeout(1000 * $options['timeout']);
119+
$request->setTransferTimeout(1000 * $options['max_duration']);
120+
$request->setBody($options['body'] ?? '');
121+
122+
return new AmpResponse($this->multi, $request, $options, $this->logger);
123+
}
124+
125+
/**
126+
* {@inheritdoc}
127+
*/
128+
public function stream($responses, float $timeout = null): ResponseStreamInterface
129+
{
130+
if ($responses instanceof AmpResponse) {
131+
$responses = [$responses];
132+
} elseif (!is_iterable($responses)) {
133+
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)));
134+
}
135+
136+
return new ResponseStream(AmpResponse::stream($responses, $timeout));
137+
}
138+
139+
public function reset()
140+
{
141+
$this->multi->dnsCache = [];
142+
}
143+
144+
private static function getBodyAsString($body): string
145+
{
146+
if (\is_resource($body)) {
147+
return stream_get_contents($body);
148+
}
149+
150+
if (!$body instanceof \Closure) {
151+
return $body;
152+
}
153+
154+
$result = '';
155+
156+
while ('' !== $data = $body(self::$CHUNK_SIZE)) {
157+
if (!\is_string($data)) {
158+
throw new TransportException(sprintf('Return value of the "body" option callback must be string, %s returned.', \gettype($data)));
159+
}
160+
161+
$result .= $data;
162+
}
163+
164+
return $result;
165+
}
166+
}

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

0 commit comments

Comments
 (0)
0