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

Connection::formatUrl()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 1
Metric Value
eloc 10
c 2
b 0
f 1
dl 0
loc 17
rs 9.9332
cc 3
nc 3
nop 3
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)){
190
			$this->acquireAccessToken();
191
		}else if($this->tokenHasExpired()){
192
			$this->acquireAccessToken_by_refreshtoken();
193
		}
194
		
195
        $client = $this->client();
196
197
        return $client;
198
    }
199
	
200
	// Acquire AccessToken by RefreshToken
201
	private function acquireAccessToken_by_refreshtoken() {
202
		$headers = array("Content-Type: application/x-www-form-urlencoded", "Cache-Control: no-cache");	
203
		$params = array(
204
			'refresh_token' => $this->refreshToken,
205
			'grant_type' => 'refresh_token',
206
			'client_id'     => $this->exactClientId,
207
            'client_secret' => $this->exactClientSecret,
208
		);
209
210
		$curl = curl_init();                  
211
		$url = $this->getTokenUrl();
212
		curl_setopt($curl, CURLOPT_URL,$url);
0 ignored issues
show
Bug introduced by
It seems like $curl can also be of type false; however, parameter $ch of curl_setopt() does only seem to accept resource, 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

212
		curl_setopt(/** @scrutinizer ignore-type */ $curl, CURLOPT_URL,$url);
Loading history...
213
		curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);				
214
		curl_setopt($curl, CURLOPT_POST, true); 
215
		curl_setopt($curl, CURLOPT_POSTFIELDS,http_build_query($params));
216
		curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
217
		$output = curl_exec ($curl);
0 ignored issues
show
Bug introduced by
It seems like $curl can also be of type false; however, parameter $ch of curl_exec() does only seem to accept resource, 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

217
		$output = curl_exec (/** @scrutinizer ignore-type */ $curl);
Loading history...
218
219
		$res = json_decode($output, true);
220
		if(!$res['error'] && $res['refresh_token']){
221
			$this->accessToken = $res['access_token'];
222
			$this->refreshToken = $res['refresh_token'];
223
			$this->tokenExpires = $this->getTimestampFromExpiresIn($res['expires_in']);
224
225
			if (is_callable($this->tokenUpdateCallback)) {
226
				call_user_func($this->tokenUpdateCallback, $this);
227
			}
228
		} else {
229
			throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $res["error"] .', '. $res["error_description"]);
230
		}
231
	}
232
233
    /**
234
     * @param string $method
235
     * @param string $endpoint
236
     * @param mixed  $body
237
     * @param array  $params
238
     * @param array  $headers
239
     *
240
     * @return Request
241
     */
242
    private function createRequest($method, $endpoint, $body = null, array $params = [], array $headers = [])
243
    {
244
        // Add default json headers to the request
245
        $headers = array_merge($headers, [
246
            'Accept'       => 'application/json',
247
            'Content-Type' => 'application/json',
248
            'Prefer'       => 'return=representation',
249
        ]);
250
251
        // If access token is not set or token has expired, acquire new token
252
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
253
            $this->acquireAccessToken();
254
        }
255
256
        // If we have a token, sign the request
257
        if (! $this->needsAuthentication() && ! empty($this->accessToken)) {
258
            $headers['Authorization'] = 'Bearer ' . $this->accessToken;
259
        }
260
261
        // Create param string
262
        if (! empty($params)) {
263
            $endpoint .= '?' . http_build_query($params);
264
        }
265
266
        // Create the request
267
        $request = new Request($method, $endpoint, $headers, $body);
268
269
        return $request;
270
    }
271
272
    /**
273
     * @param string $url
274
     * @param array  $params
275
     * @param array  $headers
276
     *
277
     * @throws ApiException
278
     *
279
     * @return mixed
280
     */
281
    public function get($url, array $params = [], array $headers = [])
282
    {
283
        $url = $this->formatUrl($url, $url !== 'current/Me', $url == $this->nextUrl);
284
285
        try {
286
            $request = $this->createRequest('GET', $url, null, $params, $headers);
287
            $response = $this->client()->send($request);
288
289
            return $this->parseResponse($response, $url != $this->nextUrl);
290
        } catch (Exception $e) {
291
            $this->parseExceptionForErrorMessages($e);
292
        }
293
    }
294
295
    /**
296
     * @param string $url
297
     * @param mixed  $body
298
     *
299
     * @throws ApiException
300
     *
301
     * @return mixed
302
     */
303
    public function post($url, $body)
304
    {
305
        $url = $this->formatUrl($url);
306
307
        try {
308
            $request = $this->createRequest('POST', $url, $body);
309
            $response = $this->client()->send($request);
310
311
            return $this->parseResponse($response);
312
        } catch (Exception $e) {
313
            $this->parseExceptionForErrorMessages($e);
314
        }
315
    }
316
317
    /**
318
     * @param string $url
319
     * @param mixed  $body
320
     *
321
     * @throws ApiException
322
     *
323
     * @return mixed
324
     */
325
    public function put($url, $body)
326
    {
327
        $url = $this->formatUrl($url);
328
329
        try {
330
            $request = $this->createRequest('PUT', $url, $body);
331
            $response = $this->client()->send($request);
332
333
            return $this->parseResponse($response);
334
        } catch (Exception $e) {
335
            $this->parseExceptionForErrorMessages($e);
336
        }
337
    }
338
339
    /**
340
     * @param string $url
341
     *
342
     * @throws ApiException
343
     *
344
     * @return mixed
345
     */
