Passed
Pull Request — master (#355)
by
unknown
01:41
created

Connection::pause()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 25
rs 9.2222
c 0
b 0
f 0
cc 6
nc 9
nop 0
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
    private $callsLimit = self::CALLS_LIMIT_PER_MINUTE;
91
    
92
    /**
93
     * @var int
94
     */
95
    private $callsLeft = self::CALLS_LIMIT_PER_MINUTE;
96
    
97
    /**
98
     * @var array Start time of the maintenance in array format [H, m, i].
99
     */
100
    private $startMaintenance = [4, 0, 0];
101
    
102
    /**
103
     * @var array End time of the maintenance in array format [H, m, i].
104
     */
105
    private $endMaintenance = [4, 30, 0];
106
107
    /**
108
     * @var mixed
109
     */
110
    private $redirectUrl;
111
112
    /**
113
     * @var mixed
114
     */
115
    private $division;
116
117
    /**
118
     * @var Client|null
119
     */
120
    private $client;
121
122
    /**
123
     * @var callable(Connection)
124
     */
125
    private $tokenUpdateCallback;
126
127
    /**
128
     * @var callable(Connection)
129
     */
130
    private $acquireAccessTokenLockCallback;
131
132
    /**
133
     * @var callable(Connection)
134
     */
135
    private $acquireAccessTokenUnlockCallback;
136
137
    /**
138
     * @var callable[]
139
     */
140
    protected $middleWares = [];
141
142
    /**
143
    * @var string|null
144
    */
145
    public $nextUrl = null;
146
147
    /**
148
     * @return Client
149
     */
150
    private function client()
151
    {
152
        if ($this->client) {
153
            return $this->client;
154
        }
155
156
        $handlerStack = HandlerStack::create();
157
        foreach ($this->middleWares as $middleWare) {
158
            $handlerStack->push($middleWare);
159
        }
160
161
        $this->client = new Client([
162
            'http_errors' => true,
163
            'handler' => $handlerStack,
164
            'expect' => false,
165
        ]);
166
167
        return $this->client;
168
    }
169
170
    public function insertMiddleWare($middleWare)
171
    {
172
        $this->middleWares[] = $middleWare;
173
    }
174
175
    public function connect()
176
    {
177
        // Redirect for authorization if needed (no access token or refresh token given)
178
        if ($this->needsAuthentication()) {
179
            $this->redirectForAuthorization();
180
        }
181
182
        // If access token is not set or token has expired, acquire new token
183
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
184
            $this->acquireAccessToken();
185
        }
186
187
        $client = $this->client();
188
189
        return $client;
190
    }
191
    
192
    /**
193
     * @return bool
194
     */
195
    public function pauseForMinuteLimitEnabled()
196
    {
197
        return $this->pauseForMinuteLimit;
198
    }
199
    
200
    /**
201
     * @return self
202
     */
203
    public function enablePauseForMinuteLimit()
204
    {
205
        $this->pauseForMinuteLimit = true;
206
        return $this;
207
    }
208
    
209
    /**
210
     * @return self
211
     */
212
    public function disablePauseForMinuteLimit()
213
    {
214
        $this->pauseForMinuteLimit = true;
215
        return $this;
216
    }
217
    
218
    /**
219
     * @return bool
220
     */
221
    public function pauseForMaintenanceEnabled()
222
    {
223
        return $this->pauseForMaintenance;
224
    }
225
    
226
    /**
227
     * @return self
228
     */
229
    public function enablePauseForMaintenance()
230
    {
231
        $this->pauseForMaintenance = true;
232
        return $this;
233
    }
234
    
235
    /**
236
     * @return self
237
     */
238
    public function disablePauseForMaintenance()
239
    {
240
        $this->pauseForMaintenance = false;
241
        return $this;
242
    }
243
    
244
    /**
245
     * Pauses the process until time limit is over
246
     */
247
    private function pause()
