Passed
Pull Request — master (#378)
by
unknown
15:16
created

Connection::parseJsonDates()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 12
c 1
b 0
f 0
dl 0
loc 17
rs 9.8666
cc 3
nc 1
nop 1
1
<?php
2
3
namespace Picqer\Financials\Exact;
4
5
use Exception;
6
use GuzzleHttp\Client;
7
use GuzzleHttp\Exception\BadResponseException;
8
use GuzzleHttp\HandlerStack;
9
use GuzzleHttp\Psr7;
10
use GuzzleHttp\Psr7\Request;
11
use GuzzleHttp\Psr7\Response;
12
13
/**
14
 * Class Connection.
15
 */
16
class Connection
17
{
18
    /**
19
     * @var string
20
     */
21
    private $baseUrl = 'https://start.exactonline.nl';
22
23
    /**
24
     * @var string
25
     */
26
    private $apiUrl = '/api/v1';
27
28
    /**
29
     * @var string
30
     */
31
    private $authUrl = '/api/oauth2/auth';
32
33
    /**
34
     * @var string
35
     */
36
    private $tokenUrl = '/api/oauth2/token';
37
38
    /**
39
     * @var mixed
40
     */
41
    private $exactClientId;
42
43
    /**
44
     * @var mixed
45
     */
46
    private $exactClientSecret;
47
48
    /**
49
     * @var mixed
50
     */
51
    private $authorizationCode;
52
53
    /**
54
     * @var mixed
55
     */
56
    private $accessToken;
57
58
    /**
59
     * @var int the Unix timestamp at which the access token expires
60
     */
61
    private $tokenExpires;
62
63
    /**
64
     * @var mixed
65
     */
66
    private $refreshToken;
67
68
    /**
69
     * @var mixed
70
     */
71
    private $redirectUrl;
72
73
    /**
74
     * @var mixed
75
     */
76
    private $division;
77
78
    /**
79
     * @var Client|null
80
     */
81
    private $client;
82
83
    /**
84
     * @var callable(Connection)
85
     */
86
    private $tokenUpdateCallback;
87
88
    /**
89
     * @var callable(Connection)
90
     */
91
    private $acquireAccessTokenLockCallback;
92
93
    /**
94
     * @var callable(Connection)
95
     */
96
    private $acquireAccessTokenUnlockCallback;
97
98
    /**
99
     * @var callable[]
100
     */
101
    protected $middleWares = [];
102
103
    /**
104
     * @var string|null
105
     */
106
    public $nextUrl = null;
107
108
    /**
109
     * @var int|null
110
     */
111
    protected $dailyLimit;
112
113
    /**
114
     * @var int|null
115
     */
116
    protected $dailyLimitRemaining;
117
118
    /**
119
     * @var int|null
120
     */
121
    protected $dailyLimitReset;
122
123
    /**
124
     * @var int|null
125
     */
126
    protected $minutelyLimit;
127
128
    /**
129
     * @var int|null
130
     */
131
    protected $minutelyLimitRemaining;
132
133
    /**
134
     * @return Client
135
     */
136
    private function client()
137
    {
138
        if ($this->client) {
139
            return $this->client;
140
        }
141
142
        $handlerStack = HandlerStack::create();
143
        foreach ($this->middleWares as $middleWare) {
144
            $handlerStack->push($middleWare);
145
        }
146
147
        $this->client = new Client([
148
            'http_errors' => true,
149
            'handler'     => $handlerStack,
150
            'expect'      => false,
151
        ]);
152
153
        return $this->client;
154
    }
155
156
    public function insertMiddleWare($middleWare)
157
    {
158
        $this->middleWares[] = $middleWare;
159
    }
160
161
    public function connect()
162
    {
163
        // Redirect for authorization if needed (no access token or refresh token given)
164
        if ($this->needsAuthentication()) {
165
            $this->redirectForAuthorization();
166
        }
167
168
        // If access token is not set or token has expired, acquire new token
169
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
170
            $this->acquireAccessToken();
171
        }
172
173
        $client = $this->client();
174
175
        return $client;
176
    }
177
178
    /**
179
     * @param string $method
180
     * @param string $endpoint
181
     * @param mixed  $body
182
     * @param array  $params
183
     * @param array  $headers
184
     *
185
     * @return Request
186
     */
187
    private function createRequest($method, $endpoint, $body = null, array $params = [], array $headers = [])
188
    {
189
        // Add default json headers to the request
190
        $headers = array_merge($headers, [
191
            'Accept'       => 'application/json',
192
            'Content-Type' => 'application/json',
193
            'Prefer'       => 'return=representation',
194
        ]);
195
196
        // If access token is not set or token has expired, acquire new token
197
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
198
            $this->acquireAccessToken();
199
        }
200
201
        // If we have a token, sign the request
202
        if (! $this->needsAuthentication() && ! empty($this->accessToken)) {
203
            $headers['Authorization'] = 'Bearer ' . $this->accessToken;
204
        }
205
206
        // Create param string
207
        if (! empty($params)) {
208
            $endpoint .= '?' . http_build_query($params);
209
        }
210
211
        // Create the request
212
        $request = new Request($method, $endpoint, $headers, $body);
213
214
        return $request;
215
    }
