Passed
Pull Request — master (#423)
by
unknown
03:47
created

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

439
            /** @scrutinizer ignore-call */ 
440
            Psr7\rewind_body($response);
Loading history...
440
            $json = json_decode($response->getBody()->getContents(), true);
441
            if (false === is_array($json)) {
442
                throw new ApiException('Json decode failed. Got response: ' . $response->getBody()->getContents());
443
            }
444
            if (array_key_exists('d', $json)) {
445
                if (array_key_exists('__next', $json['d'])) {
446
                    $this->nextUrl = $json['d']['__next'];
447
                } else {
448
                    $this->nextUrl = null;
449
                }
450
451
                if (array_key_exists('results', $json['d'])) {
452
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
453
                        return $json['d']['results'][0];
454
                    }
455
456
                    return $json['d']['results'];
457
                }
458
459
                return $json['d'];
460
            }
461
462
            return $json;
463
        } catch (\RuntimeException $e) {
464
            throw new ApiException($e->getMessage());
465
        }
466
    }
467
468
    /**
469
     * @param Response $response
470
     *
471
     * @throws ApiException
472
     *
473
     * @return mixed
474
     */
475
    private function parseResponseXml(Response $response)
476
    {
477
        try {
478
479
            if ($response->getStatusCode() === 204) {
480
                return [];
481
            }
482
483
            $answer = [];
484
            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

484
            /** @scrutinizer ignore-call */ 
485
            Psr7\rewind_body($response);
Loading history...
485
            $simpleXml = new \SimpleXMLElement($response->getBody()->getContents());
486
487
            foreach ($simpleXml->Messages as $message)
488
            {
489
                $keyAlt          = (string) $message->Message->Topic->Data->attributes()['keyAlt'];
490
                $answer[$keyAlt] = (string) $message->Message->Description;
491
            }
492
493
            return $answer;
494
        }
495
        catch (\RuntimeException $e) {
496
            throw new ApiException($e->getMessage());
497
        }
498
    }
499
500
    /**
501
     * @return mixed
502
     */
503
    private function getCurrentDivisionNumber()
504
    {
505
        if (empty($this->division)) {
506
            $me = new Me($this);
507
            $this->division = $me->find()->CurrentDivision;
508
        }
509
510
        return $this->division;
511
    }
512
513
    /**
514
     * @return mixed
515
     */
516
    public function getRefreshToken()
517
    {
518
        return $this->refreshToken;
519
    }
520
521
    /**
522
     * @return mixed
523
     */
524
    public function getAccessToken()
525
    {
526
        return $this->accessToken;
527
    }
528
529
    private function acquireAccessToken()
530
    {
531
        try {
532
            if (is_callable($this->acquireAccessTokenLockCallback)) {
533
                call_user_func($this->acquireAccessTokenLockCallback, $this);
534
            }
535
536
            // If refresh token not yet acquired, do token request
537
            if (empty($this->refreshToken)) {
538
                $body = [
539
                    'form_params' => [
540
                        'redirect_uri'  => $this->redirectUrl,
541
                        'grant_type'    => 'authorization_code',
542
                        'client_id'     => $this->exactClientId,
543
                        'client_secret' => $this->exactClientSecret,
544
                        'code'          => $this->authorizationCode,
545
                    ],
546
                ];
547
            } else { // else do refresh token request
548
                $body = [
549
                    'form_params' => [
550
                        'refresh_token' => $this->refreshToken,
551
                        'grant_type'    => 'refresh_token',
552
                        'client_id'     => $this->exactClientId,
553
                        'client_secret' => $this->exactClientSecret,
554
                    ],
555
                ];
556
            }
557
558
            $response = $this->client()->post($this->getTokenUrl(), $body);
559
560
            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

560
            /** @scrutinizer ignore-call */ 
561
            Psr7\rewind_body($response);
Loading history...
561
            $body = json_decode($response->getBody()->getContents(), true);
562
563
            if (json_last_error() === JSON_ERROR_NONE) {
564
                $this->accessToken = $body['access_token'];
565
                $this->refreshToken = $body['refresh_token'];
566
                $this->tokenExpires = $this->getTimestampFromExpiresIn($body['expires_in']);
567
568
                if (is_callable($this->tokenUpdateCallback)) {
569
                    call_user_func($this->tokenUpdateCallback, $this);
570
                }
571
            } else {
572
                throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $response->getBody()->getContents());
573
            }
574
        } catch (BadResponseException $ex) {
575
            throw new ApiException('Could not acquire or refresh tokens [http ' . $ex->getResponse()->getStatusCode() . ']', 0, $ex);
576
        } finally {
577
            if (is_callable($this->acquireAccessTokenUnlockCallback)) {
578
                call_user_func($this->acquireAccessTokenUnlockCallback, $this);
579
            }
580
        }
