Completed
Push — master ( 24a9f7...db12ca )
by Stephan
02:10 queued 17s
created

Connection::setAcquireAccessTokenLockCallback()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 2
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
namespace Picqer\Financials\Exact;
4
5
use Exception;
6
use GuzzleHttp\Client;
7
use GuzzleHttp\Exception\BadResponseException;
8
use GuzzleHttp\HandlerStack;
9
use GuzzleHttp\Psr7\Request;
10
use GuzzleHttp\Psr7\Response;
11
use GuzzleHttp\Psr7;
12
13
/**
14
 * Class Connection
15
 *
16
 * @package Picqer\Financials\Exact
17
 *
18
 */
19
class Connection
20
{
21
    /**
22
     * @var string
23
     */
24
    private $baseUrl = 'https://start.exactonline.nl';
25
26
    /**
27
     * @var string
28
     */
29
    private $apiUrl = '/api/v1';
30
31
    /**
32
     * @var string
33
     */
34
    private $authUrl = '/api/oauth2/auth';
35
36
    /**
37
     * @var string
38
     */
39
    private $tokenUrl = '/api/oauth2/token';
40
41
    /**
42
     * @var mixed
43
     */
44
    private $exactClientId;
45
46
    /**
47
     * @var mixed
48
     */
49
    private $exactClientSecret;
50
51
    /**
52
     * @var mixed
53
     */
54
    private $authorizationCode;
55
56
    /**
57
     * @var mixed
58
     */
59
    private $accessToken;
60
61
    /**
62
     * @var int The Unix timestamp at which the access token expires.
63
     */
64
    private $tokenExpires;
65
66
    /**
67
     * @var mixed
68
     */
69
    private $refreshToken;
70
71
    /**
72
     * @var mixed
73
     */
74
    private $redirectUrl;
75
76
    /**
77
     * @var mixed
78
     */
79
    private $division;
80
81
    /**
82
     * @var Client|null
83
     */
84
    private $client;
85
86
    /**
87
     * @var callable(Connection)
88
     */
89
    private $tokenUpdateCallback;
90
91
    /**
92
     * @var callable(Connection)
93
     */
94
    private $acquireAccessTokenLockCallback;
95
96
    /**
97
     * @var callable(Connection)
98
     */
99
    private $acquireAccessTokenUnlockCallback;
100
101
    /**
102
     * @var callable[]
103
     */
104
    protected $middleWares = [];
105
106
    /**
107
    * @var string|null
108
    */
109
    public $nextUrl = null;
110
111
    /**
112
     * @return Client
113
     */
114
    private function client()
115
    {
116
        if ($this->client) {
117
            return $this->client;
118
        }
119
120
        $handlerStack = HandlerStack::create();
121
        foreach ($this->middleWares as $middleWare) {
122
            $handlerStack->push($middleWare);
123
        }
124
125
        $this->client = new Client([
126
            'http_errors' => true,
127
            'handler' => $handlerStack,
128
            'expect' => false,
129
        ]);
130
131
        return $this->client;
132
    }
133
134
    public function insertMiddleWare($middleWare)
135
    {
136
        $this->middleWares[] = $middleWare;
137
    }
138
139
    public function connect()
140
    {
141
        // Redirect for authorization if needed (no access token or refresh token given)
142
        if ($this->needsAuthentication()) {
143
            $this->redirectForAuthorization();
144
        }
145
146
        // If access token is not set or token has expired, acquire new token
147
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
148
            $this->acquireAccessToken();
149
        }
150
151
        $client = $this->client();
152
153
        return $client;
154
    }
155
156
    /**
157
     * @param string $method
158
     * @param string $endpoint
159
     * @param mixed $body
160
     * @param array $params
161
     * @param array $headers
162
     * @return Request
163
     */
164
    private function createRequest($method = 'GET', $endpoint, $body = null, array $params = [], array $headers = [])
165
    {
166
        // Add default json headers to the request
167
        $headers = array_merge($headers, [
168
            'Accept' => 'application/json',
169
            'Content-Type' => 'application/json',
170
            'Prefer' => 'return=representation'
171
        ]);
172
173
        // If access token is not set or token has expired, acquire new token
174
        if (empty($this->accessToken) || $this->tokenHasExpired()) {
175
            $this->acquireAccessToken();
176
        }
177
178
        // If we have a token, sign the request
179
        if (!$this->needsAuthentication() && !empty($this->accessToken)) {
180
            $headers['Authorization'] = 'Bearer ' . $this->accessToken;
181
        }
182
183
        // Create param string
184
        if (!empty($params)) {
185
            $endpoint .= '?' . http_build_query($params);
186
        }
187
188
        // Create the request
189
        $request = new Request($method, $endpoint, $headers, $body);
190
191
        return $request;
192
    }
