Completed
Pull Request — master (#364)
by
unknown
02:01
created

Connection::getResponseHeaders()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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