mirror of
https://github.com/symfony/http-client.git
synced 2026-03-24 00:12:11 +01:00
[HttpClient] Add GuzzleHttpHandler that allows using Symfony HttpClient as a Guzzle handler
This commit is contained in:
@@ -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
|
||||
---
|
||||
|
||||
639
GuzzleHttpHandler.php
Normal file
639
GuzzleHttpHandler.php
Normal file
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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 <p@tchwork.com>
|
||||
*/
|
||||
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<SymfonyResponseInterface, array{0: RequestInterface, 1: array, 2: Promise}>
|
||||
*/
|
||||
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<SymfonyResponseInterface, ResponseInterface>
|
||||
*/
|
||||
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<string, string[]>
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
925
Tests/GuzzleHttpHandlerTest.php
Normal file
925
Tests/GuzzleHttpHandlerTest.php
Normal file
@@ -0,0 +1,925 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* 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];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user