Completed
Push — master ( 1aec34...c765e9 )
by Dan Michael O.
03:32
created

Client   C

Complexity

Total Complexity 54

Size/Duplication

Total Lines 568
Duplicated Lines 5.28 %

Coupling/Cohesion

Components 2
Dependencies 26

Importance

Changes 0
Metric Value
dl 30
loc 568
rs 6.4799
c 0
b 0
f 0
wmc 54
lcom 2
cbo 26

21 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 59 7
A lendingRequests() 0 4 1
A setSruClient() 0 4 1
A assertHasSruClient() 0 6 2
A setKey() 0 6 1
A setRegion() 0 9 2
A setBaseUrl() 0 10 2
A buildUrl() 0 17 3
C request() 0 58 11
A get() 0 10 1
A getJSON() 0 6 1
A getXML() 0 6 1
A put() 15 15 2
A putJSON() 0 6 1
A putXML() 0 6 1
A post() 15 15 2
A postJSON() 0 6 1
A postXML() 0 6 1
A getRedirectLocation() 0 16 3
A make() 0 4 1
B parseClientError() 0 47 9

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like Client often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Client, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Scriptotek\Alma;
4
5
use Danmichaelo\QuiteSimpleXMLElement\QuiteSimpleXMLElement;
6
use Http\Client\Common\Plugin\ContentLengthPlugin;
7
use Http\Client\Common\Plugin\ErrorPlugin;
8
use Http\Client\Common\PluginClient;
9
use Http\Client\Exception\HttpException;
10
use Http\Client\Exception\NetworkException;
11
use Http\Factory\Discovery\HttpClient;
12
use Http\Factory\Discovery\HttpFactory;
13
use Http\Message\UriFactory;
14
use Psr\Http\Client\ClientInterface as HttpClientInterface;
15
use Psr\Http\Message\RequestFactoryInterface;
16
use Psr\Http\Message\RequestInterface;
17
use Psr\Http\Message\ResponseInterface;
18
use Psr\Http\Message\UriFactoryInterface;
19
use Psr\Http\Message\UriInterface;
20
use Scriptotek\Alma\Analytics\Analytics;
21
use Scriptotek\Alma\Bibs\Bibs;
22
use Scriptotek\Alma\Bibs\Items;
23
use Scriptotek\Alma\Conf\Conf;
24
use Scriptotek\Alma\Conf\Jobs;
25
use Scriptotek\Alma\Conf\Libraries;
26
use Scriptotek\Alma\Conf\Library;
27
use Scriptotek\Alma\Exception\ClientException as AlmaClientException;
28
use Scriptotek\Alma\Exception\InvalidApiKey;
29
use Scriptotek\Alma\Exception\MaxNumberOfAttemptsExhausted;
30
use Scriptotek\Alma\Exception\RequestFailed;
31
use Scriptotek\Alma\Exception\ResourceNotFound;
32
use Scriptotek\Alma\Exception\SruClientNotSetException;
33
use Scriptotek\Alma\TaskLists\LendingRequests;
34
use Scriptotek\Alma\TaskLists\TaskLists;
35
use Scriptotek\Alma\Users\Users;
36
use Scriptotek\Sru\Client as SruClient;
37
use function GuzzleHttp\Psr7\stream_for;
38
39
/**
40
 * Alma client.
41
 */
