Passed
Push — master ( ef4891...235f7d )
by Stefan
01:23
created

GClient   F

Complexity

Total Complexity 60

Size/Duplication

Total Lines 567
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 209
dl 0
loc 567
rs 3.6
c 0
b 0
f 0
wmc 60

31 Methods

Rating   Name   Duplication   Size   Complexity  
A getRedirectURI() 0 3 1
A setClientSecret() 0 3 1
A isAccessTokenExpired() 0 7 3
A parseHttpHeader() 0 23 3
A checkProperty() 0 4 2
A getLastStatus() 0 3 1
A fetchTokens() 0 45 2
A refreshAccessToken() 0 42 2
A getAccessToken() 0 3 1
A getLastError() 0 3 1
B setOAuthClient() 0 20 7
A setProjectID() 0 3 1
A setClientID() 0 3 1
A getClientID() 0 3 1
A setLogger() 0 3 1
A addScope() 0 6 3
A setTokenURI() 0 3 1
A getLastResponseCode() 0 3 1
A setRedirectURI() 0 3 1
A getRefreshToken() 0 3 1
A getClientSecret() 0 3 1
A buildAuthURL() 0 22 3
A getTokenURI() 0 3 1
A setAuthURI() 0 3 1
A __construct() 0 6 1
A setAccessToken() 0 10 3
A getProjectID() 0 3 1
A getAuthHeader() 0 3 1
A setError() 0 6 1
A getAuthURI() 0 3 1
B fetchJsonResponse() 0 52 11

How to fix   Complexity   

Complex Class

Complex classes like GClient often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use GClient, and based on these observations, apply Extract Interface, too.

1
<?php
2
declare(strict_types=1);
3
4
namespace SKien\Google;
5
6
use Psr\Log\LoggerInterface;
7
use Psr\Log\NullLogger;
8
9
/**
10
 * Class to connect to the google API using OAuth2 authentication.
11
 *
12
 * This class only usese cURL.
13
 *
14
 * Best practice is to use the OAuth client JSON configuration file,
15
 * which can be downloaded from Google Cloud Console, to set all project
16
 * and customer specific information (IDs, secrets, URIs).
17
 *
18
 * Create a client configuration at https://console.cloud.google.com
19
 *
20
 * @link https://console.cloud.google.com
21
 *
22
 * @author Stefanius <[email protected]>
23
 * @copyright MIT License - see the LICENSE file for details
24
 */
