Test Failed
Push — master ( 528954...45b675 )
by Dan Michael O.
02:30
created

Client::parseClientError()   B

Complexity

Conditions 6
Paths 18

Size

Total Lines 40

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 6
nc 18
nop 1
dl 0
loc 40
rs 8.6577
c 0
b 0
f 0
1
<?php
2
3
namespace Scriptotek\Alma;
4
5
use Danmichaelo\QuiteSimpleXMLElement\QuiteSimpleXMLElement;
6
use Http\Client\Common\Exception\ClientErrorException as HttpClientErrorException;
7
use Http\Client\Common\Plugin\ContentLengthPlugin;
8
use Http\Client\Common\Plugin\ErrorPlugin;
9
use Http\Client\Common\PluginClient;
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\Exception\ClientException as AlmaClientException;
26
use Scriptotek\Alma\Exception\InvalidApiKey;
27
use Scriptotek\Alma\Exception\MaxNumberOfAttemptsExhausted;
28
use Scriptotek\Alma\Exception\RequestFailed;
29
use Scriptotek\Alma\Exception\ResourceNotFound;
30
use Scriptotek\Alma\Exception\SruClientNotSetException;
31
use Scriptotek\Alma\Users\Users;
32
use Scriptotek\Sru\Client as SruClient;
33
34
/**
35
 * Alma client.
36
 */