581
    }
582
583
    /**
584
     * Translates expires_in to a Unix timestamp.
585
     *
586
     * @param string $expiresIn number of seconds until the token expires
587
     *
588
     * @return int
589
     */
590
    private function getTimestampFromExpiresIn($expiresIn)
591
    {
592
        if (! ctype_digit($expiresIn)) {
593
            throw new \InvalidArgumentException('Function requires a numeric expires value');
594
        }
595
596
        return time() + $expiresIn;
597
    }
598
599
    /**
600
     * @return int the Unix timestamp at which the access token expires
601
     */
602
    public function getTokenExpires()
603
    {
604
        return $this->tokenExpires;
605
    }
606
607
    /**
608
     * @param int $tokenExpires the Unix timestamp at which the access token expires
609
     */
610
    public function setTokenExpires($tokenExpires)
611
    {
612
        $this->tokenExpires = $tokenExpires;
613
    }
614
615
    private function tokenHasExpired()
616
    {
617
        if (empty($this->tokenExpires)) {
618
            return true;
619
        }
620
621
        return ($this->tokenExpires - 60) < time();
622
    }
623
624
    private function formatUrl($endPoint, $includeDivision = true, $formatNextUrl = false)
625
    {
626
        if ($formatNextUrl) {
627
            return $endPoint;
628
        }
629
630
        if ($includeDivision) {
631
            return implode('/', [
632
                $this->getApiUrl(),
633
                $this->getCurrentDivisionNumber(),
634
                $endPoint,
635
            ]);
636
        }
637
638
        return implode('/', [
639
            $this->getApiUrl(),
640
            $endPoint,
641
        ]);
642
    }
643
644
    /**
645
     * @return mixed
646
     */
647
    public function getDivision()
648
    {
649
        return $this->division;
650
    }
651
652
    /**
653
     * @param mixed $division
654
     */
655
    public function setDivision($division)
656
    {
657
        $this->division = $division;
658
    }
659
660
    /**
661
     * @param callable $callback
662
     */
663
    public function setAcquireAccessTokenLockCallback($callback)
664
    {
665
        $this->acquireAccessTokenLockCallback = $callback;
666
    }
667
668
    /**
669
     * @param callable $callback
670
     */
671
    public function setAcquireAccessTokenUnlockCallback($callback)
672
    {
673
        $this->acquireAccessTokenUnlockCallback = $callback;
674
    }
675
676
    /**
677
     * @param callable $callback
678
     */
679
    public function setTokenUpdateCallback($callback)
680
    {
681
        $this->tokenUpdateCallback = $callback;
682
    }
683
684
    /**
685
     * Parse the reponse in the Exception to return the Exact error messages.
686
     *
687
     * @param Exception $e
688
     *
689
     * @throws ApiException
690
     */
691
    private function parseExceptionForErrorMessages(Exception $e)
