Test Setup Failed
Pull Request — master (#26)
by
unknown
01:59
created

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