248
    {
249
        if ($this->pauseForMinuteLimitEnabled()) {
250
            // take 90% of callsLimit so we have some tolerance when multiple process are running
251
            if($this->callsLeft <= ($this->callsLimit * 0.10)) {
252
                sleep(60);
253
            }
254
        }
255
        
256
        if ($this->pauseForMaintenanceEnabled()) {
257
            $startMaintenance = mktime(
258
                $this->startMaintenance[0],
259
                $this->startMaintenance[1],
260
                $this->startMaintenance[2]
261
            );
262
            
263
            $endMaintenance = mktime(
264
                $this->endMaintenance[0],
265
                $this->endMaintenance[1],
266
                $this->endMaintenance[2]
267
            );
268
            
269
            $now = time();
270
            if ($now >= $startMaintenance && $now <= $endMaintenance) {
271
                sleep($endMaintenance - $now);
272
            }
273
        }
274
    }
275
276
    /**
277
     * @param string $method
278
     * @param string $endpoint
279
     * @param mixed $body
280
     * @param array $params
281
     * @param array $headers
282
     * @return Request
283
     */
284
    private function createRequest($method = 'GET', $endpoint, $body = null, array $params = [], array $headers = [])
285
    {
286
        // Add default json headers to the request
287
        $headers = array_merge($headers, [
288
            'Accept' => 'application/json',
289
            'Content-Type' => 'application/json',
290
            'Prefer' => 'return=representation'
291
        ]);
292
293
        // If access token is not set or token has expired, acquire new token
294
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
295
            $this->acquireAccessToken();
296
        }
297
298
        // If we have a token, sign the request
299
        if (!$this->needsAuthentication() && !empty($this->accessToken)) {
300
            $headers['Authorization'] = 'Bearer ' . $this->accessToken;
301
        }
302
303
        // Create param string
304
        if (!empty($params)) {
305
            $endpoint .= '?' . http_build_query($params);
306
        }
307
308
        // Create the request
309
        $request = new Request($method, $endpoint, $headers, $body);
310
311
        return $request;
312
    }
313
314
    /**
315
     * @param       $url
316
     * @param array $params
317
     * @param array $headers
318
     *
319
     * @return mixed|null
320
     * @throws \GuzzleHttp\Exception\GuzzleException
321
     * @throws \Picqer\Financials\Exact\ApiException
322
     */
323
    public function get($url, array $params = [], array $headers = [])
324
    {
325
        $url = $this->formatUrl($url, $url !== 'current/Me', $url == $this->nextUrl);
326
    
327
        $this->pause();
328
329
        try {
330
            $request = $this->createRequest('GET', $url, null, $params, $headers);
331
            $response = $this->client()->send($request);
332
333
            return $this->parseResponse($response, $url != $this->nextUrl);
334
        } catch (Exception $e) {
335
            $this->parseExceptionForErrorMessages($e);
336
        }
337
        
338
        return null;
339
    }
340
    
341
    /**
342
     * @param $url
343
     * @param $body
344
     *
345
     * @return mixed|null
346
     * @throws \GuzzleHttp\Exception\GuzzleException
347
     * @throws \Picqer\Financials\Exact\ApiException
348
     */
349
    public function post($url, $body)
350
    {
351
        $url = $this->formatUrl($url);
352
    
353
        $this->pause();
354
355
        try {
356
            $request  = $this->createRequest('POST', $url, $body);
357
            $response = $this->client()->send($request);
358
359
            return $this->parseResponse($response);
360
        } catch (Exception $e) {
361
            $this->parseExceptionForErrorMessages($e);
362
        }
363
364
        return null;
365
    }
366
    
367
    /**
368
     * @param $url
369
     * @param $body
370
     *
371
     * @return mixed|null
372
     * @throws \GuzzleHttp\Exception\GuzzleException
373
     * @throws \Picqer\Financials\Exact\ApiException
374
     */
375
    public function put($url, $body)
376
    {
377
        $url = $this->formatUrl($url);
378
    
379
        $this->pause();
380
381
        try {
382
            $request  = $this->createRequest('PUT', $url, $body);
383
            $response = $this->client()->send($request);
384
385
            return $this->parseResponse($response);
386
        } catch (Exception $e) {
387
            $this->parseExceptionForErrorMessages($e);
388
        }
389
390
        return null;
391
    }
