Completed
Pull Request — master (#355)
by
unknown
02:53
created

Connection::setAccessToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 3
rs 10
cc 1
eloc 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\Request;
10
use GuzzleHttp\Psr7\Response;
11
use GuzzleHttp\Psr7;
12
13
/**
14
 * Class Connection
15
 *
16
 * @package Picqer\Financials\Exact
17
 *
18
 */
19
class Connection
20
{
21
    
22
    /**
23
     * Limit of calls per minute.
24
     */
25
    const CALLS_LIMIT_PER_MINUTE = 60;
26
    
27
    /**
28
     * @var string
29
     */
30
    private $baseUrl = 'https://start.exactonline.nl';
31
32
    /**
33
     * @var string
34
     */
35
    private $apiUrl = '/api/v1';
36
37
    /**
38
     * @var string
39
     */
40
    private $authUrl = '/api/oauth2/auth';
41
42
    /**
43
     * @var string
44
     */
45
    private $tokenUrl = '/api/oauth2/token';
46
47
    /**
48
     * @var mixed
49
     */
50
    private $exactClientId;
51
52
    /**
53
     * @var mixed
54
     */
55
    private $exactClientSecret;
56
57
    /**
58
     * @var mixed
59
     */
60
    private $authorizationCode;
61
62
    /**
63
     * @var mixed
64
     */
65
    private $accessToken;
66
67
    /**
68
     * @var int The Unix timestamp at which the access token expires.
69
     */
70
    private $tokenExpires;
71
72
    /**
73
     * @var mixed
74
     */
75
    private $refreshToken;
76
    
77
    /**
78
     * @var bool Pauses a request to make sure the calls per minute restriction is respected.
79
     */
80
    private $pauseForMinuteLimit = false;
81
    
82
    /**
83
     * @var bool Pauses a request to make sure the calls are not made during maintenance.
84
     */
85
    private $pauseForMaintenance = false;
86
    
87
    /**
88
     * @var int
89
     */
90
    protected $callsLimit = self::CALLS_LIMIT_PER_MINUTE;
91
    
92
    /**
93
     * @var int
94
     */
95
    protected $callsLeft = self::CALLS_LIMIT_PER_MINUTE;
96
    
97
    /**
98
     * @var int
99
     */
100
    protected $responseTimestamp = 0;
101
    
102
    /**
103
     * @var array Start time of the maintenance in array format [H, m, i].
104
     */
105
    private $startMaintenance = [4, 0, 0];
106
    
107
    /**
108
     * @var array End time of the maintenance in array format [H, m, i].
109
     */
110
    private $endMaintenance = [4, 30, 0];
111
112
    /**
113
     * @var mixed
114
     */
115
    private $redirectUrl;
116
117
    /**
118
     * @var mixed
119
     */
120
    private $division;
121
122
    /**
123
     * @var Client|null
124
     */
125
    private $client;
126
127
    /**
128
     * @var callable(Connection)
129
     */
130
    private $tokenUpdateCallback;
131
132
    /**
133
     * @var callable(Connection)
134
     */
135
    private $acquireAccessTokenLockCallback;
136
137
    /**
138
     * @var callable(Connection)
139
     */
140
    private $acquireAccessTokenUnlockCallback;
141
142
    /**
143
     * @var callable[]
144
     */
145
    protected $middleWares = [];
146
147
    /**
148
    * @var string|null
149
    */
150
    public $nextUrl = null;
151
152
    /**
153
     * @return Client
154
     */
155
    private function client()
156
    {
157
        if ($this->client) {
158
            return $this->client;
159
        }
160
161
        $handlerStack = HandlerStack::create();
162
        foreach ($this->middleWares as $middleWare) {
163
            $handlerStack->push($middleWare);
164
        }
165
166
        $this->client = new Client([
167
            'http_errors' => true,
168
            'handler' => $handlerStack,
169
            'expect' => false,
170
        ]);
171
172
        return $this->client;
173
    }
174
175
    public function insertMiddleWare($middleWare)
176
    {
177
        $this->middleWares[] = $middleWare;
178
    }
179
180
    public function connect()
181
    {
182
        // Redirect for authorization if needed (no access token or refresh token given)
183
        if ($this->needsAuthentication()) {
184
            $this->redirectForAuthorization();
185
        }
186
187
        // If access token is not set or token has expired, acquire new token
188
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
189
            $this->acquireAccessToken();
190
        }
191
192
        $client = $this->client();
193
194
        return $client;
195
    }
196
    
197
    /**
198
     * @return bool
199
     */
200
    public function pauseForMinuteLimitEnabled()
201
    {
202
        return $this->pauseForMinuteLimit;
203
    }
204
    
205
    /**
206
     * @return self
207
     */
208
    public function enablePauseForMinuteLimit()
209
    {
210
        $this->pauseForMinuteLimit = true;
211
        return $this;
212
    }
213
    
214
    /**
215
     * @return self
216
     */
217
    public function disablePauseForMinuteLimit()
218
    {
219
        $this->pauseForMinuteLimit = true;
220
        return $this;
221
    }
222
    
223
    /**
224
     * @return bool
225
     */
226
    public function pauseForMaintenanceEnabled()
227
    {
228
        return $this->pauseForMaintenance;
229
    }
230
    
231
    /**
232
     * @return self
233
     */
234
    public function enablePauseForMaintenance()
235
    {
236
        $this->pauseForMaintenance = true;
237
        return $this;
238
    }
239
    
240
    /**
241
     * @return self
242
     */
243
    public function disablePauseForMaintenance()
244
    {
245
        $this->pauseForMaintenance = false;
246
        return $this;
247
    }
248
    
249
    /**
250
     * Pauses the process until time limit is over
251
     */
252
    protected function pause()
253
    {
254
        if ($this->pauseForMinuteLimitEnabled()) {
255
            if($this->minuteLimitExceeded()) {
256
                sleep(60);
257
            }
258
        }
259
        
260
        if ($this->pauseForMaintenanceEnabled()) {
261
            $startMaintenance = mktime(
262
                $this->startMaintenance[0],
263
                $this->startMaintenance[1],
264
                $this->startMaintenance[2]
265
            );
266
            
267
            $endMaintenance = mktime(
268
                $this->endMaintenance[0],
269
                $this->endMaintenance[1],
270
                $this->endMaintenance[2]
271
            );
272
            
273
            $now = time();
274
            if ($now >= $startMaintenance && $now <= $endMaintenance) {
275
                sleep($endMaintenance - $now);
276
            }
277
        }
278
    }
279
    
280
    /**
281
     * @return bool
282
     */
283
    protected function minuteLimitExceeded()
284
    {
285
        return $this->callsLeft <= 0;
286
    }
287
    
288
    /**
289
     * @param string $method
290
     * @param        $endpoint
291
     * @param null   $body
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $body is correct as it would always require null to be passed?
Loading history...
292
     * @param array  $params
293
     * @param array  $headers
294
     *
295
     * @return \GuzzleHttp\Psr7\Request
296
     * @throws \Picqer\Financials\Exact\ApiException
297
     */
298
    private function createRequest($method, $endpoint, $body = null, array $params = [], array $headers = [])
299
    {
300
        // Add default json headers to the request
301
        $headers = array_merge($headers, [
302
            'Accept' => 'application/json',
303
            'Content-Type' => 'application/json',
304
            'Prefer' => 'return=representation'
305
        ]);
306
        
307
        $this->pause();
308
309
        // If access token is not set or token has expired, acquire new token
310
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
311
            $this->acquireAccessToken();
312
        }
313
314
        // If we have a token, sign the request
315
        if (!$this->needsAuthentication() && !empty($this->accessToken)) {
316
            $headers['Authorization'] = 'Bearer ' . $this->accessToken;
317
        }
318
319
        // Create param string
320
        if (!empty($params)) {
321
            $endpoint .= '?' . http_build_query($params);
322
        }
323
324
        // Create the request
325
        $request = new Request($method, $endpoint, $headers, $body);
326
327
        return $request;
328
    }