346
    public function delete($url)
347
    {
348
        $url = $this->formatUrl($url);
349
350
        try {
351
            $request = $this->createRequest('DELETE', $url);
352
            $response = $this->client()->send($request);
353
354
            return $this->parseResponse($response);
355
        } catch (Exception $e) {
356
            $this->parseExceptionForErrorMessages($e);
357
        }
358
    }
359
360
    /**
361
     * @return string
362
     */
363
    public function getAuthUrl()
364
    {
365
        return $this->baseUrl . $this->authUrl . '?' . http_build_query([
366
            'client_id'     => $this->exactClientId,
367
            'redirect_uri'  => $this->redirectUrl,
368
            'response_type' => 'code',
369
        ]);
370
    }
371
372
    /**
373
     * @param mixed $exactClientId
374
     */
375
    public function setExactClientId($exactClientId)
376
    {
377
        $this->exactClientId = $exactClientId;
378
    }
379
380
    /**
381
     * @param mixed $exactClientSecret
382
     */
383
    public function setExactClientSecret($exactClientSecret)
384
    {
385
        $this->exactClientSecret = $exactClientSecret;
386
    }
387
388
    /**
389
     * @param mixed $authorizationCode
390
     */
391
    public function setAuthorizationCode($authorizationCode)
392
    {
393
        $this->authorizationCode = $authorizationCode;
394
    }
395
396
    /**
397
     * @param mixed $accessToken
398
     */
399
    public function setAccessToken($accessToken)
400
    {
401
        $this->accessToken = $accessToken;
402
    }
403
404
    /**
405
     * @param mixed $refreshToken
406
     */
407
    public function setRefreshToken($refreshToken)
408
    {
409
        $this->refreshToken = $refreshToken;
410
    }
411
412
    public function redirectForAuthorization()
413
    {
414
        $authUrl = $this->getAuthUrl();
415
        header('Location: ' . $authUrl);
416
        exit;
417
    }
418
419
    /**
420
     * @param mixed $redirectUrl
421
     */
422
    public function setRedirectUrl($redirectUrl)
423
    {
424
        $this->redirectUrl = $redirectUrl;
425
    }
426
427
    /**
428
     * @return bool
429
     */
430
    public function needsAuthentication()
431
    {
432
        return empty($this->refreshToken) && empty($this->authorizationCode);
433
    }
434
435
    /**
436
     * @param Response $response
437
     * @param bool     $returnSingleIfPossible
438
     *
439
     * @throws ApiException
440
     *
441
     * @return mixed
442
     */
443
    private function parseResponse(Response $response, $returnSingleIfPossible = true)
444
    {
445
        try {
446
            if ($response->getStatusCode() === 204) {
447
                return [];
448
            }
449
450
            $this->extractRateLimits($response);
451
452
            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

452
            /** @scrutinizer ignore-call */ 
453
            Psr7\rewind_body($response);
Loading history...
453
            $json = json_decode($response->getBody()->getContents(), true);
454
            if (false === is_array($json)) {
455
                throw new ApiException('Json decode failed. Got response: ' . $response->getBody()->getContents());
456
            }
457
            if (array_key_exists('d', $json)) {
458
                if (array_key_exists('__next', $json['d'])) {
459
                    $this->nextUrl = $json['d']['__next'];
460
                } else {
461
                    $this->nextUrl = null;
462
                }
463
464
                if (array_key_exists('results', $json['d'])) {
465
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
466
                        return $json['d']['results'][0];
467
                    }
468
469
                    return $json['d']['results'];
470
                }
471
472
                return $json['d'];
473
            }
474
475
            return $json;
476
        } catch (\RuntimeException $e) {
477
            throw new ApiException($e->getMessage());
478
        }
479
    }
480
481
    /**
482
     * @return mixed
483
     */
484
    private function getCurrentDivisionNumber()
485
    {
486
        if (empty($this->division)) {
487
            $me = new Me($this);
488
            $this->division = $me->find()->CurrentDivision;
489
        }
490
491
        return $this->division;
492
    }
493
494
    /**
495
     * @return mixed
496
     */
497
    public function getRefreshToken()
498
    {
499
        return $this->refreshToken;
500
    }
501
502
    /**
503
     * @return mixed
504
     */
505
    public function getAccessToken()
506
    {
507
        return $this->accessToken;
508
    }
509
510
    private function acquireAccessToken()
511
    {
512
        try {
513
            if (is_callable($this->acquireAccessTokenLockCallback)) {
514
                call_user_func($this->acquireAccessTokenLockCallback, $this);
515
            }
516
517
            // If refresh token not yet acquired, do token request
518
            if (empty($this->refreshToken)) {
519
                $body = [
520
                    'form_params' => [
521
                        'redirect_uri'  => $this->redirectUrl,
522
                        'grant_type'    => 'authorization_code',
523
                        'client_id'     => $this->exactClientId,
524
                        'client_secret' => $this->exactClientSecret,
525
                        'code'          => $this->authorizationCode,
526
                    ],
527
                ];
528
            } else { // else do refresh token request
529
                $body = [
530
                    'form_params' => [
531
                        'refresh_token' => $this->refreshToken,
532
                        'grant_type'    => 'refresh_token',
533
                        'client_id'     => $this->exactClientId,
534
                        'client_secret' => $this->exactClientSecret,
535
                    ],
536
                ];
537
            }
538
539
            $response = $this->client()->post($this->getTokenUrl(), $body);
540
541
            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

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

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

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