392
    
393
    /**
394
     * @param $url
395
     *
396
     * @return mixed|null
397
     * @throws \GuzzleHttp\Exception\GuzzleException
398
     * @throws \Picqer\Financials\Exact\ApiException
399
     */
400
    public function delete($url)
401
    {
402
        $url = $this->formatUrl($url);
403
    
404
        $this->pause();
405
406
        try {
407
            $request  = $this->createRequest('DELETE', $url);
408
            $response = $this->client()->send($request);
409
410
            return $this->parseResponse($response);
411
        } catch (Exception $e) {
412
            $this->parseExceptionForErrorMessages($e);
413
        }
414
415
        return null;
416
    }
417
418
    /**
419
     * @return string
420
     */
421
    public function getAuthUrl()
422
    {
423
        return $this->baseUrl . $this->authUrl . '?' . http_build_query(array(
424
            'client_id' => $this->exactClientId,
425
            'redirect_uri' => $this->redirectUrl,
426
            'response_type' => 'code'
427
        ));
428
    }
429
430
    /**
431
     * @param mixed $exactClientId
432
     */
433
    public function setExactClientId($exactClientId)
434
    {
435
        $this->exactClientId = $exactClientId;
436
    }
437
438
    /**
439
     * @param mixed $exactClientSecret
440
     */
441
    public function setExactClientSecret($exactClientSecret)
442
    {
443
        $this->exactClientSecret = $exactClientSecret;
444
    }
445
446
    /**
447
     * @param mixed $authorizationCode
448
     */
449
    public function setAuthorizationCode($authorizationCode)
450
    {
451
        $this->authorizationCode = $authorizationCode;
452
    }
453
454
    /**
455
     * @param mixed $accessToken
456
     */
457
    public function setAccessToken($accessToken)
458
    {
459
        $this->accessToken = $accessToken;
460
    }
461
462
    /**
463
     * @param mixed $refreshToken
464
     */
465
    public function setRefreshToken($refreshToken)
466
    {
467
        $this->refreshToken = $refreshToken;
468
    }
469
470
    /**
471
     *
472
     */
473
    public function redirectForAuthorization()
474
    {
475
        $authUrl = $this->getAuthUrl();
476
        header('Location: ' . $authUrl);
477
        exit;
478
    }
479
480
    /**
481
     * @param mixed $redirectUrl
482
     */
483
    public function setRedirectUrl($redirectUrl)
484
    {
485
        $this->redirectUrl = $redirectUrl;
486
    }
487
488
    /**
489
     * @return bool
490
     */
491
    public function needsAuthentication()
492
    {
493
        return empty($this->refreshToken) && empty($this->authorizationCode);
494
    }
495
496
    /**
497
     * @param Response $response
498
     * @param bool $returnSingleIfPossible
499
     * @return mixed
500
     * @throws ApiException
501
     */
502
    private function parseResponse(Response $response, $returnSingleIfPossible = true)
503
    {
504
        try {
505
            $this->callsLimit = (int) $response->getHeaderLine('X-RateLimit-Minutely-Limit');
506
            $this->callsLeft = (int) $response->getHeaderLine('X-RateLimit-Minutely-Remaining');
507
            
508
            $this->pause();
509
510
            if ($response->getStatusCode() === 204) {
511
                return [];
512
            }
513
514
            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

514
            /** @scrutinizer ignore-call */ 
515
            Psr7\rewind_body($response);
Loading history...
515
            $json = json_decode($response->getBody()->getContents(), true);
516
            if (array_key_exists('d', $json)) {
517
                if (array_key_exists('__next', $json['d'])) {
518
                    $this->nextUrl = $json['d']['__next'];
519
                }
520
                else {
521
                    $this->nextUrl = null;
522
                }
523
524
                if (array_key_exists('results', $json['d'])) {
525
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
526
                        return $json['d']['results'][0];
527
                    }
528
529
                    return $json['d']['results'];
530
                }
531
532
                return $json['d'];
533
            }
534
535
            return $json;
536
        } catch (\RuntimeException $e) {
537
            throw new ApiException($e->getMessage());
538
        }
