CurlClient::handleCurlError()   B
last analyzed

Complexity

Conditions 7
Paths 12

Size

Total Lines 33
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 22
c 1
b 0
f 0
nc 12
nop 4
dl 0
loc 33
rs 8.6346
1
<?php
2
3
namespace bSecure\HttpClient;
4
5
use bSecure\Exception;
6
use bSecure\bSecure;
7
use bSecure\Helpers\Constant;
8
use bSecure\Util;
9
10
// @codingStandardsIgnoreStart
11
// PSR2 requires all constants be upper case. Sadly, the CURL_SSLVERSION
12
// constants do not abide by those rules.
13
14
// Note the values come from their position in the enums that
15
// defines them in cURL's source code.
16
17
// Available since PHP 5.5.19 and 5.6.3
18
if (!\defined('CURL_SSLVERSION_TLSv1_2')) {
19
    \define('CURL_SSLVERSION_TLSv1_2', 6);
20
}
21
// @codingStandardsIgnoreEnd
22
23
// Available since PHP 7.0.7 and cURL 7.47.0
24
if (!\defined('CURL_HTTP_VERSION_2TLS')) {
25
    \define('CURL_HTTP_VERSION_2TLS', 4);
26
}
27
28
class CurlClient implements ClientInterface
29
{
30
    private static $instance;
31
32
    public static function instance()
33
    {
34
        if (!self::$instance) {
35
            self::$instance = new self();
36
        }
37
38
        return self::$instance;
39
    }
40
41
    protected $defaultOptions;
42
43
    /** @var \bSecure\Util\RandomGenerator */
44
    protected $randomGenerator;
45
46
    protected $userAgentInfo;
47
48
    protected $enablePersistentConnections = true;
49
50
    protected $enableHttp2;
51
52
    protected $curlHandle;
53
54
    protected $requestStatusCallback;
55
56
    /**
57
     * CurlClient constructor.
58
     *
59
     * Pass in a callable to $defaultOptions that returns an array of CURLOPT_* values to start
60
     * off a request with, or an flat array with the same format used by curl_setopt_array() to
61
     * provide a static set of options. Note that many options are overridden later in the request
62
     * call, including timeouts, which can be set via setTimeout() and setConnectTimeout().
63
     *
64
     * Note that request() will silently ignore a non-callable, non-array $defaultOptions, and will
65
     * throw an exception if $defaultOptions returns a non-array value.
66
     *
67
     * @param null|array|callable $defaultOptions
68
     * @param null|\bSecure\Util\RandomGenerator $randomGenerator
69
     */
70
    public function __construct($defaultOptions = null, $randomGenerator = null)
71
    {
72
        $this->defaultOptions = $defaultOptions;
73
        $this->randomGenerator = $randomGenerator ?: new Util\RandomGenerator();
74
        $this->initUserAgentInfo();
75
76
        $this->enableHttp2 = $this->canSafelyUseHttp2();
77
    }
78
79
    public function __destruct()
80
    {
81
        $this->closeCurlHandle();
82
    }
83
84
    public function initUserAgentInfo()
85
    {
86
        $curlVersion = \curl_version();
87
        $this->userAgentInfo = [
88
          'httplib' => 'curl ' . $curlVersion['version'],
89
          'ssllib' => $curlVersion['ssl_version'],
90
        ];
91
    }
92
93
    public function getDefaultOptions()
94
    {
95
        return $this->defaultOptions;
96
    }
97
98
    public function getUserAgentInfo()
99
    {
100
        return $this->userAgentInfo;
101
    }
102
103
    /**
104
     * @return bool
105
     */
106
    public function getEnablePersistentConnections()
107
    {
108
        return $this->enablePersistentConnections;
109
    }
110
111
    /**
112
     * @param bool $enable
113
     */
114
    public function setEnablePersistentConnections($enable)
115
    {
116
        $this->enablePersistentConnections = $enable;
117
    }
118
119
    /**
120
     * @return bool
121
     */
122
    public function getEnableHttp2()
123
    {
124
        return $this->enableHttp2;
125
    }
126
127
    /**
128
     * @param bool $enable
129
     */
130
    public function setEnableHttp2($enable)
131
    {
132
        $this->enableHttp2 = $enable;
133
    }
134
135
    /**
136
     * @return null|callable
137
     */
138
    public function getRequestStatusCallback()
139
    {
140
        return $this->requestStatusCallback;
141
    }
142
143
    /**
144
     * Sets a callback that is called after each request. The callback will
145
     * receive the following parameters:
146
     * <ol>
147
     *   <li>string $rbody The response body</li>
148
     *   <li>integer $rcode The response status code</li>
149
     *   <li>\bSecure\Util\CaseInsensitiveArray $rheaders The response headers</li>
150
     *   <li>integer $errno The curl error number</li>
151
     *   <li>string|null $message The curl error message</li>
152
     *   <li>boolean $shouldRetry Whether the request will be retried</li>
153
     *   <li>integer $numRetries The number of the retry attempt</li>
154
     * </ol>.
155
     *
156
     * @param null|callable $requestStatusCallback
157
     */
158
    public function setRequestStatusCallback($requestStatusCallback)
159
    {
160
        $this->requestStatusCallback = $requestStatusCallback;
161
    }
162
163
    // USER DEFINED TIMEOUTS
164
165
    const DEFAULT_TIMEOUT = 80;
166
    const DEFAULT_CONNECT_TIMEOUT = 30;
167
168
    private $timeout = self::DEFAULT_TIMEOUT;
169
    private $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT;
170
171
    public function setTimeout($seconds)
172
    {
173
        $this->timeout = (int) \max($seconds, 0);
174
175
        return $this;
176
    }
177
178
    public function setConnectTimeout($seconds)
179
    {
180
        $this->connectTimeout = (int) \max($seconds, 0);
181
182
        return $this;
183
    }
184
185
    public function getTimeout()
186
    {
187
        return $this->timeout;
188
    }
189
190
    public function getConnectTimeout()
191
    {
192
        return $this->connectTimeout;
193
    }
194
195
    // END OF USER DEFINED TIMEOUTS
196
197
    public function request($method, $absUrl, $headers, $params, $hasFile = false)
198
    {
199
        $method = \strtolower($method);
200
        $optsHeaders = [];
201
        $opts = [];
202
        $opts[\CURLOPT_RETURNTRANSFER] = true;
203
204
        if ('get' === $method) {
205
            $opts[\CURLOPT_HTTPGET] = 1;
206
            if (\count($params) > 0) {
207
                $absUrl = "{$absUrl}?{$params}";
208
            }
209
        } elseif ('post' === $method) {
210
            $opts[\CURLOPT_POST] = true;
211
            $opts[\CURLOPT_POSTFIELDS] = http_build_query($params);
212
            $opts[\CURLOPT_VERBOSE] = true;
213
            $optsHeaders = [
214
              'X-HTTP-Method-Override: POST',
215
              'Content-Type:application/x-www-form-urlencoded',
216
              'Content-Length: ' .     strlen(http_build_query($params))
217
            ];
218
        } else {
219
            throw new Exception\UnexpectedValueException("Unrecognized method {$method}");
220
        }
221
222
        $opts[\CURLOPT_URL] = $absUrl;
223
        $opts[\CURLOPT_HTTPHEADER] = array_merge($optsHeaders,$headers);
224
        list($rbody, $rcode, $rheaders) = $this->executeRequestWithRetries($opts, $absUrl);
225
226
        return [$rbody, $rcode, $rheaders];
227
    }
228
229
    /**
230
     * @param array $opts cURL options
231
     * @param string $absUrl
232
     */
233
    private function executeRequestWithRetries($opts, $absUrl)
234
    {
235
        $numRetries = 0;
236
237
        while (true) {
238
            $rcode = 0;
239
            $errno = 0;
240
            $message = null;
241
242
            // Create a callback to capture HTTP headers for the response
243
            $rheaders = new Util\CaseInsensitiveArray();
244
            $this->resetCurlHandle();
245
            \curl_setopt_array($this->curlHandle, $opts);
246
            $rbody = \curl_exec($this->curlHandle);
247
248
249
            if (false === $rbody) {
250
                $errno = \curl_errno($this->curlHandle);
251
                $message = \curl_error($this->curlHandle);
252
            } else {
253
                $rcode = \curl_getinfo($this->curlHandle, \CURLINFO_HTTP_CODE);
254
            }
255
            if (!$this->getEnablePersistentConnections()) {
256
                $this->closeCurlHandle();
257
            }
258
259
            break;
260
        }
261
262
        if (false === $rbody) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $rbody does not seem to be defined for all execution paths leading up to this point.
Loading history...
263
            $this->handleCurlError($absUrl, $errno, $message, $numRetries);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $errno does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $message does not seem to be defined for all execution paths leading up to this point.
Loading history...
264
        }
265
        return [$rcode, $rbody, $rheaders];
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $rcode does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $rheaders does not seem to be defined for all execution paths leading up to this point.
Loading history...
266
    }
