Completed
Pull Request — master (#414)
by
unknown
03:10
created

Connection::setClient()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 1
c 1
b 0
f 0
dl 0
loc 3
rs 10
cc 1
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
    /**
157
     * @param Client $client
158
     */
159
    public function setClient($client)
160
    {
161
        $this->client = $client;
162
    }
163
164
    public function insertMiddleWare($middleWare)
165
    {
166
        $this->middleWares[] = $middleWare;
167
    }
168
169
    /**
170
     * @return Client
171
     */
172
    public function connect()
173
    {
174
        // Redirect for authorization if needed (no access token or refresh token given)
175
        if ($this->needsAuthentication()) {
176
            $this->redirectForAuthorization();
177
        }
178
179
        // If access token is not set or token has expired, acquire new token
180
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
181
            $this->acquireAccessToken();
182
        }
183
184
        $client = $this->client();
185
186
        return $client;
187
    }
188
189
    /**
190
     * @param string $method
191
     * @param string $endpoint
192
     * @param mixed  $body
193
     * @param array  $params
194
     * @param array  $headers
195
     *
196
     * @return Request
197
     */
198
    private function createRequest($method, $endpoint, $body = null, array $params = [], array $headers = [])
199
    {
200
        // Add default json headers to the request
201
        $headers = array_merge($headers, [
202
            'Accept'       => 'application/json',
203
            'Content-Type' => 'application/json',
204
            'Prefer'       => 'return=representation',
205
        ]);
206
207
        // If access token is not set or token has expired, acquire new token
208
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
209
            $this->acquireAccessToken();
210
        }
211
212
        // If we have a token, sign the request
213
        if (! $this->needsAuthentication() && ! empty($this->accessToken)) {
214
            $headers['Authorization'] = 'Bearer ' . $this->accessToken;
215
        }
216
217
        // Create param string
218
        if (! empty($params)) {
219
            $endpoint .= '?' . http_build_query($params);
220
        }
221
222
        // Create the request
223
        $request = new Request($method, $endpoint, $headers, $body);
224
225
        return $request;
226
    }
227
228
    /**
229
     * @param string $url
230
     * @param array  $params
231
     * @param array  $headers
232
     *
233
     * @throws ApiException
234
     *
235
     * @return mixed
236
     */
237
    public function get($url, array $params = [], array $headers = [])
238
    {
239
        $url = $this->formatUrl($url, $url !== 'current/Me', $url == $this->nextUrl);
240
241
        try {
242
            $request = $this->createRequest('GET', $url, null, $params, $headers);
243
            $response = $this->client()->send($request);
244
245
            return $this->parseResponse($response, $url != $this->nextUrl);
246
        } catch (Exception $e) {
247
            $this->parseExceptionForErrorMessages($e);
248
        }
249
    }
250
251
    /**
252
     * @param string $url
253
     * @param mixed  $body
254
     *
255
     * @throws ApiException
256
     *
257
     * @return mixed
258
     */
259
    public function post($url, $body)
260
    {
261
        $url = $this->formatUrl($url);
262
263
        try {
264
            $request = $this->createRequest('POST', $url, $body);
265
            $response = $this->client()->send($request);
266
267
            return $this->parseResponse($response);
268
        } catch (Exception $e) {
269
            $this->parseExceptionForErrorMessages($e);
270
        }
271
    }
272
273
    /**
274
     * @param string $url
275
     * @param mixed  $body
276
     *
277
     * @throws ApiException
278
     *
279
     * @return mixed
280
     */
281
    public function put($url, $body)
282
    {
283
        $url = $this->formatUrl($url);
284
285
        try {
286
            $request = $this->createRequest('PUT', $url, $body);
287
            $response = $this->client()->send($request);
288
289
            return $this->parseResponse($response);
290
        } catch (Exception $e) {
291
            $this->parseExceptionForErrorMessages($e);
292
        }
293
    }
294
295
    /**
296
     * @param string $url
297
     *
298
     * @throws ApiException
299
     *
300
     * @return mixed
301
     */
302
    public function delete($url)
303
    {
304
        $url = $this->formatUrl($url);
305
306
        try {
307
            $request = $this->createRequest('DELETE', $url);
308
            $response = $this->client()->send($request);
309
310
            return $this->parseResponse($response);
311
        } catch (Exception $e) {
312
            $this->parseExceptionForErrorMessages($e);
313
        }
314
    }
315
316
    /**
317
     * @return string
318
     */
319
    public function getAuthUrl()
320
    {
321
        return $this->baseUrl . $this->authUrl . '?' . http_build_query([
322
            'client_id'     => $this->exactClientId,
323
            'redirect_uri'  => $this->redirectUrl,
324
            'response_type' => 'code',
325
        ]);
326
    }
327
328
    /**
329
     * @param mixed $exactClientId
330
     */
