Client::request()   B
last analyzed

Complexity

Conditions 7
Paths 16

Size

Total Lines 55
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 7.0018

Importance

Changes 6
Bugs 0 Features 0
Metric Value
eloc 30
c 6
b 0
f 0
dl 0
loc 55
ccs 29
cts 30
cp 0.9667
rs 8.5066
cc 7
nc 16
nop 5
crap 7.0018

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
namespace Akkroo;
3
4
use Http\Client\HttpClient;
5
use Psr\Log\LoggerInterface;
6
use Psr\Log\NullLogger;
7
use Http\Message\RequestFactory;
8
use Http\Discovery\MessageFactoryDiscovery;
9
use Http\Discovery\UriFactoryDiscovery;
10
use Http\Message\UriFactory;
11
use Psr\Http\Message\ResponseInterface;
12
use InvalidArgumentException;
13
14
class Client
15
{
16
    /**
17
     * Public API scope
18
     */
19
    const SCOPE_PUBLIC = 'public';
20
21
    /**
22
     * Widget scope
23
     */
24
    const SCOPE_WIDGET = 'widget';
25
26
    /**
27
     * @var HttpClient
28
     */
29
    protected $httpClient;
30
31
    /**
32
     * @var array
33
     */
34
    protected $options = [];
35
36
    /**
37
     * @var array
38
     */
39
    protected $defaults = [
40
        'endpoint' => 'https://api.integrate-events.com/v2.1',
41
        'auth_endpoint' => 'https://auth.integrate.com/oauth/token',
42
        'auth_audience' => 'https://api.integrate-events.com/',
43
        'version' => '2.0.0',
44
        'scope' => self::SCOPE_PUBLIC
45
    ];
46
47
    /**
48
     * @var LoggerInterface
49
     */
50
    protected $logger = null;
51
52
    /**
53
     * @var string
54
     */
55
    protected $clientId = '';
56
57
    /**
58
     * @var string
59
     */
60
    protected $clientSecret = '';
61
62
    /**
63
     * @var string
64
     */
65
    protected $authToken = '';
66
67
    /**
68
     * @var int
69
     */
70
    protected $authTokenExpiration = 0;
71
72
    /**
73
     * Creates PSR-7 HTTP Requests
74
     *
75
     * @var RequestFactory
76
     */
77
    protected $requestFactory = null;
78
79
    /**
80
     * Creates PSR-7 URIS
81
     *
82
     * @var UriFactory
83
     */
84
    protected $uriFactory = null;
85
86
    /**
87
     * Create a new Akkroo client
88
     *
89
     * Currently supported options are 'version' and 'endpoint'.
90
     *
91
     * @param HttpClient $httpClient Client to do HTTP requests
92
     * @param string $clientId Your Akkroo API username (i.e. company username)
93
     * @param string $clientSecret Your Akkroo API key
94
     * @param array $options
95
     */
96
    public function __construct(HttpClient $httpClient, string $clientId, string $clientSecret, array $options = [])
97
    {
98
        $this->httpClient = $httpClient;
99 29
        $this->options = array_merge($this->defaults, $options);
100
        $this->clientId = $clientId;
101 29
        $this->clientSecret = $clientSecret;
102 29
        $this->logger = new NullLogger;
103 29
        $this->requestFactory = MessageFactoryDiscovery::find();
104 29
        $this->uriFactory = UriFactoryDiscovery::find();
105 29
    }
106 29
107 29
    /**
108 29
     * Inject a logger object
109
     *
110
     * @param  LoggerInterface $logger A PSR-3 compatible logger
111
     * @return Client
112
     */
113
    public function setLogger(LoggerInterface $logger)
114
    {
115
        $this->logger = $logger;
116 29
        return $this;
117
    }
118 29
119 29
    /**
120
     * Authenticate with Akkroo API and get a token
121
     *
122
     * Note: current API version returns HTTP 400 when wrong credentials are supplied
123
     *
124
     * @param string|null $username
125
     *
126
     * @return Client
127
     *
128
     * @throws Error\Authentication
129
     * @throws Error\Generic
130
     * @throws Error\NotFound
131
     */
132
    public function login(string $username = null)
133
    {
134
        $headers = [
135 3
            'Content-Type' => 'application/json'
136
        ];
137
138 3
        $body = [
139
            'grant_type' => 'client_credentials',
140
            'client_id' => $this->clientId,
141 3
            'client_secret' => $this->clientSecret,
142
            'audience' => $this->options['auth_audience'],
143
        ];
144
145
        if ($username !== null) {
146 3
            $body['company'] = $username;
147 3
        }
148 3
149 3
        $request = $this->requestFactory->createRequest('POST', $this->options['auth_endpoint'], $headers, json_encode($body));
150
        $response = $this->httpClient->sendRequest($request);
151 3
        $result = $this->parseResponse($response);
152 2
        $login = new Result($result['data']);
153 2
154 1
        if ($login->access_token) {
155 1
            $this->authToken = $login->access_token;
156 1
            $this->authTokenExpiration = time() + (int) $login->expires_in;
157 1
158
            return $this;
159 1
        }
160
161
        throw new Error\Generic('Unable to login, no access token returned');
162
    }
163
164
    /**
165
     * Returns credentials from last authentication
166
     *
167 2
     * @return array
168
     */
169
    public function getCredentials()
170 2
    {
171 2
        return [
172 2
            'authToken' => $this->authToken,
173
            'authTokenExpiration' => $this->authTokenExpiration,
174
        ];
175
    }
176
177
    /**
178
     * Fetch one or more resources
179
     *
180
     * @param  string $resource Resource name (i.e. events, registrations)
181
     * @param  array  $params   Search parameters (i.e. id, event_id, search query, range, fields, sort)
182
     * @param  array  $headers Additional headers
183
     *
184
     * @return Collection | Resource
185
     *
186
     * @throws Error\Authentication
187
     * @throws Error\NotFound
188
     * @throws Error\Generic
189 12
     */
190
    public function get($resource, array $params = [], array $headers = [])
191 12
    {
192 11
        $path = $this->buildPath($resource, $params);
193 9
        $result = $this->request('GET', $path, $headers, $params);
194 9
        $resourceMeta = [];
195 1
        if (!empty($result['headers']['X-Total-Count'])) {
196 1
            $contentRange = $this->parseContentRange($result['headers']);
197
            $resourceMeta['contentRange'] = $contentRange;
198 9
        }
199 9
        return Resource::create($resource, $result['data'], $params, $resourceMeta)
200
            ->withRequestID($result['requestID']);
201
    }
202
203
    /**
204
     * Create a new resource
205
     *
206
     * @param  string $resource Resource name (i.e. events, registrations)
207
     * @param  array  $data     Resource data
208
     * @param  array  $params   Search parameters (i.e. id, event_id, search query, range, fields, sort)
209
     * @param  array  $headers  Additional headers
210
     *
211
     * @return Resource
212
     *
213
     * @throws Error\Authentication
214
     * @throws Error\NotFound
215
     * @throws Error\Generic
216 2
     */
217
    public function post($resource, array $data, array $params = [], array $headers = [])
218 2
    {
219 2
        $path = $this->buildPath($resource, $params);
220
        $result = $this->request('POST', $path, $headers, $params, $data);
221 1
        // Store temporary resource containing only ID
222
        $tmp = Resource::create($resource, $result['data'], $params)->withRequestID($result['requestID']);
223 1
        // Return minimal object if called by Webforms, avoiding errors
224
        if ($this->options['scope'] === self::SCOPE_WIDGET) {
225
            return $tmp;
226
        }
227
        // Fetch data for inserted resource: use same request ID, so the server could avoid
228 1
        // inserting a duplicate
229
        return $this->get($resource, array_merge($params, ['id' => $tmp->id]), ['Request-ID' => $tmp->requestID]);
230
    }
231
232
    /**
233
     * Update a resource fully or partially
234
     *
235
     * If using PUT method, the $data parameter must be the full resource data
236
     *
237
     * @param  string $method   Must be PUT or PATCH
238
     * @param  string $resource Resource name (i.e. events, registrations)
239
     * @param  array  $params   URL parameters (i.e. id, event_id)
240
     * @param  array  $data     Resource data
241
     * @param  array  $headers  Additional headers
242
     *
243
     * @return Resource
244
     *
245
     * @throws Error\Authentication
246
     * @throws Error\NotFound
247
     * @throws Error\Generic
248 2
     */
249
    protected function update($method, $resource, array $params, array $data, array $headers = [])
250 2
    {
251
        $path = $this->buildPath($resource, $params);
252 2
        // Take care of modified header, but let it overridable
253 1
        if (empty($headers['If-Unmodified-Since']) && !empty($data['lastModified'])) {
254
            $headers['If-Unmodified-Since'] = $data['lastModified'];
255 2
        }
256
        $result = $this->request($method, $path, $headers, $params, $data);
257
        // If we don't have an exception here it's all right, we can fetch the updated resource
258 2
        // using the original Request-ID
259 2
        $headers['Request-ID'] = $result['requestID'];
260
        return $this->get($resource, $params, $headers);
261
    }
262
263
    /**
264
     * Update a resource
265
     *
266
     * The $data parameter must be the full resource data
267
     *
268
     * @param  string $resource Resource name (i.e. events, registrations)
269
     * @param  array  $params   URL parameters (i.e. id, event_id)
270
     * @param  array  $data     Resource data
271
     * @param  array  $headers  Additional headers
272
     *
273
     * @return Resource
274
     *
275
     * @throws Error\Authentication
276
     * @throws Error\NotFound
277
     * @throws Error\Generic
278 1
     */
279
    public function put($resource, array $params, array $data, array $headers = [])
280 1
    {
281
        return $this->update('PUT', $resource, $params, $data, $headers);
282
    }
283
284
    /**
285
     * Partially update a resource
286
     *
287
     * @param  string $resource Resource name (i.e. events, registrations)
288
     * @param  array  $params   URL parameters (i.e. id, event_id)
289
     * @param  array  $data     Resource data
290
     * @param  array  $headers  Additional headers
291
     *
292
     * @return Resource
293
     * @throws Error\Authentication
294
     * @throws Error\NotFound
295
     * @throws Error\Generic
296 1
     */
297
    public function patch($resource, array $params, array $data, array $headers = [])
298 1
    {
299
        return $this->update('PATCH', $resource, $params, $data, $headers);
300
    }
301
302
    /**
303
     * Delete a resource
304
     *
305
     * @param  string $resource Resource name (i.e. events, registrations)
306
     * @param  array  $params   URL parameters (i.e. id, event_id)
307
     *
308
     * @return Result
309
     *
310
     * @throws Error\Authentication
311
     * @throws Error\NotFound
312
     * @throws Error\Generic
313 3
     */
314
    public function delete($resource, array $params = [])
315 3
    {
316 3
        $path = $this->buildPath($resource, $params);
317 2
        $result = $this->request('DELETE', $path);
318
        return (new Result(['success' => true]))->withRequestID($result['requestID']);
319
    }
320
321
    /**
322
     * Count resources that satisfy the query
323
     *
324
     * @param  string $resource Resource name (i.e. events, registrations)
325
     * @param  array  $params   URL parameters (i.e. id, event_id)
326
     *
327
     * @return Result
328
     *
329
     * @throws Error\Authentication
330
     * @throws Error\NotFound
331
     * @throws Error\Generic
332 1
     */
333
    public function count($resource, $params = [])
334 1
    {
335 1
        $path = $this->buildPath($resource, $params);
336 1
        $result = $this->request('HEAD', $path, [], $params);
337 1
        $contentRange = $this->parseContentRange($result['headers']);
338
        return (new Result(['count' => $contentRange['total']]))->withRequestID($result['requestID']);
339
    }
340
341
    /**
342
     * Get allowed HTTP methods for a resource
343
     *
344
     * @param  string $resource Resource name (i.e. events, registrations)
345
     * @param  array  $params   URL parameters (i.e. id, event_id)
346
     *
347
     * @return Result
348
     *
349
     * @throws Error\Authentication
350
     * @throws Error\NotFound
351
     * @throws Error\Generic
352 2
     */
353
    public function options($resource, $params = [])
354 2
    {
355 2
        $path = $this->buildPath($resource, $params);
356 2
        $result = $this->request('OPTIONS', $path);
357 1
        if (empty($result['headers']['Allow'])) {
358
            throw new Error\Generic('Missing allow header');
359 1
        }
360 1
        $allow = array_map(function ($item) {
361 1
            return trim($item);
362 1
        }, explode(',', $result['headers']['Allow'][0]));
363
        return (new Result(['success' => true, 'allow' => $allow]))->withRequestID($result['requestID']);
364
    }
365
366
    /**
367
     * Send a test API request
368
     *
369
     * @param array  $headers Additional headers
370
     *
371
     * @throws Error\Generic
372 2
     */
373
    public function test($headers = [])
374 2
    {
375 2
        $endpoint = $this->uriFactory->createUri($this->options['endpoint']);
376
        $uri = $endpoint->withPath('/health');
377 2
378 2
        $request = $this->requestFactory->createRequest('GET', $uri, $headers);
379
        $response = $this->httpClient->sendRequest($request);
380 2
381
        $result = $this->parseResponse($response);
382 2
383
        return new Result($result['data']);
384
    }
385
386
    /**
387
     * Send an /authTest API request
388
     *
389
     * If the token is not supplied. it will try with the current internal token.
390
     *
391
     * This method does not throw exceptions, it logs server errors with the
392
     * internal logger, if provided
393
     *
394
     * @param  string $token An auth token to test for
395
     *
396
     * @return Result
397 5
     */
398
    public function authTest($token = null)
399
    {
400 5
        $headers = [
401
            'Content-Type' => 'application/json'
402 5
        ];
403 3
        if (!empty($token)) {
404 3
            $headers['Authorization'] = 'Bearer ' . $token;
405 2
            $this->authToken = $token;
406 1
        } elseif (!empty($this->authToken)) {
407
            $headers['Authorization'] = 'Bearer ' . $this->authToken;
408
        }
409 5
        try {
410 2
            $result = $this->request('GET', '/auth/test', $headers);
411 3
            return (new Result($result['data']))->withRequestID($result['requestID']);
412 3
        } catch (Error\Generic $e) {
413 3
            $this->logger->error(
414 3
                'Auth Test failed',
415
                ['code' => $e->getCode(), 'message' => $e->getMessage(), 'requestID' => $e->getRequestID()]
416 3
            );
417
            return (new Result(['success' => false]))->withRequestID($e->getRequestID());
418
        }
419
    }
420
421
    /**
422
     * Process response status
423
     *
424
     * @param  ResponseInterface $response
425
     * @param  string            $requestID Unique linked request
426
     *
427
     * @return array The parsed JSON body
428
     *
429
     * @throws Error\Authentication
430
     * @throws Error\NotFound
431
     * @throws Error\Generic
432 26
     */
433
    protected function parseResponse($response, $requestID = null)
434 26
    {
435 26
        $status = $response->getStatusCode();
436
        $reason = $response->getReasonPhrase();
437 26
        $body = [
438 26
            'data' => $this->parseResponseBody($response),
439
            'headers' => $response->getHeaders()
440 26
        ];
441 24
        if (!empty($requestID)) {
442
            $body['requestID'] = $requestID;
443 26
        }
444
        $this->logger->debug('Parsed response', ['status' => $status, 'reason' => $reason, 'body' => $body]);
445 26
        switch ($status) {
446 24
            case 401:
447 2
            case 403:
448
                throw new Error\Authentication($reason, $status, $body);
449
                break;
450 24
451 2
            case 404:
452
                throw new Error\NotFound('Resource Not Found', $status);
453
                break;
454
455
            default:
456
                // 3xx redirect status must be managed by the HTTP Client
457 22
                // Statuses other that what we define success are automatic errors
458 3
                if (!in_array($status, [200, 201, 202, 203, 204, 205, 206])) {
459 1
                    if (isset($body['data']['error']['data'])) {
460
                        throw new Error\Validation('Validation Error', $status, $body);
461 2
                    }
462
                    if ($body['data']['error'] === 'uniqueConflict') {
463
                        throw new Error\UniqueConflict('Unique Conflict', $status, $body);
464 2
                    }
465
                    throw new Error\Generic($reason, $status, $body);
466 19
                }
467
                break;
468 19
        }
469
        return $body;
470
    }
471
472
    /**
473
     * Parse response body to JSON
474
     *
475
     * @param  ResponseInterface $response
476
     * @return array
477 26
     */
478
    protected function parseResponseBody($response)
479 26
    {
480 26
        $body = (string) $response->getBody();
481
        return json_decode($body, true);
482
    }
483
484
    /**
485
     * Parse the Content-Range header to readable data
486
     *
487
     * @param  array $headers
488
     * @return array
489 2
     */
490
    protected function parseContentRange(array $headers)
491 2
    {
492 2
        $totalCount = (int) $headers['X-Total-Count'][0];
493 2
        $contentRange = ['from' => 1, 'to' => $totalCount, 'total' => $totalCount];
494 1
        if (!empty($headers['Link'])) {
495
            preg_match_all('/\<.+?>\;\srel\=\".+?\"/', $headers['Link'][0], $links);
496 1
497 1
            foreach ($links[0] as $link) {
498 1
                $link = explode(' ', $link);
499 1
                $matches = [];
500 1
                $linkRel = preg_match('/rel="(.*)"/', $link[1], $matches) ? $matches[1] : 'unknown';
501 1
                $matches = [];
502 1
                $linkURI = preg_match('/<\/v2(.*)>/', $link[0], $matches) ? $matches[1] : 'unknown';
503 1
                $linkURIParts = explode('?', $linkURI);
504 1
                $linkResource = trim($linkURIParts[0], '/');
505 1
                parse_str($linkURIParts[1], $linkURIParams);
506 1
                $contentRange['links'][$linkRel] = [
507 1
                    'uri' => $linkURI,
508 1
                    'resource' => $linkResource,
509
                    'params' => $linkURIParams
510
                ];
511 1
            }
512 1
            $contentRange['page'] = $contentRange['links']['self']['params']['page'];
513 1
            $contentRange['pages'] = $contentRange['links']['last']['params']['page'] ?? 1;
514 1
            $contentRange['per_page'] = $contentRange['links']['self']['params']['per_page'];
515 1
            $contentRange['from'] = $contentRange['per_page'] * ($contentRange['page'] -1) + 1;
516
            $contentRange['to'] = $contentRange['per_page'] * $contentRange['page'];
517
        }
518 2
519
        return $contentRange;
520
    }
521
522
    /**
523
     * @param string $resource Main resource path
524
     * @param array  $params   URL and querystring parameters
525
     * @return string
526 19
     */
527
    protected function buildPath($resource, array $params = [])
528 19
    {
529
        $path = '/' . $resource;
530 19
        switch ($resource) {
531 10
            case 'events':
532 8
                if (!empty($params['id'])) {
533
                    $path .= '/' . $params['id'];
534 10
                }
535 9
                break;
536 8
            case 'records':
537 1
                if (empty($params['event_id'])) {
538
                    throw new InvalidArgumentException('An event ID is required for records');
539 7
                }
540 7
                $path = '/events/' . $params['event_id'] . '/' . $resource;
541 2
                if (!empty($params['id'])) {
542
                    $path .= '/' . $params['id'];
543 7
                }
544
                break;
545 18
        }
546
        return $path;
547
    }
548
549
    /**
550
     * @param array  $params   URL and querystring parameters
551
     * @return string
552 25
     */
553
    protected function buildQuery(array $params)
554
    {
555 25
        // Add querystring parameters
556 25
        $query = [];
557
        foreach ($params as $key => $value) {
558 11
            // Exclude URL and range values
559 10
            if (in_array($key, ['id', 'event_id', 'range'])) {
560
                continue;
561 2
            }
562 1
            if ($key === 'fields' && is_array($value)) {
563 1
                $query[$key] = implode(',', $value);
564
                continue;
565 2
            }
566 1
            if ($value === true) {
567 1
                $query[$key] = 'true';
568
                continue;
569 2
            }
570 1
            if ($value === false) {
571 1
                $query[$key] = 'false';
572
                continue;
573 2
            }
574
            $query[$key] = $value;
575
        }
576
        // Decode unreserved characters adding ',' to the list
577 25
        // see https://github.com/guzzle/psr7/blob/master/README.md#guzzlehttppsr7urinormalizernormalize
578 25
        return preg_replace_callback(
579 25
            '/%(?:2C|2D|2E|5F|7E|3[0-9]|[46][1-9A-F]|[57][0-9A])/i',
580 1
            function (array $match) {
581 25
                return rawurldecode($match[0]);
582 25
            },
583
            http_build_query($query)
584
        );
585
    }
586
587
    /**
588
     * Send a request to the API endpoint
589
     *
590
     * @param string $method  HTTP method
591
     * @param string $path    Relative URL path (without query string)
592
     * @param array  $headers Additional headers
593
     * @param array  $params  Query string parameters
594
     * @param array  $data    Request body
595
     *
596
     * @return array JSON-decoded associative array from server response
597
     *
598
     * @throws Error\Authentication
599
     * @throws Error\NotFound
600
     * @throws Error\Generic
601 25
     */
602
    protected function request($method, $path, $headers = [], $params = [], $data = [])
603
    {
604 25
        // Minimal default header
605
        $acceptContentType = 'application/json';
606
607 25
        // Unique request ID
608
        $requestID = uniqid('', true);
609
610 25
        // Adding custom headers
611 25
        $requestHeaders = array_merge([
612 25
            'Accept' => $acceptContentType,
613 25
            'X-Request-ID' => $requestID
614
        ], $headers);
615
616 25
        // Add credentials
617
        if (!empty($this->authToken) && empty($requestHeaders['Authorization'])) {
618
            $requestHeaders['Authorization'] = 'Bearer ' . $this->authToken;
619
        }
620
621 25
        // Add content-type header (currently required GET requests)
622 18
        if (empty($requestHeaders['Content-Type'])) {
623
            $requestHeaders['Content-Type'] = $acceptContentType;
624
        }
625
626 25
        // Creating URI: URI params must be already provided by the calling method
627 25
        $endpoint = $this->uriFactory->createUri($this->options['endpoint']);
628 25
        $uri = $endpoint->withPath($endpoint->getPath() . $path)
629
            ->withQuery($this->buildQuery($params));
630
631 25
        // Create body, if provided
632
        $body = (!empty($data)) ? json_encode($data) : null;
633
634 25
        // Create and send a request
635 25
        $this->logger->debug('Sending request', [
636 25
            'method' => $method,
637 25
            'uri' => (string) $uri,
638 25
            'headers' => $requestHeaders,
639
            'body' => $body
640 25
        ]);
641 25
        $request = $this->requestFactory->createRequest($method, $uri, $requestHeaders, $body);
642 25
        $response = $this->httpClient->sendRequest($request);
643 25
        $this->logger->debug('Received response', [
644 25
            'status' => $response->getStatusCode(),
645 25
            'reason' => $response->getReasonPhrase(),
646 25
            'headers' => $response->getHeaders(),
647
            'body' => (string) $response->getBody()
648
        ]);
649 25
        // Check response content type match
650 25
        $contentType = $response->getHeaderLine('Content-Type');
651 1
        if (204 !== $response->getStatusCode() && $contentType !== $acceptContentType) {
652
            throw new Error\Generic(sprintf("Invalid response content type: %s", $contentType));
653
        }
654
655 24
        // Return the decoded JSON and let the caller create the appropriate result format
656
        return $this->parseResponse($response, $requestHeaders['X-Request-ID']);
657
    }
658
}
659