From dbb4eb95de6dfe3063e2a2da7c7f9fadff9aec24 Mon Sep 17 00:00:00 2001 From: Andrea Marco Sartori Date: Mon, 12 Feb 2024 15:42:35 +1000 Subject: [PATCH] Share a client per instance instead of a singleton --- README.md | 28 +++- src/Concerns/SendsAsyncRequests.php | 3 +- src/Concerns/YieldsItemsByCursor.php | 3 +- src/LazyJsonPages.php | 69 ++++++---- src/Paginations/AnyPagination.php | 2 +- src/Paginations/CustomPagination.php | 2 +- src/Paginations/Pagination.php | 2 + .../LazyJsonPagesServiceProvider.php | 4 +- src/Services/Client.php | 100 -------------- src/Services/ClientFactory.php | 122 ++++++++++++++++++ src/Sources/AnySource.php | 2 +- src/Sources/Endpoint.php | 3 +- src/Sources/LaravelClientRequest.php | 3 +- src/Sources/Psr7Request.php | 3 +- src/Sources/Source.php | 2 + src/Sources/SymfonyRequest.php | 3 +- tests/Feature/Datasets.php | 3 +- tests/Feature/OptimizationTest.php | 24 ++++ tests/Pest.php | 36 ++---- 19 files changed, 242 insertions(+), 172 deletions(-) delete mode 100644 src/Services/Client.php create mode 100644 src/Services/ClientFactory.php create mode 100644 tests/Feature/OptimizationTest.php diff --git a/README.md b/README.md index 3d2e71e..0e957f1 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ $lazyCollection = LazyJsonPages::from($source) Framework-agnostic package to load items from any paginated JSON API into a [Laravel lazy collection](https://laravel.com/docs/collections#lazy-collections) via async HTTP requests. > [!TIP] -> Need to read large JSON with no pagination in a memory-efficient way? Consider using [🐼 Lazy JSON](https://github.com/cerbero90/lazy-json) or [🧩 JSON Parser](https://github.com/cerbero90/json-parser) instead. +> Do you need to read large JSON with no pagination in a memory-efficient way? Consider using [🐼 Lazy JSON](https://github.com/cerbero90/lazy-json) or [🧩 JSON Parser](https://github.com/cerbero90/json-parser) instead. ## 📦 Install @@ -49,7 +49,7 @@ composer require cerbero/lazy-json-pages ### 👣 Basics -Depending on our coding style, we can call Lazy JSON Pages in 3 different ways: +Depending on our coding style, we can initialize Lazy JSON Pages in 3 different ways: ```php use Cerbero\LazyJsonPages\LazyJsonPages; @@ -123,7 +123,9 @@ class CustomSource extends Source } ``` -The parent class `Source` gives us access to the property `$source`, which is the custom source for our use case. +The parent class `Source` gives us access to 2 properties: +- `$source`: the custom source for our use case +- `$client`: the Guzzle HTTP client The methods to implement respectively turn our custom source into a PSR-7 request and a PSR-7 response. Please refer to the [already existing sources](https://github.com/cerbero90/json-parser/tree/master/src/Sources) to see some implementations. @@ -263,8 +265,9 @@ class CustomPagination extends Pagination } ``` -The parent class `Pagination` gives us access to 2 properties: +The parent class `Pagination` gives us access to 3 properties: - `$source`: the [source](#-sources) pointing to the paginated JSON API +- `$client`: the Guzzle HTTP client - `$config`: the configuration that we generated by chaining methods like `totalPages()` The method `getIterator()` defines the logic to extract paginated items in a memory-efficient way. Please refer to the [already existing paginations](https://github.com/cerbero90/json-parser/tree/master/src/Paginations) to see some implementations. @@ -280,8 +283,21 @@ If you find yourself implementing the same custom pagination in different projec ### 🚀 Requests optimization -> [!WARNING] -> The documentation of this feature is a work in progress. +Paginated APIs differ from each other, so Lazy JSON Pages lets us tweak our HTTP requests specifically for our use case. + +Internally, Lazy JSON Pages uses [Guzzle](https://docs.guzzlephp.org) as its HTTP client. We can customize the client behavior by adding as many [middleware](https://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#middleware) as we need: + +```php +LazyJsonPages::from($source) + ->middleware('log_requests', $logRequests) + ->middleware('cache_responses', $cacheResponses); +``` + +If we a middleware to be added every time we invoke Lazy JSON Pages, we can add a global middleware: + +```php +LazyJsonPages::globalMiddleware('fire_events', $fireEvents); +``` ### 💢 Errors handling diff --git a/src/Concerns/SendsAsyncRequests.php b/src/Concerns/SendsAsyncRequests.php index 325d96b..cdd11ae 100644 --- a/src/Concerns/SendsAsyncRequests.php +++ b/src/Concerns/SendsAsyncRequests.php @@ -4,7 +4,6 @@ namespace Cerbero\LazyJsonPages\Concerns; -use Cerbero\LazyJsonPages\Services\Client; use Generator; use GuzzleHttp\Pool; use Illuminate\Support\LazyCollection; @@ -44,7 +43,7 @@ protected function fetchPagesAsynchronously(LazyCollection $chunkedPages, UriInt */ protected function pool(UriInterface $uri, array $pages): Pool { - return new Pool(Client::instance(), $this->yieldRequests($uri, $pages), [ + return new Pool($this->client, $this->yieldRequests($uri, $pages), [ 'concurrency' => $this->config->async, 'fulfilled' => fn(ResponseInterface $response, int $page) => $this->book->addPage($page, $response), 'rejected' => fn(Throwable $e, int $page) => $this->book->addFailedPage($page) && throw $e, diff --git a/src/Concerns/YieldsItemsByCursor.php b/src/Concerns/YieldsItemsByCursor.php index bfb57fc..ec8c1c2 100644 --- a/src/Concerns/YieldsItemsByCursor.php +++ b/src/Concerns/YieldsItemsByCursor.php @@ -4,7 +4,6 @@ namespace Cerbero\LazyJsonPages\Concerns; -use Cerbero\LazyJsonPages\Services\Client; use Closure; use Generator; use Psr\Http\Message\ResponseInterface; @@ -28,7 +27,7 @@ protected function yieldItemsByCursor(Closure $callback): Generator while ($cursor = $this->toPage($generator->getReturn(), onlyNumerics: false)) { $uri = $this->uriForPage($request->getUri(), (string) $cursor); - $response = Client::instance()->send($request->withUri($uri)); + $response = $this->client->send($request->withUri($uri)); yield from $generator = $callback($response); } diff --git a/src/LazyJsonPages.php b/src/LazyJsonPages.php index 1d95a3d..3677ff9 100644 --- a/src/LazyJsonPages.php +++ b/src/LazyJsonPages.php @@ -7,7 +7,7 @@ use Cerbero\LazyJson\Pointers\DotsConverter; use Cerbero\LazyJsonPages\Dtos\Config; use Cerbero\LazyJsonPages\Paginations\AnyPagination; -use Cerbero\LazyJsonPages\Services\Client; +use Cerbero\LazyJsonPages\Services\ClientFactory; use Cerbero\LazyJsonPages\Sources\AnySource; use Closure; use GuzzleHttp\RequestOptions; @@ -19,9 +19,27 @@ final class LazyJsonPages { /** - * The source of the paginated API. + * The HTTP client factory. */ - private readonly AnySource $source; + private readonly ClientFactory $factory; + + /** + * The Guzzle HTTP request options. + * + * @var array + */ + private array $options = [ + RequestOptions::CONNECT_TIMEOUT => 5, + RequestOptions::READ_TIMEOUT => 5, + RequestOptions::TIMEOUT => 5, + ]; + + /** + * The Guzzle client middleware. + * + * @var array + */ + private array $middleware = []; /** * The raw configuration of the API pagination. @@ -31,13 +49,12 @@ final class LazyJsonPages private array $config = []; /** - * The Guzzle HTTP request options. + * Add a global middleware. */ - private array $requestOptions = [ - RequestOptions::CONNECT_TIMEOUT => 5, - RequestOptions::READ_TIMEOUT => 5, - RequestOptions::TIMEOUT => 5, - ]; + public static function globalMiddleware(string $name, callable $middleware): void + { + ClientFactory::globalMiddleware($name, $middleware); + } /** * Instantiate the class statically. @@ -50,9 +67,9 @@ public static function from(mixed $source): self /** * Instantiate the class. */ - public function __construct(mixed $source) + public function __construct(private readonly mixed $source) { - $this->source = new AnySource($source); + $this->factory = new ClientFactory(); } /** @@ -178,7 +195,7 @@ public function async(int $requests): self */ public function connectionTimeout(float|int $seconds): self { - $this->requestOptions[RequestOptions::CONNECT_TIMEOUT] = max(0, $seconds); + $this->options[RequestOptions::CONNECT_TIMEOUT] = max(0, $seconds); return $this; } @@ -188,8 +205,8 @@ public function connectionTimeout(float|int $seconds): self */ public function requestTimeout(float|int $seconds): self { - $this->requestOptions[RequestOptions::TIMEOUT] = max(0, $seconds); - $this->requestOptions[RequestOptions::READ_TIMEOUT] = max(0, $seconds); + $this->options[RequestOptions::TIMEOUT] = max(0, $seconds); + $this->options[RequestOptions::READ_TIMEOUT] = max(0, $seconds); return $this; } @@ -214,6 +231,16 @@ public function backoff(Closure $callback): self return $this; } + /** + * Add an HTTP client middleware. + */ + public function middleware(string $name, callable $middleware): self + { + $this->middleware[$name] = $middleware; + + return $this; + } + /** * Retrieve a lazy collection yielding the paginated items. * @@ -222,16 +249,12 @@ public function backoff(Closure $callback): self */ public function collect(string $dot = '*'): LazyCollection { - Client::configure($this->requestOptions); - - $config = new Config(...$this->config, itemsPointer: DotsConverter::toPointer($dot)); + return new LazyCollection(function() use ($dot) { + $config = new Config(...$this->config, itemsPointer: DotsConverter::toPointer($dot)); + $client = $this->factory->options($this->options)->middleware($this->middleware)->make(); + $source = new AnySource($this->source, $client); - return new LazyCollection(function() use ($config) { - try { - yield from new AnyPagination($this->source, $config); - } finally { - Client::reset(); - } + yield from new AnyPagination($source, $client, $config); }); } } diff --git a/src/Paginations/AnyPagination.php b/src/Paginations/AnyPagination.php index 1b51ac0..0d31c9b 100644 --- a/src/Paginations/AnyPagination.php +++ b/src/Paginations/AnyPagination.php @@ -47,7 +47,7 @@ public function getIterator(): Traversable protected function matchingPagination(): Pagination { foreach ($this->supportedPaginations as $class) { - $pagination = new $class($this->source, $this->config); + $pagination = new $class($this->source, $this->client, $this->config); if ($pagination->matches()) { return $pagination; diff --git a/src/Paginations/CustomPagination.php b/src/Paginations/CustomPagination.php index c92eb74..82b6983 100644 --- a/src/Paginations/CustomPagination.php +++ b/src/Paginations/CustomPagination.php @@ -31,6 +31,6 @@ public function getIterator(): Traversable throw new InvalidPaginationException($this->config->pagination); } - yield from new $this->config->pagination($this->source, $this->config); + yield from new ($this->config->pagination)($this->source, $this->client, $this->config); } } diff --git a/src/Paginations/Pagination.php b/src/Paginations/Pagination.php index 93709c5..1d07c21 100644 --- a/src/Paginations/Pagination.php +++ b/src/Paginations/Pagination.php @@ -9,6 +9,7 @@ use Cerbero\LazyJsonPages\Dtos\Config; use Cerbero\LazyJsonPages\Services\Book; use Cerbero\LazyJsonPages\Sources\AnySource; +use GuzzleHttp\Client; use IteratorAggregate; use Traversable; @@ -39,6 +40,7 @@ abstract public function getIterator(): Traversable; */ final public function __construct( protected readonly AnySource $source, + protected readonly Client $client, protected readonly Config $config, ) { $this->book = new Book(); diff --git a/src/Providers/LazyJsonPagesServiceProvider.php b/src/Providers/LazyJsonPagesServiceProvider.php index fbf8d7d..9c7fff8 100644 --- a/src/Providers/LazyJsonPagesServiceProvider.php +++ b/src/Providers/LazyJsonPagesServiceProvider.php @@ -4,7 +4,7 @@ namespace Cerbero\LazyJsonPages\Providers; -use Cerbero\LazyJsonPages\Services\Client; +use Cerbero\LazyJsonPages\LazyJsonPages; use GuzzleHttp\Middleware; use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Http\Client\Events\ConnectionFailed; @@ -26,7 +26,7 @@ final class LazyJsonPagesServiceProvider extends ServiceProvider */ public function boot(): void { - Client::middleware('laravel_events', Middleware::tap($this->sending(...), $this->sent(...))); + LazyJsonPages::globalMiddleware('laravel_events', Middleware::tap($this->sending(...), $this->sent(...))); } /** diff --git a/src/Services/Client.php b/src/Services/Client.php deleted file mode 100644 index ec2e7f2..0000000 --- a/src/Services/Client.php +++ /dev/null @@ -1,100 +0,0 @@ - - */ - private static array $defaultOptions = [ - RequestOptions::STREAM => true, - RequestOptions::HEADERS => [ - 'Accept' => 'application/json', - 'Content-Type' => 'application/json', - ], - ]; - - /** - * The custom options. - * - * @var array - */ - private static array $options = []; - - /** - * The client middleware. - * - * @var array - */ - private static array $middleware = []; - - /** - * The Guzzle client instance. - */ - private static ?Guzzle $guzzle = null; - - /** - * Retrieve the Guzzle client instance. - */ - public static function instance(): Guzzle - { - if (self::$guzzle) { - return self::$guzzle; - } - - $options = array_replace_recursive(self::$defaultOptions, self::$options); - $options['handler'] ??= HandlerStack::create(); - - foreach (self::$middleware as $name => $middleware) { - $options['handler']->push($middleware, $name); - } - - return self::$guzzle = new Guzzle($options); - } - - /** - * Set the Guzzle client options. - */ - public static function configure(array $options): void - { - self::$options = array_replace_recursive(self::$options, $options); - } - - /** - * Set the Guzzle client middleware. - */ - public static function middleware(string $name, callable $middleware): void - { - self::$middleware[$name] = $middleware; - } - - /** - * Clean up the static values. - */ - public static function reset(): void - { - self::$guzzle = null; - self::$options = []; - self::$middleware = []; - } - - /** - * Instantiate the class. - */ - private function __construct() - { - // disable the constructor - } -} diff --git a/src/Services/ClientFactory.php b/src/Services/ClientFactory.php new file mode 100644 index 0000000..05b4142 --- /dev/null +++ b/src/Services/ClientFactory.php @@ -0,0 +1,122 @@ + + */ + private static array $defaultOptions = [ + RequestOptions::STREAM => true, + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + ]; + + /** + * The global middleware. + * + * @var array + */ + private static array $globalMiddleware = []; + + /** + * The custom options. + * + * @var array + */ + private array $options = []; + + /** + * The local middleware. + * + * @var array + */ + private array $middleware = []; + + /** + * Add a global middleware. + */ + public static function globalMiddleware(string $name, callable $middleware): void + { + self::$globalMiddleware[$name] = $middleware; + } + + /** + * Fake HTTP requests for testing purposes. + * + * @param \Psr\Http\Message\ResponseInterface[]|GuzzleHttp\Exception\RequestException[] $responses + * @return array> + */ + public static function fake(array $responses, Closure $callback): array + { + $transactions = []; + + $handler = HandlerStack::create(new MockHandler($responses)); + + $handler->push(Middleware::history($transactions)); + + self::$defaultOptions['handler'] = $handler; + + $callback(); + + unset(self::$defaultOptions['handler']); + + return $transactions; + } + + /** + * Set the Guzzle client options. + * + * @param array $options + */ + public function options(array $options): self + { + $this->options = $options; + + return $this; + } + + /** + * Set the Guzzle client middleware. + * + * @param array $middleware + */ + public function middleware(array $middleware): self + { + $this->middleware = $middleware; + + return $this; + } + + /** + * Retrieve a configured Guzzle client instance. + */ + public function make(): Client + { + $options = array_replace_recursive(self::$defaultOptions, $this->options); + $options['handler'] ??= HandlerStack::create(); + + foreach ([...self::$globalMiddleware, ...$this->middleware] as $name => $middleware) { + $options['handler']->push($middleware, $name); + } + + return new Client($options); + } +} diff --git a/src/Sources/AnySource.php b/src/Sources/AnySource.php index 16b9269..c37769b 100644 --- a/src/Sources/AnySource.php +++ b/src/Sources/AnySource.php @@ -55,7 +55,7 @@ protected function matchingSource(): Source } foreach ($this->supportedSources as $class) { - $source = new $class($this->source); + $source = new $class($this->source, $this->client); if ($source->matches()) { return $this->matchingSource = $source; diff --git a/src/Sources/Endpoint.php b/src/Sources/Endpoint.php index 3f55961..74ecb31 100644 --- a/src/Sources/Endpoint.php +++ b/src/Sources/Endpoint.php @@ -5,7 +5,6 @@ namespace Cerbero\LazyJsonPages\Sources; use Cerbero\JsonParser\Concerns\DetectsEndpoints; -use Cerbero\LazyJsonPages\Services\Client; use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -44,6 +43,6 @@ public function request(): RequestInterface */ public function response(): ResponseInterface { - return Client::instance()->send($this->request()); + return $this->client->send($this->request()); } } diff --git a/src/Sources/LaravelClientRequest.php b/src/Sources/LaravelClientRequest.php index 072674c..ed1eb85 100644 --- a/src/Sources/LaravelClientRequest.php +++ b/src/Sources/LaravelClientRequest.php @@ -4,7 +4,6 @@ namespace Cerbero\LazyJsonPages\Sources; -use Cerbero\LazyJsonPages\Services\Client; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Illuminate\Http\Client\Request; @@ -39,6 +38,6 @@ public function request(): RequestInterface */ public function response(): ResponseInterface { - return Client::instance()->send($this->request()); + return $this->client->send($this->request()); } } diff --git a/src/Sources/Psr7Request.php b/src/Sources/Psr7Request.php index 35821d9..955b90f 100644 --- a/src/Sources/Psr7Request.php +++ b/src/Sources/Psr7Request.php @@ -4,7 +4,6 @@ namespace Cerbero\LazyJsonPages\Sources; -use Cerbero\LazyJsonPages\Services\Client; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -38,6 +37,6 @@ public function request(): RequestInterface */ public function response(): ResponseInterface { - return Client::instance()->send($this->source); + return $this->client->send($this->source); } } diff --git a/src/Sources/Source.php b/src/Sources/Source.php index b337f48..d261817 100644 --- a/src/Sources/Source.php +++ b/src/Sources/Source.php @@ -4,6 +4,7 @@ namespace Cerbero\LazyJsonPages\Sources; +use GuzzleHttp\Client; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -29,6 +30,7 @@ abstract public function response(): ResponseInterface; */ final public function __construct( protected readonly mixed $source, + protected readonly Client $client, ) {} /** diff --git a/src/Sources/SymfonyRequest.php b/src/Sources/SymfonyRequest.php index 69166c5..fba5ebc 100644 --- a/src/Sources/SymfonyRequest.php +++ b/src/Sources/SymfonyRequest.php @@ -4,7 +4,6 @@ namespace Cerbero\LazyJsonPages\Sources; -use Cerbero\LazyJsonPages\Services\Client; use GuzzleHttp\Psr7\Request as Psr7Request; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -45,6 +44,6 @@ public function request(): RequestInterface */ public function response(): ResponseInterface { - return Client::instance()->send($this->request()); + return $this->client->send($this->request()); } } diff --git a/tests/Feature/Datasets.php b/tests/Feature/Datasets.php index 45ad9b6..ed4e439 100644 --- a/tests/Feature/Datasets.php +++ b/tests/Feature/Datasets.php @@ -2,6 +2,7 @@ use Cerbero\LazyJsonPages\LazyJsonPages; use Cerbero\LazyJsonPages\Paginations\TotalPagesAwarePagination; +use Cerbero\LazyJsonPages\Services\ClientFactory; use Cerbero\LazyJsonPages\Sources\CustomSourceSample; use GuzzleHttp\Psr7\Request as Psr7Request; use GuzzleHttp\Psr7\Response as Psr7Response; @@ -18,7 +19,7 @@ $laravelClientResponse = new LaravelClientResponse($psr7Response); $laravelClientResponse->transferStats = new TransferStats($psr7Request, $psr7Response); - yield 'user-defined source' => [new CustomSourceSample(null), false]; + yield 'user-defined source' => [new CustomSourceSample(null, (new ClientFactory())->make()), false]; yield 'endpoint' => [$uri, true]; yield 'Laravel client request' => [new LaravelClientRequest($psr7Request), true]; yield 'Laravel client response' => [$laravelClientResponse, false]; diff --git a/tests/Feature/OptimizationTest.php b/tests/Feature/OptimizationTest.php new file mode 100644 index 0000000..d804a94 --- /dev/null +++ b/tests/Feature/OptimizationTest.php @@ -0,0 +1,24 @@ +middleware('log', Middleware::tap($before, $after)) + ->totalPages('meta.total_pages') + ->collect('data.*'); + + expect($lazyCollection)->toLoadItemsViaRequests([ + 'https://example.com/api/v1/users' => 'pagination/page1.json', + 'https://example.com/api/v1/users?page=2' => 'pagination/page2.json', + 'https://example.com/api/v1/users?page=3' => 'pagination/page3.json', + ]); + + expect($log)->toBe(['req1', 'res1', 'req2', 'res2', 'req3', 'res3']); +}); diff --git a/tests/Pest.php b/tests/Pest.php index ec36cc9..b6df0d5 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,10 +1,7 @@ extend('toLoadItemsViaRequests', function (array $requests, Generator|array $headers = []) { - $responses = $transactions = $expectedUris = []; + $responses = $expectedUris = []; $responseHeaders = $headers; foreach ($requests as $uri => $fixture) { @@ -48,13 +45,7 @@ $expectedUris[] = $uri; } - $stack = HandlerStack::create(new MockHandler($responses)); - - $stack->push(Middleware::history($transactions)); - - Client::configure(['handler' => $stack]); - - $this->sequence(...require fixture('items.php')); + $transactions = ClientFactory::fake($responses, fn() => $this->sequence(...require fixture('items.php'))); $actualUris = array_map(fn(array $transaction) => (string) $transaction['request']->getUri(), $transactions); @@ -62,23 +53,18 @@ }); expect()->extend('toFailRequest', function (string $uri) { - $transactions = []; - $responses = [$exception = new RequestException('connection failed', new Request('GET', $uri))]; - $stack = HandlerStack::create(new MockHandler($responses)); - - $stack->push(Middleware::history($transactions)); - - Client::configure(['handler' => $stack]); - - try { - iterator_to_array($this->value); - } catch (Throwable $e) { - expect($e)->toBe($exception); - } + $transactions = ClientFactory::fake($responses, function() use ($exception) { + try { + iterator_to_array($this->value); + } catch (Throwable $e) { + expect($e)->toBe($exception); + } + }); expect($transactions)->toHaveCount(1); + expect((string) $transactions[0]['request']->getUri())->toBe($uri); });