216
217
    /**
218
     * @param string $url
219
     * @param array  $params
220
     * @param array  $headers
221
     *
222
     * @throws ApiException
223
     *
224
     * @return mixed
225
     */
226
    public function get($url, array $params = [], array $headers = [])
227
    {
228
        $url = $this->formatUrl($url, $url !== 'current/Me', $url == $this->nextUrl);
229
230
        try {
231
            $request = $this->createRequest('GET', $url, null, $params, $headers);
232
            $response = $this->client()->send($request);
233
234
            return $this->parseResponse($response, $url != $this->nextUrl);
235
        } catch (Exception $e) {
236
            $this->parseExceptionForErrorMessages($e);
237
        }
238
    }
239
240
    /**
241
     * @param string $url
242
     * @param mixed  $body
243
     *
244
     * @throws ApiException
245
     *
246
     * @return mixed
247
     */
248
    public function post($url, $body)
249
    {
250
        $url = $this->formatUrl($url);
251
252
        try {
253
            $request = $this->createRequest('POST', $url, $body);
254
            $response = $this->client()->send($request);
255
256
            return $this->parseResponse($response);
257
        } catch (Exception $e) {
258
            $this->parseExceptionForErrorMessages($e);
259
        }
260
    }
261
262
    /**
263
     * @param string $url
264
     * @param mixed  $body
265
     *
266
     * @throws ApiException
267
     *
268
     * @return mixed
269
     */
270
    public function put($url, $body)
271
    {
272
        $url = $this->formatUrl($url);
273
274
        try {
275
            $request = $this->createRequest('PUT', $url, $body);
276
            $response = $this->client()->send($request);
277
278
            return $this->parseResponse($response);
279
        } catch (Exception $e) {
280
            $this->parseExceptionForErrorMessages($e);
281
        }
282
    }
283
284
    /**
285
     * @param string $url
286
     *
287
     * @throws ApiException
288
     *
289
     * @return mixed
290
     */
291
    public function delete($url)
292
    {
293
        $url = $this->formatUrl($url);
294
295
        try {
296
            $request = $this->createRequest('DELETE', $url);
297
            $response = $this->client()->send($request);
298
299
            return $this->parseResponse($response);
300
        } catch (Exception $e) {
301
            $this->parseExceptionForErrorMessages($e);
302
        }
303
    }
304
305
    /**
306
     * @return string
307
     */
308
    public function getAuthUrl()
309
    {
310
        return $this->baseUrl . $this->authUrl . '?' . http_build_query([
311
            'client_id'     => $this->exactClientId,
312
            'redirect_uri'  => $this->redirectUrl,
313
            'response_type' => 'code',
314
        ]);
315
    }
316
317
    /**
318
     * @param mixed $exactClientId
319
     */
320
    public function setExactClientId($exactClientId)
321
    {
322
        $this->exactClientId = $exactClientId;
323
    }
324
325
    /**
326
     * @param mixed $exactClientSecret
327
     */
328
    public function setExactClientSecret($exactClientSecret)
329
    {
330
        $this->exactClientSecret = $exactClientSecret;
331
    }
332
333
    /**
334
     * @param mixed $authorizationCode
335
     */
336
    public function setAuthorizationCode($authorizationCode)
337
    {
338
        $this->authorizationCode = $authorizationCode;
339
    }
340
341
    /**
342
     * @param mixed $accessToken
343
     */
344
    public function setAccessToken($accessToken)