193
194
    /**
195
     * @param string $url
196
     * @param array $params
197
     * @param array $headers
198
     * @return mixed
199
     * @throws ApiException
200
     */
201
    public function get($url, array $params = [], array $headers = [])
202
    {
203
        $url = $this->formatUrl($url, $url !== 'current/Me', $url == $this->nextUrl);
204
205
        try {
206
            $request = $this->createRequest('GET', $url, null, $params, $headers);
207
            $response = $this->client()->send($request);
208
209
            return $this->parseResponse($response, $url != $this->nextUrl);
210
        } catch (Exception $e) {
211
            $this->parseExceptionForErrorMessages($e);
212
        }
213
        
214
        return null;
215
    }
216
217
    /**
218
     * @param string $url
219
     * @param mixed $body
220
     * @return mixed
221
     * @throws ApiException
222
     */
223
    public function post($url, $body)
224
    {
225
        $url = $this->formatUrl($url);
226
227
        try {
228
            $request  = $this->createRequest('POST', $url, $body);
229
            $response = $this->client()->send($request);
230
231
            return $this->parseResponse($response);
232
        } catch (Exception $e) {
233
            $this->parseExceptionForErrorMessages($e);
234
        }
235
236
        return null;
237
    }
238
239
    /**
240
     * @param string $url
241
     * @param mixed $body
242
     * @return mixed
243
     * @throws ApiException
244
     */
245
    public function put($url, $body)
246
    {
247
        $url = $this->formatUrl($url);
248
249
        try {
250
            $request  = $this->createRequest('PUT', $url, $body);
251
            $response = $this->client()->send($request);
252
253
            return $this->parseResponse($response);
254
        } catch (Exception $e) {
255
            $this->parseExceptionForErrorMessages($e);
256
        }
257
258
        return null;
259
    }
260
261
    /**
262
     * @param string $url
263
     * @return mixed
264
     * @throws ApiException
265
     */
266
    public function delete($url)
267
    {
268
        $url = $this->formatUrl($url);
269
270
        try {
271
            $request  = $this->createRequest('DELETE', $url);
272
            $response = $this->client()->send($request);
273
274
            return $this->parseResponse($response);
275
        } catch (Exception $e) {
276
            $this->parseExceptionForErrorMessages($e);
277
        }
278
279
        return null;
280
    }
281
282
    /**
283
     * @return string
284
     */
285
    public function getAuthUrl()
286
    {
287
        return $this->baseUrl . $this->authUrl . '?' . http_build_query(array(
288
            'client_id' => $this->exactClientId,
289
            'redirect_uri' => $this->redirectUrl,
290
            'response_type' => 'code'
291
        ));
292
    }
293
294
    /**
295
     * @param mixed $exactClientId
296
     */
297
    public function setExactClientId($exactClientId)
298
    {
299
        $this->exactClientId = $exactClientId;
300
    }
301
302
    /**
303
     * @param mixed $exactClientSecret
304
     */
305
    public function setExactClientSecret($exactClientSecret)
306
    {
307
        $this->exactClientSecret = $exactClientSecret;
308
    }
309
310
    /**
311
     * @param mixed $authorizationCode
312
     */
313
    public function setAuthorizationCode($authorizationCode)
314
    {
315
        $this->authorizationCode = $authorizationCode;
316
    }
317
318
    /**
319
     * @param mixed $accessToken
320
     */
321
    public function setAccessToken($accessToken)
322
    {
323
        $this->accessToken = $accessToken;
324
    }
325
326
    /**
327
     * @param mixed $refreshToken
328
     */
329
    public function setRefreshToken($refreshToken)
330
    {
331
        $this->refreshToken = $refreshToken;
332
    }
333
334
    /**
335
     *
336
     */
337
    public function redirectForAuthorization()
338
    {
339
        $authUrl = $this->getAuthUrl();
340
        header('Location: ' . $authUrl);
341
        exit;
342
    }
343
344
    /**
345
     * @param mixed $redirectUrl
346
     */
347
    public function setRedirectUrl($redirectUrl)
348
    {
349
        $this->redirectUrl = $redirectUrl;
350
    }
351
352
    /**
353
     * @return bool
354
     */
355
    public function needsAuthentication()
356
    {
357
        return empty($this->refreshToken) && empty($this->authorizationCode);
358
    }