692
    {
693
        if (! $e instanceof BadResponseException) {
694
            throw new ApiException($e->getMessage(), 0, $e);
695
        }
696
697
        $response = $e->getResponse();
698
699
        $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

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

701
        /** @scrutinizer ignore-call */ 
702
        Psr7\rewind_body($response);
Loading history...
702
        $responseBody = $response->getBody()->getContents();
703
        $decodedResponseBody = json_decode($responseBody, true);
704
705
        if (! is_null($decodedResponseBody) && isset($decodedResponseBody['error']['message']['value'])) {
706
            $errorMessage = $decodedResponseBody['error']['message']['value'];
707
        } else {
708
            $errorMessage = $responseBody;
709
        }
710
711
        throw new ApiException('Error ' . $response->getStatusCode() . ': ' . $errorMessage, $response->getStatusCode(), $e);
712
    }
713
714
    /**
715
     * @return int|null The maximum number of API calls that your app is permitted to make per company, per day
716
     */
717
    public function getDailyLimit()
718
    {
719
        return $this->dailyLimit;
720
    }
721
722
    /**
723
     * @return int|null The remaining number of API calls that your app is permitted to make for a company, per day
724
     */
725
    public function getDailyLimitRemaining()
726
    {
727
        return $this->dailyLimitRemaining;
728
    }
729
730
    /**
731
     * @return int|null The time at which the rate limit window resets in UTC epoch milliseconds
732
     */
733
    public function getDailyLimitReset()
734
    {
735
        return $this->dailyLimitReset;
736
    }
737
738
    /**
739
     * @return int|null The maximum number of API calls that your app is permitted to make per company, per minute
740
     */
741
    public function getMinutelyLimit()
742
    {
743
        return $this->minutelyLimit;
744
    }
745
746
    /**
747
     * @return int|null The remaining number of API calls that your app is permitted to make for a company, per minute
748
     */
749
    public function getMinutelyLimitRemaining()
750
    {
751
        return $this->minutelyLimitRemaining;
752
    }
753
754
    /**
755
     * @return string
756
     */
757
    protected function getBaseUrl()
758
    {
759
        return $this->baseUrl;
760
    }
761
762
    /**
763
     * @return string
764
     */
765
    private function getApiUrl()
766
    {
767
        return $this->baseUrl . $this->apiUrl;
768
    }
769
770
    /**
771
     * @return string
772
     */
773
    private function getTokenUrl()
774
    {
775
        return $this->baseUrl . $this->tokenUrl;
776
    }
777
778
    /**
779
     * Set base URL for different countries according to
780
     * https://developers.exactonline.com/#Exact%20Online%20sites.html.
781
     *
782
     * @param string $baseUrl
783
     */
784
    public function setBaseUrl($baseUrl)
785
    {
786
        $this->baseUrl = $baseUrl;
787
    }
788
789
    /**
790
     * @param string $apiUrl
791
     */
792
    public function setApiUrl($apiUrl)
793
    {
794
        $this->apiUrl = $apiUrl;
795
    }
796
797
    /**
798
     * @param string $authUrl
799
     */
800
    public function setAuthUrl($authUrl)
801
    {
802
        $this->authUrl = $authUrl;
803
    }
804
805
    /**
806
     * @param string $tokenUrl
807
     */
808
    public function setTokenUrl($tokenUrl)
809
    {
810
        $this->tokenUrl = $tokenUrl;
811
    }
812
813
    private function extractRateLimits(Response $response)
814
    {
815
        $this->dailyLimit = (int) $response->getHeaderLine('X-RateLimit-Limit');
816
        $this->dailyLimitRemaining = (int) $response->getHeaderLine('X-RateLimit-Remaining');
817
        $this->dailyLimitReset = (int) $response->getHeaderLine('X-RateLimit-Reset');
818
819
        $this->minutelyLimit = (int) $response->getHeaderLine('X-RateLimit-Minutely-Limit');
820
        $this->minutelyLimitRemaining = (int) $response->getHeaderLine('X-RateLimit-Minutely-Remaining');
821
    }
822
}
823