329
    
330
    protected function beforeValidatingAccessToken()
331
    {
332
        $this->pause();
333
    }
334
335
    /**
336
     * @param       $url
337
     * @param array $params
338
     * @param array $headers
339
     *
340
     * @return mixed|null
341
     * @throws \GuzzleHttp\Exception\GuzzleException
342
     * @throws \Picqer\Financials\Exact\ApiException
343
     */
344
    public function get($url, array $params = [], array $headers = [])
345
    {
346
        $url = $this->formatUrl($url, $url !== 'current/Me', $url == $this->nextUrl);
347
348
        try {
349
            $request = $this->createRequest('GET', $url, null, $params, $headers);
350
            $response = $this->client()->send($request);
351
352
            return $this->parseResponse($response, $url != $this->nextUrl);
353
        } catch (Exception $e) {
354
            $this->parseExceptionForErrorMessages($e);
355
        }
356
        
357
        return null;
358
    }
359
    
360
    /**
361
     * @param $url
362
     * @param $body
363
     *
364
     * @return mixed|null
365
     * @throws \GuzzleHttp\Exception\GuzzleException
366
     * @throws \Picqer\Financials\Exact\ApiException
367
     */
368
    public function post($url, $body)
369
    {
370
        $url = $this->formatUrl($url);
371
372
        try {
373
            $request  = $this->createRequest('POST', $url, $body);
374
            $response = $this->client()->send($request);
375
376
            return $this->parseResponse($response);
377
        } catch (Exception $e) {
378
            $this->parseExceptionForErrorMessages($e);
379
        }
380
381
        return null;
382
    }
