Completed
Push — master ( 90835e...e5901e )
by Oleg
02:50
created

OptimizelyApiClient   C

Complexity

Total Complexity 70

Size/Duplication

Total Lines 495
Duplicated Lines 2.42 %

Coupling/Cohesion

Components 1
Dependencies 2

Test Coverage

Coverage 52.92%

Importance

Changes 0
Metric Value
dl 12
loc 495
ccs 127
cts 240
cp 0.5292
rs 5.6163
c 0
b 0
f 0
wmc 70
lcom 1
cbo 2

13 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 19 4
A getApiVersion() 0 4 1
A setApiVersion() 0 8 2
A getAuthCredentials() 0 4 1
A setAuthCredentials() 0 8 3
B getAccessToken() 0 23 6
A getRefreshToken() 0 10 3
A sendApiRequest() 0 15 3
F sendHttpRequest() 12 192 33
B isAccessTokenExpired() 0 22 5
B getAccessTokenByRefreshToken() 0 32 5
B __call() 0 30 3
A getDiagnosticsInfo() 0 4 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like OptimizelyApiClient 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 OptimizelyApiClient, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @abstract API client for Optimizely.
4
 * @author Oleg Krivtsov <[email protected]>
5
 * @date 03 October 2016
6
 * @copyright (c) 2016, Web Marketing ROI
7
 */
8
namespace WebMarketingROI\OptimizelyPHP;
9
10
use WebMarketingROI\OptimizelyPHP\Exception;
11
use WebMarketingROI\OptimizelyPHP\Result;
12
13
/**
14
 * Client for Optimizely REST API v2.
15
 */
