Skip to content

Commit d353ac5

Browse files
Merge branch '4.3' into 4.4
* 4.3: [HttpClient] Fix a bug preventing Server Pushes to be handled properly [HttpClient] fix support for 103 Early Hints and other informational status codes [DI] fix failure [Validator] Add ConstraintValidator::formatValue() tests [HttpClient] improve handling of HTTP/2 PUSH Fix #33427 [Validator] Only handle numeric values in DivisibleBy [Validator] Sync string to date behavior and throw a better exception Check phpunit configuration for listeners [DI] fix support for "!tagged_locator foo"
2 parents b37f8ca + b3a69ad commit d353ac5

12 files changed

+165
-59
lines changed

Chunk/DataChunk.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
*/
2121
class DataChunk implements ChunkInterface
2222
{
23-
private $offset;
24-
private $content;
23+
private $offset = 0;
24+
private $content = '';
2525

2626
public function __construct(int $offset = 0, string $content = '')
2727
{
@@ -53,6 +53,14 @@ public function isLast(): bool
5353
return false;
5454
}
5555

56+
/**
57+
* {@inheritdoc}
58+
*/
59+
public function getInformationalStatus(): ?array
60+
{
61+
return null;
62+
}
63+
5664
/**
5765
* {@inheritdoc}
5866
*/

Chunk/ErrorChunk.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ public function isLast(): bool
6565
throw new TransportException($this->errorMessage, 0, $this->error);
6666
}
6767

68+
/**
69+
* {@inheritdoc}
70+
*/
71+
public function getInformationalStatus(): ?array
72+
{
73+
$this->didThrow = true;
74+
throw new TransportException($this->errorMessage, 0, $this->error);
75+
}
76+
6877
/**
6978
* {@inheritdoc}
7079
*/

Chunk/InformationalChunk.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Chunk;
13+
14+
/**
15+
* @author Nicolas Grekas <[email protected]>
16+
*
17+
* @internal
18+
*/
19+
class InformationalChunk extends DataChunk
20+
{
21+
private $status;
22+
23+
public function __construct(int $statusCode, array $headers)
24+
{
25+
$this->status = [$statusCode, $headers];
26+
}
27+
28+
/**
29+
* {@inheritdoc}
30+
*/
31+
public function getInformationalStatus(): ?array
32+
{
33+
return $this->status;
34+
}
35+
}

CurlHttpClient.php

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
5656
*
5757
* @see HttpClientInterface::OPTIONS_DEFAULTS for available options
5858
*/
59-
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 50)
59+
public function __construct(array $defaultOptions = [], int $maxHostConnections = 6, int $maxPendingPushes = 0)
6060
{
6161
if (!\extension_loaded('curl')) {
6262
throw new \LogicException('You cannot use the "Symfony\Component\HttpClient\CurlHttpClient" as the "curl" extension is not installed.');
@@ -106,35 +106,29 @@ public function request(string $method, string $url, array $options = []): Respo
106106
$host = parse_url($authority, PHP_URL_HOST);
107107
$url = implode('', $url);
108108

109+
if (!isset($options['normalized_headers']['user-agent'])) {
110+
$options['normalized_headers']['user-agent'][] = $options['headers'][] = 'User-Agent: Symfony HttpClient/Curl';
111+
}
112+
109113
if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
110114
unset($this->multi->pushedResponses[$url]);
111-
// Accept pushed responses only if their headers related to authentication match the request
112-
$expectedHeaders = ['authorization', 'cookie', 'x-requested-with', 'range'];
113-
foreach ($expectedHeaders as $k => $v) {
114-
$expectedHeaders[$k] = null;
115-
116-
foreach ($options['normalized_headers'][$v] ?? [] as $h) {
117-
$expectedHeaders[$k][] = substr($h, 2 + \strlen($v));
118-
}
119-
}
120115

121-
if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) {
122-
$this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url));
116+
if (self::acceptPushForRequest($method, $options, $pushedResponse)) {
117+
$this->logger && $this->logger->debug(sprintf('Accepting pushed response: "%s %s"', $method, $url));
123118

124119
// Reinitialize the pushed response with request's options
125120
$pushedResponse->response->__construct($this->multi, $url, $options, $this->logger);
126121

127122
return $pushedResponse->response;
128123
}
129124

130-
$this->logger && $this->logger->debug(sprintf('Rejecting pushed response for "%s": authorization headers don\'t match the request', $url));
125+
$this->logger && $this->logger->debug(sprintf('Rejecting pushed response: "%s".', $url));
131126
}
132127

133128
$this->logger && $this->logger->info(sprintf('Request: "%s %s"', $method, $url));
134129