25
class GClient
26
{
27
    /** GET request */
28
    public const GET = 0;
29
    /** POST request */
30
    public const POST = 1;
31
    /** PUT request */
32
    public const PUT = 2;
33
    /** PATCH request */
34
    public const PATCH = 3;
35
    /** DELETE request */
36
    public const DELETE = 4;
37
38
    /** Default endpoint for the google OAuth2 authentication   */
39
    protected const DEF_AUTH_URI = 'https://accounts.google.com/o/oauth2/auth';
40
    /** Default endpoint to request access/refresh tokens   */
41
    protected const DEF_TOKEN_URI = 'https://oauth2.googleapis.com/token';
42
43
    /** @var string client-ID of the google cloud project     */
44
    protected string $strProjectID = '';
45
    /** @var string client-ID within the google cloud project     */
46
    protected string $strClientID = '';
47
    /** @var string Endpoint for the google OAuth2 authentication     */
48
    protected string $strAuthURI = '';
49
    /** @var string Endpoint to request access/refresh tokens     */
50
    protected string $strTokenURI = '';
51
    /** @var string client secret for authentication     */
52
    protected string $strClientSecret = '';
53
    /** @var string redirect URI configured for the client     */
54
    protected string $strRedirectURI = '';
55
    /** @var array<string>  requested scope     */
56
    protected array $aScope = [];
57
    /** @var array<mixed>   received access token     */
58
    protected array $aAccessToken = [];
59
    /** @var string   received refresh token     */
60
    protected string $strRefreshToken = '';
61
62
    /** @var int response code of the latest HTTP request     */
63
    protected int $iLastResponseCode = 0;
64
    /** @var string error description if the latest HTTP request has failed     */
65
    protected string $strLastError = '';
66
    /** @var string status if the latest HTTP request has failed     */
67
    protected string $strLastStatus = '';
68
69
    /** @var LoggerInterface    loger     */
70
    protected LoggerInterface $oLogger;
71
72
    /**
73
     * @param LoggerInterface $oLogger
74
     */
75
    public function __construct(LoggerInterface $oLogger = null)
76
    {
77
        // ensure we have a valid logger instance
78
        $this->oLogger = $oLogger ?? new NullLogger();
79
        $this->strAuthURI = self::DEF_AUTH_URI;
80
        $this->strTokenURI = self::DEF_TOKEN_URI;
81
    }
82
83
    /**
84
     * Set the OAuth2 client configuration from the google API console.
85
     * The method tries to extract
86
     * - $strClientID
87
     * - $strProjectID
88
     * - $strAuthURI
89
     * - $strTokenURI
90
     * - $strClientSecret
91
     * - $strRedirectURI
92
     * from the JSON config file.
93
     * @param string $strClientSecrets    filename
94
     */
95
    public function setOAuthClient(string $strClientSecrets) : void
96
    {
97
        if (file_exists($strClientSecrets)) {
98
            $strOAuthClient = file_get_contents($strClientSecrets);
99
            $aOAuthClient = json_decode($strOAuthClient, true);
100
            if ($aOAuthClient !== null && (isset($aOAuthClient['web']) || isset($aOAuthClient['installed']))) {
101
                $aData = $aOAuthClient['web'] ?? $aOAuthClient['installed'];
102
                $this->strClientID = $aData['client_id'] ?? '';
103
                $this->strProjectID = $aData['project_id'] ?? '';
104
                $this->strAuthURI = $aData['auth_uri'] ?? '';
105
                $this->strTokenURI = $aData['token_uri'] ?? '';
106
                $this->strClientSecret = $aData['client_secret'] ?? '';
107
                if (isset($aData['redirect_uris']) and is_array($aData['redirect_uris'])) {
108
                    $this->strRedirectURI = $aData['redirect_uris'][0];
109
                }
110
            } else {
111
                throw new MissingClientInformationException('No valid client informations from google API console available.');
112
            }
113
        } else {
114
            throw new MissingClientInformationException('Client secrets file [' . $strClientSecrets . '] not found!');
115
        }
116
    }
117
118
    /**
119
     * Build the URL to call the google OAuth2.
120
     * Description for the $strLoginHint param from google docs: <br/>
121
     * > When your application knows which user it is trying to authenticate, it may provide
122
     * > this parameter as a hint to the Authentication Server. Passing this hint will either
123
     * > pre-fill the email box on the sign-in form or select the proper multi-login session,
124
     * > thereby simplifying the login flow.
125
     * @param string $strLoginHint  an **existing** google account to preselect in the login form.
126
     * @throws MissingPropertyException
127
     * @return string
128
     */
129
    public function buildAuthURL(string $strLoginHint = '') : string
130
    {
131
        $this->checkProperty($this->strAuthURI, 'auth_uri');
132
        $this->checkProperty($this->strClientID, 'client_id');
133
        $this->checkProperty($this->strRedirectURI, 'redirect_uri');
134
        if (count($this->aScope) < 1) {
135
            throw new MissingPropertyException('The scope must be specified before call this method!');
136
        }
137
        $aLoginParams = [
138
            'response_type' => 'code',
139
            'access_type' => 'offline',
140
            'redirect_uri' => $this->strRedirectURI,
141
            'client_id' => $this->strClientID,
142
            'scope' => implode(' ', $this->aScope),
143
            'prompt' => 'consent',
144
            'include_granted_scopes' => 'true',
145
        ];
146
        if (!empty($strLoginHint)) {
147
            $aLoginParams['login_hint'] = $strLoginHint;
148
        }
149
        $this->oLogger->info('GClient: succesfully build auth URL');
150
        return $this->strAuthURI . '?' . http_build_query($aLoginParams);
151
    }
152
153
    /**
154
     * Send request to get access and refresh token from a passed auth code.
155
     * @param string $strAuthCode   the code passed from accounts.google.com
156
     * @throws MissingPropertyException
157
     * @return bool
158
     */
159
    public function fetchTokens(string $strAuthCode) : bool
160
    {
161
        $this->checkProperty($strAuthCode, 'auth code');
162
        $this->checkProperty($this->strClientID, 'client_id');
163
        $this->checkProperty($this->strClientSecret, 'client_secret');
164
        $this->checkProperty($this->strTokenURI, 'token_uri');
165
        $this->checkProperty($this->strRedirectURI, 'redirect_uri');
166
167
        $aData = [
168
            'grant_type' => 'authorization_code',
169
            'code' => $strAuthCode,
170
            'redirect_uri' => $this->strRedirectURI,
171
            'client_id' => $this->strClientID,
172
            'client_secret' => $this->strClientSecret,
173
        ];
174
175
        $aHeader = array(
176
            'Host' => $this->strTokenURI,
177
            'Cache-Control' => 'no-store',
178
            'Content-Type' => 'application/x-www-form-urlencoded',
179
        );
180
181
        // since the request only provides an 'expires in' value, we have to keep
182
        // track of the timestamp, we sent the request
183
        $timeRequest = time();
184
185
        $data = http_build_query($aData);
186
        if (($strResponse = $this->fetchJsonResponse($this->strTokenURI, self::POST, $aHeader, $data)) !== false) {
187
            // the body contains the access- and refresh token
188
            $this->aAccessToken = json_decode($strResponse, true);
189
            $this->aAccessToken['created'] = $timeRequest;
190
            $this->strRefreshToken = $this->aAccessToken['refresh_token'] ?? '';
191
            unset($this->aAccessToken['refresh_token']);
192
            $this->oLogger->info('GClient: succesfully fetched access token from auth code');
193
        } else {
194
            $this->aAccessToken = [];
195
            $this->strRefreshToken = '';
196
            $this->oLogger->error(
197
                'GClient: error fetching access token from auth code', [
198
                    'responsecode' => $this->iLastResponseCode,
199
                    'authorization_code' => $strAuthCode,
200
                ]
201
            );
202
        }
203
        return count($this->aAccessToken) > 0;
204
    }
205
206
    /**
207
     * Refresh expired access token.
208
     * @param string $strRefreshToken
209
     * @throws MissingPropertyException
210
     * @return array<mixed> new access token
211
     */
212
    public function refreshAccessToken(string $strRefreshToken) : array
213
    {
214
        $this->checkProperty($strRefreshToken, 'refresh_token');
215
        $this->checkProperty($this->strClientID, 'client_id');
216
        $this->checkProperty($this->strClientSecret, 'client_secret');
217
        $this->checkProperty($this->strTokenURI, 'token_uri');
218
        $this->checkProperty($this->strRedirectURI, 'redirect_uri');
219
220
        $aData = [
221
            'grant_type' => 'refresh_token',
222
            'refresh_token' => $strRefreshToken,
223
            'client_id' => $this->strClientID,
224
            'client_secret' => $this->strClientSecret,
225
        ];
226
227
        $aHeader = array(
228
            'Host' => $this->strTokenURI,
229
            'Cache-Control' => 'no-store',
230
            'Content-Type' => 'application/x-www-form-urlencoded',
231
        );
232
233
        // since the request only provides an 'expires in' value, we have to keep
234
        // track of the timestamp, we sent the request
235
        $timeRequest = time();
236
237
        $data = http_build_query($aData);
238
        if (($strResponse = $this->fetchJsonResponse($this->strTokenURI, self::POST, $aHeader, $data)) !== false) {
239
            // the body contains the access- and refresh token
240
            $this->aAccessToken = json_decode($strResponse, true);
241
            $this->aAccessToken['created'] = $timeRequest;
242
            $this->oLogger->info('GClient: succesfully refreshed access token');
243
        } else {
244
            $this->aAccessToken = [];
245
            $this->strRefreshToken = '';
246
            $this->oLogger->error(
247
                'GClient: error refreshing access token', [
248
                    'responsecode' => $this->iLastResponseCode,
249
                    'refresh_token' => $strRefreshToken,
250
                ]
251
            );
252
        }
253
        return $this->aAccessToken;
254
    }
255
256
    /**
257
     * The current set access token.
258
     * @return array<mixed>
259
     */
260
    public function getAccessToken() : array
261
    {
262
        return $this->aAccessToken;
263
    }
264
265
    /**
266
     * Set a saved access token.
267
     * @param string|array<mixed> $token   accesstoken as string (JSON) or array
268
     */
269
    public function setAccessToken($token) : void
270
    {
271
        if (is_array($token)) {
272
            $this->aAccessToken = $token;
273
        } else {
274
            $aToken = json_decode($token, true);
275
            if (is_array($aToken)) {
276
                $this->aAccessToken = $aToken;
277
            } else {
278
                $this->aAccessToken = [];
279
            }
280
        }
281
    }
282
283
    /**
284
     * Check, if the actual set access token has expired.
285
     * It is recommended to set an offset to give time for the execution of
286
     * the next request.
287
     * @param int $iOffset additional offset until 'real' expiration
288
     * @return bool
289
     */
290
    public function isAccessTokenExpired(int $iOffset = 20) : bool
291
    {
292
        $bExpired = true;
293
        if (isset($this->aAccessToken['expires_in']) && isset($this->aAccessToken['created'])) {
294
            $bExpired = time() > $this->aAccessToken['created'] + $this->aAccessToken['expires_in'] + $iOffset;
295
        }
296
        return $bExpired;
297
    }
298
299
    /**
300
     * Response code of the last API request.
301
     * @return int
302
     */
303
    public function getLastResponseCode() : int
304
    {
305
        return $this->iLastResponseCode;
306
    }
307
308
    /**
309
     * Error text if the last API request has failed.
310
     * @return string
311
     */
312
    public function getLastError() : string
313
    {
314
        return $this->strLastError;
315
    }
316
317
    /**
318
     * Status if the last API request has failed.
319
     * @return string
320
     */
321
    public function getLastStatus() : string
322
    {
323
        return $this->strLastStatus;
324
    }
325
326
    /**
327
     * Get the OAuth HTTP header for API requests.
328
     * @return string
329
     */
330
    public function getAuthHeader() : string
331
    {
332
        return 'Authorization: ' . $this->aAccessToken['token_type'] . ' ' . $this->aAccessToken['access_token'];
333
    }
334
335
    /**
336
     * Getter for the current refresh token.
337
     * @return string
338
     */
339
    public function getRefreshToken() : string
340
    {
341
        return $this->strRefreshToken;
342
    }
343
344
    /**
345
     * Getter for the current client ID.
346
     * @return string
347
     */
348
    public function getClientID() : string
349
    {
350
        return $this->strClientID;
351
    }
352
353
    /**
354
     * Getter for the current project ID.
355
     * @return string
356
     */
357
    public function getProjectID() : string
358
    {
359
        return $this->strProjectID;
360
    }
361
362
    /**
363
     * Getter for the current auth URI.
364
     * @return string
365
     */
366
    public function getAuthURI() : string
367
    {
368
        return $this->strAuthURI;
369
    }
370
371
    /**
372
     * Getter for the current token URI.
373
     * @return string
374
     */
375
    public function getTokenURI() : string
376
    {
377
        return $this->strTokenURI;
378
    }
379
380
    /**
381
     * Getter for the current client secret.
382
     * @return string
383
     */
384
    public function getClientSecret() : string
385
    {
386
        return $this->strClientSecret;
387
    }
388
389
    /**
390
     * Getter for the current redirect URI.
391
     * @return string
392
     */
393
    public function getRedirectURI() : string
394
    {
395
        return $this->strRedirectURI;
396
    }
397
398
    /**
399
     * Set the current client ID.
400
     * @param string $strClientID
401
     */
402
    public function setClientID(string $strClientID) : void
403
    {
404
        $this->strClientID = $strClientID;
405
    }
406
407
    /**
408
     * Set the current project ID.
409
     * @param string $strProjectID
410
     */
411
    public function setProjectID(string $strProjectID) : void
412
    {
413
        $this->strProjectID = $strProjectID;
414
    }
415
416
    /**
417
     * Set the current auth URI.
418
     * @param string $strAuthURI
419
     */
420
    public function setAuthURI(string $strAuthURI) : void
421
    {
422
        $this->strAuthURI = $strAuthURI;
423
    }
424
425
    /**
426
     * Set the current token URI.
427
     * @param string $strTokenURI
428
     */
429
    public function setTokenURI(string $strTokenURI) : void
430
    {
431
        $this->strTokenURI = $strTokenURI;
432
    }
433
434
    /**
435
     * Set the current client secret.
436
     * @param string $strClientSecret
437
     */
438
    public function setClientSecret(string $strClientSecret) : void
439
    {
440
        $this->strClientSecret = $strClientSecret;
441
    }
442
443
    /**
444
     * Set the current redirect URI.
445
     * @param string $strRedirectURI
446
     */
447
    public function setRedirectURI(string $strRedirectURI) : void
448
    {
449
        $this->strRedirectURI = $strRedirectURI;
450
    }
451
452
    /**
453
     * Add scope that is neede for following API requests.
454
     * @param string|array<string> $scope
455
     */
456
    public function addScope($scope) : void
457
    {
458
        if (is_array($scope)) {
459
            $this->aScope = array_merge($this->aScope, $scope);
460
        } else if (!in_array($scope, $this->aScope)) {
461
            $this->aScope[] = $scope;
462
        }
463
    }
464
465
    /**
466
     * Set a logger instance.
467
     * @param \Psr\Log\LoggerInterface $oLogger
468
     */
469
    public function setLogger(LoggerInterface $oLogger) : void
470
    {
471
        $this->oLogger = $oLogger;
472
    }
473
474
    /**
475
     * Cleint request to the API.
476
     * @param string $strURI
477
     * @param int $iMethod
478
     * @param array<string> $aHeader
479
     * @param string $data
480
     * @return string|false
481
     */
482
    public function fetchJsonResponse(string $strURI, int $iMethod, array $aHeader = [], string $data = '')
483
    {
484
        $result = false;
485
486
        $curl = curl_init();
487
488
        curl_setopt($curl, CURLOPT_URL, $strURI);
489
        switch ($iMethod) {
490
            case self::POST:
491
                curl_setopt($curl, CURLOPT_POST, true);
492
                break;
493
            case self::PUT:
494
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PUT');
495
                break;
496
            case self::PATCH:
497
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'PATCH');
498
                break;
499
            case self::DELETE:
500
                curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
501
                break;
502
        }
503
        if (!empty($data)) {
504
            curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
505
        }
506
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
507
        curl_setopt($curl, CURLOPT_USERAGENT, 'PHP cURL Http Request');
508
        curl_setopt($curl, CURLOPT_HTTPHEADER, $aHeader);
509
        curl_setopt($curl, CURLOPT_HEADER, true);
510
511
        $strResponse = curl_exec($curl);
512
513
        $this->iLastResponseCode = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
514
        $iHeaderSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
515
516
        curl_close($curl);
517
518
        if ($this->iLastResponseCode == 200) {
519
            $this->strLastError = '';
520
            $this->strLastStatus = '';
521
            $result = is_string($strResponse) ? substr($strResponse, $iHeaderSize) : '';
522
        } else {
523
            $strError = is_string($strResponse) ? substr($strResponse, $iHeaderSize) : '';
524
            if (strlen($strError) > 0)  {
525
                $aError = json_decode($strError, true);
526
                if (isset($aError['error'])) {
527
                    $this->strLastError = $aError['error']['message'] ?? '';
528
                    $this->strLastStatus = $aError['error']['status'] ?? '';
529
                    $this->oLogger->error('GClient: ' . $this->strLastError);
530
                }
531
            }
532
        }
533
        return $result;
534
    }
