Client   A
last analyzed

Complexity

Total Complexity 32

Size/Duplication

Total Lines 393
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 111
c 2
b 0
f 0
dl 0
loc 393
ccs 111
cts 111
cp 1
rs 9.84
wmc 32

9 Methods

Rating   Name   Duplication   Size   Complexity  
B build() 0 66 9
A buildAndSend() 0 12 2
A __construct() 0 31 5
A enableRetryAttempts() 0 5 1
A retryDecider() 0 25 3
A disableRetryAttempts() 0 5 1
A setMaxRetryAttempts() 0 5 1
A retryDelay() 0 16 3
B send() 0 56 7
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * This file is part of Esi\Api.
7
 *
8
 * (c) Eric Sizemore <[email protected]>
9
 *
10
 * This source file is subject to the MIT license. For the full
11
 * copyright and license information, please view the LICENSE
12
 * file that was distributed with this source code.
13
 */
14
15
namespace Esi\Api;
16
17
use Closure;
18
use DateTime;
19
use Esi\Api\Exceptions\RateLimitExceededException;
20
use Esi\Api\Traits\ParseJsonResponse;
21
use GuzzleHttp\Client as GuzzleClient;
22
use GuzzleHttp\Exception\BadResponseException;
23
use GuzzleHttp\Exception\ClientException;
24
use GuzzleHttp\Exception\ConnectException;
25
use GuzzleHttp\Exception\GuzzleException;
26
use GuzzleHttp\Exception\InvalidArgumentException as GuzzleInvalidArgumentException;
27
use GuzzleHttp\HandlerStack;
28
use GuzzleHttp\Middleware;
29
use GuzzleHttp\RetryMiddleware;
30
use InvalidArgumentException;
31
use Kevinrob\GuzzleCache\CacheMiddleware;
32
use Kevinrob\GuzzleCache\Storage\Psr6CacheStorage;
33
use Kevinrob\GuzzleCache\Strategy\PrivateCacheStrategy;
34
use Psr\Http\Message\MessageInterface;
35
use Psr\Http\Message\RequestInterface;
36
use Psr\Http\Message\ResponseInterface;
37
use RuntimeException;
38
use SensitiveParameter;
39
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
40
use Throwable;
41
42
use function array_keys;
43
use function is_dir;
44
use function is_numeric;
45
use function is_writable;
46
use function range;
47
use function strtoupper;
48
use function time;
49
use function trim;
50
51
/**
52
 * Essentially a wrapper, or base class of sorts, for building a Guzzle Client object.
53
 *
54
 * Note: Only designed for non-asynchronous, non-pool, and non-promise requests currently.
55
 *
56
 * @todo Allow more cache options. Currently the only option is via files using Symfony's FilesystemAdapter.
57
 *
58
 * @see Tests\ClientTest
59
 */