345
    {
346
        $this->accessToken = $accessToken;
347
    }
348
349
    /**
350
     * @param mixed $refreshToken
351
     */
352
    public function setRefreshToken($refreshToken)
353
    {
354
        $this->refreshToken = $refreshToken;
355
    }
356
357
    public function redirectForAuthorization()
358
    {
359
        $authUrl = $this->getAuthUrl();
360
        header('Location: ' . $authUrl);
361
        exit;
362
    }
363
364
    /**
365
     * @param mixed $redirectUrl
366
     */
367
    public function setRedirectUrl($redirectUrl)
368
    {
369
        $this->redirectUrl = $redirectUrl;
370
    }
371
372
    /**
373
     * @return bool
374
     */
375
    public function needsAuthentication()
376
    {
377
        return empty($this->refreshToken) && empty($this->authorizationCode);
378
    }
379
380
    /**
381
     * 
382
     * @param mixed $data
383
     * @return mixed
384
     */
385
    private function parseJsonDates($data)
386
    {
387
        array_walk_recursive($data, function (&$item, $key) {
388
            $matches = null;
389
            if (preg_match('/\/Date\((\d+)\)\//', $item, $matches)) {
390
                $microtime = ((int) $matches[1]) / 1000;
391
                if ($microtime == ((int) $microtime)) {
392
                    $microtime = "$microtime.000";
393
                } else {
394
                    $microtime = "$microtime";
395
                }
396
                $date = \DateTime::createFromFormat('U.u', $microtime);
397
                $date->setTimezone(new \DateTimeZone('UTC'));
398
                $item = $date->format('Y-m-d\TH:i:s.u');
399
            }
400
        });
401
        return $data;
402
    }
403
    
404
    /**
405
     * @param Response $response
406
     * @param bool     $returnSingleIfPossible
407
     *
408
     * @throws ApiException
409
     *
410
     * @return mixed
411
     */
412
    private function parseResponse(Response $response, $returnSingleIfPossible = true)
413
    {
414
        try {
415
            if ($response->getStatusCode() === 204) {
416
                return [];
417
            }
418
419
            $this->extractRateLimits($response);
420
421
            Psr7\rewind_body($response);
0 ignored issues
show
Bug introduced by
The function rewind_body was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

421
            /** @scrutinizer ignore-call */ 
422
            Psr7\rewind_body($response);
Loading history...
422
            $json = $this->parseJsonDates(json_decode($response->getBody()->getContents(), true));
423
            if (array_key_exists('d', $json)) {
424
                if (array_key_exists('__next', $json['d'])) {
425
                    $this->nextUrl = $json['d']['__next'];
426
                } else {
427
                    $this->nextUrl = null;
428
                }
429
430
                if (array_key_exists('results', $json['d'])) {
431
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
432
                        return $json['d']['results'][0];
433
                    }
434
435
                    return $json['d']['results'];
436
                }
437
438
                return $json['d'];
439
            }
440
441
            return $json;
442
        } catch (\RuntimeException $e) {
443
            throw new ApiException($e->getMessage());
444
        }
445
    }
446
447
    /**
448
     * @return mixed
449
     */
450
    private function getCurrentDivisionNumber()
451
    {
452
        if (empty($this->division)) {
453
            $me = new Me($this);
454
            $this->division = $me->find()->CurrentDivision;
455
        }
456
457
        return $this->division;
458
    }
459
460
    /**
461
     * @return mixed
462
     */
463
    public function getRefreshToken()
464
    {
465
        return $this->refreshToken;
466
    }
467
468
    /**
469
     * @return mixed
470
     */
471
    public function getAccessToken()
472
    {
473
        return $this->accessToken;
474
    }
475
476
    private function acquireAccessToken()
