Test Failed
Push — master ( 99cade...32f37c )
by Dan Michael O.
10:15
created

Client::lendingRequests()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 2
dl 0
loc 4
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\Client\HttpClient;
12
use Http\Discovery\HttpClientDiscovery;
13
use Http\Discovery\MessageFactoryDiscovery;
14
use Http\Discovery\UriFactoryDiscovery;
15
use Http\Message\MessageFactory;
16
use Http\Message\UriFactory;
17
use Psr\Http\Message\RequestInterface;
18
use Psr\Http\Message\ResponseInterface;
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\Libraries;
25
use Scriptotek\Alma\Conf\Library;
26
use Scriptotek\Alma\Exception\ClientException as AlmaClientException;
27
use Scriptotek\Alma\Exception\InvalidApiKey;
28
use Scriptotek\Alma\Exception\MaxNumberOfAttemptsExhausted;
29
use Scriptotek\Alma\Exception\RequestFailed;
30
use Scriptotek\Alma\Exception\ResourceNotFound;
31
use Scriptotek\Alma\Exception\SruClientNotSetException;
32
use Scriptotek\Alma\TaskLists\LendingRequests;
33
use Scriptotek\Alma\TaskLists\TaskLists;
34
use Scriptotek\Alma\Users\Users;
35
use Scriptotek\Sru\Client as SruClient;
36
37
/**
38
 * Alma client.
39
 */
40
class Client
41
{
42
    public $baseUrl;
43
44
    /** @var string Alma zone (institution or network) */
45
    public $zone;
46
47
    /** @var string Alma Developers Network API key for this zone */
48
    public $key;
49
50
    /** @var Client Network zone instance */
51
    public $nz;
52
53
    /** @var HttpClient */
54
    protected $http;
55
56
    /** @var MessageFactory */
57
    protected $messageFactory;
58
59
    /** @var UriFactory */
60
    protected $uriFactory;
61
62
    /** @var SruClient */
63
    public $sru;
64
65
    /** @var Bibs */
66
    public $bibs;
67
68
    /** @var Analytics */
69
    public $analytics;
70
71
    /** @var Users */
72
    public $users;
73
74
    /** @var Items */
75
    public $items;
76
77
    /** @var int Max number of retries if we get 429 errors */
78
    public $maxAttempts = 10;
79
80
    /** @var float Number of seconds to sleep before retrying */
81
    public $sleepTimeOnRetry = 0.5;
82
83
    /**
84
     * Create a new client to connect to a given Alma instance.
85
     *
86
     * @param string         $key            API key
87
     * @param string         $region         Hosted region code, used to build base URL
88
     * @param string         $zone           Alma zone (Either Zones::INSTITUTION or Zones::NETWORK)
89
     * @param HttpClient     $http
90
     * @param MessageFactory $messageFactory
91
     * @param UriFactory     $uriFactory
92
     *
93
     * @throws \ErrorException
94
     */
95
    public function __construct(
96
        $key = null,
97
        $region = 'eu',
98
        $zone = Zones::INSTITUTION,
99
        HttpClient $http = null,
100
        MessageFactory $messageFactory = null,
101
        UriFactory $uriFactory = null
102
    ) {
103
        $this->http = new PluginClient(
104
            $http ?: HttpClientDiscovery::find(),
105
            [
106
                new ContentLengthPlugin(),
107
                new ErrorPlugin(),
108
            ]
109
        );
110
        $this->messageFactory = $messageFactory ?: MessageFactoryDiscovery::find();
111
        $this->uriFactory = $uriFactory ?: UriFactoryDiscovery::find();
112
113
        $this->key = $key;
114
        $this->setRegion($region);
115
116
        $this->zone = $zone;
117
118
        $this->bibs = new Bibs($this);
119
        $this->items = new Items($this); // Only needed for the fromBarcode method :/
120
121
        $this->analytics = new Analytics($this);
122
        $this->users = new Users($this);
123
124
        $this->conf = new Conf($this);
0 ignored issues
show
Bug introduced by
The property conf does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
125
        $this->libraries = $this->conf->libraries;  // shortcut
0 ignored issues
show
Bug introduced by
The property libraries does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
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...
126
127
        $this->taskLists = new TaskLists($this);
0 ignored issues
show
Bug introduced by
The property taskLists does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

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