539
    }
540
541
    /**
542
     * @return mixed
543
     */
544
    private function getCurrentDivisionNumber()
545
    {
546
        if (empty($this->division)) {
547
            $me             = new Me($this);
548
            $this->division = $me->find()->CurrentDivision;
549
        }
550
551
        return $this->division;
552
    }
553
554
    /**
555
     * @return mixed
556
     */
557
    public function getRefreshToken()
558
    {
559
        return $this->refreshToken;
560
    }
561
562
    /**
563
     * @return mixed
564
     */
565
    public function getAccessToken()
566
    {
567
        return $this->accessToken;
568
    }
569
570
    private function acquireAccessToken()
571
    {
572
        // If refresh token not yet acquired, do token request
573
        if (empty($this->refreshToken)) {
574
            $body = [
575
                'form_params' => [
576
                    'redirect_uri' => $this->redirectUrl,
577
                    'grant_type' => 'authorization_code',
578
                    'client_id' => $this->exactClientId,
579
                    'client_secret' => $this->exactClientSecret,
580
                    'code' => $this->authorizationCode
581
                ]
582
            ];
583
        } else { // else do refresh token request
584
            $body = [
585
                'form_params' => [
586
                    'refresh_token' => $this->refreshToken,
587
                    'grant_type' => 'refresh_token',
588
                    'client_id' => $this->exactClientId,
589
                    'client_secret' => $this->exactClientSecret,
590
                ]
591
            ];
592
        }
593
594
595
        try {
596
            if (is_callable($this->acquireAccessTokenLockCallback)) {
597
                call_user_func($this->acquireAccessTokenLockCallback, $this);
598
            }
599
600
            $response = $this->client()->post($this->getTokenUrl(), $body);
601
602
            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

602
            /** @scrutinizer ignore-call */ 
603
            Psr7\rewind_body($response);
Loading history...
603
            $body = json_decode($response->getBody()->getContents(), true);
604
605
            if (json_last_error() === JSON_ERROR_NONE) {
606
                $this->accessToken  = $body['access_token'];
607
                $this->refreshToken = $body['refresh_token'];
608
                $this->tokenExpires = $this->getTimestampFromExpiresIn($body['expires_in']);
609
610
                if (is_callable($this->tokenUpdateCallback)) {
611
                    call_user_func($this->tokenUpdateCallback, $this);
612
                }
613
            } else {
614
                throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $response->getBody()->getContents());
615
            }
616
        } catch (BadResponseException $ex) {
617
            throw new ApiException('Could not acquire or refresh tokens [http ' . $ex->getResponse()->getStatusCode() . ']', 0, $ex);
618
        } finally {
619
            if (is_callable($this->acquireAccessTokenUnlockCallback)) {
620
                call_user_func($this->acquireAccessTokenUnlockCallback, $this);
621
            }
622
        }
623
    }
624
625
    /**
626
     * Translates expires_in to a Unix timestamp.
627
     * @param string $expiresIn Number of seconds until the token expires.
628
     * @return int
629
     */
630
    private function getTimestampFromExpiresIn($expiresIn)
631
    {
632
        if (!ctype_digit($expiresIn)) {
633
            throw new \InvalidArgumentException('Function requires a numeric expires value');
634
        }
635
636
        return time() + $expiresIn;
637
    }
638
639
    /**
640
     * @return int The Unix timestamp at which the access token expires.
641
     */
642
    public function getTokenExpires()
643
    {
644
        return $this->tokenExpires;
645
    }
646
647
    /**
648
     * @param int $tokenExpires The Unix timestamp at which the access token expires.
649
     */
650
    public function setTokenExpires($tokenExpires)
651
    {
652
        $this->tokenExpires = $tokenExpires;
653
    }
654
655
    private function tokenHasExpired()
