Skip to content

Commit 647d676

Browse files
committed
feat(http-client): add an HTTP/2 client using PHP-Standard-Library
Signed-off-by: azjezz <azjezz@protonmail.com>
1 parent 5731ab8 commit 647d676

8 files changed

Lines changed: 1126 additions & 0 deletions

File tree

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@
127127
"require-dev": {
128128
"amphp/http-client": "^5.0",
129129
"amphp/http-tunnel": "^2.0",
130+
"php-standard-library/http-client": "dev-next",
131+
"php-standard-library/dns": "dev-next",
130132
"async-aws/dynamo-db": "^3.0",
131133
"async-aws/ses": "^1.0",
132134
"async-aws/sqs": "^1.0|^2.0",
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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\ReadableBuffer;
15+
use Psl\Async\CancellationTokenInterface;
16+
use Psl\Async\NullCancellationToken;
17+
use Psl\IO;
18+
use Symfony\Component\HttpClient\Exception\TransportException;
19+
20+
/**
21+
* Request body wrapper that tracks upload progress for PSL HTTP client.
22+
*
23+
* @author Seifeddine Gmati <azjezz@carthage.software>
24+
*
25+
* @internal
26+
*/
27+
class PslBody implements IO\ReadHandleInterface
28+
{
29+
use IO\ReadHandleConvenienceMethodsTrait;
30+
31+
private IO\ReadHandleInterface $body;
32+
private ?string $content;
33+
private array $info;
34+
private ?int $offset = 0;
35+
private int $length = -1;
36+
private ?int $uploaded = null;
37+
38+
/**
39+
* @param \Closure|IO\ReadHandleInterface|resource|string $body
40+
*/
41+
public function __construct(
42+
$body,
43+
array &$info,
44+
private \Closure $onProgress,
45+
) {
46+
$this->info = &$info;
47+
48+
if ($body instanceof IO\ReadHandleInterface) {
49+
if ($body instanceof IO\SeekHandleInterface) {
50+
$this->offset = $body->tell();
51+
}
52+
53+
$this->body = $body;
54+
} else if (\is_resource($body)) {
55+
$this->offset = ftell($body);
56+
$this->length = fstat($body)['size'];
57+
$this->body = new IO\SeekReadStreamHandle($body);
58+
} elseif (\is_string($body)) {
59+
$this->content = $body;
60+
$this->body = new IO\MemoryHandle($body);
61+
} else {
62+
$this->body = new IO\IterableReadHandle((static function () use ($body) {
63+
while ('' !== $data = ($body)(16372)) {
64+
if (!\is_string($data)) {
65+
throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
66+
}
67+
68+
yield $data;
69+
}
70+
})());
71+
}
72+
}
73+
74+
public function read(
75+
?int $maxBytes = null,
76+
CancellationTokenInterface $cancellation = new NullCancellationToken(),
77+
): string {
78+
$this->info['size_upload'] += $this->uploaded;
79+
$this->uploaded = 0;
80+
($this->onProgress)();
81+
82+
$data = $this->body->read($maxBytes, $cancellation);
83+
$this->uploaded = \strlen($data);
84+
if ($this->reachedEndOfDataSource()) {
85+
$this->info['upload_content_length'] = $this->info['size_upload'];
86+
}
87+
88+
return $data;
89+
}
90+
91+
public function tryRead(?int $maxBytes = null): string
92+
{
93+
$data = $this->body->tryRead($maxBytes);
94+
if ('' !== $data) {
95+
$this->uploaded += \strlen($data);
96+
}
97+
98+
return $data;
99+
}
100+
101+
public function reachedEndOfDataSource(): bool
102+
{
103+
return $this->body->reachedEndOfDataSource();
104+
}
105+
106+
public function rewind(): void
107+
{
108+
$this->uploaded = null;
109+
if ($this->content !== null) {
110+
$this->body = new IO\MemoryHandle($this->content);
111+
return;
112+
}
113+
114+
if ($this->offset !== null && $this->body instanceof IO\SeekHandleInterface) {
115+
$this->body->seek($this->offset);
116+
}
117+
}
118+
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
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 Psl\Async;
15+
use Psl\DateTime\Duration;
16+
use Psl\DNS;
17+
use Psl\HTTP\Client as PslClient;
18+
use Psl\HTTP\Client\Connection\ConnectionMetadata;
19+
use Psl\HTTP\Message;
20+
use Psl\Network;
21+
use Psl\TCP;
22+
use Psl\TLS;
23+
use Psl\URL;
24+
use Psr\Log\LoggerInterface;
25+
26+
/**
27+
* Internal representation of the PSL client's state.
28+
*
29+
* @author Seifeddine Gmati <azjezz@carthage.software>
30+
*
31+
* @internal
32+
*/
33+
final class PslClientState extends ClientState
34+
{
35+
/** @var array<string, string> */
36+
public array $dnsCache = [];
37+
public int $responseCount = 0;
38+
39+
private DNS\BlockingSystemResolver $systemResolver;
40+
41+
/** @var array<string, array{PslClient\Connection\PooledConnector, PslClient\ClientConfiguration, PslClient\SendConfiguration}> */
42+
private array $clients = [];
43+
44+
public function __construct(
45+
private ?LoggerInterface &$logger,
46+
) {
47+
$this->systemResolver = new DNS\BlockingSystemResolver();
48+
}
49+
50+
/**
51+
* @param array<string, mixed> $options Symfony-prepared request options
52+
*/
53+
public function request(
54+
array $options,
55+
Message\Request $request,
56+
Async\CancellationTokenInterface $cancellation,
57+
array &$info,
58+
\Closure $onProgress,
59+
?\Closure $onInformationalResponse = null,
60+
): Message\Transaction {
61+
$info['start_time'] ??= microtime(true);
62+
63+
if ($options['proxy']) {
64+
if ($request->headers->has('proxy-authorization')) {
65+
$options['proxy']['auth'] = $request->headers->get('proxy-authorization');
66+
}
67+
68+
// Matching "no_proxy" should follow the behavior of curl
69+
$host = $request->url?->authority->host->toString() ?? '';
70+
foreach ($options['proxy']['no_proxy'] as $rule) {
71+
$dotRule = '.'.ltrim($rule, '.');
72+
73+
if ('*' === $rule || $host === $rule || str_ends_with($host, $dotRule)) {
74+
$options['proxy'] = null;
75+
break;
76+
}
77+
}
78+
}
79+
80+
if ($request->headers->has('proxy-authorization')) {
81+
$request = $request->withoutHeader('proxy-authorization');
82+
}
83+
84+
[$pooledConnector, $clientConfig, $sendConfig] = $this->getClient($options);
85+
86+
$isHttps = $request->url !== null && $request->url->scheme === 'https';
87+
if ($isHttps && !$options['http_version']) {
88+
$sendConfig = new PslClient\SendConfiguration(
89+
tlsConfiguration: $sendConfig->tlsConfiguration,
90+
protocolVersions: [Message\ProtocolVersion::V20, Message\ProtocolVersion::V11],
91+
);
92+
}
93+
94+
$connectionTimeout = null;
95+
if ($options['max_connect_duration'] > 0) {
96+
$connectionTimeout = Duration::nanoseconds((int) ($options['max_connect_duration'] * 1e9));
97+
}
98+
99+
$sendConfig = new PslClient\SendConfiguration(
100+
maxResponseHeaderSize: $sendConfig->maxResponseHeaderSize,
101+
maxResponseBodySize: $sendConfig->maxResponseBodySize,
102+
baseUrl: $sendConfig->baseUrl,
103+
tlsConfiguration: $sendConfig->tlsConfiguration,
104+
protocolVersions: $sendConfig->protocolVersions,
105+
proxyConfiguration: $sendConfig->proxyConfiguration,
106+
onInformationalResponse: $onInformationalResponse,
107+
onConnection: static function (ConnectionMetadata $metadata) use (&$info, $onProgress): void {
108+
$info['primary_ip'] = $metadata->peerAddress->host;
109+
$info['primary_port'] = $metadata->peerAddress->port ?? 0;
110+
$info['local_ip'] = $metadata->localAddress->host;
111+
$info['local_port'] = $metadata->localAddress->port ?? 0;
112+
$info['connect_time'] = microtime(true) - $info['start_time'];
113+
$info['pretransfer_time'] = $info['connect_time'];
114+
$onProgress();
115+
},
116+
connectionTimeout: $connectionTimeout,
117+
);
118+
119+
$resolver = new PslResolver($this->dnsCache, $this->systemResolver);
120+
$connector = new DNS\HTTP\Connector($pooledConnector, $resolver);
121+
$client = new PslClient\Client(connector: $connector, configuration: $clientConfig);
122+
123+
$transaction = $client->send($request, $sendConfig, $cancellation);
124+
125+
$info['starttransfer_time'] = microtime(true) - $info['start_time'];
126+
127+
return $transaction;
128+
}
129+
130+
/**
131+
* @return array{PslClient\Connection\PooledConnector, PslClient\ClientConfiguration, PslClient\SendConfiguration}
132+
*/
133+
private function getClient(array $options): array
134+
{
135+
$cacheKey = [
136+
'bindto' => $options['bindto'] ?: '0',
137+
'verify_peer' => $options['verify_peer'],
138+
'capath' => $options['capath'],
139+
'cafile' => $options['cafile'],
140+
'local_cert' => $options['local_cert'],
141+
'local_pk' => $options['local_pk'],
142+
'ciphers' => $options['ciphers'],
143+
'proxy' => $options['proxy'],
144+
'crypto_method' => $options['crypto_method'],
145+
];
146+
147+
$key = hash('xxh128', serialize($cacheKey));
148+
149+
if (isset($this->clients[$key])) {
150+
return $this->clients[$key];
151+
}
152+
153+
$tlsConfig = new TLS\ClientConfiguration();
154+
155+
if (!$options['verify_peer']) {
156+
$tlsConfig = $tlsConfig->withPeerVerification(false);
157+
}
158+
if ($options['cafile']) {
159+
$tlsConfig = $tlsConfig->withCertificateAuthority($options['cafile']);
160+
}
161+
if ($options['capath']) {
162+
$tlsConfig = $tlsConfig->withCertificateAuthorityPath($options['capath']);
163+
}
164+
if ($options['local_cert']) {
165+
$tlsConfig = $tlsConfig->withCertificate(new TLS\Certificate(
166+
certificateFile: $options['local_cert'],
167+
keyFile: $options['local_pk'] ?? $options['local_cert'],
168+
passphrase: $options['passphrase'] ?? null,
169+
));
170+
}
171+
if ($options['ciphers']) {
172+
$tlsConfig = $tlsConfig->withCiphers($options['ciphers']);
173+
}
174+
if ($options['peer_fingerprint'] && isset($options['peer_fingerprint']['pin-sha256'])) {
175+
$pins = (array) $options['peer_fingerprint']['pin-sha256'];
176+
$hexPins = [];
177+
foreach ($pins as $pin) {
178+
$decoded = base64_decode($pin, true);
179+
if ($decoded !== false) {
180+
$hexPins['sha256'] = bin2hex($decoded);
181+
}
182+
}
183+
if ($hexPins) {
184+
$tlsConfig = $tlsConfig->withPeerFingerprints($hexPins);
185+
}
186+
}
187+
188+
$protocolVersions = [Message\ProtocolVersion::V11];
189+
if ($options['http_version']) {
190+
$protocolVersions = match ((float) $options['http_version']) {
191+
1.0 => [Message\ProtocolVersion::V10],
192+
1.1 => [Message\ProtocolVersion::V11],
193+
default => [Message\ProtocolVersion::V20, Message\ProtocolVersion::V11],
194+
};
195+
}
196+
197+
$unixSocket = null;
198+
$bindTo = null;
199+
if ($options['bindto']) {
200+
if (file_exists($options['bindto'])) {
201+
$unixSocket = $options['bindto'];
202+
} else {
203+
$bindTo = $options['bindto'];
204+
}
205+
}
206+
207+
$proxyConfig = null;
208+
if ($options['proxy']) {
209+
$proxyUrl = str_replace(['tcp://', 'ssl://'], ['http://', 'https://'], $options['proxy']['url']);
210+
211+
$proxyConfig = new PslClient\ProxyConfiguration(
212+
url: URL\parse($proxyUrl),
213+
authorization: $options['proxy']['auth'] ?? null,
214+
skipProxyFor: $options['proxy']['no_proxy'] ?? [],
215+
);
216+
}
217+
218+
$clientConfig = new PslClient\ClientConfiguration(
219+
tlsConfiguration: $tlsConfig,
220+
protocolVersions: $protocolVersions,
221+
unixSocket: $unixSocket,
222+
proxyConfiguration: $proxyConfig,
223+
);
224+
225+
$sendConfig = new PslClient\SendConfiguration(
226+
tlsConfiguration: $tlsConfig,
227+
protocolVersions: $protocolVersions,
228+
);
229+
230+
$tcpConfig = new TCP\ConnectConfiguration(noDelay: true, bindTo: $bindTo);
231+
$connector = new PslClient\Connection\PooledConnector(
232+
tcpConnector: new TCP\Connector($tcpConfig),
233+
);
234+
235+
return $this->clients[$key] = [$connector, $clientConfig, $sendConfig];
236+
}
237+
}

0 commit comments

Comments
 (0)