Completed
Push — master ( 536c67...1399a7 )
by Dan Michael O.
08:12
created

Client::parseClientError()   B

Complexity

Conditions 8
Paths 36

Size

Total Lines 43

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
nc 36
nop 1
dl 0
loc 43
rs 7.9875
c 0
b 0
f 0
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
    /**
92
     * @var Conf
93
     */
94
    public $conf;
95
96
    /**
97
     * @var Libraries
98
     */
99
    public $libraries;
100
101
    /**
102
     * @var Jobs
103
     */
104
    public $jobs;
105
106
    /**
107
     * @var TaskLists
108
     */
109
    public $taskLists;
110
111
    /**
112
     * Create a new client to connect to a given Alma instance.
113
     *
114
     * @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...
115
     * @param string                   $region         Hosted region code, used to build base URL
116
     * @param string                   $zone           Alma zone (Either Zones::INSTITUTION or Zones::NETWORK)
117
     * @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...
118
     * @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...
119
     * @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...
120
     * @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...
121
     *
122
     * @throws \ErrorException
123
     */
124
    public function __construct(
125
        $key = null,
126
        $region = 'eu',
127
        $zone = Zones::INSTITUTION,
128
        HttpClientInterface $http = null,
129
        RequestFactoryInterface $requestFactory = null,
130
        UriFactoryInterface $uriFactory = null,
131
        string $baseUrl = null
132
    ) {
133
        $this->http = new PluginClient(
134
            $http ?: HttpClient::client(),
135
            [
136
                new ContentLengthPlugin(),
137
                new ErrorPlugin(),
138
            ]
139
        );
140
        $this->requestFactory = $requestFactory ?: HttpFactory::requestFactory();
141
        $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...
142
143
        $this->key = $key;
144
145
        if (!is_null($baseUrl)) {
146
            $this->setBaseUrl($baseUrl);
147
        } else {
148
            $this->setRegion($region);
149
        }
150
151
        $this->zone = $zone;
152
153
        $this->bibs = new Bibs($this);
154
        $this->items = new Items($this); // Only needed for the fromBarcode method :/
155
156
        $this->analytics = new Analytics($this);
157
        $this->users = new Users($this);
158
159
        $this->conf = new Conf($this);
160
        $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...
161
        $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...
162
163
        $this->taskLists = new TaskLists($this);
164
165
        if ($zone == Zones::INSTITUTION) {
166
            $this->nz = new self(null, $region, Zones::NETWORK, $this->http, $this->requestFactory, $this->uriFactory, $baseUrl);
167
        } elseif ($zone != Zones::NETWORK) {
168
            throw new AlmaClientException('Invalid zone name.');
169
        }
170
    }
171
172
    public function lendingRequests(Library $library, $params = [])
173
    {
174
        return new LendingRequests($this, $library, $params);
175
    }
176
177
    /**
178
     * Attach an SRU client (so you can search for Bib records).
179
     *
180
     * @param SruClient $sru
181
     */
182
    public function setSruClient(SruClient $sru)
183
    {
184
        $this->sru = $sru;
185
    }
186
187
    /**
188
     * Assert that an SRU client is connected. Throws SruClientNotSetException if not.
189
     *
190
     * @throws SruClientNotSetException
191
     */
192
    public function assertHasSruClient()
193
    {
194
        if (!isset($this->sru)) {
195
            throw new SruClientNotSetException();
196
        }
197
    }
198
199
    /**
200
     * Set the API key for this Alma instance.
201
     *
202
     * @param string $key The API key
203
     *
204
     * @return $this
205
     */
206
    public function setKey($key)
207
    {
208
        $this->key = $key;
209
210
        return $this;
211
    }
212
213
    /**
214
     * Set the Alma region code ('na' for North America, 'eu' for Europe, 'ap' for Asia Pacific).
215
     *
216
     * @param $regionCode
217
     * @throws AlmaClientException
218
     * @return $this
219
     */
220
    public function setRegion($regionCode)
221
    {
222
        if (!in_array($regionCode, ['na', 'eu', 'ap'])) {
223
            throw new AlmaClientException('Invalid region code');
224
        }
225
        $this->setBaseUrl('https://api-' . $regionCode . '.hosted.exlibrisgroup.com/almaws/v1');
226
227
        return $this;
228
    }
229
230
    /**
231
     * Set the Alma API base url.
232
     *
233
     * @param string $baseUrl
234
     * @return $this
235
     */
236
    public function setBaseUrl(string $baseUrl)
237
    {
238
        $this->baseUrl = $baseUrl;
239
240
        if (!is_null($this->nz)) {
241
            $this->nz->setBaseUrl($baseUrl);
242
        }
243
244
        return $this;
245
    }
246
247
    /**
248
     * Extend an URL with query string parameters and return an UriInterface object.
249
     *
250
     * @param string $url
251
     * @param array  $query
252
     *
253
     * @return UriInterface
254
     */