656
    {
657
        if (empty($this->tokenExpires)) {
658
            return true;
659
        }
660
661
        return $this->tokenExpires <= time() + 10;
662
    }
663
664
    private function formatUrl($endPoint, $includeDivision = true, $formatNextUrl = false)
665
    {
666
        if ($formatNextUrl) {
667
            return $endPoint;
668
        }
669
670
        if ($includeDivision) {
671
            return implode('/', [
672
                $this->getApiUrl(),
673
                $this->getCurrentDivisionNumber(),
674
                $endPoint
675
            ]);
676
        }
677
678
        return implode('/', [
679
            $this->getApiUrl(),
680
            $endPoint
681
        ]);
682
    }
683
684
685
    /**
686
     * @return mixed
687
     */
688
    public function getDivision()
689
    {
690
        return $this->division;
691
    }
692
693
694
    /**
695
     * @param mixed $division
696
     */
697
    public function setDivision($division)
698
    {
699
        $this->division = $division;
700
    }
701
702
    /**
703
     * @param callable $callback
704
     */
705
    public function setAcquireAccessTokenLockCallback($callback) {
706
        $this->acquireAccessTokenLockCallback = $callback;
707
    }
708
709
    /**
710
     * @param callable $callback
711
     */
712
    public function setAcquireAccessTokenUnlockCallback($callback) {
713
        $this->acquireAccessTokenUnlockCallback = $callback;
714
    }
715
716
    /**
717
     * @param callable $callback
718
     */
719
    public function setTokenUpdateCallback($callback) {
720
        $this->tokenUpdateCallback = $callback;
721
    }
722
723
    /**
724
     * Parse the reponse in the Exception to return the Exact error messages
725
     * @param Exception $e
726
     * @throws ApiException
727
     */
728
    private function parseExceptionForErrorMessages(Exception $e)
729
    {
730
        if (! $e instanceof BadResponseException) {
731
            throw new ApiException($e->getMessage());
732
        }
733
734
        $response = $e->getResponse();
735
        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

735
        /** @scrutinizer ignore-call */ 
736
        Psr7\rewind_body($response);
Loading history...
736
        $responseBody = $response->getBody()->getContents();
737
        $decodedResponseBody = json_decode($responseBody, true);
738
739
        if (! is_null($decodedResponseBody) && isset($decodedResponseBody['error']['message']['value'])) {
740
            $errorMessage = $decodedResponseBody['error']['message']['value'];
741
        } else {
742
            $errorMessage = $responseBody;
743
        }
744
745
        throw new ApiException('Error ' . $response->getStatusCode() .': ' . $errorMessage);
746
    }
747
748
    /**
749
     * @return string
750
     */
751
    protected function getBaseUrl()
752
    {
753
        return $this->baseUrl;
754
    }
755
756
    /**
757
     * @return string
758
     */
759
    private function getApiUrl()
760
    {
761
        return $this->baseUrl . $this->apiUrl;
762
    }
763
764
    /**
765
     * @return string
766
     */
767
    private function getTokenUrl()
768
    {
769
        return $this->baseUrl . $this->tokenUrl;
770
    }
771
772
    /**
773
     * Set base URL for different countries according to
774
     * https://developers.exactonline.com/#Exact%20Online%20sites.html
775
     *
776
     * @param string $baseUrl
777
     */
778
    public function setBaseUrl($baseUrl)
779
    {
780
        $this->baseUrl = $baseUrl;
781
    }
782
783
    /**
784
     * @param string $apiUrl
785
     */
786
    public function setApiUrl($apiUrl)
787
    {
788
        $this->apiUrl = $apiUrl;
789
    }
790
791
    /**
792
     * @param string $authUrl
793
     */
794
    public function setAuthUrl($authUrl)
795
    {
796
        $this->authUrl = $authUrl;
797
    }
798
799
    /**
800
     * @param string $tokenUrl
801
     */
802
    public function setTokenUrl($tokenUrl)
803
    {
804
        $this->tokenUrl = $tokenUrl;
805
    }
806
}
807