37
class Client
38
{
39
    public $baseUrl;
40
41
    /** @var string Alma zone (institution or network) */
42
    public $zone;
43
44
    /** @var string Alma Developers Network API key for this zone */
45
    public $key;
46
47
    /** @var Client Network zone instance */
48
    public $nz;
49
50
    /** @var HttpClient */
51
    protected $http;
52
53
    /** @var MessageFactory */
54
    protected $messageFactory;
55
56
    /** @var UriFactory */
57
    protected $uriFactory;
58
59
    /** @var SruClient */
60
    public $sru;
61
62
    /** @var Bibs */
63
    public $bibs;
64
65
    /** @var Analytics */
66
    public $analytics;
67
68
    /** @var Users */
69
    public $users;
70
71
    /** @var Items */
72
    public  $items;
73
74
    /** @var int Max number of retries if we get 429 errors */
75
    public $maxAttempts = 10;
76
77
    /** @var float Number of seconds to sleep before retrying */
78
    public $sleepTimeOnRetry = 0.5;
79
80
    /**
81
     * Create a new client to connect to a given Alma instance.
82
     *
83
     * @param string         $key            API key
84
     * @param string         $region         Hosted region code, used to build base URL
85
     * @param string         $zone           Alma zone (Either Zones::INSTITUTION or Zones::NETWORK)
86
     * @param HttpClient     $http
87
     * @param MessageFactory $messageFactory
88
     * @param UriFactory     $uriFactory
89
     *
90
     * @throws \ErrorException
91
     */
92
    public function __construct(
93
        $key = null,
94
        $region = 'eu',
95
        $zone = Zones::INSTITUTION,
96
        HttpClient $http = null,
97
        MessageFactory $messageFactory = null,
98
        UriFactory $uriFactory = null
99
    ) {
100
        $this->http = new PluginClient(
101
            $http ?: HttpClientDiscovery::find(), [
102
                new ContentLengthPlugin(),
103
                new ErrorPlugin(),
104
            ]
105
        );
106
        $this->messageFactory = $messageFactory ?: MessageFactoryDiscovery::find();
107
        $this->uriFactory = $uriFactory ?: UriFactoryDiscovery::find();
108
109
        $this->key = $key;
110
        $this->setRegion($region);
111
112
        $this->zone = $zone;
113
114
        $this->bibs = new Bibs($this);
115
        $this->items = new Items($this); // Only needed for the fromBarcode method :/
116
117
        $this->analytics = new Analytics($this);
118
        $this->users = new Users($this);
119
120
        $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...
121
        $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...
122
123
        if ($zone == Zones::INSTITUTION) {
124
            $this->nz = new self(null, $region, Zones::NETWORK, $this->http, $this->messageFactory, $this->uriFactory);
125
        } elseif ($zone != Zones::NETWORK) {
126
            throw new AlmaClientException('Invalid zone name.');
127
        }
128
    }
129
130
    /**
131
     * Attach an SRU client (so you can search for Bib records).
132
     *
133
     * @param SruClient $sru
134
     */
135
    public function setSruClient(SruClient $sru)
136
    {
137
        $this->sru = $sru;
138
    }
139
140
    /**
141
     * Assert that an SRU client is connected. Throws SruClientNotSetException if not.
142
     *
143
     * @throws SruClientNotSetException
144
     */
145
    public function assertHasSruClient()
146
    {
147
        if (!isset($this->sru)) {
148
            throw new SruClientNotSetException();
149
        }
150
    }
151
152
    /**
153
     * Set the API key for this Alma instance.
154
     *
155
     * @param string $key The API key
156
     *
157
     * @return $this
158
     */
159
    public function setKey($key)
160
    {
161
        $this->key = $key;
162
163
        return $this;
164
    }
165
166
    /**
167
     * Set the Alma region code ('na' for North America, 'eu' for Europe, 'ap' for Asia Pacific).
168
     *
169
     * @param $regionCode
170
     *
171
     * @throws \ErrorException
172
     *
173
     * @return $this
174
     */
175
    public function setRegion($regionCode)
176
    {
177
        if (!in_array($regionCode, ['na', 'eu', 'ap'])) {
178
            throw new AlmaClientException('Invalid region code');
179
        }
180
        $this->baseUrl = 'https://api-' . $regionCode . '.hosted.exlibrisgroup.com/almaws/v1';
181
182
        return $this;
183
    }
184
185
    /**
186
     * @param string $url|UriInterface
0 ignored issues
show
Bug introduced by
There is no parameter named $url|UriInterface. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
187
     * @param array  $query
188
     *
189
     * @return UriInterface
190
     */
191
    public function buildUrl($url, $query = [])
192
    {
193
        if (!is_a($url, UriInterface::class)) {
194
            if (strpos($url, $this->baseUrl) === false) {
195
                $url = $this->baseUrl . $url;
196
            }
197
            $url = $this->uriFactory->createUri($url);
198
        }
199
200
        $query['apikey'] = $this->key;
201
202
        return $url->withQuery(http_build_query($query));
203
    }
204
205
    /**
206
     * Make a synchronous HTTP request and return a PSR7 response if successful.
207
     * In the case of intermittent errors (connection problem, 429 or 5XX error), the request is
208
     * attempted a maximum of {$this->maxAttempts} times with a sleep of {$this->sleepTimeOnRetry}
209
     * between each attempt to avoid hammering the server.
210
     *
211
     * @param RequestInterface $request
212
     * @param int              $attempt
213
     *
214
     * @return ResponseInterface
215
     */
216
    public function request(RequestInterface $request, $attempt = 1)
217
    {
218
        if (!$this->key) {
219
            throw new AlmaClientException('No API key defined for ' . $this->zone);
220
        }
221
222
        try {
223
            return $this->http->sendRequest($request);
224
        } catch (HttpClientErrorException $e) {
225
            // Thrown for 400 level errors
226
            $error = $this->parseClientError($e);
227
228
            if ($error->getErrorCode() === 'PER_SECOND_THRESHOLD') {
0 ignored issues
show
Bug introduced by
The method getErrorCode() does not seem to exist on object<Scriptotek\Alma\Exception\RequestFailed>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
229
                // We've run into the "Max 25 API calls per institution per second" limit.
230
                // Wait a sec and retry, unless we've tried too many times already.
231
                if ($attempt > $this->maxAttempts) {
232
                    throw new MaxNumberOfAttemptsExhausted(
233
                        'Rate limiting error - max number of retry attempts exhausted.',
234
                        0,
235
                        $e
236
                    );
237
                }
238
                time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
239
                return $this->request($request, $attempt + 1);
240
            }
241
242
            // Throw exception for other 4XX errors
243
            throw $error;
244
245
        } catch (NetworkException $e) {
246
            // Thrown in case of a networking error
247
            // Wait a sec and retry, unless we've tried too many times already.
248
            if ($attempt > $this->maxAttempts) {
249
                throw new MaxNumberOfAttemptsExhausted(
250
                    'Network error - max number of retry attempts exhausted.',
251
                    0,
252
                    $e
253
                );
254
            }
255
            time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
256
            return $this->request($request, $attempt + 1);
257
        }
258
    }
259
260
    /**
261
     * Make a GET request.
262
     *
263
     * @param string $url|UriInterface
0 ignored issues
show
Bug introduced by
There is no parameter named $url|UriInterface. Was it maybe removed?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function.

Consider the following example. The parameter $italy is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $island
 * @param array $italy
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was removed, but the annotation was not.

Loading history...
264
     * @param array  $query
265
     * @param string $contentType
266
     *
267
     * @return string Response body
268
     */
269
    public function get($url, $query = [], $contentType = 'application/json')
270
    {
271
        $url = $this->buildUrl($url, $query);
272
        $headers = [
273
            'Accept' => $contentType,
274
        ];
275
        $request = $this->messageFactory->createRequest('GET', $url, $headers);
276
        $response = $this->request($request);
277
278
        return strval($response->getBody());
279
    }
280
281
    /**
282
     * Make a GET request, accepting JSON.
283
     *
284
     * @param string $url
285
     * @param array  $query
286
     *
287
     * @return \stdClass JSON response as an object.
288
     */
289
    public function getJSON($url, $query = [])
290
    {
291
        $responseBody = $this->get($url, $query, 'application/json');
292
293
        return json_decode($responseBody);
294
    }
295
296
    /**
297
     * Make a GET request, accepting XML.
298
     *
299
     * @param string $url
300
     * @param array  $query
301
     *
302
     * @return QuiteSimpleXMLElement
303
     */
304
    public function getXML($url, $query = [])
305
    {
306
        $responseBody = $this->get($url, $query, 'application/xml');
307
308
        return new QuiteSimpleXMLElement($responseBody);
309
    }
310
311
    /**
312
     * Make a PUT request.
313
     *
314
     * @param string $url
315
     * @param $data
316
     * @param string $contentType
317
     *
318
     * @return bool
319
     */
320 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...
321
    {
322
        $uri = $this->buildUrl($url);
323
        $headers = [];
324
        if (!is_null($contentType)) {
325
            $headers['Content-Type'] = $contentType;
326
            $headers['Accept'] = $contentType;
327
        }
328
        $request = $this->messageFactory->createRequest('PUT', $uri, $headers, $data);
329
        $response = $this->request($request);
330
331
        // Consider it a success if status code is 2XX
332
        return substr($response->getStatusCode(), 0, 1) == '2';
333
    }
334
335
    /**
336
     * Make a PUT request, sending JSON data.
337
     *
338
     * @param string $url
339
     * @param $data
340
     *
341
     * @return bool
342
     */
343
    public function putJSON($url, $data)
344
    {
345
        $data = json_encode($data);
346
347
        return $this->put($url, $data, 'application/json');
348
    }
349
350
    /**
351
     * Make a PUT request, sending XML data.
352
     *
353
     * @param string $url
354
     * @param $data
355
     *
356
     * @return bool
357
     */
358
    public function putXML($url, $data)
359
    {
360
        return $this->put($url, $data, 'application/xml');
361
    }
362
363
    /**
364
     * Make a POST request.
365
     *
366
     * @param string $url
367
     * @param $data
368
     * @param string $contentType
369
     *
370
     * @return bool
371
     */
372 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...
373
    {
374
        $uri = $this->buildUrl($url);
375
        $headers = [];
376
        if (!is_null($contentType)) {
377
            $headers['Content-Type'] = $contentType;
378
            $headers['Accept'] = $contentType;
379
        }
380
        $request = $this->messageFactory->createRequest('POST', $uri, $headers, $data);
381
        $response = $this->request($request);
382
383
        // Consider it a success if status code is 2XX
384
        return substr($response->getStatusCode(), 0, 1) == '2';
385
    }
386
387
    /**
388
     * Make a POST request, sending JSON data.
389
     *
390
     * @param string $url
391
     * @param $data
392
     *
393
     * @return bool
394
     */
395
    public function postJSON($url, $data = null)
396
    {
397
        $data = json_encode($data);
398
399
        return $this->post($url, $data, 'application/json');
400
    }
401
402
    /**
403
     * Make a POST request, sending XML data.
404
     *
405
     * @param string $url
406
     * @param $data
407
     *
408
     * @return bool
409
     */
410
    public function postXML($url, $data = null)
411
    {
412
        return $this->post($url, $data, 'application/xml');
413
    }
414
415
    /**
416
     * Get the redirect target location of an URL, or null if not a redirect.
417
     *
418
     * @param string $url
419
     * @param array  $query
420
     *
421
     * @return string|null
422
     */
423
    public function getRedirectLocation($url, $query = [])
424
    {
425
        $url = $this->buildUrl($url, $query);
426
427
        $request = $this->messageFactory->createRequest('GET', $url);
428
429
        try {
430
            $response = $this->request($request);
431
        } catch (ResourceNotFound $e) {
432
            return;
433
        }
434
435
        $locations = $response->getHeader('Location');
436
437
        return count($locations) ? $locations[0] : null;
438
    }
439
440
    /**
441
     * @param class $className
442
     * @param array ...$params
443
     * @return mixed
444
     */
445
    public function make($className, ...$params)
446
    {
447
        return new $className($this, ...$params);
448
    }
449
450
    /**
451
     * Generate a client exception.
452
     *
453
     * @param HttpClientErrorException $exception
454
     * @return RequestFailed
455
     */
456
    protected function parseClientError(HttpClientErrorException $exception)
457
    {
458
        $contentType = explode(';', $exception->getResponse()->getHeaderLine('Content-Type'))[0];
459
        $responseBody = (string) $exception->getResponse()->getBody();
460
461
        switch ($contentType) {
462
            case 'application/json':
463
                $res = json_decode($responseBody, true);
464
                $err = $res['errorList']['error'][0];
465
                $message = $err['errorMessage'];
466
                $code = $err['errorCode'];
467
                break;
468
469
            case 'application/xml':
470
                $xml = new QuiteSimpleXMLElement($responseBody);
471
                $xml->registerXPathNamespace('xb', 'http://com/exlibris/urm/general/xmlbeans');
472
473
                $message = $xml->text('//xb:errorMessage');
474
                $code = $xml->text('//xb:errorCode');
475
                break;
476
477
            default:
478
                $message = $responseBody;
479
                $code = '';
480
        }
481
482
        // The error code is often an integer, but sometimes a string,
483
        // so we generalize it as a string.
484
        $code = empty($code) ? null : (string) $code;
485
486
        if (strtolower($message) == 'invalid api key') {
487
            return new InvalidApiKey($message, null, $exception);
488
        }
489
490
        if (preg_match('/no items? found/i', $message)) {
491
            return new ResourceNotFound($message, $code, $exception);
492
        }
493
494
        return new RequestFailed($message, $code, $exception);
495
    }
496
}
497