359
360
    /**
361
     * @param Response $response
362
     * @param bool $returnSingleIfPossible
363
     * @return mixed
364
     * @throws ApiException
365
     */
366
    private function parseResponse(Response $response, $returnSingleIfPossible = true)
367
    {
368
        try {
369
370
            if ($response->getStatusCode() === 204) {
371
                return [];
372
            }
373
374
            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

374
            /** @scrutinizer ignore-call */ 
375
            Psr7\rewind_body($response);
Loading history...
375
            $json = json_decode($response->getBody()->getContents(), true);
376
            if (array_key_exists('d', $json)) {
377
                if (array_key_exists('__next', $json['d'])) {
378
                    $this->nextUrl = $json['d']['__next'];
379
                }
380
                else {
381
                    $this->nextUrl = null;
382
                }
383
384
                if (array_key_exists('results', $json['d'])) {
385
                    if ($returnSingleIfPossible && count($json['d']['results']) == 1) {
386
                        return $json['d']['results'][0];
387
                    }
388
389
                    return $json['d']['results'];
390
                }
391
392
                return $json['d'];
393
            }
394
395
            return $json;
396
        } catch (\RuntimeException $e) {
397
            throw new ApiException($e->getMessage());
398
        }
399
    }
400
401
    /**
402
     * @return mixed
403
     */
404
    private function getCurrentDivisionNumber()
405
    {
406
        if (empty($this->division)) {
407
            $me             = new Me($this);
408
            $this->division = $me->find()->CurrentDivision;
409
        }
410
411
        return $this->division;
412
    }
413
414
    /**
415
     * @return mixed
416
     */
417
    public function getRefreshToken()
418
    {
419
        return $this->refreshToken;
420
    }
421
422
    /**
423
     * @return mixed
424
     */
425
    public function getAccessToken()
426
    {
427
        return $this->accessToken;
428
    }
429
430
    private function acquireAccessToken()
431
    {
432
        // If refresh token not yet acquired, do token request
433
        if (empty($this->refreshToken)) {
434
            $body = [
435
                'form_params' => [
436
                    'redirect_uri' => $this->redirectUrl,
437
                    'grant_type' => 'authorization_code',
438
                    'client_id' => $this->exactClientId,
439
                    'client_secret' => $this->exactClientSecret,
440
                    'code' => $this->authorizationCode
441
                ]
442
            ];
443
        } else { // else do refresh token request
444
            $body = [
445
                'form_params' => [
446
                    'refresh_token' => $this->refreshToken,
447
                    'grant_type' => 'refresh_token',
448
                    'client_id' => $this->exactClientId,
449
                    'client_secret' => $this->exactClientSecret,
450
                ]
451
            ];
452
        }
453
454
455
        try {
456
            if (is_callable($this->acquireAccessTokenLockCallback)) {
457
                call_user_func($this->acquireAccessTokenLockCallback, $this);
458
            }
459
460
            $response = $this->client()->post($this->getTokenUrl(), $body);
461
462
            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

462
            /** @scrutinizer ignore-call */ 
463
            Psr7\rewind_body($response);
Loading history...
463
            $body = json_decode($response->getBody()->getContents(), true);
464
465
            if (json_last_error() === JSON_ERROR_NONE) {
466
                $this->accessToken  = $body['access_token'];
467
                $this->refreshToken = $body['refresh_token'];
468
                $this->tokenExpires = $this->getTimestampFromExpiresIn($body['expires_in']);
469
470
                if (is_callable($this->tokenUpdateCallback)) {
471
                    call_user_func($this->tokenUpdateCallback, $this);
472
                }
473
            } else {
474
                throw new ApiException('Could not acquire tokens, json decode failed. Got response: ' . $response->getBody()->getContents());
475
            }
476
        } catch (BadResponseException $ex) {
477
            throw new ApiException('Could not acquire or refresh tokens [http ' . $ex->getResponse()->getStatusCode() . ']', 0, $ex);
478
        } finally {
479
            if (is_callable($this->acquireAccessTokenUnlockCallback)) {
480
                call_user_func($this->acquireAccessTokenUnlockCallback, $this);
481
            }
482
        }
483
    }
484
485
    /**
486
     * Translates expires_in to a Unix timestamp.
487
     * @param string $expiresIn Number of seconds until the token expires.
488
     * @return int
489
     */
490
    private function getTimestampFromExpiresIn($expiresIn)
491
    {
492
        if (!ctype_digit($expiresIn)) {
493
            throw new \InvalidArgumentException('Function requires a numeric expires value');
494
        }
495
496
        return time() + $expiresIn;
497
    }
