diff --git a/composer.json b/composer.json index d064c57e2dc..165f3890ca3 100644 --- a/composer.json +++ b/composer.json @@ -138,7 +138,7 @@ "doctrine/orm": "^3.4", "dragonmantank/cron-expression": "^3.1", "egulias/email-validator": "^2.1.10|^3.1|^4", - "guzzlehttp/promises": "^1.4|^2.0", + "guzzlehttp/guzzle": "^7.10", "jolicode/jolinotif": "^2.7.2|^3.0", "jsonpath-standard/jsonpath-compliance-test-suite": "*", "league/html-to-markdown": "^5.0", diff --git a/src/Symfony/Component/HttpClient/CHANGELOG.md b/src/Symfony/Component/HttpClient/CHANGELOG.md index 99985dbb7f9..cc866ef010d 100644 --- a/src/Symfony/Component/HttpClient/CHANGELOG.md +++ b/src/Symfony/Component/HttpClient/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add support for the `max_connect_duration` option * Add option `extra.use_persistent_connections` to `CurlHttpClient` to control the use of persistent connections introduced in PHP 8.5 + * Add `GuzzleHttpHandler` that allows using Symfony HttpClient as a Guzzle handler 8.0 --- diff --git a/src/Symfony/Component/HttpClient/GuzzleHttpHandler.php b/src/Symfony/Component/HttpClient/GuzzleHttpHandler.php new file mode 100644 index 00000000000..5de9176d10b --- /dev/null +++ b/src/Symfony/Component/HttpClient/GuzzleHttpHandler.php @@ -0,0 +1,639 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient; + +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Promise\Promise; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Promise\Utils as PromiseUtils; +use GuzzleHttp\Psr7\Response as GuzzleResponse; +use GuzzleHttp\Psr7\Utils as Psr7Utils; +use GuzzleHttp\TransferStats; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as SymfonyResponseInterface; + +/** + * A Guzzle handler that uses Symfony's HttpClientInterface as its transport. + * + * This lets SDKs tightly coupled to Guzzle benefit from Symfony HttpClient's + * features (e.g. retry logic, tracing, scoping, mocking) by plugging this + * handler into a Guzzle client: + * + * $handler = new GuzzleHttpHandler(HttpClient::create()); + * $guzzle = new \GuzzleHttp\Client(['handler' => $handler]); + * + * The handler is truly asynchronous: __invoke() returns a *pending* Promise + * immediately without performing any I/O. The actual work is driven by + * Symfony's HttpClientInterface::stream(), which multiplexes all in-flight + * requests together - the same approach CurlMultiHandler takes with + * curl_multi_*. Waiting on any single promise drives the whole pool so + * concurrent requests benefit from parallelism automatically. + * + * Guzzle request options are mapped to their Symfony equivalents as faithfully + * as possible; unsupported options are silently ignored so that existing SDK + * option sets do not cause errors. + * + * @author Nicolas Grekas + */ +final class GuzzleHttpHandler +{ + private readonly HttpClientInterface $client; + + /** + * Maps each Symfony response (key) to a 3-tuple: + * [Psr7 RequestInterface, Guzzle options array, Guzzle Promise] + * + * @var \SplObjectStorage + */ + private readonly \SplObjectStorage $pending; + + /** + * PSR-7 response created eagerly on the first chunk so that the same + * instance is passed to on_headers and later resolved by the promise. + * + * @var \SplObjectStorage + */ + private readonly \SplObjectStorage $psr7Responses; + + private readonly bool $autoUpgradeHttpVersion; + + public function __construct(?HttpClientInterface $client = null, bool $autoUpgradeHttpVersion = true) + { + $this->client = $client ?? HttpClient::create(); + $this->autoUpgradeHttpVersion = $autoUpgradeHttpVersion; + $this->pending = new \SplObjectStorage(); + $this->psr7Responses = new \SplObjectStorage(); + } + + /** + * Returns a *pending* Promise - no I/O is performed here. + * + * The wait function passed to the Promise drives Symfony's stream() loop, + * which resolves all currently queued requests concurrently. + */ + public function __invoke(RequestInterface $request, array $options): PromiseInterface + { + $symfonyOptions = $this->buildSymfonyOptions($request, $options); + + try { + $symfonyResponse = $this->client->request($request->getMethod(), (string) $request->getUri(), $symfonyOptions); + } catch (\Exception $e) { + // Option validation errors surface here synchronously. + $p = new Promise(); + $p->reject($e); + + return $p; + } + + $promise = new Promise( + function () use ($symfonyResponse): void { + $this->streamPending(null, $symfonyResponse); + }, + function () use ($symfonyResponse): void { + unset($this->pending[$symfonyResponse], $this->psr7Responses[$symfonyResponse]); + $symfonyResponse->cancel(); + }, + ); + + $this->pending[$symfonyResponse] = [$request, $options, $promise]; + + if (isset($options['delay'])) { + $pause = $symfonyResponse->getInfo('pause_handler'); + if (\is_callable($pause)) { + $pause($options['delay'] / 1000.0); + } else { + usleep((int) ($options['delay'] * 1000)); + } + } + + return $promise; + } + + /** + * Ticks the event loop: processes available I/O and runs queued tasks. + * + * @param float $timeout Maximum time in seconds to wait for network activity (0 = non-blocking) + */ + public function tick(float $timeout = 1.0): void + { + $queue = PromiseUtils::queue(); + + // Push streaming work onto the Guzzle task queue so that .then() + // callbacks and other queued tasks get cooperative scheduling. + $queue->add(fn () => $this->streamPending($timeout, true)); + + $queue->run(); + } + + /** + * Runs until all outstanding connections have completed. + */ + public function execute(): void + { + while ($this->pending->count()) { + $this->streamPending(null, false); + } + } + + /** + * Performs one pass of streaming I/O over all pending responses. + * + * @param float|null $timeout Idle timeout passed to stream(); 0 for non-blocking, null for default + */ + private function streamPending(?float $timeout, bool|SymfonyResponseInterface $breakAfter): void + { + if (!$this->pending->count()) { + return; + } + + $queue = PromiseUtils::queue(); + + $responses = []; + foreach ($this->pending as $r) { + $responses[] = $r; + } + + foreach ($this->client->stream($responses, $timeout) as $response => $chunk) { + try { + if ($chunk->isTimeout()) { + continue; + } + + if ($chunk->isFirst()) { + // Deactivate 4xx/5xx exception throwing for this response; + // Guzzle's http_errors middleware handles that layer. + $response->getStatusCode(); + + [, $guzzleOpts] = $this->pending[$response] ?? [null, []]; + $sink = $guzzleOpts['sink'] ?? null; + $body = Psr7Utils::streamFor(\is_string($sink) ? fopen($sink, 'w+') : ($sink ?? fopen('php://temp', 'r+'))); + + if (600 <= $response->getStatusCode()) { + $psrResponse = new GuzzleResponse(567, $response->getHeaders(false), $body); + (new \ReflectionProperty($psrResponse, 'statusCode'))->setValue($psrResponse, $response->getStatusCode()); + } else { + $psrResponse = new GuzzleResponse($response->getStatusCode(), $response->getHeaders(false), $body); + } + $this->psr7Responses[$response] = $psrResponse; + + if (isset($guzzleOpts['on_headers'])) { + try { + ($guzzleOpts['on_headers'])($psrResponse); + } catch (\Throwable $e) { + [$guzzleRequest, , $promise] = $this->pending[$response]; + unset($this->pending[$response], $this->psr7Responses[$response]); + $this->fireOnStats($guzzleOpts, $guzzleRequest, $psrResponse, $e, $response); + $promise->reject(new RequestException($e->getMessage(), $guzzleRequest, $psrResponse, $e)); + + $response->cancel(); + } + } + } + + $content = $chunk->getContent(); + if ('' !== $content && isset($this->psr7Responses[$response])) { + $this->psr7Responses[$response]->getBody()->write($content); + } + + if (!$chunk->isLast()) { + if (true === $breakAfter) { + break; + } + continue; + } + if (!isset($this->pending[$response])) { + unset($this->psr7Responses[$response]); + } else { + $this->resolveResponse($response); + } + if (\in_array($breakAfter, [true, $response], true)) { + break; + } + } catch (TransportExceptionInterface $e) { + if (isset($this->pending[$response])) { + $this->rejectResponse($response, $e); + } else { + unset($this->psr7Responses[$response]); + } + if (\in_array($breakAfter, [true, $response], true)) { + break; + } + } finally { + // Run .then() callbacks; they may add new entries to $this->pending. + $queue->run(); + } + } + } + + private function resolveResponse(SymfonyResponseInterface $response): void + { + [$guzzleRequest, $options, $promise] = $this->pending[$response]; + $psrResponse = $this->psr7Responses[$response]; + unset($this->pending[$response], $this->psr7Responses[$response]); + + $body = $psrResponse->getBody(); + if ($body->isSeekable()) { + try { + $body->seek(0); + } catch (\RuntimeException) { + // ignore + } + } + + $this->fireOnStats($options, $guzzleRequest, $psrResponse, null, $response); + $promise->resolve($psrResponse); + } + + private function rejectResponse(SymfonyResponseInterface $response, TransportExceptionInterface $e): void + { + [$guzzleRequest, $options, $promise] = $this->pending[$response]; + $psrResponse = $this->psr7Responses[$response] ?? null; + unset($this->pending[$response], $this->psr7Responses[$response]); + + if ($body = $psrResponse?->getBody()) { + // Headers were already received: use RequestException so Guzzle middleware (e.g. retry) + // can distinguish a mid-stream failure from a connection-level one. + if ($body->isSeekable()) { + try { + $body->seek(0); + } catch (\RuntimeException) { + // ignore + } + } + + $this->fireOnStats($options, $guzzleRequest, $psrResponse, $e, $response); + $promise->reject(new RequestException($e->getMessage(), $guzzleRequest, $psrResponse, $e)); + } else { + // No headers received: connection-level failure. + $this->fireOnStats($options, $guzzleRequest, null, $e, $response); + $promise->reject(new ConnectException($e->getMessage(), $guzzleRequest, null, [], $e)); + } + } + + private function fireOnStats(array $options, RequestInterface $request, ?ResponseInterface $psrResponse, ?\Throwable $error, SymfonyResponseInterface $symfonyResponse): void + { + if (!isset($options['on_stats'])) { + return; + } + + $handlerStats = $symfonyResponse->getInfo(); + ($options['on_stats'])(new TransferStats($request, $psrResponse, $handlerStats['total_time'] ?? 0.0, $error, $handlerStats)); + } + + private function buildSymfonyOptions(RequestInterface $request, array $guzzleOptions): array + { + $options = []; + + $options['headers'] = $this->extractHeaders($request, $guzzleOptions); + + $this->applyBody($request, $options); + $this->applyAuth($guzzleOptions, $options); + $this->applyTimeouts($guzzleOptions, $options); + $this->applySsl($guzzleOptions, $options); + $this->applyProxy($request, $guzzleOptions, $options); + $this->applyRedirects($guzzleOptions, $options); + $this->applyMisc($request, $guzzleOptions, $options); + $this->applyDecodeContent($guzzleOptions, $options); + if (\extension_loaded('curl') && isset($guzzleOptions['curl'])) { + $this->applyCurlOptions($guzzleOptions['curl'], $options); + } + + return $options; + } + + /** + * Merges headers from the PSR-7 request with any headers supplied via the + * Guzzle 'headers' option (Guzzle option takes precedence). + * + * @return array + */ + private function extractHeaders(RequestInterface $request, array $guzzleOptions): array + { + $headers = $request->getHeaders(); + + foreach ($guzzleOptions['headers'] ?? [] as $name => $value) { + $headers[$name] = (array) $value; + } + + return $headers; + } + + private function applyBody(RequestInterface $request, array &$options): void + { + $key = 'content-length'; + $body = $request->getBody(); + if (!$size = $options['headers'][$key][0] ?? $options['headers'][$key = 'Content-Length'][0] ?? $body->getSize() ?? -1) { + return; + } + + if ($size < 0 || 1 << 21 < $size) { + $options['body'] = static function (int $size) use ($body) { + if ($body->isSeekable()) { + try { + $body->seek(0); + } catch (\RuntimeException) { + // ignore + } + } + + while (!$body->eof()) { + yield $body->read($size); + } + }; + } else { + if ($body->isSeekable()) { + try { + $body->seek(0); + } catch (\RuntimeException) { + // ignore + } + } + $options['body'] = $body->getContents(); + } + + if (0 < $size) { + $options['headers'][$key] = [$size]; + } + } + + /** + * Maps Guzzle's 'auth' option. + * + * Supported forms: + * ['user', 'pass'] -> auth_basic + * ['user', 'pass', 'basic'] -> auth_basic + * ['token', '', 'bearer'] -> auth_bearer + * ['token', '', 'token'] -> auth_bearer (alias) + */ + private function applyAuth(array $guzzleOptions, array &$options): void + { + if (!isset($guzzleOptions['auth'])) { + return; + } + + $auth = $guzzleOptions['auth']; + $type = strtolower($auth[2] ?? 'basic'); + + if ('bearer' === $type || 'token' === $type) { + $options['auth_bearer'] = $auth[0]; + } elseif ('ntlm' === $type) { + array_pop($auth); + $options['auth_ntlm'] = $auth; + } else { + $options['auth_basic'] = [$auth[0], $auth[1] ?? '']; + } + } + + private function applyTimeouts(array $guzzleOptions, array &$options): void + { + if (0 < ($guzzleOptions['timeout'] ?? 0)) { + $options['max_duration'] = (float) $guzzleOptions['timeout']; + } + + if (0 < ($guzzleOptions['read_timeout'] ?? 0)) { + $options['timeout'] = (float) $guzzleOptions['read_timeout']; + } + + if (0 < ($guzzleOptions['connect_timeout'] ?? 0)) { + $options['max_connect_duration'] = (float) $guzzleOptions['connect_timeout']; + } + } + + /** + * Maps SSL/TLS related options. + * + * Guzzle 'verify' (bool|string) -> Symfony verify_peer / verify_host / cafile / capath + * Guzzle 'cert' (string|array) -> Symfony local_cert [+ passphrase] + * Guzzle 'ssl_key'(string|array) -> Symfony local_pk [+ passphrase] + * Guzzle 'crypto_method' -> Symfony crypto_method (same PHP stream constants) + */ + private function applySsl(array $guzzleOptions, array &$options): void + { + if (isset($guzzleOptions['verify'])) { + if (false === $guzzleOptions['verify']) { + $options['verify_peer'] = false; + $options['verify_host'] = false; + } elseif (\is_string($guzzleOptions['verify'])) { + if (is_dir($guzzleOptions['verify'])) { + $options['capath'] = $guzzleOptions['verify']; + } else { + $options['cafile'] = $guzzleOptions['verify']; + } + } + } + + if (isset($guzzleOptions['cert'])) { + $cert = $guzzleOptions['cert']; + if (\is_array($cert)) { + [$certPath, $certPass] = $cert; + $options['local_cert'] = $certPath; + $options['passphrase'] = $certPass; + } else { + $options['local_cert'] = $cert; + } + } + + if (isset($guzzleOptions['ssl_key'])) { + $key = $guzzleOptions['ssl_key']; + if (\is_array($key)) { + [$keyPath, $keyPass] = $key; + $options['local_pk'] = $keyPath; + // Do not clobber a passphrase already set by 'cert'. + $options['passphrase'] ??= $keyPass; + } else { + $options['local_pk'] = $key; + } + } + + if (isset($guzzleOptions['crypto_method'])) { + $options['crypto_method'] = $guzzleOptions['crypto_method']; + } + } + + /** + * Maps Guzzle's 'proxy' option. + * + * String form -> proxy + * Array form -> selects proxy by URI scheme; 'no' key maps to no_proxy + */ + private function applyProxy(RequestInterface $request, array $guzzleOptions, array &$options): void + { + if (!isset($guzzleOptions['proxy'])) { + return; + } + + if (\is_string($proxy = $guzzleOptions['proxy'])) { + $options['proxy'] = $proxy; + + return; + } + + $scheme = $request->getUri()->getScheme(); + if (isset($proxy[$scheme])) { + $options['proxy'] = $proxy[$scheme]; + } + + if (isset($proxy['no'])) { + $options['no_proxy'] = implode(',', (array) $proxy['no']); + } + } + + /** + * Maps Guzzle's 'allow_redirects' to Symfony's 'max_redirects'. + * + * false -> 0 (disable redirects) + * true -> (no override; Symfony defaults apply) + * ['max' => N, ...] -> N + */ + private function applyRedirects(array $guzzleOptions, array &$options): void + { + if (!isset($guzzleOptions['allow_redirects'])) { + return; + } + + if (!$ar = $guzzleOptions['allow_redirects']) { + $options['max_redirects'] = 0; + } elseif (\is_array($ar)) { + // 5 matches Guzzle's own default for the 'max' sub-key. + $options['max_redirects'] = $ar['max'] ?? 5; + } + } + + /** + * Miscellaneous options that do not fit a dedicated category. + */ + private function applyMisc(RequestInterface $request, array $guzzleOptions, array &$options): void + { + // We always drive I/O via stream(), so tell Symfony not to build its + // own internal buffer - chunks are written directly to the PSR-7 response body stream. + $options['buffer'] = false; + + if (!$this->autoUpgradeHttpVersion || '1.0' === $request->getProtocolVersion()) { + $options['http_version'] = $request->getProtocolVersion(); + } + + // progress callback: (dlTotal, dlNow, ulTotal, ulNow) in Guzzle + // on_progress: (dlNow, dlTotal, info) in Symfony + if (isset($guzzleOptions['progress'])) { + $guzzleProgress = $guzzleOptions['progress']; + $options['on_progress'] = static function (int $dlNow, int $dlSize, array $info) use ($guzzleProgress): void { + $guzzleProgress($dlSize, $dlNow, max(0, (int) ($info['upload_content_length'] ?? 0)), (int) ($info['size_upload'] ?? 0)); + }; + } + } + + /** + * Maps Guzzle's 'decode_content' option. + * + * true/string -> remove any explicit Accept-Encoding the caller set, so + * Symfony's HttpClient manages the header and auto-decodes + * false -> ensure an Accept-Encoding header is sent to disable + * Symfony's auto-decode behavior + */ + private function applyDecodeContent(array $guzzleOptions, array &$options): void + { + if ($guzzleOptions['decode_content'] ?? true) { + unset($options['headers']['Accept-Encoding'], $options['headers']['accept-encoding']); + } elseif (!isset($options['headers']['Accept-Encoding']) && !isset($options['headers']['accept-encoding'])) { + $options['headers']['Accept-Encoding'] = ['identity']; + } + } + + /** + * Maps raw cURL options from Guzzle's 'curl' option bag to Symfony options. + * + * Constants that have a direct named Symfony equivalent are translated; + * everything else is forwarded verbatim via CurlHttpClient's 'extra.curl' + * pass-through so that no option is silently dropped when the underlying + * transport happens to be CurlHttpClient. + * + * Options managed internally by CurlHttpClient (or Symfony's other + * mechanisms) are silently dropped to avoid the "Cannot set X with + * extra.curl" exception that CurlHttpClient::validateExtraCurlOptions() + * throws for those constants. + */ + private function applyCurlOptions(array $curlOptions, array &$options): void + { + // Build a set of constants that CurlHttpClient rejects in extra.curl + // together with options whose Symfony equivalents are already applied + // via the PSR-7 request or other Guzzle option mappings. + static $blocked; + $blocked ??= array_flip(array_filter([ + // Auth - handled by applyAuth() / requires NTLM-specific logic. + \CURLOPT_HTTPAUTH, \CURLOPT_USERPWD, + // Body - set from the PSR-7 request body by applyBody(). + \CURLOPT_READDATA, \CURLOPT_READFUNCTION, \CURLOPT_INFILESIZE, + \CURLOPT_POSTFIELDS, \CURLOPT_UPLOAD, + // HTTP method - taken from the PSR-7 request. + \CURLOPT_POST, \CURLOPT_PUT, \CURLOPT_CUSTOMREQUEST, + \CURLOPT_HTTPGET, \CURLOPT_NOBODY, + // Headers - merged by extractHeaders(). + \CURLOPT_HTTPHEADER, + // Internal curl signal / redirect-type flags with no Symfony equiv. + \CURLOPT_NOSIGNAL, \CURLOPT_POSTREDIR, + // Progress - handled by applyMisc() via Guzzle's 'progress' option. + \CURLOPT_NOPROGRESS, \CURLOPT_PROGRESSFUNCTION, + // Blocked by CurlHttpClient::validateExtraCurlOptions(). + \CURLOPT_PRIVATE, \CURLOPT_HEADERFUNCTION, \CURLOPT_WRITEFUNCTION, + \CURLOPT_VERBOSE, \CURLOPT_STDERR, \CURLOPT_RETURNTRANSFER, + \CURLOPT_URL, \CURLOPT_FOLLOWLOCATION, \CURLOPT_HEADER, + \CURLOPT_HTTP_VERSION, \CURLOPT_PORT, \CURLOPT_DNS_USE_GLOBAL_CACHE, + \CURLOPT_PROTOCOLS, \CURLOPT_REDIR_PROTOCOLS, \CURLOPT_COOKIEFILE, + \CURLINFO_REDIRECT_COUNT, + \defined('CURLOPT_HTTP09_ALLOWED') ? \CURLOPT_HTTP09_ALLOWED : null, + \defined('CURLOPT_HEADEROPT') ? \CURLOPT_HEADEROPT : null, + // Pinned public key: curl uses "sha256//base64" which is + // incompatible with Symfony's peer_fingerprint array format. + \defined('CURLOPT_PINNEDPUBLICKEY') ? \CURLOPT_PINNEDPUBLICKEY : null, + ])); + + foreach ($curlOptions as $opt => $value) { + if (isset($blocked[$opt])) { + continue; + } + + // CURLOPT_UNIX_SOCKET_PATH is conditionally defined; maps to bindto. + if (\defined('CURLOPT_UNIX_SOCKET_PATH') && \CURLOPT_UNIX_SOCKET_PATH === $opt) { + $options['bindto'] = $value; + continue; + } + + match ($opt) { + \CURLOPT_CAINFO => $options['cafile'] = $value, + \CURLOPT_CAPATH => $options['capath'] = $value, + \CURLOPT_SSLCERT => $options['local_cert'] = $value, + \CURLOPT_SSLKEY => $options['local_pk'] = $value, + \CURLOPT_SSLCERTPASSWD, + \CURLOPT_SSLKEYPASSWD => $options['passphrase'] = $value, + \CURLOPT_SSL_CIPHER_LIST => $options['ciphers'] = $value, + \CURLOPT_CERTINFO => $options['capture_peer_cert_chain'] = (bool) $value, + \CURLOPT_PROXY => $options['proxy'] = $value, + \CURLOPT_NOPROXY => $options['no_proxy'] = $value, + \CURLOPT_USERAGENT => $options['headers']['User-Agent'] = [$value], + \CURLOPT_REFERER => $options['headers']['Referer'] = [$value], + \CURLOPT_INTERFACE => $options['bindto'] = $value, + \CURLOPT_SSL_VERIFYPEER => $options['verify_peer'] = (bool) $value, + \CURLOPT_SSL_VERIFYHOST => $options['verify_host'] = $value > 0, + \CURLOPT_MAXREDIRS => $options['max_redirects'] = $value, + \CURLOPT_TIMEOUT => $options['max_duration'] = (float) $value, + \CURLOPT_TIMEOUT_MS => $options['max_duration'] = $value / 1000.0, + \CURLOPT_CONNECTTIMEOUT => $options['max_connect_duration'] = (float) $value, + \CURLOPT_CONNECTTIMEOUT_MS => $options['max_connect_duration'] = $value / 1000.0, + default => $options['extra']['curl'][$opt] = $value, + }; + } + } +} diff --git a/src/Symfony/Component/HttpClient/Tests/GuzzleHttpHandlerTest.php b/src/Symfony/Component/HttpClient/Tests/GuzzleHttpHandlerTest.php new file mode 100644 index 00000000000..fc5bb819d6b --- /dev/null +++ b/src/Symfony/Component/HttpClient/Tests/GuzzleHttpHandlerTest.php @@ -0,0 +1,925 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpClient\Tests; + +use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Request; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\TestCase; +use Psr\Http\Message\ResponseInterface; +use Symfony\Component\HttpClient\GuzzleHttpHandler; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +class GuzzleHttpHandlerTest extends TestCase +{ + public function testBasicGetRequest() + { + [$handler] = $this->makeHandler(); + $request = new Request('GET', 'https://example.com/foo'); + + $promise = $handler($request, []); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + $response = $promise->wait(); + $this->assertInstanceOf(ResponseInterface::class, $response); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testMethodAndUrlAreForwarded() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('DELETE', 'https://example.com/resource/42'), [])->wait(); + + $this->assertSame('DELETE', $spy->method); + $this->assertSame('https://example.com/resource/42', $spy->url); + } + + public function testRequestHeadersAreForwarded() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/', ['X-Custom-Header' => 'my-value']), [])->wait(); + + // MockHttpClient normalises headers into normalized_headers as "Name: value" strings. + $this->assertContains('X-Custom-Header: my-value', $spy->options['normalized_headers']['x-custom-header'] ?? []); + } + + public function testGuzzleHeadersOptionMergesWithRequestHeaders() + { + [$handler, $spy] = $this->makeHandler(); + + $handler( + new Request('GET', 'https://example.com/', ['X-From-Request' => 'req']), + ['headers' => ['X-From-Options' => 'opt']], + )->wait(); + + $this->assertContains('X-From-Request: req', $spy->options['normalized_headers']['x-from-request'] ?? []); + $this->assertContains('X-From-Options: opt', $spy->options['normalized_headers']['x-from-options'] ?? []); + } + + public function testBodyIsForwarded() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('POST', 'https://example.com/', ['Content-Type' => 'text/plain'], 'hello body'), [])->wait(); + + $this->assertSame('hello body', $spy->options['body']); + } + + public function testEmptyBodyIsNotSent() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('POST', 'https://example.com/', [], ''), [])->wait(); + + $this->assertArrayNotHasKey('body', $spy->options ?? []); + } + + public function testKnownSizeBodySetsContentLengthHeader() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('POST', 'https://example.com/', [], 'hello body'), [])->wait(); + + $this->assertContains('Content-Length: 10', $spy->options['normalized_headers']['content-length'] ?? []); + } + + public function testBodyWithUnknownSizeIsStreamedAsGenerator() + { + [$handler, $spy] = $this->makeHandler(); + + // PumpStream has no known size (getSize() === null), triggering the streaming path. + $body = new \GuzzleHttp\Psr7\PumpStream(static function (): string|false { + static $content = 'streamed content'; + $chunk = $content; + $content = ''; + + return $chunk ?: false; + }); + + $handler(new Request('POST', 'https://example.com/', [], $body), [])->wait(); + + $this->assertIsCallable($spy->options['body']); + } + + public function testAuthBasicOption() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['auth' => ['user', 'pass']])->wait(); + + // auth_basic is merged into the Authorization header by prepareRequest(). + $expected = 'Authorization: Basic '.base64_encode('user:pass'); + $this->assertContains($expected, $spy->options['normalized_headers']['authorization'] ?? []); + } + + public function testAuthBearerMappedWhenTypeIsBearer() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['auth' => ['mytoken', '', 'bearer']])->wait(); + + $this->assertContains('Authorization: Bearer mytoken', $spy->options['normalized_headers']['authorization'] ?? []); + } + + public function testAuthNtlmMapsToSymfonyOption() + { + $handler = new GuzzleHttpHandler(); + $buildOptions = new \ReflectionMethod($handler, 'buildSymfonyOptions'); + + $options = $buildOptions->invoke($handler, new Request('GET', 'https://example.com/'), ['auth' => ['user', 'pass', 'ntlm']]); + + $this->assertSame(['user', 'pass'], $options['auth_ntlm']); + } + + public function testVerifyFalseMapsToVerifyPeerAndHost() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['verify' => false])->wait(); + + $this->assertFalse($spy->options['verify_peer']); + $this->assertFalse($spy->options['verify_host']); + } + + public function testVerifyStringCaFileMapsToSymfony() + { + [$handler, $spy] = $this->makeHandler(); + + $caBundle = tempnam(sys_get_temp_dir(), 'ca'); + file_put_contents($caBundle, 'fake-cert'); + + try { + $handler(new Request('GET', 'https://example.com/'), ['verify' => $caBundle])->wait(); + $this->assertSame($caBundle, $spy->options['cafile']); + } finally { + unlink($caBundle); + } + } + + public function testTimeoutMapsToMaxDuration() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['timeout' => 30.5])->wait(); + + $this->assertSame(30.5, $spy->options['max_duration']); + } + + public function testConnectTimeoutMapsToMaxConnectDuration() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['connect_timeout' => 5.0])->wait(); + + $this->assertSame(5.0, $spy->options['max_connect_duration']); + } + + public function testProxyStringIsForwarded() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['proxy' => 'http://proxy.example.com:8080'])->wait(); + + $this->assertSame('http://proxy.example.com:8080', $spy->options['proxy']); + } + + public function testProxyArraySelectsSchemeSpecificProxy() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'proxy' => ['https' => 'http://secure-proxy:8080', 'http' => 'http://plain-proxy:8080'], + ])->wait(); + + $this->assertSame('http://secure-proxy:8080', $spy->options['proxy']); + } + + public function testProxyArrayWithNoProxyList() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'proxy' => ['https' => 'http://proxy:8080', 'no' => ['no-proxy.com', 'internal.net']], + ])->wait(); + + $this->assertSame('no-proxy.com,internal.net', $spy->options['no_proxy']); + } + + public function testAllowRedirectsFalseSetsMaxRedirectsZero() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['allow_redirects' => false])->wait(); + + $this->assertSame(0, $spy->options['max_redirects']); + } + + public function testAllowRedirectsArrayMapsMaxField() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['allow_redirects' => ['max' => 3]])->wait(); + + $this->assertSame(3, $spy->options['max_redirects']); + } + + public function testAllowRedirectsTrueLeavesSymfonyDefault() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['allow_redirects' => true])->wait(); + + // We must not pass max_redirects when allow_redirects=true so Symfony keeps its own default. + $this->assertArrayNotHasKey('max_redirects', $spy->options); + } + + public function testBufferIsAlwaysFalse() + { + // The handler always drives I/O via stream(), so buffer is always false + // regardless of the Guzzle 'stream' option. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [])->wait(); + + $this->assertFalse($spy->options['buffer']); + } + + public function testHttp10IsPreservedEvenWithAutoUpgrade() + { + [$handler, $spy] = $this->makeHandler(); + $handler(new Request('GET', 'https://example.com/', [], null, '1.0'), [])->wait(); + + // 1.0 must never be silently upgraded — lock it regardless of autoUpgradeHttpVersion. + $this->assertSame('1.0', $spy->options['http_version']); + } + + public function testHttp20NotForcedByDefault() + { + [$handler, $spy] = $this->makeHandler(); + $handler(new Request('GET', 'https://example.com/', [], null, '2.0'), [])->wait(); + + // With autoUpgradeHttpVersion=true, Symfony picks the best version; don't override it. + $this->assertNull($spy->options['http_version'] ?? null); + } + + public function testHttpVersionForwardedWhenAutoUpgradeIsDisabled() + { + $spy = new \stdClass(); + $spy->options = null; + $client = new MockHttpClient(static function (string $m, string $u, array $opts) use ($spy) { + $spy->options = $opts; + + return new MockResponse(''); + }); + $handler = new GuzzleHttpHandler($client, autoUpgradeHttpVersion: false); + + $handler(new Request('GET', 'https://example.com/', [], null, '2.0'), [])->wait(); + + $this->assertSame('2.0', $spy->options['http_version']); + } + + public function testDefaultHttpVersionNotOverridden() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [])->wait(); + + // Protocol 1.1 should not force a version; prepareRequest() normalises '' -> null. + $this->assertNull($spy->options['http_version']); + } + + public function testCryptoMethodIsForwarded() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT])->wait(); + + $this->assertSame(\STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, $spy->options['crypto_method']); + } + + public function testCertOptionWithPassword() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['cert' => ['/path/to/cert.pem', 'secret']])->wait(); + + $this->assertSame('/path/to/cert.pem', $spy->options['local_cert']); + $this->assertSame('secret', $spy->options['passphrase']); + } + + public function testSslKeyOptionWithPassword() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['ssl_key' => ['/path/to/key.pem', 'keypass']])->wait(); + + $this->assertSame('/path/to/key.pem', $spy->options['local_pk']); + $this->assertSame('keypass', $spy->options['passphrase']); + } + + public function testProgressCallbackIsWrapped() + { + [$handler, $spy] = $this->makeHandler(); + $capturedArgs = null; + $progress = static function (int $dlTotal, int $dlNow, int $ulTotal, int $ulNow) use (&$capturedArgs): void { + $capturedArgs = [$dlTotal, $dlNow, $ulTotal, $ulNow]; + }; + + $handler(new Request('GET', 'https://example.com/'), ['progress' => $progress])->wait(); + + $this->assertIsCallable($spy->options['on_progress']); + // Invoke on_progress with known values and verify the argument mapping: + // Symfony: (dlNow, dlTotal, info) → Guzzle: (dlTotal, dlNow, ulTotal, ulNow) + ($spy->options['on_progress'])(42, 100, ['upload_content_length' => 200.0, 'size_upload' => 30.0]); + $this->assertSame([100, 42, 200, 30], $capturedArgs); + } + + public function testOnStatsIsCalledAfterSuccessfulResponse() + { + [$handler] = $this->makeHandler(static fn () => new MockResponse('body', ['http_code' => 201])); + + $statsCalled = false; + $capturedStats = null; + + $response = $handler(new Request('GET', 'https://example.com/'), [ + 'on_stats' => static function (\GuzzleHttp\TransferStats $stats) use (&$statsCalled, &$capturedStats) { + $statsCalled = true; + $capturedStats = $stats; + }, + ])->wait(); + + $this->assertTrue($statsCalled); + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame(201, $capturedStats?->getResponse()->getStatusCode()); + // Transfer time and handler stats are populated from Symfony's on_progress info. + $this->assertIsFloat($capturedStats?->getTransferTime()); + $this->assertArrayHasKey('total_time', $capturedStats?->getHandlerStats()); + } + + public function testOnStatsIsCalledOnTransportError() + { + $client = new MockHttpClient(static fn () => new MockResponse('', ['error' => 'Connection refused'])); + $handler = new GuzzleHttpHandler($client); + + $statsCalled = false; + $capturedStats = null; + + $promise = $handler(new Request('GET', 'https://example.com/'), [ + 'on_stats' => static function (\GuzzleHttp\TransferStats $stats) use (&$statsCalled, &$capturedStats) { + $statsCalled = true; + $capturedStats = $stats; + }, + ]); + + try { + $promise->wait(); + $this->fail('Expected ConnectException'); + } catch (ConnectException) { + } + + $this->assertTrue($statsCalled); + $this->assertNull($capturedStats?->getResponse()); + $this->assertIsFloat($capturedStats?->getTransferTime()); + } + + public function testTransportExceptionBecomesConnectException() + { + $client = new MockHttpClient(static fn () => new MockResponse('', ['error' => 'Connection refused'])); + $handler = new GuzzleHttpHandler($client); + + $this->expectException(ConnectException::class); + $handler(new Request('GET', 'https://example.com/'), [])->wait(); + } + + public function testWaitOnRejectedPromiseDoesNotBlockOtherResponses() + { + $client = new MockHttpClient([ + new MockResponse('', ['error' => 'Connection refused']), + new MockResponse('ok', ['http_code' => 200]), + ]); + $handler = new GuzzleHttpHandler($client); + + $p1 = $handler(new Request('GET', 'https://example.com/fail'), []); + $p2 = $handler(new Request('GET', 'https://example.com/ok'), []); + + try { + $p1->wait(); + $this->fail('Expected ConnectException'); + } catch (ConnectException) { + } + + // p2 must still resolve successfully after p1 was rejected. + $r2 = $p2->wait(); + $this->assertSame(200, $r2->getStatusCode()); + $this->assertSame('ok', (string) $r2->getBody()); + } + + public function testDelayOptionPausesResponse() + { + [$handler] = $this->makeHandler(); + + $start = microtime(true); + $handler(new Request('GET', 'https://example.com/'), ['delay' => 50])->wait(); + + $elapsed = (microtime(true) - $start) * 1000; + $this->assertGreaterThanOrEqual(40, $elapsed); + } + + public function testResponseBodyAndStatusAreReturned() + { + [$handler] = $this->makeHandler( + static fn () => new MockResponse('{"result":"ok"}', ['http_code' => 202, 'response_headers' => ['content-type: application/json']]), + ); + + $response = $handler(new Request('GET', 'https://example.com/'), [])->wait(); + + $this->assertSame(202, $response->getStatusCode()); + $this->assertSame('{"result":"ok"}', (string) $response->getBody()); + $this->assertSame('application/json', $response->getHeaderLine('content-type')); + } + + public function testOnHeadersCallbackIsInvoked() + { + [$handler] = $this->makeHandler( + static fn () => new MockResponse('body', ['http_code' => 200, 'response_headers' => ['x-foo: bar']]), + ); + + $onHeadersCalled = false; + $capturedResponse = null; + + $handler(new Request('GET', 'https://example.com/'), [ + 'on_headers' => static function (ResponseInterface $response) use (&$onHeadersCalled, &$capturedResponse) { + $onHeadersCalled = true; + $capturedResponse = $response; + }, + ])->wait(); + + $this->assertTrue($onHeadersCalled); + $this->assertSame(200, $capturedResponse?->getStatusCode()); + } + + public function testOnHeadersReceivesSameResponseInstanceAsResolvedPromise() + { + [$handler] = $this->makeHandler( + static fn () => new MockResponse('body', ['http_code' => 200, 'response_headers' => ['x-foo: bar']]), + ); + + $onHeadersResponse = null; + + $resolvedResponse = $handler(new Request('GET', 'https://example.com/'), [ + 'on_headers' => static function (ResponseInterface $response) use (&$onHeadersResponse) { + $onHeadersResponse = $response; + }, + ])->wait(); + + $this->assertSame($onHeadersResponse, $resolvedResponse); + $this->assertSame('body', (string) $resolvedResponse->getBody()); + } + + public function testOnHeadersExceptionRejectsWithRequestException() + { + [$handler] = $this->makeHandler( + static fn () => new MockResponse('body', ['http_code' => 200]), + ); + + $promise = $handler(new Request('GET', 'https://example.com/'), [ + 'on_headers' => static function () { throw new \RuntimeException('Abort!'); }, + ]); + + try { + $promise->wait(); + $this->fail('Expected RequestException'); + } catch (RequestException $e) { + $this->assertSame('Abort!', $e->getMessage()); + $this->assertNotNull($e->getResponse()); + $this->assertSame(200, $e->getResponse()->getStatusCode()); + } + } + + // -- Async / concurrency -------------------------------------------------- + + public function testInvokeReturnsPendingPromise() + { + [$handler] = $this->makeHandler(); + + $promise = $handler(new Request('GET', 'https://example.com/'), []); + + // Before wait() is called the promise must be PENDING, not already resolved. + $this->assertSame(PromiseInterface::PENDING, $promise->getState()); + } + + public function testConcurrentRequestsAreAllResolved() + { + $client = new MockHttpClient([ + new MockResponse('one', ['http_code' => 200]), + new MockResponse('two', ['http_code' => 201]), + new MockResponse('three', ['http_code' => 202]), + ]); + $handler = new GuzzleHttpHandler($client); + + $p1 = $handler(new Request('GET', 'https://example.com/1'), []); + $p2 = $handler(new Request('GET', 'https://example.com/2'), []); + $p3 = $handler(new Request('GET', 'https://example.com/3'), []); + + // All three are pending before any wait(). + $this->assertSame(PromiseInterface::PENDING, $p1->getState()); + $this->assertSame(PromiseInterface::PENDING, $p2->getState()); + $this->assertSame(PromiseInterface::PENDING, $p3->getState()); + + // Waiting on p1 should drive the shared event loop and resolve all queued promises. + $r1 = $p1->wait(); + + $this->assertSame(PromiseInterface::FULFILLED, $p1->getState()); + // p2 and p3 may be resolved as a side-effect of driving the loop for p1. + // At minimum they must resolve when individually waited on. + $r2 = $p2->wait(); + $r3 = $p3->wait(); + + $this->assertSame(200, $r1->getStatusCode()); + $this->assertSame('one', (string) $r1->getBody()); + $this->assertSame(201, $r2->getStatusCode()); + $this->assertSame('two', (string) $r2->getBody()); + $this->assertSame(202, $r3->getStatusCode()); + $this->assertSame('three', (string) $r3->getBody()); + } + + public function testPromiseThenChainIsExecutedAsynchronously() + { + [$handler] = $this->makeHandler(static fn () => new MockResponse('hello', ['http_code' => 200])); + + $thenCalled = false; + $capturedBody = null; + + $promise = $handler(new Request('GET', 'https://example.com/'), []) + ->then(static function (ResponseInterface $response) use (&$thenCalled, &$capturedBody) { + $thenCalled = true; + $capturedBody = (string) $response->getBody(); + + return $response; + }); + + // The ->then() callback hasn't run yet. + $this->assertFalse($thenCalled); + + $promise->wait(); + + $this->assertTrue($thenCalled); + $this->assertSame('hello', $capturedBody); + } + + public function testCancelRemovesPendingRequest() + { + $client = new MockHttpClient([ + new MockResponse('first', ['http_code' => 200]), + new MockResponse('second', ['http_code' => 200]), + ]); + $handler = new GuzzleHttpHandler($client); + + $p1 = $handler(new Request('GET', 'https://example.com/1'), []); + $p2 = $handler(new Request('GET', 'https://example.com/2'), []); + + $p1->cancel(); + + $this->assertSame(PromiseInterface::REJECTED, $p1->getState()); + + // p2 must still be resolvable after p1 is cancelled. + $r2 = $p2->wait(); + $this->assertSame(PromiseInterface::FULFILLED, $p2->getState()); + $this->assertSame(200, $r2->getStatusCode()); + $this->assertSame('second', (string) $r2->getBody()); + } + + // -- extra.curl pass-through --- + + #[RequiresPhpExtension('curl')] + public function testUnmappedCurlOptIsForwardedToExtraCurl() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'curl' => [ + \CURLOPT_BUFFERSIZE => 65536, + ], + ])->wait(); + + $this->assertSame(65536, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE] ?? null); + } + + #[RequiresPhpExtension('curl')] + public function testKnownCurlOptIsNotDuplicatedInExtraCurl() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'curl' => [ + \CURLOPT_CAINFO => '/path/to/ca.pem', // mapped -> cafile + \CURLOPT_BUFFERSIZE => 65536, // unmapped -> extra.curl + ], + ])->wait(); + + // Known opt: translated to the named Symfony option. + $this->assertSame('/path/to/ca.pem', $spy->options['cafile']); + // Known opt: must NOT appear in extra.curl (would be redundant / contradictory). + $this->assertArrayNotHasKey(\CURLOPT_CAINFO, $spy->options['extra']['curl'] ?? []); + // Unknown opt: forwarded. + $this->assertSame(65536, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE] ?? null); + } + + #[RequiresPhpExtension('curl')] + public function testKnownCurlOptWithFalsyValueIsNotForwardedToExtraCurl() + { + // CURLOPT_SSL_VERIFYPEER => false maps to verify_peer=false. + // The mapping return value is (bool)false - must NOT be mistaken for + // "unmapped" and end up in extra.curl as well. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'curl' => [\CURLOPT_SSL_VERIFYPEER => false], + ])->wait(); + + $this->assertFalse($spy->options['verify_peer']); + $this->assertArrayNotHasKey(\CURLOPT_SSL_VERIFYPEER, $spy->options['extra']['curl'] ?? []); + } + + #[RequiresPhpExtension('curl')] + public function testExtraCurlIsMergedWithExistingExtras() + { + // Multiple unmapped opts all end up in the same sub-array. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'curl' => [ + \CURLOPT_LOW_SPEED_LIMIT => 1024, + \CURLOPT_BUFFERSIZE => 32768, + ], + ])->wait(); + + $this->assertSame(1024, $spy->options['extra']['curl'][\CURLOPT_LOW_SPEED_LIMIT]); + $this->assertSame(32768, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE]); + } + + #[RequiresPhpExtension('curl')] + public function testBlockedCurlOptIsDroppedSilently() + { + // Options that CurlHttpClient rejects in extra.curl must be silently + // dropped rather than forwarded, to avoid a runtime exception. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), [ + 'curl' => [ + \CURLOPT_VERBOSE => true, // in $curloptsToCheck -> blocked + \CURLOPT_BUFFERSIZE => 65536, // safe unmapped -> forwarded + ], + ])->wait(); + + $this->assertArrayNotHasKey(\CURLOPT_VERBOSE, $spy->options['extra']['curl'] ?? []); + $this->assertSame(65536, $spy->options['extra']['curl'][\CURLOPT_BUFFERSIZE] ?? null); + } + + #[RequiresPhpExtension('curl')] + public function testCurlOptMaxDirsIsMapped() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_MAXREDIRS => 3]])->wait(); + + $this->assertSame(3, $spy->options['max_redirects']); + } + + #[RequiresPhpExtension('curl')] + public function testCurlOptSslCipherListIsMapped() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_SSL_CIPHER_LIST => 'ECDHE-RSA-AES256-GCM-SHA384']])->wait(); + + $this->assertSame('ECDHE-RSA-AES256-GCM-SHA384', $spy->options['ciphers']); + } + + #[RequiresPhpExtension('curl')] + public function testCurlOptRefererIsMapped() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_REFERER => 'https://referrer.example.com/']])->wait(); + + $this->assertContains('Referer: https://referrer.example.com/', $spy->options['normalized_headers']['referer'] ?? []); + } + + #[RequiresPhpExtension('curl')] + public function testCurlOptCertInfoIsMapped() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['curl' => [\CURLOPT_CERTINFO => 1]])->wait(); + + $this->assertTrue($spy->options['capture_peer_cert_chain']); + } + + // --- sink --- + + public function testSinkAsFilePathWritesBodyToFile() + { + $path = tempnam(sys_get_temp_dir(), 'guzzle_sink_'); + $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('hello sink'))); + + $handler(new Request('GET', 'https://example.com/'), ['sink' => $path])->wait(); + + $this->assertSame('hello sink', file_get_contents($path)); + unlink($path); + } + + public function testSinkAsResourceWritesBodyToResource() + { + $resource = fopen('php://temp', 'rw+'); + $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('hello resource'))); + + $response = $handler(new Request('GET', 'https://example.com/'), ['sink' => $resource])->wait(); + + rewind($resource); + $this->assertSame('hello resource', stream_get_contents($resource)); + $this->assertSame('hello resource', (string) $response->getBody()); + } + + public function testSinkAsStreamInterfaceWritesBody() + { + $stream = \GuzzleHttp\Psr7\Utils::streamFor(fopen('php://temp', 'rw+')); + $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('hello stream'))); + + $handler(new Request('GET', 'https://example.com/'), ['sink' => $stream])->wait(); + + $stream->rewind(); + $this->assertSame('hello stream', (string) $stream); + } + + // --- read_timeout --- + + public function testReadTimeoutIsMappedToTimeout() + { + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['read_timeout' => 4.5])->wait(); + + $this->assertSame(4.5, $spy->options['timeout']); + } + + // --- decode_content --- + + public function testDecodeContentFalsePreservesExplicitAcceptEncoding() + { + // Caller already chose an encoding: we must not override it. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/', ['Accept-Encoding' => 'br']), ['decode_content' => false])->wait(); + + $this->assertContains('Accept-Encoding: br', $spy->options['normalized_headers']['accept-encoding'] ?? []); + } + + public function testDecodeContentFalseInjectsIdentityEncoding() + { + // Without an explicit Accept-Encoding, Symfony would auto-decompress. + // Injecting 'identity' tells the server not to compress, which in turn + // prevents Symfony from decoding the body. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/'), ['decode_content' => false])->wait(); + + $this->assertContains('Accept-Encoding: identity', $spy->options['normalized_headers']['accept-encoding'] ?? []); + } + + public function testDecodeContentTrueRemovesExplicitAcceptEncoding() + { + [$handler, $spy] = $this->makeHandler(); + + // Caller explicitly set Accept-Encoding; decode_content=true means Symfony + // should manage it, so we strip the caller's value. + $handler(new Request('GET', 'https://example.com/', ['Accept-Encoding' => 'gzip']), ['decode_content' => true])->wait(); + + $this->assertArrayNotHasKey('accept-encoding', $spy->options['normalized_headers'] ?? []); + } + + public function testDecodeContentStringRemovesExplicitAcceptEncoding() + { + // A string value (e.g. 'gzip') means "request this encoding and decode". + // We let Symfony manage Accept-Encoding by stripping any explicit value. + [$handler, $spy] = $this->makeHandler(); + + $handler(new Request('GET', 'https://example.com/', ['Accept-Encoding' => 'br']), ['decode_content' => 'gzip'])->wait(); + + $this->assertArrayNotHasKey('accept-encoding', $spy->options['normalized_headers'] ?? []); + } + + // --- tick() --- + + public function testTickProgressesResponsesWithoutBlocking() + { + $handler = new GuzzleHttpHandler(new MockHttpClient(new MockResponse('tick body'))); + + $promise = $handler(new Request('GET', 'https://example.com/'), []); + + // tick() should make non-blocking progress and eventually resolve the response. + $handler->tick(); + + $response = $promise->wait(); + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('tick body', (string) $response->getBody()); + } + + public function testTickProcessesMultipleConcurrentRequests() + { + $handler = new GuzzleHttpHandler(new MockHttpClient([ + new MockResponse('first'), + new MockResponse('second'), + ])); + + $p1 = $handler(new Request('GET', 'https://example.com/1'), []); + $p2 = $handler(new Request('GET', 'https://example.com/2'), []); + + // Drive all pending requests via tick + execute. + $handler->execute(); + + $this->assertSame('first', (string) $p1->wait()->getBody()); + $this->assertSame('second', (string) $p2->wait()->getBody()); + } + + public function testTickBreaksAfterProcessingAvailableChunks() + { + $handler = new GuzzleHttpHandler(new MockHttpClient([ + new MockResponse('one'), + new MockResponse('two'), + ])); + + $p1 = $handler(new Request('GET', 'https://example.com/1'), []); + $p2 = $handler(new Request('GET', 'https://example.com/2'), []); + + $handler->tick(); + $this->assertSame(0, (PromiseInterface::FULFILLED === $p1->getState()) + (PromiseInterface::FULFILLED === $p2->getState())); + + $handler->tick(); + $handler->tick(); + $this->assertSame(1, (PromiseInterface::FULFILLED === $p1->getState()) + (PromiseInterface::FULFILLED === $p2->getState())); + + $handler->tick(); + $handler->tick(); + $handler->tick(); + $this->assertSame(2, (PromiseInterface::FULFILLED === $p1->getState()) + (PromiseInterface::FULFILLED === $p2->getState())); + $this->assertSame('one', (string) $p1->wait()->getBody()); + $this->assertSame('two', (string) $p2->wait()->getBody()); + } + + public function testNonStandardStatusCodeAbove599IsPreserved() + { + // GuzzleResponse rejects status codes >= 600 in its constructor, so the + // handler must smuggle the real code through reflection. + [$handler] = $this->makeHandler( + static fn () => new MockResponse('proprietary', ['http_code' => 999]) + ); + + $response = $handler(new Request('GET', 'https://example.com/'), [])->wait(); + + $this->assertSame(999, $response->getStatusCode()); + $this->assertSame('proprietary', (string) $response->getBody()); + } + + /** + * Build a handler backed by a spy MockHttpClient. + * + * The returned stdClass is populated with method / url / options after + * each request is fired through the handler. + */ + private function makeHandler(?callable $responseFactory = null): array + { + $spy = new \stdClass(); + $spy->method = null; + $spy->url = null; + $spy->options = null; + + $client = new MockHttpClient( + static function (string $method, string $url, array $options) use ($responseFactory, $spy) { + $spy->method = $method; + $spy->url = $url; + $spy->options = $options; + + return $responseFactory + ? ($responseFactory)($method, $url, $options) + : new MockResponse('{"ok":true}', ['http_code' => 200, 'response_headers' => ['content-type: application/json']]); + }, + ); + + return [new GuzzleHttpHandler($client), $spy]; + } +} diff --git a/src/Symfony/Component/HttpClient/composer.json b/src/Symfony/Component/HttpClient/composer.json index 4875f5a92f4..50dcbc5fbc9 100644 --- a/src/Symfony/Component/HttpClient/composer.json +++ b/src/Symfony/Component/HttpClient/composer.json @@ -30,7 +30,7 @@ "require-dev": { "amphp/http-client": "^5.3.2", "amphp/http-tunnel": "^2.0", - "guzzlehttp/promises": "^1.4|^2.0", + "guzzlehttp/guzzle": "^7.10", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0",