OptimizelyApiClient::sendHttpRequest()   F
last analyzed

Complexity

Conditions 33
Paths 4115

Size

Total Lines 202

Duplication

Lines 12
Ratio 5.94 %

Code Coverage

Tests 70
CRAP Score 114.5493

Importance

Changes 0
Metric Value
cc 33
nc 4115
nop 4
dl 12
loc 202
rs 0
c 0
b 0
f 0
ccs 70
cts 121
cp 0.5785
crap 114.5493

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
{    
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 13
    public function __construct($authCredentials, $apiVersion='v2')
54
    {
55 13
        if (!is_array($authCredentials)) {
56 2
            throw new Exception('Auth credentials must be an array');            
57
        }
58
        
59 11
        if ($apiVersion!='v2') {
60 1
            throw new Exception('Invalid API version passed');
61
        }
62
        
63 10
        $this->setAuthCredentials($authCredentials);
64 9
        $this->setApiVersion($apiVersion);
65
        
66 9
        $this->curlHandle = curl_init();
67 9
        if (!$this->curlHandle) {
68
            throw new Exception('Error initializing CURL',
69
                    Exception::CODE_CURL_ERROR);
70
        }
71 9
    }
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 9
    public function setApiVersion($apiVersion)
87
    {
88 9
        if ($apiVersion!='v2') {
89 1
            throw new Exception('Invalid API version passed');
90
        }
91
        
92 9
        $this->apiVersion = $apiVersion;
93 9
    }
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 10
    public function setAuthCredentials($authCredentials)
109
    {
110 10
        if (!is_array($authCredentials) || count($authCredentials)==0) {
111 1
            throw new Exception('Auth credentials must be an non-empty array');            
112
        }
113
        
114 9
        $this->authCredentials = $authCredentials;
115 9
    }
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
            }
128
            
129 1
            if (isset($this->authCredentials['access_token_timestamp'])) {
130 1
                $accessToken['access_token_timestamp'] = $this->authCredentials['access_token_timestamp'];
131
            }
132
            
133 1
            if (isset($this->authCredentials['token_type'])) {
134 1
                $accessToken['token_type'] = $this->authCredentials['token_type'];
135
            }
136
            
137 1
            if (isset($this->authCredentials['expires_in'])) {
138 1
                $accessToken['expires_in'] = $this->authCredentials['expires_in'];
139
            }
140
        }
141
        
142 1
        return $accessToken;
143
    }
144
    
145
    /**
146
     * Returns refresh token.
147
     * @return string
148
     */
149 2
    public function getRefreshToken()