255
    public function buildUrl($url, $query = [])
256
    {
257
        $url = explode('?', $url, 2);
258
        if (count($url) == 2) {
259
            parse_str($url[1], $query0);
260
            $query = array_merge($query0, $query);
261
        }
262
        $query['apikey'] = $this->key;
263
264
        $url = $url[0];
265
266
        if (strpos($url, $this->baseUrl) === false) {
267
            $url = $this->baseUrl . $url;
268
        }
269
270
        return $this->uriFactory->createUri($url)
271
            ->withQuery(http_build_query($query));
272
    }
273
274
    /**
275
     * Make a synchronous HTTP request and return a PSR7 response if successful.
276
     * In the case of intermittent errors (connection problem, 429 or 5XX error), the request is
277
     * attempted a maximum of {$this->maxAttempts} times with a sleep of {$this->sleepTimeOnRetry}
278
     * between each attempt to avoid hammering the server.
279
     *
280
     * @param RequestInterface $request
281
     * @param int              $attempt
282
     *
283
     * @return ResponseInterface
284
     */
285
    public function request(RequestInterface $request, $attempt = 1)
286
    {
287
        if (!$this->key) {
288
            throw new AlmaClientException('No API key defined for ' . $this->zone);
289
        }
290
291
        try {
292
            return $this->http->sendRequest($request);
293
        } catch (HttpException $e) {
294
            // Thrown for 400 and 500 level errors.
295
            $statusCode = $e->getResponse()->getStatusCode();
296
297
            $error = $this->parseClientError($e);
298
299
            if ($error->getErrorCode() === 'PER_SECOND_THRESHOLD') {
300
                // We've run into the "Max 25 API calls per institution per second" limit.
301
                // Wait a sec and retry, unless we've tried too many times already.
302
                if ($attempt >= $this->maxAttempts) {
303
                    throw new MaxNumberOfAttemptsExhausted(
304
                        'Rate limiting error - max number of retry attempts exhausted.',
305
                        0,
306
                        $e
307
                    );
308
                }
309
                time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
310
311
                return $this->request($request, $attempt + 1);
312
            }
313
314
            if ($statusCode >= 500 && $statusCode < 600) {
315
                if ($attempt >= $this->maxAttemptsOnServerError) {
316
                    throw $error;
317
                }
318
                time_nanosleep(0, $this->sleepTimeOnServerError * 1000000000);
319
320
                return $this->request($request, $attempt + 1);
321
            }
322
323
            // Throw exception for other errors
324
            throw $error;
325
        } catch (NetworkException $e) {
326
            // Thrown in case of a networking error
327
            // Wait a sec and retry, unless we've tried too many times already.
328
            if ($attempt > $this->maxAttempts) {
329
                throw new MaxNumberOfAttemptsExhausted(
330
                    'Network error - max number of retry attempts exhausted.',
331
                    0,
332
                    $e
333
                );
334
            }
335
            time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
336
337
            return $this->request($request, $attempt + 1);
338
        }
339
    }
340
341
    /**
342
     * Make a GET request.
343
     *
344
     * @param string $url
345
     * @param array  $query
346
     * @param string $contentType
347
     *
348
     * @return string The response body
349
     */
350
    public function get($url, $query = [], $contentType = 'application/json')
351
    {
352
        $url = $this->buildUrl($url, $query);
353
        $request = $this->requestFactory->createRequest('GET', $url)
354
            ->withHeader('Accept', $contentType);
355
356
        $response = $this->request($request);
357
358
        return strval($response->getBody());
359
    }
360
361
    /**
362
     * Make a GET request, accepting JSON.
363
     *
364
     * @param string $url
365
     * @param array  $query
366
     *
367
     * @return \stdClass JSON response as an object.
368
     */
369
    public function getJSON($url, $query = [])
370
    {
371
        $responseBody = $this->get($url, $query, 'application/json');
372
373
        return json_decode($responseBody);
374
    }
375
376
    /**
377
     * Make a GET request, accepting XML.
378
     *
379
     * @param string $url
380
     * @param array  $query
381
     *
382
     * @return QuiteSimpleXMLElement
383
     */
384
    public function getXML($url, $query = [])
385
    {
386
        $responseBody = $this->get($url, $query, 'application/xml');
387
388
        return new QuiteSimpleXMLElement($responseBody);
389
    }
390
391
    /**
392
     * Make a PUT request.
393
     *
394
     * @param string $url
395
     * @param $data
396
     * @param string $contentType
397
     *
398
     * @return string The response body
399
     */
400 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...
401
    {
402
        $uri = $this->buildUrl($url);
403
404
        $request = $this->requestFactory->createRequest('PUT', $uri);
405
        if (!is_null($contentType)) {
406
            $request = $request->withHeader('Content-Type', $contentType);
407
            $request = $request->withHeader('Accept', $contentType);
408
        }
409
        $request = $request->withBody(stream_for($data));
410
411
        $response = $this->request($request);
412
413
        return strval($response->getBody());
414
    }