135130
$curlopts = [
136131
CURLOPT_URL => $url,
137-
CURLOPT_USERAGENT => 'Symfony HttpClient/Curl',
138132
CURLOPT_TCP_NODELAY => true,
139133
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
140134
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
@@ -330,7 +324,7 @@ public function __destruct()
330324
$active = 0;
331325
while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active));
332326

333-
foreach ($this->multi->openHandles as $ch) {
327+
foreach ($this->multi->openHandles as [$ch]) {
334328
curl_setopt($ch, CURLOPT_VERBOSE, false);
335329
}
336330
}
@@ -342,17 +336,17 @@ private static function handlePush($parent, $pushed, array $requestHeaders, Curl
342336

343337
foreach ($requestHeaders as $h) {
344338
if (false !== $i = strpos($h, ':', 1)) {
345-
$headers[substr($h, 0, $i)] = substr($h, 1 + $i);
339+
$headers[substr($h, 0, $i)][] = substr($h, 1 + $i);
346340
}
347341
}
348342

349-
if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path']) || 'GET' !== $headers[':method'] || isset($headers['range'])) {
343+
if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path'])) {
350344
$logger && $logger->debug(sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin));
351345

352346
return CURL_PUSH_DENY;
353347
}
354348

355-
$url = $headers[':scheme'].'://'.$headers[':authority'];
349+
$url = $headers[':scheme'][0].'://'.$headers[':authority'][0];
356350

357351
if ($maxPendingPushes <= \count($multi->pushedResponses)) {
358352
$logger && $logger->debug(sprintf('Rejecting pushed response from "%s" for "%s": the queue is full', $origin, $url));
@@ -369,22 +363,43 @@ private static function handlePush($parent, $pushed, array $requestHeaders, Curl
369363
return CURL_PUSH_DENY;
370364
}
371365

372-
$url .= $headers[':path'];
366+
$url .= $headers[':path'][0];
373367
$logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url));
374368

375-
$multi->pushedResponses[$url] = new PushedResponse(
376-
new CurlResponse($multi, $pushed),
377-
[
378-
$headers['authorization'] ?? null,
379-
$headers['cookie'] ?? null,
380-
$headers['x-requested-with'] ?? null,
381-
null,
382-
]
383-
);
369+
$multi->pushedResponses[$url] = new PushedResponse(new CurlResponse($multi, $pushed), $headers, $multi->openHandles[(int) $parent][1] ?? []);
384370

385371
return CURL_PUSH_OK;
386372
}
387373

374+
/**
375+
* Accepts pushed responses only if their headers related to authentication match the request.
376+
*/
377+
private static function acceptPushForRequest(string $method, array $options, PushedResponse $pushedResponse): bool
378+
{
379+
if ($options['body'] || $method !== $pushedResponse->requestHeaders[':method'][0]) {
380+
return false;
381+
}
382+
383+
foreach (['proxy', 'no_proxy', 'bindto'] as $k) {
384+
if ($options[$k] !== $pushedResponse->parentOptions[$k]) {
385+
return false;
386+
}
387+
}
388+
389+
foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
390+
$normalizedHeaders = $options['normalized_headers'][$k] ?? [];
391+
foreach ($normalizedHeaders as $i => $v) {
392+
$normalizedHeaders[$i] = substr($v, \strlen($k) + 2);
393+
}
394+
395+
if (($pushedResponse->requestHeaders[$k] ?? []) !== $normalizedHeaders) {
396+
return false;
397+
}
398+
}
399+
400+
return true;
401+
}
402+
388403
/**
389404
* Wraps the request's body callback to allow it to return strings longer than curl requested.
390405
*/

Internal/PushedResponse.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,25 @@
1414
use Symfony\Component\HttpClient\Response\CurlResponse;
1515

1616
/**
17-
* A pushed response with headers.
17+
* A pushed response with its request headers.
1818
*
1919
* @author Alexander M. Turek <[email protected]>
2020
*
2121
* @internal
2222
*/
2323
final class PushedResponse
2424
{
25-
/** @var CurlResponse */
2625
public $response;
2726

2827
/** @var string[] */
29-
public $headers;
28+
public $requestHeaders;
3029

31-
public function __construct(CurlResponse $response, array $headers)
30+
public $parentOptions = [];
31+
32+
public function __construct(CurlResponse $response, array $requestHeaders, array $parentOptions)
3233
{
3334
$this->response = $response;
34-
$this->headers = $headers;
35+
$this->requestHeaders = $requestHeaders;
36+
$this->parentOptions = $parentOptions;
3537
}
3638
}

Response/CurlResponse.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Log\LoggerInterface;
1515
use Symfony\Component\HttpClient\Chunk\FirstChunk;
16+
use Symfony\Component\HttpClient\Chunk\InformationalChunk;
1617
use Symfony\Component\HttpClient\Exception\TransportException;
1718
use Symfony\Component\HttpClient\Internal\CurlClientState;
1819
use Symfony\Contracts\HttpClient\ResponseInterface;
@@ -120,9 +121,6 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
120121