331
    public function setExactClientId($exactClientId)
332
    {
333
        $this->exactClientId = $exactClientId;
334
    }
335
336
    /**
337
     * @param mixed $exactClientSecret
338
     */
339
    public function setExactClientSecret($exactClientSecret)
340
    {
341
        $this->exactClientSecret = $exactClientSecret;
342
    }
343
344
    /**
345
     * @param mixed $authorizationCode
346
     */
347
    public function setAuthorizationCode($authorizationCode)
348
    {
349
        $this->authorizationCode = $authorizationCode;
350
    }
351
352
    /**
353
     * @param mixed $accessToken
354
     */
355
    public function setAccessToken($accessToken)
356
    {
357
        $this->accessToken = $accessToken;
358
    }
359
360
    /**
361
     * @param mixed $refreshToken
362
     */
363
    public function setRefreshToken($refreshToken)
364
    {
365
        $this->refreshToken = $refreshToken;
366
    }
367
368
    public function redirectForAuthorization()
369
    {
370
        $authUrl = $this->getAuthUrl();
371
        header('Location: ' . $authUrl);
372
        exit;
373
    }
374
375
    /**
376
     * @param mixed $redirectUrl
377
     */
378
    public function setRedirectUrl($redirectUrl)
379
    {
380
        $this->redirectUrl = $redirectUrl;
381
    }
382
383
    /**
384
     * @return bool
385
     */
386
    public function needsAuthentication()
387
    {
388
        return empty($this->refreshToken) && empty($this->authorizationCode);
389
    }
390
391
    /**
392
     * @param Response $response
393
     * @param bool     $returnSingleIfPossible
394
     *
395
     * @throws ApiException
396
     *
397
     * @return mixed
398
     */
399
    private function parseResponse(Response $response, $returnSingleIfPossible = true)
400
    {
401
        try {
402
            if ($response->getStatusCode() === 204) {
403
                return [];
404
            }
405
406
            $this->extractRateLimits($response);
407
408
            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

408
            /** @scrutinizer ignore-call */ 
409
            Psr7\rewind_body($response);
Loading history...
409
            $json = json_decode($response->getBody()->getContents(), true);
410
            if (false === is_array($json)) {
411
                throw new ApiException('Json decode failed. Got response: ' . $response->getBody()->getContents());
412
            }
413
            if (array_key_exists('d', $json)) {
414
                if (array_key_exists('__next', $json['d'])) {
415
                    $this->nextUrl = $json['d']['__next'];
416
                } else {
417
                    $this->nextUrl = null;
418
                }
419
420
                if (array_key_exists('results', $json['d'])) {
421
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
422
                        return $json['d']['results'][0];
423
                    }
424
425
                    return $json['d']['results'];
426
                }
427
428
                return $json['d'];
429
            }
430
431
            return $json;
432
        } catch (\RuntimeException $e) {
433
            throw new ApiException($e->getMessage());
434
        }
435
    }
436
437
    /**
438
     * @return mixed
439
     */
440
    private function getCurrentDivisionNumber()
441
    {
442
        if (empty($this->division)) {
443
            $me = new Me($this);
444
            $this->division = $me->find()->CurrentDivision;
445
        }
446
447
        return $this->division;
448
    }
449
450
    /**
451
     * @return mixed
452
     */
453
    public function getRefreshToken()
454
    {
455
        return $this->refreshToken;
456
    }
457
458
    /**
459
     * @return mixed
460
     */
461
    public function getAccessToken()
462
    {
463
        return $this->accessToken;
464
    }
465
466
    private function acquireAccessToken()
467
    {
468
        try {
469
            if (is_callable($this->acquireAccessTokenLockCallback)) {
470
                call_user_func($this->acquireAccessTokenLockCallback, $this);
471
            }
472
473
            // If refresh token not yet acquired, do token request
474
            if (empty($this->refreshToken)) {
475
                $body = [
476
                    'form_params' => [
477
                        'redirect_uri'  => $this->redirectUrl,
478
                        'grant_type'    => 'authorization_code',
479
                        'client_id'     => $this->exactClientId,
480
                        'client_secret' => $this->exactClientSecret,
481
                        'code'          => $this->authorizationCode,
482
                    ],
483
                ];
484
            } else { // else do refresh token request
485
                $body = [
486
                    'form_params' => [
487
                        'refresh_token' => $this->refreshToken,
488
                        'grant_type'    => 'refresh_token',
489
                        'client_id'     => $this->exactClientId,
490
                        'client_secret' => $this->exactClientSecret,
491
                    ],
492
                ];
493
            }
494
495
            $response = $this->client()->post($this->getTokenUrl(), $body);
496
497
            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

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

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

636
        $this->extractRateLimits(/** @scrutinizer ignore-type */ $response);
Loading history...
637
638
        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

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