8000 feature #54179 [HttpClient] Add support for amphp/http-client v5 (nic… · symfony/symfony@a44311e · GitHub
[go: up one dir, main page]

Skip to content

Commit a44311e

Browse files
committed
feature #54179 [HttpClient] Add support for amphp/http-client v5 (nicolas-grekas)
This PR was merged into the 7.2 branch. Discussion ---------- [HttpClient] Add support for amphp/http-client v5 | Q | A | ------------- | --- | Branch? | 7.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | #52008 | License | MIT This PR adds support for amphp/http-client version 5 as a transport for our HttpClient component. It started as a draft at nicolas-grekas#43, which helped spot that PHP is missing the capability to suspend fibers in destructors. This was reported as php/php-src#11389 and is being fixed at php/php-src#13460. Since the fix for php-src is going to land on PHP 8.4, using amphp/http-client version 5 will require php >= 8.4. The implementation duplicates the one we have for v4 with the needed changes to use the v5 API. The difference are not big in size of code, but they're very low level (generators vs fibers). That would be quite useless to factor IMHO. Commits ------- a5fb61c [HttpClient] Add support for amphp/http-client v5
2 parents 5e76ca0 + a5fb61c commit a44311e

18 files changed

+1120
-42
lines changed

.github/patch-types.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
case false !== strpos($file, '/src/Symfony/Component/DependencyInjection/Tests/Fixtures/php/'):
4747
case false !== strpos($file, '/src/Symfony/Component/DependencyInjection/Tests/Fixtures/TestServiceSubscriberIntersectionWithTrait.php'):
4848
case false !== strpos($file, '/src/Symfony/Component/ErrorHandler/Tests/Fixtures/'):
49+
case false !== strpos($file, '/src/Symfony/Component/HttpClient/Internal/'):
4950
case false !== strpos($file, '/src/Symfony/Component/Form/Tests/Fixtures/Answer.php'):
5051
case false !== strpos($file, '/src/Symfony/Component/Form/Tests/Fixtures/Number.php'):
5152
case false !== strpos($file, '/src/Symfony/Component/Form/Tests/Fixtures/Suit.php'):

composer.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,8 @@
122122
"symfony/yaml": "self.version"
123123
},
124124
"require-dev": {
125-
"amphp/amp": "^2.5",
126-
"amphp/http-client": "^4.2.1",
127-
"amphp/http-tunnel": "^1.0",
125+
"amphp/http-client": "^4.2.1|^5.0",
126+
"amphp/http-tunnel": "^1.0|^2.0",
128127
"async-aws/ses": "^1.0",
129128
"async-aws/sqs": "^1.0|^2.0",
130129
"async-aws/sns": "^1.0",
@@ -151,6 +150,7 @@
151150
"psr/http-client": "^1.0",
152151
"psr/simple-cache": "^1.0|^2.0|^3.0",
153152
"seld/jsonlint": "^1.10",
153+
"symfony/amphp-http-client-meta": "^1.0|^2.0",
154154
"symfony/mercure-bundle": "^0.3",
155155
"symfony/phpunit-bridge": "^6.4|^7.0",
156156
"symfony/runtime": "self.version",
@@ -162,6 +162,7 @@
162162
},
163163
"conflict": {
164164
"ext-psr": "<1.1|>=2",
165+
"amphp/amp": "<2.5",
165166
"async-aws/core": "<1.5",
166167
"doctrine/collections": "<1.8",
167168
"doctrine/dbal": "<3.6",

src/Symfony/Component/HttpClient/AmpHttpClient.php

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
namespace Symfony\Component\HttpClient;
1313

1414
use Amp\CancelledException;
15+
use Amp\DeferredFuture;
1516
use Amp\Http\Client\DelegateHttpClient;
1617
use Amp\Http\Client\InterceptedHttpClient;
1718
use Amp\Http\Client\PooledHttpClient;
1819
use Amp\Http\Client\Request;
20+
use Amp\Http\HttpMessage;
1921
use Amp\Http\Tunnel\Http1TunnelConnector;
20-
use Amp\Promise;
2122
use Psr\Log\LoggerAwareInterface;
2223
use Psr\Log\LoggerAwareTrait;
2324
use Symfony\Component\HttpClient\Exception\TransportException;
24-
use Symfony\Component\HttpClient\Internal\AmpClientState;
25-
use Symfony\Component\HttpClient\Response\AmpResponse;
25+
use Symfony\Component\HttpClient\Internal\AmpClientStateV4;
26+
use Symfony\Component\HttpClient\Internal\AmpClientStateV5;
27+
use Symfony\Component\HttpClient\Response\AmpResponseV4;
28+
use Symfony\Component\HttpClient\Response\AmpResponseV5;
2629
use Symfony\Component\HttpClient\Response\ResponseStream;
2730
use Symfony\Contracts\HttpClient\HttpClientInterface;
2831
use Symfony\Contracts\HttpClient\ResponseInterface;
@@ -33,8 +36,8 @@
3336
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:^4.2.1".');
3437
}
3538

