diff --git a/composer.json b/composer.json index d54ec3eb8ee8..c3202cbf55a9 100644 --- a/composer.json +++ b/composer.json @@ -32,9 +32,10 @@ "dragonmantank/cron-expression": "^3.4", "egulias/email-validator": "^4.0", "fruitcake/php-cors": "^1.3", - "guzzlehttp/guzzle": "^7.8.2", - "guzzlehttp/promises": "^2.0.3", - "guzzlehttp/uri-template": "^1.0", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9.1 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", "laravel/prompts": "^0.3.0", "laravel/serializable-closure": "^2.0.10", "league/commonmark": "^2.8.1", @@ -45,6 +46,7 @@ "nesbot/carbon": "^3.8.4", "nunomaduro/termwind": "^2.0", "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", @@ -71,7 +73,6 @@ "ably/ably-php": "^1.0", "aws/aws-sdk-php": "^3.322.9", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.9", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -166,7 +167,6 @@ "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", - "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", diff --git a/src/Illuminate/Console/composer.json b/src/Illuminate/Console/composer.json index 2ac0ac5e2bf2..2e503c1d8edc 100755 --- a/src/Illuminate/Console/composer.json +++ b/src/Illuminate/Console/composer.json @@ -30,7 +30,7 @@ "suggest": { "ext-pcntl": "Required to use signal trapping.", "dragonmantank/cron-expression": "Required to use scheduler (^3.3.2).", - "guzzlehttp/guzzle": "Required to use the ping methods on schedules (^7.8).", + "guzzlehttp/guzzle": "Required to use the ping methods on schedules (^7.8.2 || ^8.0).", "illuminate/bus": "Required to use the scheduled job dispatcher (^13.0).", "illuminate/container": "Required to use the scheduler (^13.0).", "illuminate/filesystem": "Required to use the generator command (^13.0).", diff --git a/src/Illuminate/Filesystem/composer.json b/src/Illuminate/Filesystem/composer.json index 57a6abddfbb2..4359492154fd 100644 --- a/src/Illuminate/Filesystem/composer.json +++ b/src/Illuminate/Filesystem/composer.json @@ -30,7 +30,7 @@ "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", - "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0 || ^2.0).", "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", "symfony/mime": "Required to enable support for guessing extensions (^7.4 || ^8.0)." }, diff --git a/src/Illuminate/Http/Client/Factory.php b/src/Illuminate/Http/Client/Factory.php index dd5eadb5a10e..a435df0a05fe 100644 --- a/src/Illuminate/Http/Client/Factory.php +++ b/src/Illuminate/Http/Client/Factory.php @@ -47,6 +47,13 @@ class Factory */ protected $globalOptions = []; + /** + * The persistent transport (connection sharing) mode to apply to every request. + * + * @var \Illuminate\Http\Client\PersistentTransport + */ + protected PersistentTransport $globalPersistentTransport = PersistentTransport::None; + /** * The stub callables that will handle requests. * @@ -153,6 +160,19 @@ public function globalOptions($options) return $this; } + /** + * Set the persistent transport (connection sharing) mode to apply to every request. + * + * @param \Illuminate\Http\Client\PersistentTransport $mode + * @return $this + */ + public function globalPersistentTransport(PersistentTransport $mode) + { + $this->globalPersistentTransport = $mode; + + return $this; + } + /** * Create a new response instance for use during stubbing. * @@ -559,7 +579,9 @@ public function createPendingRequest() */ protected function newPendingRequest() { - return (new PendingRequest($this, $this->globalMiddleware))->withOptions(value($this->globalOptions)); + return (new PendingRequest($this, $this->globalMiddleware)) + ->withOptions(value($this->globalOptions)) + ->persistentTransport($this->globalPersistentTransport); } /** diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php index cd95c3316673..da1a9e3e0f75 100644 --- a/src/Illuminate/Http/Client/PendingRequest.php +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -6,14 +6,16 @@ use Exception; use GuzzleHttp\Client; use GuzzleHttp\Cookie\CookieJar; -use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\ResponseException; use GuzzleHttp\Exception\TransferException; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Promise\EachPromise; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\TransportSharing; use GuzzleHttp\UriTemplate\UriTemplate; +use GuzzleHttp\Utils; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\Client\Events\ConnectionFailed; use Illuminate\Http\Client\Events\RequestSending; @@ -30,6 +32,8 @@ use JsonSerializable; use Psr\Http\Message\MessageInterface; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Symfony\Component\VarDumper\VarDumper; use Throwable; @@ -61,6 +65,13 @@ class PendingRequest */ protected $handler; + /** + * The persistent transport (connection sharing) mode for the request. + * + * @var \Illuminate\Http\Client\PersistentTransport + */ + protected PersistentTransport $persistentTransport = PersistentTransport::None; + /** * The base URL for the request. * @@ -1083,16 +1094,10 @@ public function send(string $method, string $url, array $options = []) } }); } catch (TransferException $e) { - if ($e instanceof ConnectException) { - $this->marshalConnectionException($e); - } - - if ($e instanceof RequestException && ! $e->hasResponse()) { - $this->marshalRequestExceptionWithoutResponse($e); - } - - if ($e instanceof RequestException && $e->hasResponse()) { - $this->marshalRequestExceptionWithResponse($e); + if (($response = $this->responseFromException($e)) !== null) { + $this->marshalTransportExceptionWithResponse($e, $response); + } else { + $this->marshalTransportException($e); } throw $e; @@ -1114,6 +1119,10 @@ public function send(string $method, string $url, array $options = []) */ protected function expandUrlParameters(string $url) { + if (! str_contains($url, '{')) { + return $url; + } + return UriTemplate::expand($url, $this->urlParameters); } @@ -1198,7 +1207,11 @@ protected function makePromise(string $method, string $url, array $options = [], throw $e; } - if ($e instanceof ConnectException || ($e instanceof RequestException && ! $e->hasResponse())) { + if (($response = $this->responseFromException($e)) !== null) { + return $this->populateResponse($this->newResponse($response)); + } + + if ($e instanceof TransferException) { $exception = new ConnectionException($e->getMessage(), 0, $e); $this->dispatchConnectionFailedEvent( @@ -1209,7 +1222,7 @@ protected function makePromise(string $method, string $url, array $options = [], return $exception; } - return $e instanceof RequestException && $e->hasResponse() ? $this->populateResponse($this->newResponse($e->getResponse())) : $e; + return $e; }) ->then(function (Response|Throwable $response) use ($method, $url, $options, $attempt) { return $this->handlePromiseResponse($response, $method, $url, $options, $attempt); @@ -1232,8 +1245,9 @@ protected function handlePromiseResponse(Response|Throwable $response, $method, return $response; } - if ($response instanceof RequestException) { - $response = $this->populateResponse($this->newResponse($response->getResponse())); + if ($response instanceof RequestException + && ($psrResponse = $this->responseFromException($response)) !== null) { + $response = $this->populateResponse($this->newResponse($psrResponse)); } try { @@ -1584,7 +1598,69 @@ public function createClient($handlerStack) */ public function buildHandlerStack() { - return $this->pushHandlers(HandlerStack::create($this->handler)); + return $this->pushHandlers(HandlerStack::create($this->buildDefaultHandler())); + } + + /** + * Resolve the base Guzzle handler, applying persistent transport sharing when enabled. + * + * @return callable|null + */ + protected function buildDefaultHandler() + { + // Transport sharing can only be applied when Guzzle builds the handler. + if (! is_null($this->handler)) { + return $this->handler; + } + + $mode = $this->resolveTransportSharingMode(); + + return is_null($mode) + ? $this->handler + : Utils::chooseHandler(['transport_sharing' => $mode]); + } + + /** + * Resolve the Guzzle "transport_sharing" mode for the configured persistence level. + * + * @return string|null + * + * @throws \RuntimeException + */ + protected function resolveTransportSharingMode() + { + if ($this->persistentTransport === PersistentTransport::None) { + return null; + } + + // When faking, the stub handler answers before the base handler runs, so a + // sharing transport is never needed (and "Required" must not throw in tests). + if (($this->stubCallbacks?->isNotEmpty() ?? false) || $this->preventStrayRequests) { + return null; + } + + $required = $this->persistentTransport === PersistentTransport::Required; + + // Guzzle 8: persistent (cross-request) sharing. + if (defined(TransportSharing::class.'::PERSISTENT_PREFER')) { + return $required + ? TransportSharing::PERSISTENT_REQUIRE + : TransportSharing::PERSISTENT_PREFER; + } + + if ($required) { + throw new RuntimeException( + 'Persistent HTTP transport sharing is set to "Required", but persistent cURL ' + .'share handles require guzzlehttp/guzzle ^8.0.' + ); + } + + // Guzzle 7.11: handler-lifetime sharing only, best-effort. + if (class_exists(TransportSharing::class)) { + return TransportSharing::HANDLER_PREFER; + } + + return null; } /** @@ -1945,37 +2021,35 @@ public function dontTruncateExceptions() } /** - * Handle the given connection exception. + * Get the PSR-7 response carried by the given exception, if any. * - * @param \GuzzleHttp\Exception\ConnectException $e - * @return void - * - * @throws \Illuminate\Http\Client\ConnectionException + * @param \Throwable $e + * @return \Psr\Http\Message\ResponseInterface|null */ - protected function marshalConnectionException(ConnectException $e) + protected function responseFromException(Throwable $e) { - $exception = new ConnectionException($e->getMessage(), 0, $e); - - $request = (new Request($e->getRequest()))->setRequestAttributes($this->attributes); - - $this->factory?->recordRequestResponsePair( - $request, null - ); + // Guzzle 8 uses ResponseException + if ($e instanceof ResponseException) { + return $e->getResponse(); + } - $this->dispatchConnectionFailedEvent($request, $exception); + // Guzzle 7 uses RequestException with hasResponse() true + if ($e instanceof RequestException && is_callable([$e, 'hasResponse']) && $e->hasResponse()) { + return $e->getResponse(); + } - throw $exception; + return null; } /** - * Handle the given request exception. + * Handle the given transport exception. * - * @param \GuzzleHttp\Exception\RequestException $e + * @param \GuzzleHttp\Exception\TransferException $e * @return void * * @throws \Illuminate\Http\Client\ConnectionException */ - protected function marshalRequestExceptionWithoutResponse(RequestException $e) + protected function marshalTransportException(TransferException $e) { $exception = new ConnectionException($e->getMessage(), 0, $e); @@ -1991,17 +2065,18 @@ protected function marshalRequestExceptionWithoutResponse(RequestException $e) } /** - * Handle the given request exception. + * Handle the given transport exception that carried a response. * - * @param \GuzzleHttp\Exception\RequestException $e + * @param \GuzzleHttp\Exception\TransferException $e + * @param \Psr\Http\Message\ResponseInterface $response * @return void * * @throws \Illuminate\Http\Client\RequestException * @throws \Illuminate\Http\Client\ConnectionException */ - protected function marshalRequestExceptionWithResponse(RequestException $e) + protected function marshalTransportExceptionWithResponse(TransferException $e, ResponseInterface $response) { - $response = $this->populateResponse($this->newResponse($e->getResponse())); + $response = $this->populateResponse($this->newResponse($response)); $this->factory?->recordRequestResponsePair( (new Request($e->getRequest()))->setRequestAttributes($this->attributes), @@ -2037,6 +2112,19 @@ public function setHandler($handler) return $this; } + /** + * Set the persistent transport (connection sharing) mode for the request. + * + * @param \Illuminate\Http\Client\PersistentTransport $mode + * @return $this + */ + public function persistentTransport(PersistentTransport $mode) + { + $this->persistentTransport = $mode; + + return $this; + } + /** * Get the pending request options. * diff --git a/src/Illuminate/Http/Client/PersistentTransport.php b/src/Illuminate/Http/Client/PersistentTransport.php new file mode 100644 index 000000000000..c15e237f506e --- /dev/null +++ b/src/Illuminate/Http/Client/PersistentTransport.php @@ -0,0 +1,10 @@ +guzzlePromise->resolve($value); } diff --git a/src/Illuminate/Http/Client/Promises/LazyPromise.php b/src/Illuminate/Http/Client/Promises/LazyPromise.php index a986e1376b90..9a8766cc0288 100644 --- a/src/Illuminate/Http/Client/Promises/LazyPromise.php +++ b/src/Illuminate/Http/Client/Promises/LazyPromise.php @@ -90,7 +90,7 @@ public function getState(): string } #[\Override] - public function resolve($value): void + public function resolve($value = null): void { throw new \LogicException('Cannot resolve a lazy promise.'); } diff --git a/src/Illuminate/Http/composer.json b/src/Illuminate/Http/composer.json index 727c1c995653..7ffedb5df773 100755 --- a/src/Illuminate/Http/composer.json +++ b/src/Illuminate/Http/composer.json @@ -17,8 +17,10 @@ "php": "^8.3", "ext-filter": "*", "fruitcake/php-cors": "^1.3", - "guzzlehttp/guzzle": "^7.8.2", - "guzzlehttp/uri-template": "^1.0", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9.1 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", "illuminate/collections": "^13.0", "illuminate/macroable": "^13.0", "illuminate/session": "^13.0", diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 30d9556acceb..977d5b4228fd 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -5,6 +5,7 @@ use Exception; use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\NetworkException; use GuzzleHttp\Exception\RequestException as GuzzleRequestException; use GuzzleHttp\Exception\TooManyRedirectsException; use GuzzleHttp\Middleware; @@ -25,6 +26,7 @@ use Illuminate\Http\Client\Events\ResponseReceived; use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\PersistentTransport; use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\Request; use Illuminate\Http\Client\RequestException; @@ -4790,6 +4792,110 @@ public function testJsonDecodingWithEmptyArrayRespectsKeyAccess() $response->json(); $this->assertSame(1, $response->bodyCallCount); } + + public function testRequiredPersistentTransportDoesNotThrowWhenFaking() + { + $this->factory->fake(); + $this->factory->globalPersistentTransport(PersistentTransport::Required); + + $response = $this->factory->get('https://example.com'); + + $this->assertSame(200, $response->status()); + } + + public function testGlobalRequiredPersistentTransportThrowsWhenPersistentSharingIsUnavailable() + { + if (defined('GuzzleHttp\TransportSharing::PERSISTENT_PREFER')) { + $this->markTestSkipped('Persistent transport sharing is available.'); + } + + $this->factory->globalPersistentTransport(PersistentTransport::Required); + + $this->expectException(RuntimeException::class); + + $this->factory->get('https://example.com'); + } + + public function testRequiredPersistentTransportCanBeSetPerRequest() + { + if (defined('GuzzleHttp\TransportSharing::PERSISTENT_PREFER')) { + $this->markTestSkipped('Persistent transport sharing is available.'); + } + + $this->expectException(RuntimeException::class); + + $this->factory->createPendingRequest() + ->persistentTransport(PersistentTransport::Required) + ->get('https://example.com'); + } + + public function testNetworkExceptionIsConvertedToConnectionException() + { + if (! class_exists(NetworkException::class)) { + $this->markTestSkipped('NetworkException requires guzzlehttp/guzzle ^7.11.'); + } + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('Network error'); + + $pendingRequest = new PendingRequest(); + + $pendingRequest->setHandler(function () { + throw new NetworkException( + 'Network error', + new GuzzleRequest('GET', 'https://network-error.laravel.example') + ); + }); + + $pendingRequest->get('https://network-error.laravel.example'); + } + + public function testNetworkExceptionInPoolIsConsideredConnectionException() + { + if (! class_exists(NetworkException::class)) { + $this->markTestSkipped('NetworkException requires guzzlehttp/guzzle ^7.11.'); + } + + $networkException = new NetworkException('Network error', new GuzzleRequest('GET', '/')); + + $this->factory->fake([ + 'network-error.com' => new RejectedPromise($networkException), + ]); + + $responses = $this->factory->pool(function (Pool $pool) { + return [ + $pool->get('network-error.com'), + ]; + }); + + $this->assertInstanceOf(ConnectionException::class, $responses[0]); + $this->assertSame($networkException, $responses[0]->getPrevious()); + } + + public function testUrlsWithoutTemplateExpressionsAreNotExpanded() + { + $this->factory->fake(); + + $this->factory->withUrlParameters(['page' => 'docs'])->get('https://laravel.com/docs'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'https://laravel.com/docs'; + }); + } + + public function testUrlsWithTemplateExpressionsAreStillExpanded() + { + $this->factory->fake(); + + $this->factory->withUrlParameters([ + 'endpoint' => 'https://laravel.com', + 'page' => 'docs', + ])->get('{+endpoint}/{page}'); + + $this->factory->assertSent(function (Request $request) { + return $request->url() === 'https://laravel.com/docs'; + }); + } } class CustomFactory extends Factory