16
class OptimizelyApiClient 
17
{    
0 ignored issues
show
Coding Style introduced by
The opening class brace should be on a newline by itself.
Loading history...
18
    /**
19
     * Auth credentials.
20
     * @var array
21
     */
22
    private $authCredentials = array();
23
    
24
    /**
25
     * API version.
26
     * @var string
27
     */
28
    private $apiVersion;
29
    
30
    /**
31
     * CURL handle.
32
     * @var resource 
33
     */
34
    private $curlHandle;
35
    
36
    /**
37
     * Debugging information. Typically contains the last HTTP request/response.
38
     * @var type 
39
     */
40
    private $diagnosticsInfo = array();
41
    
42
    /**
43
     * Instantiated services (used internally).
44
     * @var array
45
     */
46
    private $services = array();
47
    
48
    /**
49
     * Constructor.
50
     * @param array $authCredentials Auth credentials.
51
     * @param string $apiVersion Optional. Currently supported 'v2' only.     
52
     */
53 8
    public function __construct($authCredentials, $apiVersion='v2')
54
    {
55 8
        if (!is_array($authCredentials)) {
56 1
            throw new Exception('Auth credentials must be an array');            
57
        }
58
        
59 7
        if ($apiVersion!='v2') {
60 1
            throw new Exception('Invalid API version passed');
61
        }
62
        
63 6
        $this->authCredentials = $authCredentials;
64 6
        $this->apiVersion = $apiVersion;
65
        
66 6
        $this->curlHandle = curl_init();
67 6
        if (!$this->curlHandle) {
68
            throw new Exception('Error initializing CURL',
69
                    Exception::CODE_CURL_ERROR);
70
        }
71 6
    }
72
    
73
    /**
74
     * Returns API version (currently it is always 'v2').
75
     * @return string
76
     */
77 1
    public function getApiVersion()
78
    {
79 1
        return $this->apiVersion;
80
    }
81
    
82
    /**
83
     * Sets API version. 
84
     * @param string $apiVersion Currently, 'v2' only.
85
     */
86 1
    public function setApiVersion($apiVersion)
87
    {
88 1
        if ($apiVersion!='v2') {
89
            throw new Exception('Invalid API version passed');
90
        }
91
        
92 1
        $this->apiVersion = $apiVersion;
93 1
    }
94
    
95
    /**
96
     * Returns auth credentials
97
     * @return array
98
     */
99 1
    public function getAuthCredentials()
100
    {
101 1
        return $this->authCredentials;
102
    }
103
    
104
    /**
105
     * Sets Auth credentials.
106
     * @param array $authCredentials
107
     */
108 1
    public function setAuthCredentials($authCredentials)
109
    {
110 1
        if (!is_array($authCredentials) || count($authCredentials)==0) {
111
            throw new Exception('Auth credentials must be an array');            
112
        }
113
        
114 1
        $this->authCredentials = $authCredentials;
115 1
    }
116
    
117
    /**
118
     * Returns access token information as array.
119
     * @return array
120
     */
121 1
    public function getAccessToken()
122
    {
123 1
        $accessToken = array();
124 1
        if (is_array($this->authCredentials)) {
125 1
            if (isset($this->authCredentials['access_token'])) {
126 1
                $accessToken['access_token'] = $this->authCredentials['access_token'];
127 1
            }
128
            
129 1
            if (isset($this->authCredentials['access_token_timestamp'])) {
130
                $accessToken['access_token_timestamp'] = $this->authCredentials['access_token_timestamp'];
131
            }
132
            
133 1
            if (isset($this->authCredentials['token_type'])) {
134
                $accessToken['token_type'] = $this->authCredentials['token_type'];
135
            }
136
            
137 1
            if (isset($this->authCredentials['expires_in'])) {
138
                $accessToken['expires_in'] = $this->authCredentials['expires_in'];
139
            }
140 1
        }
141
        
142 1
        return $accessToken;
143
    }
144
    
145
    /**
146
     * Returns refresh token.
147
     * @return string
148
     */
149 1
    public function getRefreshToken()
150
    {
151 1
        if (is_array($this->authCredentials)) {
152 1
            if (isset($this->authCredentials['refresh_token'])) {
153
                return $this->authCredentials['refresh_token'];
154
            }
155 1
        }
156
        
157 1
        return null;
158
    }
159
    
160
    /**
161
     * Sends an HTTP request to the given URL and returns response in form of array. 
162
     * @param string $url The URL of Optimizely endpoint (relative, without host and API version).
163
     * @param array $queryParams The list of query parameters.
164
     * @param string $method HTTP method (GET or POST).
165
     * @param array $postData Data send in request body (only for POST method).
166
     * @return array Optimizely response in form of array.
167
     * @throws Exception
168
     */
169 1
    public function sendApiRequest($url, $queryParams = array(), $method='GET', 
170
            $postData = array())
171
    {
172
        // If access token has expired, try to get another one with refresh token.
173 1
        if ($this->isAccessTokenExpired() && $this->getRefreshToken()!=null) {
174
            $this->getAccessTokenByRefreshToken();
175
        }
176
        
177
        // Produce absolute URL
178 1
        $url = 'https://api.optimizely.com/' . $this->apiVersion . $url;
179
        
180 1
        $result = $this->sendHttpRequest($url, $queryParams, $method, $postData);
181
        
182
        return $result;
183
    }
184
    
185
    /**
186
     * Sends an HTTP request to the given URL and returns response in form of array. 
187
     * @param string $url The URL of Optimizely endpoint.
188
     * @param array $queryParams The list of query parameters.
189
     * @param string $method HTTP method (GET or POST).
190
     * @param array $postData Data send in request body (only for POST method).
191
     * @return array Optimizely response in form of array.
192
     * @throws Exception
193
     */
194 1
    private function sendHttpRequest($url, $queryParams = array(), $method='GET', 
195
            $postData = array())
196
    {
197
        // Reset diagnostics info.
198 1
        $this->diagnosticsInfo = array();
0 ignored issues
show
Documentation Bug introduced by
It seems like array() of type array is incompatible with the declared type object<WebMarketingROI\OptimizelyPHP\type> of property $diagnosticsInfo.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
199
        
200
        // Check if CURL is initialized (it should have been initialized in 
201
        // constructor).
202 1
        if ($this->curlHandle==false) {
203
            throw new Exception('CURL is not initialized', 
204
                    Exception::CODE_CURL_ERROR);
205
        }
206
        
207 1
        if ($method!='GET' && $method!='POST' && $method!='PUT' && 
208 1
            $method!='PATCH' && $method!='DELETE') {
209
            throw new Exception('Invalid HTTP method passed: ' . $method);
210
        }
211
        
212 1
        if (!isset($this->authCredentials['access_token'])) {
213
            throw new Exception('OAuth access token is not set. You should pass ' . 
214
                    'it to the class constructor when initializing the Optimizely client.');
215
        }
216
                
217
        // Append query parameters to URL.
218 1
        if (count($queryParams)!=0) {            
219
            $query = http_build_query($queryParams);
220
            $url .= '?' . $query;
221
        }
222
        
223
        $headers = array(
224 1
            "Authorization: Bearer " . $this->authCredentials['access_token'],
225
            "Content-Type: application/json"
226 1
            );
227 1
        $content = '';
228 1
        if (count($postData)!=0) {
229
            $content = json_encode($postData);            
230
        }
231 1
        $headers[] = "Content-length:" . strlen($content);            
232
        
233
        // Set HTTP options.
234 1
        curl_setopt($this->curlHandle, CURLOPT_URL, $url);
235 1
        curl_setopt($this->curlHandle, CURLOPT_CUSTOMREQUEST, $method);        
236 1
        if (count($postData)!=0) {
237
            curl_setopt($this->curlHandle, CURLOPT_POSTFIELDS, $content);            
238
        }
239 1
        curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true);
240 1
        curl_setopt($this->curlHandle, CURLOPT_HEADER, true);        
241 1
        curl_setopt($this->curlHandle, CURLOPT_HTTPHEADER, $headers);
242 1
        curl_setopt($this->curlHandle, CURLINFO_HEADER_OUT, true);
243
        
244
        // Save diagnostics info.
245 1
        $this->diagnosticsInfo['request']['method'] = $method;
246 1
        $this->diagnosticsInfo['request']['headers'] = $headers;
247 1
        $this->diagnosticsInfo['request']['content'] = $content;        
248
        
249
        // Execute HTTP request and get response.
250 1
        $result = curl_exec($this->curlHandle);                        
251 1
        if ($result === false) {
252
            $code = curl_errno($this->curlHandle);
253
            $error = curl_error($this->curlHandle);
254
            throw new Exception("Failed to send HTTP request $method '$url', " . 
255
                    "the error code was $code, error message was: '$error'", 
256
                    Exception::CODE_CURL_ERROR, $code, $error);
0 ignored issues
show
Documentation introduced by
$code is of type integer, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Unused Code introduced by
The call to Exception::__construct() has too many arguments starting with $error.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
257
        }        
258
        
259
        // Split headers and body
260 1
        $headerSize = curl_getinfo($this->curlHandle, CURLINFO_HEADER_SIZE);
261 1
        $headers = substr($result, 0, $headerSize);
262 1
        $body = substr($result, $headerSize);
263
264
        // Parse headers
265 1
        $headers = explode("\n", $headers);
266 1
        $parsedHeaders = array();
267 1
        foreach ($headers as $i=>$header) {
268 1
            if ($i==0)
269 1
                continue; // Skip first line (http code).
270 1
            $pos = strpos($header, ':');
271 1
            if ($pos!=false) {
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $pos of type integer to the boolean false. If you are specifically checking for non-zero, consider using something more explicit like > 0 or !== 0 instead.
Loading history...
272 1
                $headerName = trim(strtolower(substr($header, 0, $pos)));
273 1
                $headerValue = trim(substr($header, $pos+1));
274 1
                $parsedHeaders[$headerName] = $headerValue;
275 1
            }
276 1
        }
277
        
278
        // Get HTTP code
279 1
        $info = curl_getinfo($this->curlHandle);    
280 1
        $httpCode = $info['http_code'];
281
        
282
        // Save diagnostics info.
283 1
        $this->diagnosticsInfo['response']['http_code'] = $httpCode;
284 1
        $this->diagnosticsInfo['response']['headers'] = $headers;
285 1
        $this->diagnosticsInfo['response']['content'] = $body;                       
286
        
287
        // Determine if we have rate limiting headers
288 1
        $rateLimit = null;
289 1
        $rateLimitRemaining = null;
290 1
        $rateLimitReset = null;
291 1
        if (isset($parsedHeaders['x-ratelimit-limit'])) {
292
            $rateLimit = $parsedHeaders['x-ratelimit-limit'];
293
        }
294
        
295 1
        if (isset($parsedHeaders['x-ratelimit-remaining'])) {
296
            $rateLimitRemaining = $parsedHeaders['x-ratelimit-remaining'];
297
        }
298
        
299 1
        if (isset($parsedHeaders['x-ratelimit-reset'])) {
300
            $rateLimitReset = $parsedHeaders['x-ratelimit-reset'];
301
        }        
302
        
303
        // JSON-decode payload.
304 1
        $decodedPayload = json_decode($body, true);
305 1
        if ($decodedPayload===false) {
306
            throw new Exception('Could not JSON-decode the Optimizely API response. Request was ' . 
307
                    $method . ' "' . $url . '". The response was: "' . $body . '"',
308
                    Exception::CODE_API_ERROR, array('http_code'=>$httpCode));
309
        }
310
        
311
        // Check HTTP response code.
312 1
        if ($httpCode<200 || $httpCode>299) {
313
            
314 1
            if (!isset($decodedPayload['message']) || 
315 1
                !isset($decodedPayload['code']) ||
316 1
                !isset($decodedPayload['uuid']) ||
317 1
                $httpCode != !isset($decodedPayload['code'])) {
318 1
                throw new Exception('Optimizely API responded with error code ' . $httpCode . 
319 1
                    ', but response format was unexpected. Request was ' . $method . ' "' . $url . '". Response was "' . $body . '"',
320 1
                    Exception::CODE_API_ERROR, array(
321 1
                        'http_code' => $httpCode,
322 1
                        'rate_limit' => $rateLimit,
323 1
                        'rate_limit_remaining' => $rateLimitRemaining,
324 1
                        'rate_limit_reset' => $rateLimitReset,
325 1
                    ));
326
            }
327
            
328
            throw new Exception($decodedPayload['message'], Exception::CODE_API_ERROR, 
329
                    array(
330
                        'http_code'=>$decodedPayload['code'], 
331
                        'uuid'=>$decodedPayload['uuid'],
332
                        'rate_limit' => $rateLimit,
333
                        'rate_limit_remaining' => $rateLimitRemaining,
334
                        'rate_limit_reset' => $rateLimitReset
335
                    ));
336
        }        
337
        
338
        // Create Result object
339
        $result = new Result($decodedPayload, $httpCode);
340
        $result->setRateLimit($rateLimit);
341
        $result->setRateLimitRemaining($rateLimitRemaining);
342
        $result->setRateLimitReset($rateLimitReset);
343
        
344
        // Determine if we have prev/next/last page headers.
345
        if (isset($parsedHeaders['link']))
346
        {
347
            // Parse LINK header
348
            $matched = preg_match_all('/<(.+)>;\s+rel=(\w+)(,|\z)/U', 
349
                    $parsedHeaders['link'], $matches, PREG_SET_ORDER);
350
            if (!$matched) {
351
                throw new Exception('Error parsing LINK header: ' . 
352
                        $parsedHeaders['link'], Exception::CODE_API_ERROR, $httpCode);
353
            }
354
            
355
            foreach ($matches as $match) {
356
                
357
                $url = $match[1];
358
                $rel = $match[2];
359
                
360
                $matched = preg_match('/page=(\d+)/U', $url, $pageMatches);
361 View Code Duplication
                if (!$matched || count($pageMatches)!=2) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
362
                    throw new Exception('Error extracting page argument while parsing LINK header: ' . 
363
                            $parsedHeaders['link'], Exception::CODE_API_ERROR, 
364
                            array('http_code'=>$httpCode));
365
                }
366
                
367
                $pageNumber = $pageMatches[1];
368
                
369
                if ($rel=='prev') {
370
                    $result->setPrevPage($pageNumber);
371
                } else if ($rel=='next') {
372
                    $result->setNextPage($pageNumber);
373 View Code Duplication
                } else if ($rel=='last') {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
374
                    $result->setLastPage($pageNumber);
375
                } else {
376
                    throw new Exception('Unexpected rel argument while parsing LINK header: ' . 
377
                            $parsedHeaders['link'], Exception::CODE_API_ERROR, 
378
                            array('http_code'=>$httpCode));
379
                }
380
            }
381
        }        