121122
if (\in_array($waitFor, ['headers', 'destruct'], true)) {
122123
try {
123-
if (\defined('CURLOPT_STREAM_WEIGHT')) {
124-
curl_setopt($ch, CURLOPT_STREAM_WEIGHT, 32);
125-
}
126124
self::stream([$response])->current();
127125
} catch (\Throwable $e) {
128126
// Persist timeouts thrown during initialization
@@ -140,7 +138,7 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
140138
};
141139

142140
// Schedule the request in a non-blocking way
143-
$multi->openHandles[$id] = $ch;
141+
$multi->openHandles[$id] = [$ch, $options];
144142
curl_multi_add_handle($multi->handle, $ch);
145143
self::perform($multi);
146144
}
@@ -314,8 +312,11 @@ private static function parseHeaderLine($ch, string $data, array &$info, array &
314312
return \strlen($data);
315313
}
316314

317-
// End of headers: handle redirects and add to the activity list
315+
// End of headers: handle informational responses, redirects, etc.
316+
318317
if (200 > $statusCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE)) {
318+
$multi->handlesActivity[$id][] = new InformationalChunk($statusCode, $headers);
319+
319320
return \strlen($data);
320321
}
321322

@@ -342,7 +343,7 @@ private static function parseHeaderLine($ch, string $data, array &$info, array &
342343

343344
if ($statusCode < 300 || 400 <= $statusCode || curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
344345
// Headers and redirects completed, time to get the response's body
345-
$multi->handlesActivity[$id] = [new FirstChunk()];
346+
$multi->handlesActivity[$id][] = new FirstChunk();
346347

347348
if ('destruct' === $waitFor) {
348349
return 0;

Response/MockResponse.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class MockResponse implements ResponseInterface
4545
public function __construct($body = '', array $info = [])
4646
{
4747
$this->body = is_iterable($body) ? $body : (string) $body;
48-
$this->info = $info + $this->info;
48+
$this->info = $info + ['http_code' => 200] + $this->info;
4949

5050
if (!isset($info['response_headers'])) {
5151
return;
@@ -59,7 +59,8 @@ public function __construct($body = '', array $info = [])
5959
}
6060
}
6161

62-
$this->info['response_headers'] = $responseHeaders;
62+
$this->info['response_headers'] = [];
63+
self::addResponseHeaders($responseHeaders, $this->info, $this->headers);
6364
}
6465

6566
/**

Response/ResponseStream.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717

1818
/**
1919
* @author Nicolas Grekas <[email protected]>
20-
*
21-
* @internal
2220
*/
2321
final class ResponseStream implements ResponseStreamInterface
2422
{

Tests/CurlHttpClientTest.php

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,22 @@ public function log($level, $message, array $context = []): void
4747
}
4848
};
4949

50-
$client = new CurlHttpClient();
50+
$client = new CurlHttpClient([], 6, 2);
5151
$client->setLogger($logger);
5252

53-
$index = $client->request('GET', 'https://http2-push.io');
53+
$index = $client->request('GET', 'https://http2.akamai.com/');
5454
$index->getContent();
5555

56-
$css = $client->request('GET', 'https://http2-push.io/css/style.css');
57-
$js = $client->request('GET', 'https://http2-push.io/js/http2-push.js');
56+
$css = $client->request('GET', 'https://http2.akamai.com/resources/push.css');
5857

5958
$css->getHeaders();
60-
$js->getHeaders();
6159

6260
$expected = [
63-
'Request: "GET https://http2-push.io/"',
64-
'Queueing pushed response: "https://http2-push.io/css/style.css"',
65-
'Queueing pushed response: "https://http2-push.io/js/http2-push.js"',
66-
'Response: "200 https://http2-push.io/"',
67-
'Connecting request to pushed response: "GET https://http2-push.io/css/style.css"',
68-
'Connecting request to pushed response: "GET https://http2-push.io/js/http2-push.js"',
69-
'Response: "200 https://http2-push.io/css/style.css"',
70-
'Response: "200 https://http2-push.io/js/http2-push.js"',
61+
'Request: "GET https://http2.akamai.com/"',
62+
'Queueing pushed response: "https://http2.akamai.com/resources/push.css"',
63+
'Response: "200 https://http2.akamai.com/"',
64+
'Accepting pushed response: "GET https://http2.akamai.com/resources/push.css"',
65+
'Response: "200 https://http2.akamai.com/resources/push.css"',
7166
];
7267
$this->assertSame($expected, $logger->logs);
7368
}

0 commit comments

Comments
 (0)