415
416
    /**
417
     * Make a PUT request, sending JSON data.
418
     *
419
     * @param string $url
420
     * @param $data
421
     *
422
     * @return \stdClass
423
     */
424
    public function putJSON($url, $data)
425
    {
426
        $responseBody = $this->put($url, json_encode($data), 'application/json');
427
428
        return json_decode($responseBody);
429
    }
430
431
    /**
432
     * Make a PUT request, sending XML data.
433
     *
434
     * @param string $url
435
     * @param $data
436
     *
437
     * @return QuiteSimpleXMLElement
438
     */
439
    public function putXML($url, $data)
440
    {
441
        $responseBody = $this->put($url, $data, 'application/xml');
442
443
        return new QuiteSimpleXMLElement($responseBody);
444
    }
445
446
    /**
447
     * Make a POST request.
448
     *
449
     * @param string $url
450
     * @param $data
451
     * @param string $contentType
452
     *
453
     * @return string The response body
454
     */
455 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...
456
    {
457
        $uri = $this->buildUrl($url);
458
459
        $request = $this->requestFactory->createRequest('POST', $uri);
460
        if (!is_null($contentType)) {
461
            $request = $request->withHeader('Content-Type', $contentType);
462
            $request = $request->withHeader('Accept', $contentType);
463
        }
464
        $request = $request->withBody(stream_for($data));
465
466
        $response = $this->request($request);
467
468
        return strval($response->getBody());
469
    }
470
471
    /**
472
     * Make a POST request, sending JSON data.
473
     *
474
     * @param string $url
475
     * @param $data
476
     *
477
     * @return \stdClass
478
     */
479
    public function postJSON($url, $data = null)
480
    {
481
        $responseBody = $this->post($url, json_encode($data), 'application/json');
482
483
        return json_decode($responseBody);
484
    }
485
486
    /**
487
     * Make a POST request, sending XML data.
488
     *
489
     * @param string $url
490
     * @param $data
491
     *
492
     * @return QuiteSimpleXMLElement
493
     */
494
    public function postXML($url, $data = null)
495
    {
496
        $responseBody = $this->post($url, $data, 'application/xml');
497
498
        return new QuiteSimpleXMLElement($responseBody);
499
    }
500
501
    /**
502
     * Get the redirect target location of an URL, or null if not a redirect.
503
     *
504
     * @param string $url
505
     * @param array  $query
506
     *
507
     * @return string|null
508
     */
509
    public function getRedirectLocation($url, $query = [])
510
    {
511
        $url = $this->buildUrl($url, $query);
512
513
        $request = $this->requestFactory->createRequest('GET', $url);
514
515
        try {
516
            $response = $this->request($request);
517
        } catch (ResourceNotFound $e) {
518
            return;
519
        }
520
521
        $locations = $response->getHeader('Location');
522
523
        return count($locations) ? $locations[0] : null;
524
    }
525
526
    /**
527
     * @param class $className
528
     * @param array ...$params
529
     *
530
     * @return mixed
531
     */
532
    public function make($className, ...$params)
533
    {
534
        return new $className($this, ...$params);
535
    }
536
537
    /**
538
     * Generate a client exception.
539
     *
540
     * @param HttpException $exception
541
     *
542
     * @return RequestFailed
543
     */
544
    protected function parseClientError(HttpException $exception)
545
    {
546
        $contentType = explode(';', $exception->getResponse()->getHeaderLine('Content-Type'))[0];
547
        $responseBody = (string) $exception->getResponse()->getBody();
548
549
        switch ($contentType) {
550
            case 'application/json':
551
                $res = json_decode($responseBody, true);
552
                if (isset($res['web_service_result'])) {
553
                    $res = $res['web_service_result'];
554
                }
555
                $err = isset($res['errorList']['error'][0]) ? $res['errorList']['error'][0] : $res['errorList']['error'];
556
                $message = $err['errorMessage'];
557
                $code = $err['errorCode'];
558
                break;
559
560
            case 'application/xml':
561
                $xml = new QuiteSimpleXMLElement($responseBody);
562
                $xml->registerXPathNamespace('xb', 'http://com/exlibris/urm/general/xmlbeans');
563
564
                $message = $xml->text('//xb:errorMessage');
565
                $code = $xml->text('//xb:errorCode');
566
                break;
567
568
            default:
569
                $message = $responseBody;
570
                $code = '';
571
        }
572
573
        // The error code is often an integer, but sometimes a string,
574
        // so we generalize it as a string.
575
        $code = empty($code) ? null : (string) $code;
576
577
        if (strtolower($message) == 'invalid api key') {
578
            return new InvalidApiKey($message, null, $exception);
579
        }
580
581
        if (preg_match('/(no items?|not) found/i', $message)) {
582
            return new ResourceNotFound($message, $code, $exception);
583
        }
584
585
        return new RequestFailed($message, $code, $exception);
586
    }
587
}
588