382
                
383
        // Return the result.
384
        return $result;
385
    }
386
    
387
    /**
388
     * Determines whether the access token has expired or not. Returns true if 
389
     * token has expired; false if token is valid.
390
     * @return boolean
391
     */
392 1
    private function isAccessTokenExpired() 
393
    {
394 1
        if(!isset($this->authCredentials['access_token'])) {
395
            return true; // We do not have access token.
396
        }
397
        
398 1
        if (!isset($this->authCredentials['expires_in']) || 
399 1
            !isset($this->authCredentials['access_token_timestamp'])) {
400 1
            return true; // Assume it has expired, since we can't tell for sure.
401
        } 
402
        
403
        $expiresIn = $this->authCredentials['expires_in'];
404
        $timestamp = $this->authCredentials['access_token_timestamp'];
405
        
406
        if ($timestamp + $expiresIn < time()) {
407
            // Access token has expired.
408
            return true;
409
        }
410
        
411
        // Access token is valid.
412
        return false;
413
    }
414
    
415
    /**
416
     * This method retrieves the access token by refresh token.
417
     * @return array
418
     * @throw Exception
419
     */
420
    private function getAccessTokenByRefreshToken()
421
    {
422
        if (!isset($this->authCredentials['client_id']))
423
            throw new Exception('OAuth 2.0 client ID is not set');
424
        
425
        if (!isset($this->authCredentials['client_secret']))
426
            throw new Exception('OAuth 2.0 client secret is not set');
427
        
428
        if (!isset($this->authCredentials['refresh_token']))
429
            throw new Exception('Refresh token is not set');
430
        
431
        $clientId = $this->authCredentials['client_id'];
432
        $clientSecret = $this->authCredentials['client_secret'];
433
        $refreshToken = $this->authCredentials['refresh_token'];
434
        
435
        $url = "https://app.optimizely.com/oauth2/token?refresh_token=$refreshToken" . 
436
                "&client_id=$clientId&client_secret=$clientSecret&grant_type=refresh_token";
437
        
438
        $response = $this->sendHttpRequest($url, array(), 'POST');
439
        $decodedJsonData = $response->getDecodedJsonData();
440
        
441
        if (!isset($decodedJsonData['access_token'])) {
442
            throw new Exception('Not found access token in response. Request URL was "' . 
443
                    $url. '". Response was "' . print_r(json_encode($decodedJsonData), true). '"',
444
                    Exception::CODE_API_ERROR, $response->getHttpCode());
0 ignored issues
show
Documentation introduced by
$response->getHttpCode() is of type integer, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
445
        }
446
        
447
        $this->authCredentials['access_token'] = $decodedJsonData['access_token'];
448
        $this->authCredentials['token_type'] = $decodedJsonData['token_type'];
449
        $this->authCredentials['expires_in'] = $decodedJsonData['expires_in'];
450
        $this->authCredentials['access_token_timestamp'] = time(); 
451
    }
