diff --git a/src/Illuminate/Http/Client/Factory.php b/src/Illuminate/Http/Client/Factory.php index 4db3e1fa98a0..6fc069d390b7 100644 --- a/src/Illuminate/Http/Client/Factory.php +++ b/src/Illuminate/Http/Client/Factory.php @@ -34,6 +34,8 @@ * @method \Illuminate\Http\Client\PendingRequest withoutVerifying() * @method \Illuminate\Http\Client\PendingRequest dump() * @method \Illuminate\Http\Client\PendingRequest dd() + * @method \Illuminate\Http\Client\PendingRequest async() + * @method \Illuminate\Http\Client\Pool pool() * @method \Illuminate\Http\Client\Response delete(string $url, array $data = []) * @method \Illuminate\Http\Client\Response get(string $url, array $query = []) * @method \Illuminate\Http\Client\Response head(string $url, array $query = []) diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php index 5801bc0b70f0..56f29dec2e5e 100644 --- a/src/Illuminate/Http/Client/PendingRequest.php +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -5,10 +5,13 @@ use GuzzleHttp\Client; use GuzzleHttp\Cookie\CookieJar; use GuzzleHttp\Exception\ConnectException; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Exception\TransferException; use GuzzleHttp\HandlerStack; use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Psr\Http\Message\MessageInterface; use Symfony\Component\VarDumper\VarDumper; class PendingRequest @@ -22,6 +25,13 @@ class PendingRequest */ protected $factory; + /** + * The client instance. + * + * @var \GuzzleHttp\Client + */ + protected $client; + /** * The base URL for the request. * @@ -106,6 +116,20 @@ class PendingRequest */ protected $middleware; + /** + * Whether the requests should be asynchronous. + * + * @var bool + */ + protected $async = false; + + /** + * The pending request promise. + * + * @var \GuzzleHttp\Promise\PromiseInterface + */ + protected $promise; + /** * Create a new HTTP Client instance. * @@ -601,18 +625,14 @@ public function send(string $method, string $url, array $options = []) [$this->pendingBody, $this->pendingFiles] = [null, []]; + if ($this->async) { + return $this->makePromise($method, $url, $options); + } + return retry($this->tries ?? 1, function () use ($method, $url, $options) { try { - $laravelData = $this->parseRequestData($method, $url, $options); - - return tap(new Response($this->buildClient()->request($method, $url, $this->mergeOptions([ - 'laravel_data' => $laravelData, - 'on_stats' => function ($transferStats) { - $this->transferStats = $transferStats; - }, - ], $options))), function ($response) { - $response->cookies = $this->cookies; - $response->transferStats = $this->transferStats; + return tap(new Response($this->sendRequest($method, $url, $options)), function ($response) { + $this->populateResponse($response); if ($this->tries > 1 && ! $response->successful()) { $response->throw(); @@ -637,6 +657,49 @@ protected function parseMultipartBodyFormat(array $data) })->values()->all(); } + /** + * Send an asynchronous request to the given URL. + * + * @param string $method + * @param string $url + * @param array $options + * @return \GuzzleHttp\Promise\PromiseInterface + */ + protected function makePromise(string $method, string $url, array $options = []) + { + return $this->promise = $this->sendRequest($method, $url, $options) + ->then(function (MessageInterface $message) { + return $this->populateResponse(new Response($message)); + }) + ->otherwise(function (TransferException $e) { + return $e instanceof RequestException ? $this->populateResponse(new Response($e->getResponse())) : $e; + }); + } + + /** + * Send a request either synchronously or asynchronously. + * + * @param string $method + * @param string $url + * @param array $options + * @return \Psr\Http\Message\MessageInterface|\GuzzleHttp\Promise\PromiseInterface + * + * @throws \Exception + */ + protected function sendRequest(string $method, string $url, array $options = []) + { + $clientMethod = $this->async ? 'requestAsync' : 'request'; + + $laravelData = $this->parseRequestData($method, $url, $options); + + return $this->buildClient()->$clientMethod($method, $url, $this->mergeOptions([ + 'laravel_data' => $laravelData, + 'on_stats' => function ($transferStats) { + $this->transferStats = $transferStats; + }, + ], $options)); + } + /** * Get the request data as an array so that we can attach it to the request for convenient assertions. * @@ -664,6 +727,34 @@ protected function parseRequestData($method, $url, array $options) return $laravelData; } + /** + * Populate the given response with additional data. + * + * @param \Illuminate\Http\Client\Response $response + * @return \Illuminate\Http\Client\Response + */ + protected function populateResponse(Response $response) + { + $response->cookies = $this->cookies; + + $response->transferStats = $this->transferStats; + + return $response; + } + + /** + * Set the client instance. + * + * @param \GuzzleHttp\Client $client + * @return $this + */ + public function setClient(Client $client) + { + $this->client = $client; + + return $this; + } + /** * Build the Guzzle client. * @@ -671,7 +762,7 @@ protected function parseRequestData($method, $url, array $options) */ public function buildClient() { - return new Client([ + return $this->client = $this->client ?: new Client([ 'handler' => $this->buildHandlerStack(), 'cookies' => true, ]); @@ -826,4 +917,48 @@ public function stub($callback) return $this; } + + /** + * Toggle asynchronicity in requests. + * + * @param bool $async + * @return $this + */ + public function async(bool $async = true) + { + $this->async = $async; + + return $this; + } + + /** + * Send a pool of asynchronous requests concurrently. + * + * @param callable $callback + * @return array + */ + public function pool(callable $callback) + { + $results = []; + + $requests = tap(new Pool($this->factory), $callback)->getRequests(); + + foreach ($requests as $key => $item) { + $results[$key] = $item instanceof static ? $item->getPromise()->wait() : $item->wait(); + } + + ksort($results); + + return $results; + } + + /** + * Retrieve the pending request promise. + * + * @return \GuzzleHttp\Promise\PromiseInterface|null + */ + public function getPromise() + { + return $this->promise; + } } diff --git a/src/Illuminate/Http/Client/Pool.php b/src/Illuminate/Http/Client/Pool.php new file mode 100644 index 000000000000..8b04f4300155 --- /dev/null +++ b/src/Illuminate/Http/Client/Pool.php @@ -0,0 +1,84 @@ +factory = $factory ?: new Factory(); + + $this->client = $this->factory->buildClient(); + } + + /** + * Add a request to the pool with a key. + * + * @param string $key + * @return \Illuminate\Http\Client\PendingRequest + */ + public function add(string $key) + { + return $this->pool[$key] = $this->asyncRequest(); + } + + /** + * Retrieve a new async pending request. + * + * @return \Illuminate\Http\Client\PendingRequest + */ + protected function asyncRequest() + { + // the same client instance needs to be shared across all async requests + return $this->factory->setClient($this->client)->async(); + } + + /** + * Retrieve the requests in the pool. + * + * @return array + */ + public function getRequests() + { + return $this->pool; + } + + /** + * Add a request to the pool with a numeric index. + * + * @param string $method + * @param array $parameters + * @return \Illuminate\Http\Client\PendingRequest + */ + public function __call($method, $parameters) + { + return $this->pool[] = $this->asyncRequest()->$method(...$parameters); + } +} diff --git a/src/Illuminate/Support/Facades/Http.php b/src/Illuminate/Support/Facades/Http.php index 426d574789c5..51763b6caef0 100644 --- a/src/Illuminate/Support/Facades/Http.php +++ b/src/Illuminate/Support/Facades/Http.php @@ -32,6 +32,8 @@ * @method static \Illuminate\Http\Client\PendingRequest withoutVerifying() * @method static \Illuminate\Http\Client\PendingRequest dump() * @method static \Illuminate\Http\Client\PendingRequest dd() + * @method static \Illuminate\Http\Client\PendingRequest async() + * @method static \Illuminate\Http\Client\Pool pool() * @method static \Illuminate\Http\Client\Response delete(string $url, array $data = []) * @method static \Illuminate\Http\Client\Response get(string $url, array $query = []) * @method static \Illuminate\Http\Client\Response head(string $url, array $query = []) diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 12d7a1806c21..473a5b4e06c5 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -2,8 +2,11 @@ namespace Illuminate\Tests\Http; +use GuzzleHttp\Promise\PromiseInterface; use GuzzleHttp\Psr7\Response as Psr7Response; use Illuminate\Http\Client\Factory; +use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\Request; use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\Response; @@ -833,4 +836,70 @@ public function testResponseSequenceIsMacroable() $this->assertSame('yes!', $this->factory->fakeSequence()->customMethod()); } + + public function testRequestsCanBeAsync() + { + $request = new PendingRequest($this->factory); + + $promise = $request->async()->get('http://foo.com'); + + $this->assertInstanceOf(PromiseInterface::class, $promise); + + $this->assertSame($promise, $request->getPromise()); + } + + public function testClientCanBeSet() + { + $client = $this->factory->buildClient(); + + $request = new PendingRequest($this->factory); + + $this->assertNotSame($client, $request->buildClient()); + + $request->setClient($client); + + $this->assertSame($client, $request->buildClient()); + } + + public function testMultipleRequestsAreSentInThePool() + { + $this->factory->fake([ + '200.com' => $this->factory::response('', 200), + '400.com' => $this->factory::response('', 400), + '500.com' => $this->factory::response('', 500), + ]); + + $responses = $this->factory->pool(function (Pool $pool) { + return [ + $pool->get('200.com'), + $pool->get('400.com'), + $pool->get('500.com'), + ]; + }); + + $this->assertSame(200, $responses[0]->status()); + $this->assertSame(400, $responses[1]->status()); + $this->assertSame(500, $responses[2]->status()); + } + + public function testMultipleRequestsAreSentInThePoolWithKeys() + { + $this->factory->fake([ + '200.com' => $this->factory::response('', 200), + '400.com' => $this->factory::response('', 400), + '500.com' => $this->factory::response('', 500), + ]); + + $responses = $this->factory->pool(function (Pool $pool) { + return [ + $pool->add('test200')->get('200.com'), + $pool->add('test400')->get('400.com'), + $pool->add('test500')->get('500.com'), + ]; + }); + + $this->assertSame(200, $responses['test200']->status()); + $this->assertSame(400, $responses['test400']->status()); + $this->assertSame(500, $responses['test500']->status()); + } }