477
    {
478
        // If refresh token not yet acquired, do token request
479
        if (empty($this->refreshToken)) {
480
            $body = [
481
                'form_params' => [
482
                    'redirect_uri'  => $this->redirectUrl,
483
                    'grant_type'    => 'authorization_code',
484
                    'client_id'     => $this->exactClientId,
485
                    'client_secret' => $this->exactClientSecret,
486
                    'code'          => $this->authorizationCode,
487
                ],
488
            ];
489
        } else { // else do refresh token request
490
            $body = [
491
                'form_params' => [
492
                    'refresh_token' => $this->refreshToken,
493
                    'grant_type'    => 'refresh_token',
494
                    'client_id'     => $this->exactClientId,
495
                    'client_secret' => $this->exactClientSecret,
496
                ],
497
            ];
498
        }
499
500
        try {
501
            if (is_callable($this->acquireAccessTokenLockCallback)) {
502
                call_user_func($this->acquireAccessTokenLockCallback, $this);
503
            }
504
505
            $response = $this->client()->post($this->getTokenUrl(), $body);
506
507
            Psr7\rewind_body($response);
0 ignored issues
show
Bug introduced by
The function rewind_body was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

507
            /** @scrutinizer ignore-call */ 
508
            Psr7\rewind_body($response);
Loading history...
508
            $body = json_decode($response->getBody()->getContents(), true);
509
510
            if (json_last_error() === JSON_ERROR_NONE) {
511
                $this->accessToken = $body['access_token'];
512
                $this->refreshToken = $body['refresh_token'];
513
                $this->tokenExpires = $this->getTimestampFromExpiresIn($body['expires_in']);
514
515
                if (is_callable($this->tokenUpdateCallback)) {
516
                    call_user_func($this->tokenUpdateCallback, $this);
517
                }
518
            } else {
519
                throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $response->getBody()->getContents());
520
            }
521
        } catch (BadResponseException $ex) {
522
            throw new ApiException('Could not acquire or refresh tokens [http ' . $ex->getResponse()->getStatusCode() . ']', 0, $ex);
523
        } finally {
524
            if (is_callable($this->acquireAccessTokenUnlockCallback)) {
525
                call_user_func($this->acquireAccessTokenUnlockCallback, $this);
526
            }
527
        }
528
    }
529
530
    /**
531
     * Translates expires_in to a Unix timestamp.
532
     *
533
     * @param string $expiresIn number of seconds until the token expires
534
     *
535
     * @return int
536
     */
537
    private function getTimestampFromExpiresIn($expiresIn)
538
    {
539
        if (! ctype_digit($expiresIn)) {
540
            throw new \InvalidArgumentException('Function requires a numeric expires value');
541
        }
542
543
        return time() + $expiresIn;
544
    }
545
546
    /**
547
     * @return int the Unix timestamp at which the access token expires
548
     */
549
    public function getTokenExpires()
550
    {
551
        return $this->tokenExpires;
552
    }
553
554
    /**
555
     * @param int $tokenExpires the Unix timestamp at which the access token expires
556
     */
557
    public function setTokenExpires($tokenExpires)
558
    {
559
        $this->tokenExpires = $tokenExpires;
560
    }
561
562
    private function tokenHasExpired()
563
    {
564
        if (empty($this->tokenExpires)) {
565
            return true;
566
        }
567
568
        return $this->tokenExpires <= time() + 10;
569
    }
570
571
    private function formatUrl($endPoint, $includeDivision = true, $formatNextUrl = false)
572
    {
573
        if ($formatNextUrl) {
574
            return $endPoint;
575
        }
576
577
        if ($includeDivision) {
578
            return implode('/', [
579
                $this->getApiUrl(),
580
                $this->getCurrentDivisionNumber(),
581
                $endPoint,
582
            ]);
583
        }
584
585
        return implode('/', [
586
            $this->getApiUrl(),
587
            $endPoint,
588
        ]);
589
    }
590
591
    /**
592
     * @return mixed
593
     */
594
    public function getDivision()
595
    {
596
        return $this->division;
597
    }
598
599
    /**
600
     * @param mixed $division
601
     */
602
    public function setDivision($division)
603
    {
604
        $this->division = $division;
605
    }
606
607
    /**
608
     * @param callable $callback
609
     */
610
    public function setAcquireAccessTokenLockCallback($callback)
611
    {
612
        $this->acquireAccessTokenLockCallback = $callback;
613
    }
614
615
    /**
616
     * @param callable $callback
617
     */
618
    public function setAcquireAccessTokenUnlockCallback($callback)
619
    {
620
        $this->acquireAccessTokenUnlockCallback = $callback;
621
    }
622
623
    /**
624
     * @param callable $callback
625
     */
626
    public function setTokenUpdateCallback($callback)
627
    {
628
        $this->tokenUpdateCallback = $callback;
629
    }
630
631
    /**
632
     * Parse the reponse in the Exception to return the Exact error messages.
633
     *
634
     * @param Exception $e
635
     *
636
     * @throws ApiException
637
     */