383
    
384
    /**
385
     * @param $url
386
     * @param $body
387
     *
388
     * @return mixed|null
389
     * @throws \GuzzleHttp\Exception\GuzzleException
390
     * @throws \Picqer\Financials\Exact\ApiException
391
     */
392
    public function put($url, $body)
393
    {
394
        $url = $this->formatUrl($url);
395
396
        try {
397
            $request  = $this->createRequest('PUT', $url, $body);
398
            $response = $this->client()->send($request);
399
400
            return $this->parseResponse($response);
401
        } catch (Exception $e) {
402
            $this->parseExceptionForErrorMessages($e);
403
        }
404
405
        return null;
406
    }
407
    
408
    /**
409
     * @param $url
410
     *
411
     * @return mixed|null
412
     * @throws \GuzzleHttp\Exception\GuzzleException
413
     * @throws \Picqer\Financials\Exact\ApiException
414
     */
415
    public function delete($url)
416
    {
417
        $url = $this->formatUrl($url);
418
419
        try {
420
            $request  = $this->createRequest('DELETE', $url);
421
            $response = $this->client()->send($request);
422
423
            return $this->parseResponse($response);
424
        } catch (Exception $e) {
425
            $this->parseExceptionForErrorMessages($e);
426
        }
427
428
        return null;
429
    }
430
431
    /**
432
     * @return string
433
     */
434
    public function getAuthUrl()
435
    {
436
        return $this->baseUrl . $this->authUrl . '?' . http_build_query(array(
437
            'client_id' => $this->exactClientId,
438
            'redirect_uri' => $this->redirectUrl,
439
            'response_type' => 'code'
440
        ));
441
    }
442
443
    /**
444
     * @param mixed $exactClientId
445
     */
446
    public function setExactClientId($exactClientId)
447
    {
448
        $this->exactClientId = $exactClientId;
449
    }
450
451
    /**
452
     * @param mixed $exactClientSecret
453
     */
454
    public function setExactClientSecret($exactClientSecret)
455
    {
456
        $this->exactClientSecret = $exactClientSecret;
457
    }
458
459
    /**
460
     * @param mixed $authorizationCode
461
     */
462
    public function setAuthorizationCode($authorizationCode)
463
    {
464
        $this->authorizationCode = $authorizationCode;
465
    }
466
467
    /**
468
     * @param mixed $accessToken
469
     */
470
    public function setAccessToken($accessToken)
471
    {
472
        $this->accessToken = $accessToken;
473
    }
474
475
    /**
476
     * @param mixed $refreshToken
477
     */
478
    public function setRefreshToken($refreshToken)
479
    {
480
        $this->refreshToken = $refreshToken;
481
    }
482
483
    /**
484
     *
485
     */
486
    public function redirectForAuthorization()
487
    {
488
        $authUrl = $this->getAuthUrl();
489
        header('Location: ' . $authUrl);
490
        exit;
491
    }
492
493
    /**
494
     * @param mixed $redirectUrl
495
     */
496
    public function setRedirectUrl($redirectUrl)
497
    {
498
        $this->redirectUrl = $redirectUrl;
499
    }
500
501
    /**
502
     * @return bool
503
     */
504
    public function needsAuthentication()
505
    {
506
        return empty($this->refreshToken) && empty($this->authorizationCode);
507
    }
508
509
    /**
510
     * @param Response $response
511
     * @param bool $returnSingleIfPossible
512
     * @return mixed
513
     * @throws ApiException
514
     */
515
    protected function parseResponse(Response $response, $returnSingleIfPossible = true)
516
    {
517
        try {
518
            $this->handleRateLimitsFromResponse($response);
519
520
            if ($response->getStatusCode() === 204) {
521
                return [];
522
            }
523
524
            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

524
            /** @scrutinizer ignore-call */ 
525
            Psr7\rewind_body($response);
Loading history...
525
            $json = json_decode($response->getBody()->getContents(), true);
526
            if (array_key_exists('d', $json)) {
527
                if (array_key_exists('__next', $json['d'])) {
528
                    $this->nextUrl = $json['d']['__next'];
529
                }
530
                else {
531
                    $this->nextUrl = null;
532
                }
533
534
                if (array_key_exists('results', $json['d'])) {
535
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
536
                        return $json['d']['results'][0];
537
                    }
538
539
                    return $json['d']['results'];
540
                }
541
542
                return $json['d'];
543
            }
544
545
            return $json;
546
        } catch (\RuntimeException $e) {
547
            throw new ApiException($e->getMessage());
548
        }
