Passed
Push — master ( 1daef7...41af4b )
by
unknown
01:38 queued 11s
created

Client::buildQuery()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 7

Importance

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