36-
if (!interface_exists(Promise::class)) {
37-
throw new \LogicException('You cannot use "Symfony\Component\HttpClient\AmpHttpClient" as the installed "amphp/http-client" is not compatible with this version of "symfony/http-client". Try downgrading "amphp/http-client" to "^4.2.1".');
39+
if (\PHP_VERSION_ID < 80400 && is_subclass_of(Request::class, HttpMessage::class)) {
40+
throw new \LogicException('Using "Symfony\Component\HttpClient\AmpHttpClient" with amphp/http-client >= 5 requires PHP >= 8.4. Try running "composer require amphp/http-client:^4.2.1" or upgrade to PHP >= 8.4.');
3841
}
3942

4043
/**
@@ -53,7 +56,7 @@ final class AmpHttpClient implements HttpClientInterface, LoggerAwareInterface,
5356

5457
private array $defaultOptions = self::OPTIONS_DEFAULTS;
5558
private static array $emptyDefaults = self::OPTIONS_DEFAULTS;
56-
private AmpClientState $multi;
59+
private AmpClientStateV4|AmpClientStateV5 $multi;
5760

5861
/**
5962
* @param array $defaultOptions Default requests' options
@@ -72,7 +75,11 @@ public function __construct(array $defaultOptions = [], ?callable $clientConfigu
7275
[, $this->defaultOptions] = self::prepareRequest(null, null, $defaultOptions, $this->defaultOptions);
7376
}
7477

75-
$this->multi = new AmpClientState($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger);
78+
if (is_subclass_of(Request::class, HttpMessage::class)) {
79+
$this->multi = new AmpClientStateV5($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger);
80+
} else {
81+
$this->multi = new AmpClientStateV4($clientConfigurator, $maxHostConnections, $maxPendingPushes, $this->logger);
82+
}
7683
}
7784

7885
/**
@@ -132,9 +139,10 @@ public function request(string $method, string $url, array $options = []): Respo
132139
$request->addHeader($h[0], $h[1]);
133140
}
134141

135-
$request->setTcpConnectTimeout(1000 * $options['timeout']);
136-
$request->setTlsHandshakeTimeout(1000 * $options['timeout']);
137-
$request->setTransferTimeout(1000 * $options['max_duration']);
142+
$coef = $request instanceof HttpMessage ? 1 : 1000;
143+
$request->setTcpConnectTimeout($coef * $options['timeout']);
144+
$request->setTlsHandshakeTimeout($coef * $options['timeout']);
145+
$request->setTransferTimeout($coef * $options['max_duration']);
138146
if (method_exists($request, 'setInactivityTimeout')) {
139147
$request->setInactivityTimeout(0);
140148
}
@@ -145,25 +153,37 @@ public function request(string $method, string $url, array $options = []): Respo
145153
$request->setHeader('Authorization', 'Basic '.base64_encode(implode(':', $auth)));
146154
}
147155

148-
return new AmpResponse($this->multi, $request, $options, $this->logger);
156+
if ($request instanceof HttpMessage) {
157+
return new AmpResponseV5($this->multi, $request, $options, $this->logger);
158+
}
159+
160+
return new AmpResponseV4($this->multi, $request, $options, $this->logger);
149161
}
150162

151163
public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface
152164
{
153-
if ($responses instanceof AmpResponse) {
165+
if ($responses instanceof AmpResponseV4 || $responses instanceof AmpResponseV5) {
154166
$responses = [$responses];
155167
}
156168

157-
return new ResponseStream(AmpResponse::stream($responses, $timeout));
169+
if ($this->multi instanceof AmpClientStateV5) {
170+
return new ResponseStream(AmpResponseV5::stream($responses, $timeout));
171+
}
172+
173+
return new ResponseStream(AmpResponseV4::stream($responses, $timeout));
158174
}
159175

160176
public function reset(): void
161177
{
162178
$this->multi->dnsCache = [];
163179

164-
foreach ($this->multi->pushedResponses as $authority => $pushedResponses) {
180+
foreach ($this->multi->pushedResponses as $pushedResponses) {
165181
foreach ($pushedResponses as [$pushedUrl, $pushDeferred]) {
166-
$pushDeferred->fail(new CancelledException());
182+
if ($pushDeferred instanceof DeferredFuture) {
183+
$pushDeferred->error(new CancelledException());
184+
} else {
185+
$pushDeferred->fail(new CancelledException());
186+
}
167187

168188
$this->logger?->debug(\sprintf('Unused pushed response: "%s"', $pushedUrl));
169189
}

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+
7.2
5+
---
6+
7+
* Add support for amphp/http-client v5 on PHP 8.4+
8+
49
7.1
510
---
611

src/Symfony/Component/HttpClient/HttpClient.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111

1212
namespace Symfony\Component\HttpClient;
1313

14-
use Amp\Http\Client\Connection\ConnectionLimitingPool;
15-
use Amp\Promise;
14+
use Amp\Http\Client\Request as AmpRequest;
15+
use Amp\Http\HttpMessage;
1616
use Symfony\Contracts\HttpClient\HttpClientInterface;
1717

1818
/**
@@ -31,7 +31,7 @@ final class HttpClient
3131
*/
3232
public static function create(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50): HttpClientInterface
3333
{
34-
if ($amp = class_exists(ConnectionLimitingPool::class) && interface_exists(Promise::class)) {
34+
if ($amp = class_exists(AmpRequest::class) && (\PHP_VERSION_ID >= 80400 || is_subclass_of(AmpRequest::class, HttpMessage::class))) {
3535
if (!\extension_loaded('curl')) {
3636
return new AmpHttpClient($defaultOptions, null, $maxHostConnections, $maxPendingPushes);
3737
}

src/Symfony/Component/HttpClient/Internal/AmpBody.php renamed to src/Symfony/Component/HttpClient/Internal/AmpBodyV4.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
*
2424
* @internal
2525
*/
26-
class AmpBody implements RequestBody, InputStream
26+
class AmpBodyV4 implements RequestBody, InputStream
2727
{
2828
private ResourceInputStream|\Closure|string $body;
2929
private array $info;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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 Amp\ByteStream\ReadableIterableStream;
16+
use Amp\ByteStream\ReadableResourceStream;
17+
use Amp\ByteStream\ReadableStream;
18+
use Amp\Cancellation;
19+
use Amp\Http\Client\HttpContent;
20+
use Symfony\Component\HttpClient\Exception\TransportException;
21+
22+
/**
23+
* @author Nicolas Grekas <p@tchwork.com>
24+
*
25+
* @internal
26+
*/
27+
class AmpBodyV5 implements HttpContent, ReadableStream, \IteratorAggregate
28+
{
29+
private ReadableStream $body;
30+
private ?string $content;
31+
private array $info;
32+
private ?int $offset = 0;
33+
private int $length = -1;
34+
private ?int $uploaded = null;
35+
36+
/**
37+
* @param \Closure|resource|string $body
38+
*/
39+
public function __construct(
40+
$body,
41+
&$info,
42+
private \Closure $onProgress,
43+
) {
44+
$this->info = &$info;
45+
46+
if (\is_resource($body)) {
47+
$this->offset = ftell($body);
48+
$this->length = fstat($body)['size'];
49+
$this->body = new ReadableResourceStream($body);
50+
} elseif (\is_string($body)) {
51+
$this->length = \strlen($body);
52+
$this->body = new ReadableBuffer($body);
53+
$this->content = $body;
54+
} else {
55+
$this->body = new ReadableIterableStream((static function () use ($body) {
56+
while ('' !== $data = ($body)(16372)) {
57+
if (!\is_string($data)) {
58+
throw new TransportException(\sprintf('Return value of the "body" option callback must be string, "%s" returned.', get_debug_type($data)));
59+
}
60+
61+
yield $data;
62+
}
63+
})());
64+
}
65+
}
66+
67+
public function getContent(): ReadableStream
68+
{
69+
if (null !== $this->uploaded) {
70+
$this->uploaded = null;
71+
72+
if (\is_string($this->body)) {
73+
$this->offset = 0;
74+
} elseif ($this->body instanceof ReadableResourceStream) {
75+
fseek($this->body->getResource(), $this->offset);
76+
}
77+
}
78+
79+
return $this;
80+
}
81+
82+
public function getContentType(): ?string
83+
{
84+
return null;
85+
}
86+
87+
public function getContentLength(): ?int
88+
{
89+
return 0 <= $this->length ? $this->length - $this->offset : null;
90+
}
91+
92+
public function read(?Cancellation $cancellation = null): ?string
93+
{
94+
$this->info['size_upload'] += $this->uploaded;
95+
$this->uploaded = 0;
96+
($this->onProgress)();
97+
98+
if (null !== $data = $this->body->read($cancellation)) {
99+
$this->uploaded = \strlen($data);
100+
} else {
101+
$this->info['upload_content_length'] = $this->info['size_upload'];
102+
}
103+
104+
return $data;
105+
}
106+
107+
public function isReadable(): bool
108+
{
109+
return $this->body->isReadable();
110+
}
111+
112+
public function close(): void
113+
{
114+
$this->body->close();
115+
}
116+
117+
public function isClosed(): bool
118+
{
119+
return $this->body->isClosed();
120+
}
121+
122+
public function onClose(\Closure $onClose): void
123+
{
124+
$this->body->onClose($onClose);
125+
}
126+
127+
public function getIterator(): \Traversable
128+
{
129+
return $this->body;
130+
}
131+
132+
public static function rewind(HttpContent $body): HttpContent
133+
{
134+
if (!$body instanceof self) {
135+
return $body;
136+
}
137+
138+
$body->uploaded = null;
139+
140+
if ($body->body instanceof ReadableResourceStream && !$body->body->isClosed()) {
141+
fseek($body->body->getResource(), $body->offset);
142+
}
143+
144+
if ($body->body instanceof ReadableBuffer) {
145+
return new $body($body->content, $body->info, $body->onProgress);
146+
}
147+
148+
return $body;
149+
}
150+
}

src/Symfony/Component/HttpClient/Internal/AmpClientState.php renamed to src/Symfony/Component/HttpClient/Internal/AmpClientStateV4.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
*
4040
* @internal
4141
*/
42-
final class AmpClientState extends ClientState
42+
final class AmpClientStateV4 extends ClientState
4343
{
4444
public array $dnsCache = [];
4545
public int $responseCount = 0;
@@ -90,7 +90,7 @@ public function request(array $options, Request $request, CancellationToken $can
9090
$info['peer_certificate_chain'] = [];
9191
}
9292

93-
$request->addEventListener(new AmpListener($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle));
93+
$request->addEventListener(new AmpListenerV4($info, $options['peer_fingerprint']['pin-sha256'] ?? [], $onProgress, $handle));
9494
$request->setPushHandler(fn ($request, $response): Promise => $this->handlePush($request, $response, $options));
9595

9696
($request->hasHeader('content-length') ? new Success((int) $request->getHeader('content-length')) : $request->getBody()->getBodyLength())
@@ -157,7 +157,7 @@ public function connect(string $uri, ?ConnectContext $context = null, ?Cancellat
157157
return $result;
158158
}
159159
};
160-
$connector->connector = new DnsConnector(new AmpResolver($this->dnsCache));
160+
$connector->connector = new DnsConnector(new AmpResolverV4($this->dnsCache));
161161

162162
$context = (new ConnectContext())
163163
->withTcpNoDelay()

0 commit comments

Comments
 (0)
0