638
    private function parseExceptionForErrorMessages(Exception $e)
639
    {
640
        if (! $e instanceof BadResponseException) {
641
            throw new ApiException($e->getMessage());
642
        }
643
644
        $response = $e->getResponse();
645
646
        $this->extractRateLimits($response);
647
648
        Psr7\rewind_body($response);
0 ignored issues
show
Bug introduced by
The function rewind_body was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

648
        /** @scrutinizer ignore-call */ 
649
        Psr7\rewind_body($response);
Loading history...
649
        $responseBody = $response->getBody()->getContents();
650
        $decodedResponseBody = json_decode($responseBody, true);
651
652
        if (! is_null($decodedResponseBody) && isset($decodedResponseBody['error']['message']['value'])) {
653
            $errorMessage = $decodedResponseBody['error']['message']['value'];
654
        } else {
655
            $errorMessage = $responseBody;
656
        }
657
658
        throw new ApiException('Error ' . $response->getStatusCode() . ': ' . $errorMessage, $response->getStatusCode());
659
    }
660
661
    /**
662
     * @return int|null The maximum number of API calls that your app is permitted to make per company, per day
663
     */
664
    public function getDailyLimit()
665
    {
666
        return $this->dailyLimit;
667
    }
668
669
    /**
670
     * @return int|null The remaining number of API calls that your app is permitted to make for a company, per day
671
     */
672
    public function getDailyLimitRemaining()
673
    {
674
        return $this->dailyLimitRemaining;
675
    }
676
677
    /**
678
     * @return int|null The time at which the rate limit window resets in UTC epoch milliseconds
679
     */
680
    public function getDailyLimitReset()
681
    {
682
        return $this->dailyLimitReset;
683
    }
684
685
    /**
686
     * @return int|null The maximum number of API calls that your app is permitted to make per company, per minute
687
     */
688
    public function getMinutelyLimit()
689
    {
690
        return $this->minutelyLimit;
691
    }
692
693
    /**
694
     * @return int|null The remaining number of API calls that your app is permitted to make for a company, per minute
695
     */
696
    public function getMinutelyLimitRemaining()
697
    {
698
        return $this->minutelyLimitRemaining;
699
    }
700
701
    /**
702
     * @return string
703
     */
704
    protected function getBaseUrl()
705
    {
706
        return $this->baseUrl;
707
    }
708
709
    /**
710
     * @return string
711
     */
712
    private function getApiUrl()
713
    {
714
        return $this->baseUrl . $this->apiUrl;
715
    }
716
717
    /**
718
     * @return string
719
     */
720
    private function getTokenUrl()
721
    {
722
        return $this->baseUrl . $this->tokenUrl;
723
    }
724
725
    /**
726
     * Set base URL for different countries according to
727
     * https://developers.exactonline.com/#Exact%20Online%20sites.html.
728
     *
729
     * @param string $baseUrl
730
     */
731
    public function setBaseUrl($baseUrl)
732
    {
733
        $this->baseUrl = $baseUrl;
734
    }
735
736
    /**
737
     * @param string $apiUrl
738
     */
739
    public function setApiUrl($apiUrl)
740
    {
741
        $this->apiUrl = $apiUrl;
742
    }
743
744
    /**
745
     * @param string $authUrl
746
     */
747
    public function setAuthUrl($authUrl)
748
    {
749
        $this->authUrl = $authUrl;
750
    }
751
752
    /**
753
     * @param string $tokenUrl
754
     */
755
    public function setTokenUrl($tokenUrl)
756
    {
757
        $this->tokenUrl = $tokenUrl;
758
    }
759
760
    private function extractRateLimits(Response $response)
761
    {
762
        $this->dailyLimit = (int) $response->getHeaderLine('X-RateLimit-Limit');
763
        $this->dailyLimitRemaining = (int) $response->getHeaderLine('X-RateLimit-Remaining');
764
        $this->dailyLimitReset = (int) $response->getHeaderLine('X-RateLimit-Reset');
765
766
        $this->minutelyLimit = (int) $response->getHeaderLine('X-RateLimit-Minutely-Limit');
767
        $this->minutelyLimitRemaining = (int) $response->getHeaderLine('X-RateLimit-Minutely-Remaining');
768
    }
769
}
770