452
    
453
    /**
454
     * Provides access to API services (experiments, campaigns, etc.)
455
     * @method Audiences audiences()
456
     * @method Campaigns campaigns()
457
     * @method Events events()
458
     * @method Experiment experiments()
459
     * @method Pages pages()
460
     * @method Projects projects()
461
     */
462 1
    public function __call($name, $arguments)
463
    {
464
        $allowedServiceNames = array(
465 1
            'audiences',
466 1
            'campaigns',
467 1
            'events',
468 1
            'experiments',
469 1
            'pages',
470
            'projects'
471 1
        );
472
        
473
        // Check if the service name is valid
474 1
        if (!in_array($name, $allowedServiceNames)) {
475
            throw new Exception("Unexpected service name: $name");
476
        }
477
        
478
        // Check if such service already instantiated
479 1
        if (isset($this->services[$this->apiVersion][$name])) {
480
            $service = $this->services[$this->apiVersion][$name];
481
        } else {
482
            // Instantiate the service
483 1
            $apiVersion = $this->apiVersion;
484 1
            $serviceName = ucwords($name);
485 1
            $className = "\\WebMarketingROI\\OptimizelyPHP\\Service\\$apiVersion\\$serviceName";
486 1
            $service = new $className($this); 
487 1
            $this->services[$apiVersion][$name] = $service;
488
        }
489
490 1
        return $service;
491
    }
492
    
493
    /**
494
     * Returns last HTTP request/response information (for diagnostics/debugging
495
     * purposes):
496
     *   - request
497
     *     - method
498
     *     - headers
499
     *     - content
500
     *   - response
501
     *     - http_code
502
     *     - headers
503
     *     - content
504
     * @return array
505
     */
506
    public function getDiagnosticsInfo()
507
    {
508
        return $this->diagnosticsInfo;
509
    }
510
}