549
    }
550
    
551
    /**
552
     * @param Response $response
553
     *
554
     * @return void
555
     */
556
    protected function handleRateLimitsFromResponse(Response $response)
557
    {
558
        $this->callsLimit = (int) $response->getHeaderLine('X-RateLimit-Minutely-Limit');
559
        $this->callsLeft = (int) $response->getHeaderLine('X-RateLimit-Minutely-Remaining');
560
        $this->responseTimestamp = microtime(true);
561
        
562
        $this->pause();
563
    }
564
565
    /**
566
     * @return mixed
567
     */
568
    private function getCurrentDivisionNumber()
569
    {
570
        if (empty($this->division)) {
571
            $me             = new Me($this);
572
            $this->division = $me->find()->CurrentDivision;
573
        }
574
575
        return $this->division;
576
    }
577
578
    /**
579
     * @return mixed
580
     */
581
    public function getRefreshToken()
582
    {
583
        return $this->refreshToken;
584
    }
585
586
    /**
587
     * @return mixed
588
     */
589
    public function getAccessToken()
590
    {
591
        return $this->accessToken;
592
    }
593
    
594
    /**
595
     * @return int
596
     */
597
    public function getResponseTimestamp()
598
    {
599
        return $this->responseTimestamp;
600
    }
601
    
602
    /**
603
     * @throws \Picqer\Financials\Exact\ApiException
604
     */
605
    protected function acquireAccessToken()
606
    {
607
        $this->beforeValidatingAccessToken();
608
        
609
        // If refresh token not yet acquired, do token request
610
        if (empty($this->refreshToken)) {
611
            $body = [
612
                'form_params' => [
613
                    'redirect_uri'  => $this->redirectUrl,
614
                    'grant_type'    => 'authorization_code',
615
                    'client_id'     => $this->exactClientId,
616
                    'client_secret' => $this->exactClientSecret,
617
                    'code'          => $this->authorizationCode,
618
                ],
619
            ];
620
        } else { // else do refresh token request
621
            $body = [
622
                'form_params' => [
623
                    'refresh_token' => $this->refreshToken,
624
                    'grant_type'    => 'refresh_token',
625
                    'client_id'     => $this->exactClientId,
626
                    'client_secret' => $this->exactClientSecret,
627
                ],
628
            ];
629
        }
630
631
        try {
632
            if (is_callable($this->acquireAccessTokenLockCallback)) {
633
                call_user_func($this->acquireAccessTokenLockCallback, $this);
634
            }
635
636
            $response = $this->client()->post($this->getTokenUrl(), $body);
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
            $body = json_decode($response->getBody()->getContents(), true);
640
641
            if (json_last_error() === JSON_ERROR_NONE) {
642
                $this->accessToken  = $body['access_token'];
643
                $this->refreshToken = $body['refresh_token'];
644
                $this->tokenExpires = $this->getTimestampFromExpiresIn($body['expires_in']);
645
                $this->responseTimestamp = strtotime($response->getHeaderLine('Date'));
646
                
647
                $this->refreshTokens();
648
649
                if (is_callable($this->tokenUpdateCallback)) {
650
                    call_user_func($this->tokenUpdateCallback, $this);
651
                }
652
            } else {
653
                throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $response->getBody()->getContents());
654
            }
655
        } catch (BadResponseException $ex) {
656
            throw new ApiException('Could not acquire or refresh tokens [http ' . $ex->getResponse()->getStatusCode() . ']', 0, $ex);
657
        } finally {
658
            if (is_callable($this->acquireAccessTokenUnlockCallback)) {
659
                call_user_func($this->acquireAccessTokenUnlockCallback, $this);
660
            }
661
        }
662
    }
663
    
664
    protected function refreshTokens()
665
    {
666
    
667
    }
668
669
    /**
670
     * Translates expires_in to a Unix timestamp.
671
     * @param string $expiresIn Number of seconds until the token expires.
672
     * @return int
673
     */
674
    private function getTimestampFromExpiresIn($expiresIn)
675
    {
676
        if (!ctype_digit($expiresIn)) {
677
            throw new \InvalidArgumentException('Function requires a numeric expires value');
678
        }
679
680
        return time() + $expiresIn;
681
    }