498
499
    /**
500
     * @return int The Unix timestamp at which the access token expires.
501
     */
502
    public function getTokenExpires()
503
    {
504
        return $this->tokenExpires;
505
    }
506
507
    /**
508
     * @param int $tokenExpires The Unix timestamp at which the access token expires.
509
     */
510
    public function setTokenExpires($tokenExpires)
511
    {
512
        $this->tokenExpires = $tokenExpires;
513
    }
514
515
    private function tokenHasExpired()
516
    {
517
        if (empty($this->tokenExpires)) {
518
            return true;
519
        }
520
521
        return $this->tokenExpires <= time() + 10;
522
    }
523
524
    private function formatUrl($endPoint, $includeDivision = true, $formatNextUrl = false)
525
    {
526
        if ($formatNextUrl) {
527
            return $endPoint;
528
        }
529
530
        if ($includeDivision) {
531
            return implode('/', [
532
                $this->getApiUrl(),
533
                $this->getCurrentDivisionNumber(),
534
                $endPoint
535
            ]);
536
        }
537
538
        return implode('/', [
539
            $this->getApiUrl(),
540
            $endPoint
541
        ]);
542
    }
543
544
545
    /**
546
     * @return mixed
547
     */
548
    public function getDivision()
549
    {
550
        return $this->division;
551
    }
552
553
554
    /**
555
     * @param mixed $division
556
     */
557
    public function setDivision($division)
558
    {
559
        $this->division = $division;
560
    }
561
562
    /**
563
     * @param callable $callback
564
     */
565
    public function setAcquireAccessTokenLockCallback($callback) {
566
        $this->acquireAccessTokenLockCallback = $callback;
567
    }
568
569
    /**
570
     * @param callable $callback
571
     */
572
    public function setAcquireAccessTokenUnlockCallback($callback) {
573
        $this->acquireAccessTokenUnlockCallback = $callback;
574
    }
575
576
    /**
577
     * @param callable $callback
578
     */
579
    public function setTokenUpdateCallback($callback) {
580
        $this->tokenUpdateCallback = $callback;
581
    }
582
583
    /**
584
     * Parse the reponse in the Exception to return the Exact error messages
585
     * @param Exception $e
586
     * @throws ApiException
587
     */
588
    private function parseExceptionForErrorMessages(Exception $e)
589
    {
590
        if (! $e instanceof BadResponseException) {
591
            throw new ApiException($e->getMessage());
592
        }
593
594
        $response = $e->getResponse();
595
        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

595
        /** @scrutinizer ignore-call */ 
596
        Psr7\rewind_body($response);
Loading history...
596
        $responseBody = $response->getBody()->getContents();
597
        $decodedResponseBody = json_decode($responseBody, true);
598
599
        if (! is_null($decodedResponseBody) && isset($decodedResponseBody['error']['message']['value'])) {
600
            $errorMessage = $decodedResponseBody['error']['message']['value'];
601
        } else {
602
            $errorMessage = $responseBody;
603
        }
604
605
        throw new ApiException('Error ' . $response->getStatusCode() .': ' . $errorMessage);
606
    }
607
608
    /**
609
     * @return string
610
     */
611
    protected function getBaseUrl()
612
    {
613
        return $this->baseUrl;
614
    }
615
616
    /**
617
     * @return string
618
     */
619
    private function getApiUrl()
620
    {
621
        return $this->baseUrl . $this->apiUrl;
622
    }
623
624
    /**
625
     * @return string
626
     */
627
    private function getTokenUrl()
628
    {
629
        return $this->baseUrl . $this->tokenUrl;
630
    }
631
632
    /**
633
     * Set base URL for different countries according to
634
     * https://developers.exactonline.com/#Exact%20Online%20sites.html
635
     *
636
     * @param string $baseUrl
637
     */
638
    public function setBaseUrl($baseUrl)
639
    {
640
        $this->baseUrl = $baseUrl;
641
    }
642
643
    /**
644
     * @param string $apiUrl
645
     */
646
    public function setApiUrl($apiUrl)
647
    {
648
        $this->apiUrl = $apiUrl;
649
    }
650
651
    /**
652
     * @param string $authUrl
653
     */
654
    public function setAuthUrl($authUrl)
655
    {
656
        $this->authUrl = $authUrl;
657
    }
658
659
    /**
660
     * @param string $tokenUrl
661
     */
662
    public function setTokenUrl($tokenUrl)
663
    {
664
        $this->tokenUrl = $tokenUrl;
665
    }
666
}
667