267
268
    /**
269
     * @param string $url
270
     * @param int $errno
271
     * @param string $message
272
     * @param int $numRetries
273
     *
274
     * @throws Exception\ApiConnectionException
275
     */
276
    private function handleCurlError($url, $errno, $message, $numRetries)
277
    {
278
        switch ($errno) {
279
            case \CURLE_COULDNT_CONNECT:
280
            case \CURLE_COULDNT_RESOLVE_HOST:
281
            case \CURLE_OPERATION_TIMEOUTED:
282
                $msg = "Could not connect to bSecure ({$url}).  Please check your "
283
                  . 'internet connection and try again, or';
284
285
                break;
286
287
            case \CURLE_SSL_CACERT:
288
            case \CURLE_SSL_PEER_CERTIFICATE:
289
                $msg = "Could not verify bSecure's SSL certificate.  Please make sure "
290
                  . 'that your network is not intercepting certificates.  '
291
                  . "(Try going to {$url} in your browser.)  "
292
                  . 'If this problem persists,';
293
294
                break;
295
296
            default:
297
                $msg = 'Unexpected error communicating with bSecure.  '
298
                  . 'If this problem persists,';
299
        }
300
        $msg .= ' let us know at '.Constant::SUPPORT_EMAIL.'.';
301
302
        $msg .= "\n\n(Network error [errno {$errno}]: {$message})";
303
304
        if ($numRetries > 0) {
305
            $msg .= "\n\nRequest was retried {$numRetries} times.";
306
        }
307
308
        throw new Exception\ApiConnectionException($msg);
309
    }