682
683
    /**
684
     * @return int The Unix timestamp at which the access token expires.
685
     */
686
    public function getTokenExpires()
687
    {
688
        return $this->tokenExpires;
689
    }
690
691
    /**
692
     * @param int $tokenExpires The Unix timestamp at which the access token expires.
693
     */
694
    public function setTokenExpires($tokenExpires)
695
    {
696
        $this->tokenExpires = $tokenExpires;
697
    }
698
699
    private function tokenHasExpired()
700
    {
701
        if (empty($this->tokenExpires)) {
702
            return true;
703
        }
704
705
        return $this->tokenExpires <= time() + 10;
706
    }
707
708
    private function formatUrl($endPoint, $includeDivision = true, $formatNextUrl = false)
709
    {
710
        if ($formatNextUrl) {
711
            return $endPoint;
712
        }
713
714
        if ($includeDivision) {
715
            return implode('/', [
716
                $this->getApiUrl(),
717
                $this->getCurrentDivisionNumber(),
718
                $endPoint
719
            ]);
720
        }
721
722
        return implode('/', [
723
            $this->getApiUrl(),
724
            $endPoint
725
        ]);
726
    }
727
728
729
    /**
730
     * @return mixed
731
     */
732
    public function getDivision()
733
    {
734
        return $this->division;
735
    }
736
737
738
    /**
739
     * @param mixed $division
740
     */
741
    public function setDivision($division)
742
    {
743
        $this->division = $division;
744
    }
745
746
    /**
747
     * @param callable $callback
748
     */
749
    public function setAcquireAccessTokenLockCallback($callback) {
750
        $this->acquireAccessTokenLockCallback = $callback;
751
    }
752
753
    /**
754
     * @param callable $callback
755
     */
756
    public function setAcquireAccessTokenUnlockCallback($callback) {
757
        $this->acquireAccessTokenUnlockCallback = $callback;
758
    }
759
760
    /**
761
     * @param callable $callback
762
     */
763
    public function setTokenUpdateCallback($callback) {
764
        $this->tokenUpdateCallback = $callback;
765
    }
766
767
    /**
768
     * Parse the reponse in the Exception to return the Exact error messages
769
     * @param Exception $e
770
     * @throws ApiException
771
     */
772
    private function parseExceptionForErrorMessages(Exception $e)
773
    {
774
        if (! $e instanceof BadResponseException) {
775
            throw new ApiException($e->getMessage());
776
        }
777
778
        $response = $e->getResponse();
779
        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

779
        /** @scrutinizer ignore-call */ 
780
        Psr7\rewind_body($response);
Loading history...
780
        $responseBody = $response->getBody()->getContents();
781
        $decodedResponseBody = json_decode($responseBody, true);
782
783
        if (! is_null($decodedResponseBody) && isset($decodedResponseBody['error']['message']['value'])) {
784
            $errorMessage = $decodedResponseBody['error']['message']['value'];
785
        } else {
786
            $errorMessage = $responseBody;
787
        }
788
789
        throw new ApiException('Error ' . $response->getStatusCode() .': ' . $errorMessage);
790
    }
791
792
    /**
793
     * @return string
794
     */
795
    protected function getBaseUrl()
796
    {
797
        return $this->baseUrl;
798
    }
799
800
    /**
801
     * @return string
802
     */
803
    private function getApiUrl()
804
    {
805
        return $this->baseUrl . $this->apiUrl;
806
    }
807
808
    /**
809
     * @return string
810
     */
811
    private function getTokenUrl()
812
    {
813
        return $this->baseUrl . $this->tokenUrl;
814
    }
815
816
    /**
817
     * Set base URL for different countries according to
818
     * https://developers.exactonline.com/#Exact%20Online%20sites.html
819
     *
820
     * @param string $baseUrl
821
     */
822
    public function setBaseUrl($baseUrl)
823
    {
824
        $this->baseUrl = $baseUrl;
825
    }
826
827
    /**
828
     * @param string $apiUrl
829
     */
830
    public function setApiUrl($apiUrl)
831
    {
832
        $this->apiUrl = $apiUrl;
833
    }
834
835
    /**
836
     * @param string $authUrl
837
     */
838
    public function setAuthUrl($authUrl)
839
    {
840
        $this->authUrl = $authUrl;
841
    }
842
843
    /**
844
     * @param string $tokenUrl
845
     */
846
    public function setTokenUrl($tokenUrl)
847
    {
848
        $this->tokenUrl = $tokenUrl;
849
    }
850
}
851