diff --git a/src/Symfony/Component/HttpClient/CurlHttpClient.php b/src/Symfony/Component/HttpClient/CurlHttpClient.php index c235ddcedd..702491f8fa 100644 --- a/src/Symfony/Component/HttpClient/CurlHttpClient.php +++ b/src/Symfony/Component/HttpClient/CurlHttpClient.php @@ -108,12 +108,14 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) { unset($this->multi->pushedResponses[$url]); // Accept pushed responses only if their headers related to authentication match the request - $expectedHeaders = [ - $options['headers']['authorization'] ?? null, - $options['headers']['cookie'] ?? null, - $options['headers']['x-requested-with'] ?? null, - $options['headers']['range'] ?? null, - ]; + $expectedHeaders = ['authorization', 'cookie', 'x-requested-with', 'range']; + foreach ($expectedHeaders as $k => $v) { + $expectedHeaders[$k] = null; + + foreach ($options['normalized_headers'][$v] ?? [] as $h) { + $expectedHeaders[$k][] = substr($h, 2 + \strlen($v)); + } + } if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) { $this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url)); @@ -206,11 +208,11 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface $curlopts[CURLOPT_NOSIGNAL] = true; } - if (!isset($options['headers']['accept-encoding'])) { + if (!isset($options['normalized_headers']['accept-encoding'])) { $curlopts[CURLOPT_ENCODING] = ''; // Enable HTTP compression } - foreach ($options['request_headers'] as $header) { + foreach ($options['headers'] as $header) { if (':' === $header[-2] && \strlen($header) - 2 === strpos($header, ': ')) { // curl requires a special syntax to send empty headers $curlopts[CURLOPT_HTTPHEADER][] = substr_replace($header, ';', -2); @@ -221,7 +223,7 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface // Prevent curl from sending its default Accept and Expect headers foreach (['accept', 'expect'] as $header) { - if (!isset($options['headers'][$header])) { + if (!isset($options['normalized_headers'][$header])) { $curlopts[CURLOPT_HTTPHEADER][] = $header.':'; } } @@ -237,9 +239,9 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface }; } - if (isset($options['headers']['content-length'][0])) { - $curlopts[CURLOPT_INFILESIZE] = $options['headers']['content-length'][0]; - } elseif (!isset($options['headers']['transfer-encoding'])) { + if (isset($options['normalized_headers']['content-length'][0])) { + $curlopts[CURLOPT_INFILESIZE] = substr($options['normalized_headers']['content-length'][0], \strlen('Content-Length: ')); + } elseif (!isset($options['normalized_headers']['transfer-encoding'])) { $curlopts[CURLOPT_HTTPHEADER][] = 'Transfer-Encoding: chunked'; // Enable chunked request bodies } @@ -387,12 +389,12 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface $redirectHeaders = []; if (0 < $options['max_redirects']) { $redirectHeaders['host'] = $host; - $redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) { + $redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) { return 0 !== stripos($h, 'Host:'); }); - if (isset($options['headers']['authorization']) || isset($options['headers']['cookie'])) { - $redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) { + if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) { + $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) { return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'); }); } diff --git a/src/Symfony/Component/HttpClient/HttpClientTrait.php b/src/Symfony/Component/HttpClient/HttpClientTrait.php index 4d263f46db..1c5e4578c7 100644 --- a/src/Symfony/Component/HttpClient/HttpClientTrait.php +++ b/src/Symfony/Component/HttpClient/HttpClientTrait.php @@ -50,7 +50,10 @@ trait HttpClientTrait } $options['body'] = self::jsonEncode($options['json']); unset($options['json']); - $options['headers']['content-type'] = $options['headers']['content-type'] ?? ['application/json']; + + if (!isset($options['normalized_headers']['content-type'])) { + $options['normalized_headers']['content-type'] = [$options['headers'][] = 'Content-Type: application/json']; + } } if (isset($options['body'])) { @@ -61,19 +64,6 @@ trait HttpClientTrait $options['peer_fingerprint'] = self::normalizePeerFingerprint($options['peer_fingerprint']); } - // Compute request headers - $requestHeaders = $headers = []; - - foreach ($options['headers'] as $name => $values) { - foreach ($values as $value) { - $requestHeaders[] = $name.': '.$headers[$name][] = $value = (string) $value; - - if (\strlen($value) !== strcspn($value, "\r\n\0")) { - throw new InvalidArgumentException(sprintf('Invalid header value: CR/LF/NUL found in "%s".', $value)); - } - } - } - // Validate on_progress if (!\is_callable($onProgress = $options['on_progress'] ?? 'var_dump')) { throw new InvalidArgumentException(sprintf('Option "on_progress" must be callable, %s given.', \is_object($onProgress) ? \get_class($onProgress) : \gettype($onProgress))); @@ -102,15 +92,14 @@ trait HttpClientTrait if (null !== $url) { // Merge auth with headers - if (($options['auth_basic'] ?? false) && !($headers['authorization'] ?? false)) { - $requestHeaders[] = 'authorization: '.$headers['authorization'][] = 'Basic '.base64_encode($options['auth_basic']); + if (($options['auth_basic'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) { + $options['normalized_headers']['authorization'] = [$options['headers'][] = 'Authorization: Basic '.base64_encode($options['auth_basic'])]; } // Merge bearer with headers - if (($options['auth_bearer'] ?? false) && !($headers['authorization'] ?? false)) { - $requestHeaders[] = 'authorization: '.$headers['authorization'][] = 'Bearer '.$options['auth_bearer']; + if (($options['auth_bearer'] ?? false) && !($options['normalized_headers']['authorization'] ?? false)) { + $options['normalized_headers']['authorization'] = [$options['headers'][] = 'Authorization: Bearer '.$options['auth_bearer']]; } - $options['request_headers'] = $requestHeaders; unset($options['auth_basic'], $options['auth_bearer']); // Parse base URI @@ -124,7 +113,6 @@ trait HttpClientTrait } // Finalize normalization of options - $options['headers'] = $headers; $options['http_version'] = (string) ($options['http_version'] ?? '') ?: null; $options['timeout'] = (float) ($options['timeout'] ?? ini_get('default_socket_timeout')); @@ -136,31 +124,38 @@ trait HttpClientTrait */ private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array { - unset($options['request_headers'], $defaultOptions['request_headers']); - - $options['headers'] = self::normalizeHeaders($options['headers'] ?? []); + $options['normalized_headers'] = self::normalizeHeaders($options['headers'] ?? []); if ($defaultOptions['headers'] ?? false) { - $options['headers'] += self::normalizeHeaders($defaultOptions['headers']); + $options['normalized_headers'] += self::normalizeHeaders($defaultOptions['headers']); } - if ($options['resolve'] ?? false) { - $options['resolve'] = array_change_key_case($options['resolve']); + $options['headers'] = array_merge(...array_values($options['normalized_headers']) ?: [[]]); + + if ($resolve = $options['resolve'] ?? false) { + $options['resolve'] = []; + foreach ($resolve as $k => $v) { + $options['resolve'][substr(self::parseUrl('http://'.$k)['authority'], 2)] = (string) $v; + } } // Option "query" is never inherited from defaults $options['query'] = $options['query'] ?? []; foreach ($defaultOptions as $k => $v) { - $options[$k] = $options[$k] ?? $v; + if ('normalized_headers' !== $k && !isset($options[$k])) { + $options[$k] = $v; + } } if (isset($defaultOptions['extra'])) { $options['extra'] += $defaultOptions['extra']; } - if ($defaultOptions['resolve'] ?? false) { - $options['resolve'] += array_change_key_case($defaultOptions['resolve']); + if ($resolve = $defaultOptions['resolve'] ?? false) { + foreach ($resolve as $k => $v) { + $options['resolve'] += [substr(self::parseUrl('http://'.$k)['authority'], 2) => (string) $v]; + } } if ($allowExtraOptions || !$defaultOptions) { @@ -169,7 +164,7 @@ trait HttpClientTrait // Look for unsupported options foreach ($options as $name => $v) { - if (\array_key_exists($name, $defaultOptions)) { + if (\array_key_exists($name, $defaultOptions) || 'normalized_headers' === $name) { continue; } @@ -188,9 +183,9 @@ trait HttpClientTrait } /** - * Normalizes headers by putting their names as lowercased keys. - * * @return string[][] + * + * @throws InvalidArgumentException When an invalid header is found */ private static function normalizeHeaders(array $headers): array { @@ -204,10 +199,15 @@ trait HttpClientTrait $values = (array) $values; } - $normalizedHeaders[$name = strtolower($name)] = []; + $lcName = strtolower($name); + $normalizedHeaders[$lcName] = []; foreach ($values as $value) { - $normalizedHeaders[$name][] = $value; + $normalizedHeaders[$lcName][] = $value = $name.': '.$value; + + if (\strlen($value) !== strcspn($value, "\r\n\0")) { + throw new InvalidArgumentException(sprintf('Invalid header: CR/LF/NUL found in "%s".', $value)); + } } } diff --git a/src/Symfony/Component/HttpClient/NativeHttpClient.php b/src/Symfony/Component/HttpClient/NativeHttpClient.php index 58c1d3d65f..7067d0b4b4 100644 --- a/src/Symfony/Component/HttpClient/NativeHttpClient.php +++ b/src/Symfony/Component/HttpClient/NativeHttpClient.php @@ -73,13 +73,13 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac $options['body'] = self::getBodyAsString($options['body']); - if ('' !== $options['body'] && 'POST' === $method && !isset($options['headers']['content-type'])) { - $options['request_headers'][] = 'content-type: application/x-www-form-urlencoded'; + if ('' !== $options['body'] && 'POST' === $method && !isset($options['normalized_headers']['content-type'])) { + $options['headers'][] = 'Content-Type: application/x-www-form-urlencoded'; } - if ($gzipEnabled = \extension_loaded('zlib') && !isset($options['headers']['accept-encoding'])) { + if ($gzipEnabled = \extension_loaded('zlib') && !isset($options['normalized_headers']['accept-encoding'])) { // gzip is the most widely available algo, no need to deal with deflate - $options['request_headers'][] = 'accept-encoding: gzip'; + $options['headers'][] = 'Accept-Encoding: gzip'; } if ($options['peer_fingerprint']) { @@ -160,12 +160,12 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac [$host, $port, $url['authority']] = self::dnsResolve($url, $this->multi, $info, $onProgress); - if (!isset($options['headers']['host'])) { - $options['request_headers'][] = 'host: '.$host.$port; + if (!isset($options['normalized_headers']['host'])) { + $options['headers'][] = 'Host: '.$host.$port; } - if (!isset($options['headers']['user-agent'])) { - $options['request_headers'][] = 'user-agent: Symfony HttpClient/Native'; + if (!isset($options['normalized_headers']['user-agent'])) { + $options['headers'][] = 'User-Agent: Symfony HttpClient/Native'; } $context = [ @@ -208,7 +208,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac $resolveRedirect = self::createRedirectResolver($options, $host, $proxy, $noProxy, $info, $onProgress); $context = stream_context_create($context, ['notification' => $notification]); - self::configureHeadersAndProxy($context, $host, $options['request_headers'], $proxy, $noProxy); + self::configureHeadersAndProxy($context, $host, $options['headers'], $proxy, $noProxy); return new NativeResponse($this->multi, $context, implode('', $url), $options, $gzipEnabled, $info, $resolveRedirect, $onProgress, $this->logger); } @@ -335,12 +335,12 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac $redirectHeaders = []; if (0 < $maxRedirects = $options['max_redirects']) { $redirectHeaders = ['host' => $host]; - $redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) { + $redirectHeaders['with_auth'] = $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) { return 0 !== stripos($h, 'Host:'); }); - if (isset($options['headers']['authorization']) || isset($options['headers']['cookie'])) { - $redirectHeaders['no_auth'] = array_filter($options['request_headers'], static function ($h) { + if (isset($options['normalized_headers']['authorization']) || isset($options['normalized_headers']['cookie'])) { + $redirectHeaders['no_auth'] = array_filter($options['headers'], static function ($h) { return 0 !== stripos($h, 'Authorization:') && 0 !== stripos($h, 'Cookie:'); }); } @@ -393,7 +393,7 @@ final class NativeHttpClient implements HttpClientInterface, LoggerAwareInterfac if (false !== (parse_url($location, PHP_URL_HOST) ?? false)) { // Authorization and Cookie headers MUST NOT follow except for the initial host name $requestHeaders = $redirectHeaders['host'] === $host ? $redirectHeaders['with_auth'] : $redirectHeaders['no_auth']; - $requestHeaders[] = 'host: '.$host.$port; + $requestHeaders[] = 'Host: '.$host.$port; self::configureHeadersAndProxy($context, $host, $requestHeaders, $proxy, $noProxy); } diff --git a/src/Symfony/Component/HttpClient/Response/NativeResponse.php b/src/Symfony/Component/HttpClient/Response/NativeResponse.php index 766506479f..7aa2d8022d 100644 --- a/src/Symfony/Component/HttpClient/Response/NativeResponse.php +++ b/src/Symfony/Component/HttpClient/Response/NativeResponse.php @@ -241,6 +241,7 @@ final class NativeResponse implements ResponseInterface try { // Notify the progress callback so that it can e.g. cancel // the request if the stream is inactive for too long + $info['total_time'] = microtime(true) - $info['start_time']; $onProgress(); } catch (\Throwable $e) { // no-op diff --git a/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php b/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php index 4948822c5e..f39771a46e 100644 --- a/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php +++ b/src/Symfony/Component/HttpClient/Tests/HttpClientTraitTest.php @@ -172,8 +172,8 @@ class HttpClientTraitTest extends TestCase public function testAuthBearerOption() { [, $options] = self::prepareRequest('POST', 'http://example.com', ['auth_bearer' => 'foobar'], HttpClientInterface::OPTIONS_DEFAULTS); - $this->assertSame('Bearer foobar', $options['headers']['authorization'][0]); - $this->assertSame('authorization: Bearer foobar', $options['request_headers'][0]); + $this->assertSame(['Authorization: Bearer foobar'], $options['headers']); + $this->assertSame(['Authorization: Bearer foobar'], $options['normalized_headers']['authorization']); } /** @@ -226,7 +226,7 @@ class HttpClientTraitTest extends TestCase public function testPrepareAuthBasic($arg, $result) { [, $options] = $this->prepareRequest('POST', 'http://example.com', ['auth_basic' => $arg], HttpClientInterface::OPTIONS_DEFAULTS); - $this->assertSame('Basic '.$result, $options['headers']['authorization'][0]); + $this->assertSame('Authorization: Basic '.$result, $options['normalized_headers']['authorization'][0]); } public function provideFingerprints() diff --git a/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php b/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php index e4dbcf6c9a..27fe23e9c2 100644 --- a/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php +++ b/src/Symfony/Component/HttpClient/Tests/ScopingHttpClientTest.php @@ -44,9 +44,9 @@ class ScopingHttpClientTest extends TestCase $client = new ScopingHttpClient($mockClient, $options); $response = $client->request('GET', $url); - $reuestedOptions = $response->getRequestOptions(); + $requestedOptions = $response->getRequestOptions(); - $this->assertEquals($reuestedOptions['case'], $options[$regexp]['case']); + $this->assertSame($options[$regexp]['case'], $requestedOptions['case']); } public function provideMatchingUrls() @@ -64,8 +64,8 @@ class ScopingHttpClientTest extends TestCase public function testMatchingUrlsAndOptions() { $defaultOptions = [ - '.*/foo-bar' => ['headers' => ['x-app' => 'unit-test-foo-bar']], - '.*' => ['headers' => ['content-type' => 'text/html']], + '.*/foo-bar' => ['headers' => ['X-FooBar' => 'unit-test-foo-bar']], + '.*' => ['headers' => ['Content-Type' => 'text/html']], ]; $mockClient = new MockHttpClient(); @@ -73,20 +73,20 @@ class ScopingHttpClientTest extends TestCase $response = $client->request('GET', 'http://example.com/foo-bar', ['json' => ['url' => 'http://example.com']]); $requestOptions = $response->getRequestOptions(); - $this->assertEquals($requestOptions['headers']['content-type'][0], 'application/json'); + $this->assertSame('Content-Type: application/json', $requestOptions['headers'][1]); $requestJson = json_decode($requestOptions['body'], true); - $this->assertEquals($requestJson['url'], 'http://example.com'); - $this->assertEquals($requestOptions['headers']['x-app'][0], $defaultOptions['.*/foo-bar']['headers']['x-app']); + $this->assertSame('http://example.com', $requestJson['url']); + $this->assertSame('X-FooBar: '.$defaultOptions['.*/foo-bar']['headers']['X-FooBar'], $requestOptions['headers'][0]); - $response = $client->request('GET', 'http://example.com/bar-foo', ['headers' => ['x-app' => 'unit-test']]); + $response = $client->request('GET', 'http://example.com/bar-foo', ['headers' => ['X-FooBar' => 'unit-test']]); $requestOptions = $response->getRequestOptions(); - $this->assertEquals($requestOptions['headers']['x-app'][0], 'unit-test'); - $this->assertEquals($requestOptions['headers']['content-type'][0], 'text/html'); + $this->assertSame('X-FooBar: unit-test', $requestOptions['headers'][0]); + $this->assertSame('Content-Type: text/html', $requestOptions['headers'][1]); - $response = $client->request('GET', 'http://example.com/foobar-foo', ['headers' => ['x-app' => 'unit-test']]); + $response = $client->request('GET', 'http://example.com/foobar-foo', ['headers' => ['X-FooBar' => 'unit-test']]); $requestOptions = $response->getRequestOptions(); - $this->assertEquals($requestOptions['headers']['x-app'][0], 'unit-test'); - $this->assertEquals($requestOptions['headers']['content-type'][0], 'text/html'); + $this->assertSame('X-FooBar: unit-test', $requestOptions['headers'][0]); + $this->assertSame('Content-Type: text/html', $requestOptions['headers'][1]); } public function testForBaseUri() diff --git a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php index b985585220..6636af7a23 100644 --- a/src/Symfony/Contracts/HttpClient/HttpClientInterface.php +++ b/src/Symfony/Contracts/HttpClient/HttpClientInterface.php @@ -40,8 +40,8 @@ interface HttpClientInterface // value if they are not defined - typically "application/json" 'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that // MUST be available via $response->getInfo('user_data') - not used internally - 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower or equal to 0 means - // redirects should not be followed; "Authorization" and "Cookie" headers MUST + 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower than or equal to 0 + // means redirects should not be followed; "Authorization" and "Cookie" headers MUST // NOT follow except for the initial host name 'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0 'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2