42
class Client
43
{
44
    public $baseUrl;
45
46
    /** @var string Alma zone (institution or network) */
47
    public $zone;
48
49
    /** @var string Alma Developers Network API key for this zone */
50
    public $key;
51
52
    /** @var Client Network zone instance */
53
    public $nz;
54
55
    /** @var HttpClientInterface */
56
    protected $http;
57
58
    /** @var RequestFactoryInterface */
59
    protected $requestFactory;
60
61
    /** @var UriFactory */
62
    protected $uriFactory;
63
64
    /** @var SruClient */
65
    public $sru;
66
67
    /** @var Bibs */
68
    public $bibs;
69
70
    /** @var Analytics */
71
    public $analytics;
72
73
    /** @var Users */
74
    public $users;
75
76
    /** @var Items */
77
    public $items;
78
79
    /** @var int Max number of retries if we get 429 errors */
80
    public $maxAttempts = 10;
81
82
    /** @var int Max number of retries if we get 5XX errors */
83
    public $maxAttemptsOnServerError = 1;
84
85
    /** @var float Number of seconds to sleep before retrying */
86
    public $sleepTimeOnRetry = 0.5;
87
88
    /** @var float Number of seconds to sleep before retrying after a server error */
89
    public $sleepTimeOnServerError = 10;
90
91
    /** @var array Extra request headers */
92
    public $extraHeaders = [];
93
94
    /**
95
     * @var Conf
96
     */
97
    public $conf;
98
99
    /**
100
     * @var Libraries
101
     */
102
    public $libraries;
103
104
    /**
105
     * @var Jobs
106
     */
107
    public $jobs;
108
109
    /**
110
     * @var TaskLists
111
     */
112
    public $taskLists;
113
114
    /**
115
     * Create a new client to connect to a given Alma instance.
116
     *
117
     * @param ?string                  $key            API key
0 ignored issues
show
Documentation introduced by
The doc-type ?string could not be parsed: Unknown type name "?string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
118
     * @param string                   $region         Hosted region code, used to build base URL
119
     * @param string                   $zone           Alma zone (Either Zones::INSTITUTION or Zones::NETWORK)
120
     * @param ?HttpClientInterface     $http
0 ignored issues
show
Documentation introduced by
The doc-type ?HttpClientInterface could not be parsed: Unknown type name "?HttpClientInterface" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
121
     * @param ?RequestFactoryInterface $requestFactory
0 ignored issues
show
Documentation introduced by
The doc-type ?RequestFactoryInterface could not be parsed: Unknown type name "?RequestFactoryInterface" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
122
     * @param ?UriFactoryInterface     $uriFactory
0 ignored issues
show
Documentation introduced by
The doc-type ?UriFactoryInterface could not be parsed: Unknown type name "?UriFactoryInterface" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
123
     * @param ?string                  $baseUrl
0 ignored issues
show
Documentation introduced by
The doc-type ?string could not be parsed: Unknown type name "?string" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
124
     * @param array                    $extraHeaders
125
     *
126
     * @throws \ErrorException
127
     */
128
    public function __construct(
129
        $key = null,
130
        $region = 'eu',
131
        $zone = Zones::INSTITUTION,
132
        HttpClientInterface $http = null,
133
        RequestFactoryInterface $requestFactory = null,
134
        UriFactoryInterface $uriFactory = null,
135
        string $baseUrl = null,
136
        array $extraHeaders = []
137
    ) {
138
        $this->http = new PluginClient(
139
            $http ?: HttpClient::client(),
140
            [
141
                new ContentLengthPlugin(),
142
                new ErrorPlugin(),
143
            ]
144
        );
145
        $this->requestFactory = $requestFactory ?: HttpFactory::requestFactory();
146
        $this->uriFactory = $uriFactory ?: HttpFactory::uriFactory();
0 ignored issues
show
Documentation Bug introduced by
It seems like $uriFactory ?: \Http\Fac...tpFactory::uriFactory() of type object<Psr\Http\Message\UriFactoryInterface> is incompatible with the declared type object<Http\Message\UriFactory> of property $uriFactory.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
147
148
        $this->key = $key;
149
150
        if (!is_null($baseUrl)) {
151
            $this->setBaseUrl($baseUrl);
152
        } else {
153
            $this->setRegion($region);
154
        }
155
156
        $this->extraHeaders = $extraHeaders;
157
158
        $this->zone = $zone;
159
160
        $this->bibs = new Bibs($this);
161
        $this->items = new Items($this); // Only needed for the fromBarcode method :/
162
163
        $this->analytics = new Analytics($this);
164
        $this->users = new Users($this);
165
166
        $this->conf = new Conf($this);
167
        $this->libraries = $this->conf->libraries;  // shortcut
0 ignored issues
show
Bug introduced by
The property libraries does not seem to exist in Scriptotek\Alma\Conf\Conf.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
168
        $this->jobs = $this->conf->jobs;  // shortcut
0 ignored issues
show
Bug introduced by
The property jobs does not seem to exist in Scriptotek\Alma\Conf\Conf.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
169
170
        $this->taskLists = new TaskLists($this);
171
172
        if ($zone == Zones::INSTITUTION) {
173
            $this->nz = new self(
174
                null,
175
                $region,
176
                Zones::NETWORK,
177
                $this->http,
178
                $this->requestFactory,
179
                $this->uriFactory,
180
                $baseUrl,
181
                $extraHeaders
182
            );
183
        } elseif ($zone != Zones::NETWORK) {
184
            throw new AlmaClientException('Invalid zone name.');
185
        }
186
    }
187
188
    public function lendingRequests(Library $library, $params = [])
189
    {
190
        return new LendingRequests($this, $library, $params);
191
    }
192
193
    /**
194
     * Attach an SRU client (so you can search for Bib records).
195
     *
196
     * @param SruClient $sru
197
     */
198
    public function setSruClient(SruClient $sru)
199
    {
200
        $this->sru = $sru;
201
    }
202
203
    /**
204
     * Assert that an SRU client is connected. Throws SruClientNotSetException if not.
205
     *
206
     * @throws SruClientNotSetException
207
     */
208
    public function assertHasSruClient()
209
    {
210
        if (!isset($this->sru)) {
211
            throw new SruClientNotSetException();
212
        }
213
    }
214
215
    /**
216
     * Set the API key for this Alma instance.
217
     *
218
     * @param string $key The API key
219
     *
220
     * @return $this
221
     */
222
    public function setKey($key)
223
    {
224
        $this->key = $key;
225
226
        return $this;
227
    }
228
229
    /**
230
     * Set the Alma region code ('na' for North America, 'eu' for Europe, 'ap' for Asia Pacific).
231
     *
232
     * @param $regionCode
233
     * @throws AlmaClientException
234
     * @return $this
235
     */
236
    public function setRegion($regionCode)
237
    {
238
        if (!in_array($regionCode, ['na', 'eu', 'ap'])) {
239
            throw new AlmaClientException('Invalid region code');
240
        }
241
        $this->setBaseUrl('https://api-' . $regionCode . '.hosted.exlibrisgroup.com/almaws/v1');
242
243
        return $this;
244
    }
245
246
    /**
247
     * Set the Alma API base url.
248
     *
249
     * @param string $baseUrl
250
     * @return $this
251
     */
252
    public function setBaseUrl(string $baseUrl)
253
    {
254
        $this->baseUrl = $baseUrl;
255
256
        if (!is_null($this->nz)) {
257
            $this->nz->setBaseUrl($baseUrl);
258
        }
259
260
        return $this;
261
    }
262
263
    /**
264
     * Extend an URL with query string parameters and return an UriInterface object.
265
     *
266
     * @param string $url
267
     * @param array  $query
268
     *
269
     * @return UriInterface
270
     */
271
    public function buildUrl($url, $query = [])
272
    {
273
        $url = explode('?', $url, 2);
274
        if (count($url) == 2) {
275
            parse_str($url[1], $query0);
276
            $query = array_merge($query0, $query);
277
        }
278
279
        $url = $url[0];
280
281
        if (strpos($url, $this->baseUrl) === false) {
282
            $url = $this->baseUrl . $url;
283
        }
284
285
        return $this->uriFactory->createUri($url)
286
            ->withQuery(http_build_query($query));
287
    }
288
289
    /**
290
     * Make a synchronous HTTP request and return a PSR7 response if successful.
291
     * In the case of intermittent errors (connection problem, 429 or 5XX error), the request is
292
     * attempted a maximum of {$this->maxAttempts} times with a sleep of {$this->sleepTimeOnRetry}
293
     * between each attempt to avoid hammering the server.
294
     *
295
     * @param RequestInterface $request
296
     * @param int              $attempt
297
     *
298
     * @return ResponseInterface
299
     */
300
    public function request(RequestInterface $request, $attempt = 1)
301
    {
302
        if (isset($this->key)) {
303
            $request = $request->withHeader('Authorization', 'apikey ' . $this->key);
304
        }
305
        foreach ($this->extraHeaders as $key => $val) {
306
            $request = $request->withHeader($key, $val);
307
        }
308
309
        try {
310
            return $this->http->sendRequest($request);
311
        } catch (HttpException $e) {
312
            // Thrown for 400 and 500 level errors.
313
            $statusCode = $e->getResponse()->getStatusCode();
314
315
            $error = $this->parseClientError($e);
316
317
            if ($error->getErrorCode() === 'PER_SECOND_THRESHOLD') {
318
                // We've run into the "Max 25 API calls per institution per second" limit.
319
                // Wait a sec and retry, unless we've tried too many times already.
320
                if ($attempt >= $this->maxAttempts) {
321
                    throw new MaxNumberOfAttemptsExhausted(
322
                        'Rate limiting error - max number of retry attempts exhausted.',
323
                        0,
324
                        $e
325
                    );
326
                }
327
                time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
328
329
                return $this->request($request, $attempt + 1);
330
            }
331
332
            if ($statusCode >= 500 && $statusCode < 600) {
333
                if ($attempt >= $this->maxAttemptsOnServerError) {
334
                    throw $error;
335
                }
336
                time_nanosleep(0, $this->sleepTimeOnServerError * 1000000000);
337
338
                return $this->request($request, $attempt + 1);
339
            }
340
341
            // Throw exception for other errors
342
            throw $error;
343
        } catch (NetworkException $e) {
344
            // Thrown in case of a networking error
345
            // Wait a sec and retry, unless we've tried too many times already.
346
            if ($attempt > $this->maxAttempts) {
347
                throw new MaxNumberOfAttemptsExhausted(
348
                    'Network error - max number of retry attempts exhausted.',
349
                    0,
350
                    $e
351
                );
352
            }
353
            time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
354
355
            return $this->request($request, $attempt + 1);
356
        }
357
    }
358
359
    /**
360
     * Make a GET request.
361
     *
362
     * @param string $url
363
     * @param array  $query
364
     * @param string $contentType
365
     *
366
     * @return string The response body
367
     */
368
    public function get($url, $query = [], $contentType = 'application/json')
369
    {
370
        $url = $this->buildUrl($url, $query);
371
        $request = $this->requestFactory->createRequest('GET', $url)
372
            ->withHeader('Accept', $contentType);
373
374
        $response = $this->request($request);
375
376
        return strval($response->getBody());
377
    }
378
379
    /**
380
     * Make a GET request, accepting JSON.
381
     *
382
     * @param string $url
383
     * @param array  $query
384
     *
385
     * @return \stdClass JSON response as an object.
386
     */
387
    public function getJSON($url, $query = [])
388
    {
389
        $responseBody = $this->get($url, $query, 'application/json');
390
391
        return json_decode($responseBody);
392
    }
393
394
    /**
395
     * Make a GET request, accepting XML.
396
     *
397
     * @param string $url
398
     * @param array  $query
399
     *
400
     * @return QuiteSimpleXMLElement
401
     */
402
    public function getXML($url, $query = [])
403
    {
404
        $responseBody = $this->get($url, $query, 'application/xml');
405
406
        return new QuiteSimpleXMLElement($responseBody);
407
    }
408
409
    /**
410
     * Make a PUT request.
411
     *
412
     * @param string $url
413
     * @param $data
414
     * @param string $contentType
415
     *
416
     * @return string The response body
417
     */
418 View Code Duplication
    public function put($url, $data, $contentType = 'application/json')
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
419
    {
420
        $uri = $this->buildUrl($url);
421
422
        $request = $this->requestFactory->createRequest('PUT', $uri);
423
        if (!is_null($contentType)) {
424
            $request = $request->withHeader('Content-Type', $contentType);
425
            $request = $request->withHeader('Accept', $contentType);
426
        }
427
        $request = $request->withBody(stream_for($data));
428
429
        $response = $this->request($request);
430
431
        return strval($response->getBody());
432
    }
433
434
    /**
435
     * Make a PUT request, sending JSON data.
436
     *
437
     * @param string $url
438
     * @param $data
439
     *
440
     * @return \stdClass
441
     */
442
    public function putJSON($url, $data)
443
    {
444
        $responseBody = $this->put($url, json_encode($data), 'application/json');
445
446
        return json_decode($responseBody);
447
    }
448
449
    /**
450
     * Make a PUT request, sending XML data.
451
     *
452
     * @param string $url
453
     * @param $data
454
     *
455
     * @return QuiteSimpleXMLElement
456
     */
457
    public function putXML($url, $data)
458
    {
459
        $responseBody = $this->put($url, $data, 'application/xml');
460
461
        return new QuiteSimpleXMLElement($responseBody);
462
    }
463
464
    /**
465
     * Make a POST request.
466
     *
467
     * @param string $url
468
     * @param $data
469
     * @param string $contentType
470
     *
471
     * @return string The response body
472
     */
473 View Code Duplication
    public function post($url, $data, $contentType = 'application/json')
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
474
    {
475
        $uri = $this->buildUrl($url);
476
477
        $request = $this->requestFactory->createRequest('POST', $uri);
478
        if (!is_null($contentType)) {
479
            $request = $request->withHeader('Content-Type', $contentType);
480
            $request = $request->withHeader('Accept', $contentType);
481
        }
482
        $request = $request->withBody(stream_for($data));
483
484
        $response = $this->request($request);
485
486
        return strval($response->getBody());
487
    }
488
489
    /**
490
     * Make a POST request, sending JSON data.
491
     *
492
     * @param string $url
493
     * @param $data
494
     *
495
     * @return \stdClass
496
     */
497
    public function postJSON($url, $data = null)
498
    {
499
        $responseBody = $this->post($url, json_encode($data), 'application/json');
500
501
        return json_decode($responseBody);
502
    }
503
504
    /**
505
     * Make a POST request, sending XML data.
506
     *
507
     * @param string $url
508
     * @param $data
509
     *
510
     * @return QuiteSimpleXMLElement
511
     */
512
    public function postXML($url, $data = null)
513
    {
514
        $responseBody = $this->post($url, $data, 'application/xml');
515
516
        return new QuiteSimpleXMLElement($responseBody);
517
    }
518
519
    /**
520
     * Get the redirect target location of an URL, or null if not a redirect.
521
     *
522
     * @param string $url
523
     * @param array  $query
524
     *
525
     * @return string|null
526
     */
527
    public function getRedirectLocation($url, $query = [])
528
    {
529
        $url = $this->buildUrl($url, $query);
530
531
        $request = $this->requestFactory->createRequest('GET', $url);
532
533
        try {
534
            $response = $this->request($request);
535
        } catch (ResourceNotFound $e) {
536
            return;
537
        }
538
539
        $locations = $response->getHeader('Location');
540
541
        return count($locations) ? $locations[0] : null;
542
    }
543
544
    /**
545
     * @param class $className
546
     * @param array ...$params
547
     *
548
     * @return mixed
549
     */
550
    public function make($className, ...$params)
551
    {
552
        return new $className($this, ...$params);
553
    }
554
555
    /**
556
     * Generate a client exception.
557
     *
558
     * @param HttpException $exception
559
     *
560
     * @return RequestFailed
561
     */
562
    protected function parseClientError(HttpException $exception)
563
    {
564
        $contentType = explode(';', $exception->getResponse()->getHeaderLine('Content-Type'))[0];
565
        $responseBody = (string) $exception->getResponse()->getBody();
566
567
        switch ($contentType) {
568
            case 'application/json':
569
                $res = json_decode($responseBody, true);
570
                if (isset($res['web_service_result'])) {
571
                    $res = $res['web_service_result'];
572
                }
573
                $err = isset($res['errorList']['error'][0]) ? $res['errorList']['error'][0] : $res['errorList']['error'];
574
                $message = $err['errorMessage'];
575
                $code = $err['errorCode'];
576
                break;
577
578
            case 'application/xml':
579
                $xml = new QuiteSimpleXMLElement($responseBody);
580
                $xml->registerXPathNamespace('xb', 'http://com/exlibris/urm/general/xmlbeans');
581
582
                $message = $xml->text('//xb:errorMessage');
583
                $code = $xml->text('//xb:errorCode');
584
                break;
585
586
            default:
587
                $message = $responseBody;
588
                $code = '';
589
        }
590
591
        // The error code is often an integer, but sometimes a string,
592
        // so we generalize it as a string.
593
        $code = empty($code) ? null : (string) $code;
594
595
        if ($code == 'UNAUTHORIZED') {
596
            return new InvalidApiKey($message, null, $exception);
597
        }
598
599
        if (strtolower($message) == 'invalid api key') {
600
            return new InvalidApiKey($message, null, $exception);
601
        }
602
603
        if (preg_match('/(no items?|not) found/i', $message)) {
604
            return new ResourceNotFound($message, $code, $exception);
605
        }
606
607
        return new RequestFailed($message, $code, $exception);
608
    }
609
}
610