Completed
Push — master ( 05ac47...dbb859 )
by Dan Michael O.
06:29
created

Client::setEntryPoint()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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