60
final class Client
61
{
62
    /**
63
     * Adds helper functions for handling API json responses.
64
     *
65
     * @see Esi\Api\Traits\ParseJsonResponse
66
     */
67
    use ParseJsonResponse;
68
69
    /**
70
     * GuzzleHttp Client.
71
     */
72
    public ?GuzzleClient $client = null;
73
74
    /**
75
     * API key.
76
     */
77
    private readonly string $apiKey;
78
79
    /**
80
     * If $this->apiRequiresQuery = true, then what is the name of the
81
     * query parameter for the API key? For example: api_key.
82
     */
83
    private readonly string $apiParamName;
84
85
    /**
86
     * Base API endpoint.
87
     */
88
    private readonly string $apiUrl;
89
90
    /**
91
     * Will add the Guzzle Retry middleware to requests if enabled.
92
     *
93
     * @see self::enableRetryAttempts()
94
     * @see self::disableRetryAttempts()
95
     */
96
    private bool $attemptRetry = false;
97
98
    /**
99
     * Path to your cache folder on the file system.
100
     */
101
    private ?string $cachePath = null;
102
103
    /**
104
     * Maximum number of retries that the Retry middleware will attempt.
105
     */
106
    private int $maxRetries = 5;
107
108
    /**
109
     * Helps in testing, mostly. Used to keep track of how many retries
110
     * have been attempted.
111
     *
112
     * @internal
113
     */
114
    private int $retryCalls = 0;
115
116
    /**
117
     * Constructor.
118
     *
119
     * @param string  $apiUrl           URL to the API.
120
     * @param ?string $apiKey           Your API Key.
121
     * @param ?string $cachePath        The path to your cache on the filesystem.
122
     * @param bool    $apiRequiresQuery True if the API requires the api key sent via query/query string, false otherwise.
123
     * @param string  $apiParamName     If $apiRequiresQuery = true, then the param name for the api key. E.g.: api_key
124
     *
125
     * @throws InvalidArgumentException If either the api key or api url are empty strings.
126
     */
127 8
    public function __construct(
128
        string $apiUrl,
129
        #[SensitiveParameter]
130
        ?string $apiKey = null,
131
        ?string $cachePath = null,
132
        // Does this particular API require the API key to be sent in the query string?
133
        private readonly bool $apiRequiresQuery = false,
134
        string $apiParamName = ''
135
    ) {
136 8
        $apiUrl       = trim($apiUrl);
137 8
        $apiKey       = trim((string) $apiKey);
138 8
        $apiParamName = trim($apiParamName);
139 8
        $cachePath    = trim((string) $cachePath);
140
141 8
        if ($apiUrl === '') {
142 1
            throw new InvalidArgumentException('API URL expects non-empty-string, empty-string provided.');
143
        }
144
145
        /**
146
         * @todo Some APIs require api keys, some don't.
147
         */
148 7
        if ($apiKey === '') {
149 2
            throw new InvalidArgumentException('API key expects a non-empty-string, empty-string provided.');
150
        }
151
152 5
        $this->apiUrl       = $apiUrl;
153 5
        $this->apiKey       = $apiKey;
154 5
        $this->apiParamName = $apiParamName;
155
156 5
        if (is_dir($cachePath) && is_writable($cachePath)) {
157 5
            $this->cachePath = $cachePath;
158
        }
159
    }
160
161
    /**
162
     * Builds our GuzzleHttp client.
163
     *
164
     * Some APIs require requests be made with the api key via query string; others, by setting a header. If the particular API you are querying
165
     * needs it sent via query string, be sure to instantiate this class with $apiRequiresQuery set to true and by providing the expected field
166
     * name used for the api key. E.g.: api_key
167
     *
168
     * Otherwise, if it is sent via header, be sure to add the headers in the $options array when calling build(). This can be done with
169
     * 'persistentHeaders' which contains key => value pairs of headers to set. For e.g.:
170
     *
171
     * <code>
172
     *      $client = new Client('https://myapiurl.com/api', 'myApiKey', '/var/tmp');
173
     *      $client->build([
174
     *          'persistentHeaders' => [
175
     *              'Accept'        => 'application/json',
176
     *              'Client-ID'     => 'apiKey',
177
     *              'Authorization' => 'Bearer {someAccessToken}',
178
     *          ]
179
     *      ]);
180
     *      $response = $client->send(...);
181
     * </code>
182
     *
183
     * @param array<string, mixed> $options An associative array with options to set in the initial config of the Guzzle
184
     *                                      client. {@see https://docs.guzzlephp.org/en/stable/request-options.html}
185
     *                                      One exception to this is the use of non-Guzzle, Esi\Api specific options:
186
     *                                      persistentHeaders - Key => Value array where key is the header name and value is the header value.
187
     *                                      Also of note, right now this class is built in such a way that adding 'query' to the build options
188
     *                                      should be avoided, and instead sent with the {@see self::send()} method when making a request. If a
189
     *                                      'query' key is found in the $options array, it will raise an InvalidArgumentException.
190
     *
191
     * @throws GuzzleInvalidArgumentException If Guzzle encounters an error with passed options
192
     * @throws InvalidArgumentException       If 'query' is passed in options. Should only be done on the send() call.
193
     *                                        Or if an invalid headers array is passed in options.
194
     */
195 19
    public function build(?array $options = null): GuzzleClient
196
    {
197
        // Default options
198 19
        $defaultOptions = [
199 19
            'base_uri'    => $this->apiUrl,
200 19
            'http_errors' => true,
201 19
            'timeout'     => 10,
202 19
        ];
203
204
        // Create default HandlerStack
205 19
        $handlerStack = HandlerStack::create();
206
207
        // Process options.
208 19
        if ($options !== null) {
209
            // Do we need to add any persistent headers (headers sent with every request)?
210 17
            if (isset($options['persistentHeaders'])) {
211
                /** @var array<string> $headers * */
212 17
                $headers = $options['persistentHeaders'];
213
214 17
                if (array_keys($headers) === range(0, \count($headers) - 1)) {
215 1
                    throw new InvalidArgumentException('The headers array must have header name as keys.');
216
                }
217
218
                // We use Middleware and add the headers to our handler stack.
219 16
                foreach ($headers as $header => $value) {
220 16
                    $handlerStack->unshift(Middleware::mapRequest(
221 16
                        static fn (RequestInterface $request): MessageInterface => $request->withHeader($header, $value)
222 16
                    ));
223
                }
224
            }
225
226 16
            if (isset($options['query'])) {
227 1
                throw new InvalidArgumentException('Please only specify a query parameter for options when using ::send()');
228
            }
229
230 15
            Utils::verifyOptions($options);
231
232 14
            $defaultOptions += $options;
233
        }
234
235
        // Does the API key need to be sent as part of the query?
236 16
        if ($this->apiRequiresQuery) {
237 6
            $defaultOptions += [
238 6
                'query' => [$this->apiParamName => $this->apiKey],
239 6
            ];
240
        }
241
242
        // If we have a cache path, create our Cache handler.
243 16
        if ($this->cachePath !== null) {
244 16
            $handlerStack->push(new CacheMiddleware(new PrivateCacheStrategy(
245 16
                new Psr6CacheStorage(new FilesystemAdapter('', 300, $this->cachePath))
246 16
            )), 'cache');
247
        }
248
249 16
        if ($this->attemptRetry) {
250 7
            $handlerStack->push(Middleware::retry($this->retryDecider(), $this->retryDelay()));
251
        }
252
253 16
        $defaultOptions += ['handler' => $handlerStack, ];
254
255
        // Attempt instantiation of the client. Generally we should only run into issues if any options
256
        // passed to Guzzle are incorrectly defined/configured.
257 16
        $this->client = new GuzzleClient($defaultOptions);
258
259
        // All done!
260 16
        return $this->client;
261
    }
262
263
    /**
264
     * Builds the client and sends the request, all in one: basically just combines {@see self::build()} and {@see self::send()}.
265
     *
266
     * @param string               $method   The method to use, such as GET.
267
     * @param ?string              $endpoint Endpoint to call on the API.
268
     * @param array<string, mixed> $options  An associative array with options to set in the initial config of the Guzzle
269
     *                                       client. {@see https://docs.guzzlephp.org/en/stable/request-options.html}
270
     *                                       One exception to this is the use of non-Guzzle, Esi\Api specific options:
271
     *                                       persistentHeaders - Key => Value array where key is the header name and value is the header value.
272
     *                                       Also of note, right now this class is built in such a way that adding 'query' to the build options
273
     *                                       should be avoided, and instead sent with the {@see self::send()} method when making a request. If a
274
     *                                       'query' key is found in the $options array, it will raise an InvalidArgumentException.
275
     *
276
     * @throws GuzzleInvalidArgumentException                       If Guzzle encounters an error with passed options
277
     * @throws InvalidArgumentException                             If 'query' is passed in options. Should only be done on the send() call.
278
     *                                                              Or if an invalid headers array is passed in options.
279
     * @throws RuntimeException
280
     * @throws BadResponseException|ClientException|GuzzleException
281
     */
282 1
    public function buildAndSend(string $method, ?string $endpoint = null, ?array $options = null): ResponseInterface
283
    {
284 1
        $query = $options['query'] ?? [];
285 1
        unset($options['query']);
286
287 1
        $this->build($options);
288
289 1
        if ($query !== []) {
290 1
            $options['query'] = $query;
291
        }
292
293 1
        return $this->send($method, $endpoint, $options);
294
    }
295
296
    /**
297
     * Disable the Retry middleware.
298
     */
299 1
    public function disableRetryAttempts(): Client
300
    {
301 1
        $this->attemptRetry = false;
302
303 1
        return $this;
304
    }
305
306
    /**
307
     * Enable the Retry middleware.
308
     */
309 4
    public function enableRetryAttempts(): Client
310
    {
311 4
        $this->attemptRetry = true;
312
313 4
        return $this;
314
    }
315
316
    /**
317
     * Performs a synchronous request with given method and API endpoint.
318
     *
319
     * @param string                $method   The method to use, such as GET.
320
     * @param ?string               $endpoint Endpoint to call on the API.
321
     * @param ?array<string, mixed> $options  An associative array with options to set per request.
322
     *
323
     * @see https://docs.guzzlephp.org/en/stable/request-options.html
324
     *
325
     * @throws InvalidArgumentException
326
     * @throws RuntimeException
327
     * @throws BadResponseException|ClientException|GuzzleException
328
     *
329
     * @return ResponseInterface An object implementing PSR's ResponseInterface object.
330
     */
331 17
    public function send(string $method, ?string $endpoint = null, ?array $options = null): ResponseInterface
332
    {
333
        // Check for a valid method
334 17
        $method = trim(strtoupper($method));
335
336 17
        Utils::verifyMethod($method);
337
338
        /**
339
         * If passing options, verify against the request options Guzzle expects.
340
         * {@see https://docs.guzzlephp.org/en/stable/request-options.html}
341
         * {@see Utils::verifyOptions()}.
342
         */
343 16
        if ($options !== null) {
344 9
            Utils::verifyOptions($options);
345
        } else {
346 7
            $options = [];
347
        }
348
349 16
        if ($this->apiRequiresQuery) {
350 6
            if (isset($options['query'])) {
351
                /**
352
                 * @var array<mixed> $tempQuery
353
                 */
354 5
                $tempQuery = $options['query'];
355 5
                $tempQuery = array_merge(
356 5
                    $tempQuery,
357 5
                    [$this->apiParamName => $this->apiKey]
358 5
                );
359 5
                $options['query'] = $tempQuery;
360
361 5
                unset($tempQuery);
362
            } else {
363 1
                $options['query'] = [$this->apiParamName => $this->apiKey];
364
            }
365
        }
366
367 16
        $endpoint = Utils::normalizeEndpoint($endpoint, $this->apiUrl);
368
369
        // Do we have Guzzle instantiated already?
370 16
        if (!$this->client instanceof GuzzleClient) {
371 1
            throw new RuntimeException(\sprintf(
372 1
                'No valid Guzzle client detected, a client must be built with the "%s" method of "%s" first.',
373 1
                'build',
374 1
                Client::class
375 1
            ));
376
        }
377
378
        try {
379 15
            return $this->client->request($method, $endpoint, $options);
380 5
        } catch (ClientException $clientException) {
381
            // This is not necessarily standard across the many APIs out there, but included as it does indicate "Too Many Requests".
382 3
            if ($clientException->getResponse()->getStatusCode() === 429) {
383 2
                throw new RateLimitExceededException('API rate limit exceeded.', previous: $clientException);
384
            }
385
386 1
            throw $clientException;
387
        }
388
    }
389
390
    /**
391
     * Set the maximum number of retry attempts.
392
     */
393 4
    public function setMaxRetryAttempts(int $maxRetries): Client
394
    {
395 4
        $this->maxRetries = $maxRetries;
396
397 4
        return $this;
398
    }
399
400
    /**
401
     * For use in the Retry middleware. Decides when to retry a request.
402
     *
403
     * NOTE: The Retry middleware will not be used without calling {@see self::enableRetryAttempts()}.
404
     */
405 7
    private function retryDecider(): Closure
406
    {
407 7
        return function (
408 7
            int $retries,
409 7
            RequestInterface $request,
410 7
            ?ResponseInterface $response = null,
411 7
            ?Throwable $throwable = null
412 7
        ): bool {
413 7
            $this->retryCalls = $retries;
414
415
            // Don't retry if we have run out of retries.
416 7
            if ($this->retryCalls >= $this->maxRetries) {
417 4
                return false;
418
            }
419
420
            /**
421
             * @todo Add the ability/option to log retry attempts.
422
             */
423
            return match (true) {
424
                // Retry connection exceptions.
425 7
                $throwable instanceof ConnectException => true,
426
                // Retry on server errors.
427 7
                $response instanceof ResponseInterface => ($response->getStatusCode() >= 500 || $response->getStatusCode() === 429),
428
                // Do not retry.
429 7
                default => false
430
            };
431 7
        };
432
    }
433
434
    /**
435
     * Adds a delay to each retry attempt, based on the number of retries.
436
     */
437 7
    private function retryDelay(): Closure
438
    {
439 7
        return static function (int $numberOfRetries, ResponseInterface $response): int {
440 4
            if (!$response->hasHeader('Retry-After')) {
441 1
                return RetryMiddleware::exponentialDelay($numberOfRetries);
442
            }
443
444 3
            $retryAfter = $response->getHeaderLine('Retry-After');
445
446
            // @codeCoverageIgnoreStart
447
            if (!is_numeric($retryAfter)) {
448
                $retryAfter = (new DateTime($retryAfter))->getTimestamp() - time();
449
            }
450
451
            // @codeCoverageIgnoreEnd
452 3
            return 1000 * (int) $retryAfter;
453 7
        };
454
    }
455
}
456