310
311
    /**
312
     * Initializes the curl handle. If already initialized, the handle is closed first.
313
     */
314
    private function initCurlHandle()
315
    {
316
        $this->closeCurlHandle();
317
        $this->curlHandle = \curl_init();
318
    }
319
320
    /**
321
     * Closes the curl handle if initialized. Do nothing if already closed.
322
     */
323
    private function closeCurlHandle()
324
    {
325
        if (null !== $this->curlHandle) {
326
            \curl_close($this->curlHandle);
327
            $this->curlHandle = null;
328
        }
329
    }
330
331
    /**
332
     * Resets the curl handle. If the handle is not already initialized, or if persistent
333
     * connections are disabled, the handle is reinitialized instead.
334
     */
335
    private function resetCurlHandle()
336
    {
337
        if (null !== $this->curlHandle && $this->getEnablePersistentConnections()) {
338
            \curl_reset($this->curlHandle);
339
        } else {
340
            $this->initCurlHandle();
341
        }
342
    }
343
344
    /**
345
     * Indicates whether it is safe to use HTTP/2 or not.
346
     *
347
     * @return bool
348
     */
349
    private function canSafelyUseHttp2()
350
    {
351
        // Versions of curl older than 7.60.0 don't respect GOAWAY frames
352
        // (cf. https://github.com/curl/curl/issues/2416), which bSecure use.
353
        $curlVersion = \curl_version()['version'];
354
355
        return \version_compare($curlVersion, '7.60.0') >= 0;
356
    }
357
}