150
    {
151 2
        if (is_array($this->authCredentials)) {
152 2
            if (isset($this->authCredentials['refresh_token'])) {
153 1
                return $this->authCredentials['refresh_token'];
154
            }
155
        }
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 2
    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 2
        if ($this->isAccessTokenExpired() && $this->getRefreshToken()!=null) {
174 1
            $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 2
    private function sendHttpRequest($url, $queryParams = array(), $method='GET', 
195
            $postData = array())
196
    {
197
        // Reset diagnostics info.
198 2
        $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 2
        if ($this->curlHandle==false) {
203
            throw new Exception('CURL is not initialized', 
204
                    Exception::CODE_CURL_ERROR);
205
        }
206
        
207 2
        if ($method!='GET' && $method!='POST' && $method!='PUT' && 
208 2
            $method!='PATCH' && $method!='DELETE') {
209
            throw new Exception('Invalid HTTP method passed: ' . $method);
210
        }
211
        
212 2
        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 2
        if (count($queryParams)!=0) {            
219
            $query = http_build_query($queryParams);
220
            $url .= '?' . $query;
221
        }
222
        
223
        $headers = array(
224 2
            "Authorization: Bearer " . $this->authCredentials['access_token'],
225 2
            "Content-Type: application/json"
226
            );
227 2
        $content = '';
228 2
        if (count($postData)!=0) {
229
            $content = json_encode($postData, JSON_NUMERIC_CHECK);            
230
        }
231 2
        $headers[] = "Content-length:" . strlen($content);            
232
        
233
        // Reset CURL state.
234 2
        if (!function_exists('curl_reset')) {
235
            curl_close($this->curlHandle);
236
            $this->curlHandle = curl_init(); 
237
        } else {
238 2
            curl_reset($this->curlHandle);
239
        }
240
        
241
        // Set HTTP options.
242 2
        curl_setopt($this->curlHandle, CURLOPT_URL, $url);
243 2
        curl_setopt($this->curlHandle, CURLOPT_CUSTOMREQUEST, $method);        
244 2
        if (count($postData)!=0) {
245
            curl_setopt($this->curlHandle, CURLOPT_POSTFIELDS, $content);            
246
        }
247 2
        curl_setopt($this->curlHandle, CURLOPT_RETURNTRANSFER, true);
248 2
        curl_setopt($this->curlHandle, CURLOPT_HEADER, true);        
249 2
        curl_setopt($this->curlHandle, CURLOPT_HTTPHEADER, $headers);
250 2
        curl_setopt($this->curlHandle, CURLINFO_HEADER_OUT, true);
251
        
252
        // Save diagnostics info.
253 2
        $this->diagnosticsInfo['request']['method'] = $method;
254 2
        $this->diagnosticsInfo['request']['url'] = $url;
255 2
        $this->diagnosticsInfo['request']['headers'] = $headers;
256 2
        $this->diagnosticsInfo['request']['content'] = $content;        
257
        
258
        // Execute HTTP request and get response.
259 2
        $result = curl_exec($this->curlHandle);                        
260 2
        if ($result === false) {
261
            $code = curl_errno($this->curlHandle);
262
            $error = curl_error($this->curlHandle);
263
            throw new Exception("Failed to send HTTP request $method '$url', " . 
264
                    "the error code was $code, error message was: '$error'", 
265
                    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...
266
        }        
267
        
268
        // Split response headers and body
269 2
        $headerSize = curl_getinfo($this->curlHandle, CURLINFO_HEADER_SIZE);
270 2
        $headers = substr($result, 0, $headerSize);
271 2
        $body = substr($result, $headerSize);
272
273
        // Parse response headers.
274 2
        $headers = explode("\n", $headers);
275 2
        $parsedHeaders = array();
276 2
        foreach ($headers as $i=>$header) {
277 2
            if ($i==0)
278 2
                continue; // Skip first line (http code).
279 2
            $pos = strpos($header, ':');
280 2
            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...
281 2
                $headerName = trim(strtolower(substr($header, 0, $pos)));
282 2
                $headerValue = trim(substr($header, $pos+1));
283 2
                $parsedHeaders[$headerName] = $headerValue;
284
            }
285
        }
286
        
287
        // Get HTTP code.
288 2
        $info = curl_getinfo($this->curlHandle);    
289 2
        $httpCode = $info['http_code'];
290
        
291
        // Save diagnostics info.
292 2
        $this->diagnosticsInfo['response']['http_code'] = $httpCode;
293 2
        $this->diagnosticsInfo['response']['headers'] = $headers;
294 2
        $this->diagnosticsInfo['response']['content'] = $body;                       
295
        
296
        // Determine if we have rate limiting headers
297 2
        $rateLimit = null;
298 2
        $rateLimitRemaining = null;
299 2
        $rateLimitReset = null;
300 2
        if (isset($parsedHeaders['x-ratelimit-limit'])) {
301
            $rateLimit = $parsedHeaders['x-ratelimit-limit'];
302
        }
303
        
304 2
        if (isset($parsedHeaders['x-ratelimit-remaining'])) {
305
            $rateLimitRemaining = $parsedHeaders['x-ratelimit-remaining'];
306
        }
307
        
308 2
        if (isset($parsedHeaders['x-ratelimit-reset'])) {
309
            $rateLimitReset = $parsedHeaders['x-ratelimit-reset'];
310
        }        
311
        
312
        // JSON-decode payload.
313 2
        $decodedPayload = json_decode($body, true);
314 2
        if ($decodedPayload===false) {
315
            throw new Exception('Could not JSON-decode the Optimizely API response. Request was ' . 
316
                    $method . ' "' . $url . '". The response was: "' . $body . '"',
317
                    Exception::CODE_API_ERROR, array('http_code'=>$httpCode));
318
        }
319
        
320
        // Check HTTP response code.
321 2
        if ($httpCode<200 || $httpCode>299) {
322
            
323 2
            if (!isset($decodedPayload['message']) || 
324 1
                !isset($decodedPayload['code']) ||
325 2
                !isset($decodedPayload['uuid'])) {
326 1
                throw new Exception('Optimizely API responded with error code ' . $httpCode . 
327 1
                    '. Request was ' . $method . ' "' . $url . '". Response was "' . $body . '"',
328 1
                    Exception::CODE_API_ERROR, array(
329 1
                        'http_code' => $httpCode,
330 1
                        'rate_limit' => $rateLimit,
331 1
                        'rate_limit_remaining' => $rateLimitRemaining,
332 1
                        'rate_limit_reset' => $rateLimitReset,
333
                    ));
334
            }
335
            
336
            //print_r($this->getDiagnosticsInfo());
337
            
338 1
            throw new Exception($decodedPayload['message'], Exception::CODE_API_ERROR, 
339
                    array(
340 1
                        'http_code'=>$decodedPayload['code'], 
341 1
                        'uuid'=>$decodedPayload['uuid'],
342 1
                        'rate_limit' => $rateLimit,
343 1
                        'rate_limit_remaining' => $rateLimitRemaining,
344 1
                        'rate_limit_reset' => $rateLimitReset
345
                    ));
346
        }        
347
        
348
        // Create Result object
349
        $result = new Result($decodedPayload, $httpCode);
350
        $result->setRateLimit($rateLimit);
351
        $result->setRateLimitRemaining($rateLimitRemaining);
352
        $result->setRateLimitReset($rateLimitReset);
353
        
354
        // Determine if we have prev/next/last page headers.
355
        if (isset($parsedHeaders['link']))
356
        {
357
            // Parse LINK header
358
            $matched = preg_match_all('/<(.+)>;\s+rel=(\w+)(,|\z)/U', 
359
                    $parsedHeaders['link'], $matches, PREG_SET_ORDER);
360
            if (!$matched) {
361
                throw new Exception('Error parsing LINK header: ' . 
362
                        $parsedHeaders['link'], Exception::CODE_API_ERROR, $httpCode);
363
            }
364
            
365
            foreach ($matches as $match) {
366
                
367
                $url = $match[1];
368
                $rel = $match[2];
369
                
370
                $matched = preg_match('/page=(\d+)/U', $url, $pageMatches);
371 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...
372
                    throw new Exception('Error extracting page argument while parsing LINK header: ' . 
373
                            $parsedHeaders['link'], Exception::CODE_API_ERROR, 
374
                            array('http_code'=>$httpCode));
375
                }
376
                
377
                $pageNumber = $pageMatches[1];
378
                
379
                if ($rel=='prev') {
380
                    $result->setPrevPage($pageNumber);
381
                } else if ($rel=='next') {
382
                    $result->setNextPage($pageNumber);
383 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...
384
                    $result->setLastPage($pageNumber);
385
                } else {
386
                    throw new Exception('Unexpected rel argument while parsing LINK header: ' . 
387
                            $parsedHeaders['link'], Exception::CODE_API_ERROR, 
388
                            array('http_code'=>$httpCode));
389
                }
390
            }
391
        }        
392
                
393
        // Return the result.
394
        return $result;
395
    }
396
    
397
    /**
398
     * Determines whether the access token has expired or not. Returns true if 
399
     * token has expired; false if token is valid.
400
     * @return boolean
401
     */
402 2
    private function isAccessTokenExpired() 
403
    {
404 2
        if(!isset($this->authCredentials['access_token'])) {
405
            return true; // We do not have access token.
406
        }
407
        
408 2
        if (!isset($this->authCredentials['expires_in']) || 
409 2
            !isset($this->authCredentials['access_token_timestamp'])) {
410 1
            return true; // Assume it has expired, since we can't tell for sure.
411
        } 
412
        
413 1
        $expiresIn = $this->authCredentials['expires_in'];
414 1
        $timestamp = $this->authCredentials['access_token_timestamp'];
415
        
416 1
        if ($timestamp + $expiresIn < time()) {
417
            // Access token has expired.
418 1
            return true;
419
        }
420
        
421
        // Access token is valid.
422
        return false;
423
    }
424
    
425
    /**
426
     * This method retrieves the access token by refresh token.
427
     * @return array
428
     * @throw Exception
429
     */
430 1
    private function getAccessTokenByRefreshToken()
431
    {
432 1
        if (!isset($this->authCredentials['client_id']))
433
            throw new Exception('OAuth 2.0 client ID is not set');
434
        
435 1
        if (!isset($this->authCredentials['client_secret']))
436
            throw new Exception('OAuth 2.0 client secret is not set');
437
        
438 1
        if (!isset($this->authCredentials['refresh_token']))
439
            throw new Exception('Refresh token is not set');
440
        
441 1
        $clientId = $this->authCredentials['client_id'];
442 1
        $clientSecret = $this->authCredentials['client_secret'];
443 1
        $refreshToken = $this->authCredentials['refresh_token'];
444
        
445 1
        $url = "https://app.optimizely.com/oauth2/token?refresh_token=$refreshToken" . 
446 1
                "&client_id=$clientId&client_secret=$clientSecret&grant_type=refresh_token";
447
        
448 1
        $response = $this->sendHttpRequest($url, array(), 'POST');
449
        $decodedJsonData = $response->getDecodedJsonData();
450
        
451
        if (!isset($decodedJsonData['access_token'])) {
452
            throw new Exception('Not found access token in response. Request URL was "' . 
453
                    $url. '". Response was "' . print_r(json_encode($decodedJsonData), true). '"',
454
                    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...
455
        }
456
        
457
        $this->authCredentials['access_token'] = $decodedJsonData['access_token'];
458
        $this->authCredentials['token_type'] = $decodedJsonData['token_type'];
459
        $this->authCredentials['expires_in'] = $decodedJsonData['expires_in'];
460
        $this->authCredentials['access_token_timestamp'] = time(); 
461
    }
462
    
463
    /**
464
     * Provides access to API services (experiments, campaigns, etc.)
465
     * @method Audiences audiences()
466
     * @method Campaigns campaigns()
467
     * @method Events events()
468
     * @method Experiment experiments()
469
     * @method Pages pages()
470
     * @method Projects projects()
471
     */
472 1
    public function __call($name, $arguments)
473
    {
474
        $allowedServiceNames = array(
475 1
            'audiences',
476
            'campaigns',
477
            'events',
478
            'experiments',
479
            'pages',
480
            'projects',
481
            'attributes',
482
            'plan',
483
        );
484
        
485
        // Check if the service name is valid
486 1
        if (!in_array($name, $allowedServiceNames)) {
487
            throw new Exception("Unexpected service name: $name");
488
        }
489
        
490
        // Check if such service already instantiated
491 1
        if (isset($this->services[$this->apiVersion][$name])) {
492
            $service = $this->services[$this->apiVersion][$name];
493
        } else {
494
            // Instantiate the service
495 1
            $apiVersion = $this->apiVersion;
496 1
            $serviceName = ucwords($name);
497 1
            $className = "\\WebMarketingROI\\OptimizelyPHP\\Service\\$apiVersion\\$serviceName";
498 1
            $service = new $className($this); 
499 1
            $this->services[$apiVersion][$name] = $service;
500
        }
501
502 1
        return $service;
503
    }
504
    
505
    /**
506
     * Returns last HTTP request/response information (for diagnostics/debugging
507
     * purposes):
508
     *   - request
509
     *     - method
510
     *     - headers
511
     *     - content
512
     *   - response
513
     *     - http_code
514
     *     - headers
515
     *     - content
516
     * @return array
517
     */
518 1
    public function getDiagnosticsInfo()
519
    {
520 1
        return $this->diagnosticsInfo;
521
    }
522
}