535
536
    /**
537
     * Set information about the last error occured.
538
     * If any error can be detected before an API request is made, use this
539
     * method to set an reproduceable errormessage.
540
     * @param int $iResponseCode
541
     * @param string $strError
542
     * @param string $strStatus
543
     */
544
    public function setError(int $iResponseCode, string $strError, string $strStatus) : void
545
    {
546
        $this->iLastResponseCode = $iResponseCode;
547
        $this->strLastError = $strError;
548
        $this->strLastStatus = $strStatus;
549
        $this->oLogger->error('GClient: ' . $this->strLastError);
550
    }
551
552
    /**
553
     * Parse the header of an HTTP response.
554
     * @param string $strHeader
555
     * @return array<string,string>
556
     */
557
    public function parseHttpHeader(string $strHeader) : array
558
    {
559
        $aHeader = [];
560
        $strHeader = trim($strHeader);
561
        $aLine = explode("\n",$strHeader);
562
        $aHeader['status'] = $aLine[0];
563
        array_shift($aLine);
564
565
        foreach($aLine as $strLine){
566
            // only consider the first colon, since other colons can also appear in
567
            // the header value - the rest of such a value would be lost
568
            // (eg "Location: https: // www ...." - "// www ...." would be gone !)
569
            $aValue = explode(":", $strLine, 2);
570
571
            // header names are NOT case sensitive, so make all lowercase!
572
            $strName = strtolower(trim($aValue[0]));
573
            if (count($aValue) == 2) {
574
                $aHeader[$strName] = trim($aValue[1]);
575
            } else {
576
                $aHeader[$strName] = true;
577
            }
578
        }
579
        return $aHeader;
580
    }
581
582
    /**
583
     * Check, if given property is set and throw exception, if not so.
584
     * @param string $strValue
585
     * @param string $strName
586
     * @throws MissingPropertyException
587
     */
588
    private function checkProperty(string $strValue, string $strName) : void
589
    {
590
        if (empty($strValue)) {
591
            throw new MissingPropertyException($strName . ' must be set before call this method!');
592
        }
593
    }
594
}