Completed
Pull Request — master (#2)
by Quetzy
04:27
created

Client::request()   B

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