Completed
Push — master ( 2b1385...7c6a84 )
by Thomas
07:21
created

CurlFactory   D

Complexity

Total Complexity 101

Size/Duplication

Total Lines 548
Duplicated Lines 5.47 %

Coupling/Cohesion

Components 1
Dependencies 5

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 101
c 1
b 0
f 0
lcom 1
cbo 5
dl 30
loc 548
rs 4.8718

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

1
<?php
2
namespace GuzzleHttp\Ring\Client;
3
4
use GuzzleHttp\Ring\Core;
5
use GuzzleHttp\Ring\Exception\ConnectException;
6
use GuzzleHttp\Ring\Exception\RingException;
7
use GuzzleHttp\Stream\LazyOpenStream;
8
use GuzzleHttp\Stream\StreamInterface;
9
10
/**
11
 * Creates curl resources from a request
12
 */
13
class CurlFactory
14
{
15
    /**
16
     * Creates a cURL handle, header resource, and body resource based on a
17
     * transaction.
18
     *
19
     * @param array         $request Request hash
20
     * @param null|resource $handle  Optionally provide a curl handle to modify
21
     *
22
     * @return array Returns an array of the curl handle, headers array, and
23
     *               response body handle.
24
     * @throws \RuntimeException when an option cannot be applied
25
     */
26
    public function __invoke(array $request, $handle = null)
27
    {
28
        $headers = [];
29
        $options = $this->getDefaultOptions($request, $headers);
30
        $this->applyMethod($request, $options);
31
32
        if (isset($request['client'])) {
33
            $this->applyHandlerOptions($request, $options);
34
        }
35
36
        $this->applyHeaders($request, $options);
37
        unset($options['_headers']);
38
39
        // Add handler options from the request's configuration options
40
        if (isset($request['client']['curl'])) {
41
            $options = $this->applyCustomCurlOptions(
42
                $request['client']['curl'],
43
                $options
44
            );
45
        }
46
47
        if (!$handle) {
48
            $handle = curl_init();
49
        }
50
51
        $body = $this->getOutputBody($request, $options);
52
        curl_setopt_array($handle, $options);
53
54
        return [$handle, &$headers, $body];
55
    }
56
57
    /**
58
     * Creates a response hash from a cURL result.
59
     *
60
     * @param callable $handler  Handler that was used.
61
     * @param array    $request  Request that sent.
62
     * @param array    $response Response hash to update.
63
     * @param array    $headers  Headers received during transfer.
64
     * @param resource $body     Body fopen response.
65
     *
66
     * @return array
67
     */
68
    public static function createResponse(
69
        callable $handler,
70
        array $request,
71
        array $response,
72
        array $headers,
73
        $body
74
    ) {
75
        if (isset($response['transfer_stats']['url'])) {
76
            $response['effective_url'] = $response['transfer_stats']['url'];
77
        }
78
79
        if (!empty($headers)) {
80
            $startLine = explode(' ', array_shift($headers), 3);
81
            $headerList = Core::headersFromLines($headers);
82
            $response['headers'] = $headerList;
83
            $response['version'] = isset($startLine[0]) ? substr($startLine[0], 5) : null;
84
            $response['status'] = isset($startLine[1]) ? (int) $startLine[1] : null;
85
            $response['reason'] = isset($startLine[2]) ? $startLine[2] : null;
86
            $response['body'] = $body;
87
            Core::rewindBody($response);
88
        }
89
90
        return !empty($response['curl']['errno']) || !isset($response['status'])
91
            ? self::createErrorResponse($handler, $request, $response)
92
            : $response;
93
    }
94
95
    private static function createErrorResponse(
96
        callable $handler,
97
        array $request,
98
        array $response
99
    ) {
100
        static $connectionErrors = [
101
            CURLE_OPERATION_TIMEOUTED  => true,
102
            CURLE_COULDNT_RESOLVE_HOST => true,
103
            CURLE_COULDNT_CONNECT      => true,
104
            CURLE_SSL_CONNECT_ERROR    => true,
105
            CURLE_GOT_NOTHING          => true,
106
        ];
107
108
        // Retry when nothing is present or when curl failed to rewind.
109
        if (!isset($response['err_message'])
110
            && (empty($response['curl']['errno'])
111
                || $response['curl']['errno'] == 65)
112
        ) {
113
            return self::retryFailedRewind($handler, $request, $response);
114
        }
115
116
        $message = isset($response['err_message'])
117
            ? $response['err_message']
118
            : sprintf('cURL error %s: %s',
119
                $response['curl']['errno'],
120
                isset($response['curl']['error'])
121
                    ? $response['curl']['error']
122
                    : 'See http://curl.haxx.se/libcurl/c/libcurl-errors.html');
123
124
        $error = isset($response['curl']['errno'])
125
            && isset($connectionErrors[$response['curl']['errno']])
126
            ? new ConnectException($message)
127
            : new RingException($message);
128
129
        return $response + [
130
            'status'  => null,
131
            'reason'  => null,
132
            'body'    => null,
133
            'headers' => [],
134
            'error'   => $error,
135
        ];
136
    }
137
138
    private function getOutputBody(array $request, array &$options)
139
    {
140
        // Determine where the body of the response (if any) will be streamed.
141
        if (isset($options[CURLOPT_WRITEFUNCTION])) {
142
            return $request['client']['save_to'];
143
        }
144
145
        if (isset($options[CURLOPT_FILE])) {
146
            return $options[CURLOPT_FILE];
147
        }
148
149
        if ($request['http_method'] != 'HEAD') {
150
            // Create a default body if one was not provided
151
            return $options[CURLOPT_FILE] = fopen('php://temp', 'w+');
152
        }
153
154
        return null;
155
    }
156
157
    private function getDefaultOptions(array $request, array &$headers)
158
    {
159
        $url = Core::url($request);
160
        $startingResponse = false;
161
162
        $options = [
163
            '_headers'             => $request['headers'],
164
            CURLOPT_CUSTOMREQUEST  => $request['http_method'],
165
            CURLOPT_URL            => $url,
166
            CURLOPT_RETURNTRANSFER => false,
167
            CURLOPT_HEADER         => false,
168
            CURLOPT_CONNECTTIMEOUT => 150,
169
            CURLOPT_HEADERFUNCTION => function ($ch, $h) use (&$headers, &$startingResponse) {
170
                $value = trim($h);
171
                if ($value === '') {
172
                    $startingResponse = true;
173
                } elseif ($startingResponse) {
174
                    $startingResponse = false;
175
                    $headers = [$value];
176
                } else {
177
                    $headers[] = $value;
178
                }
179
                return strlen($h);
180
            },
181
        ];
182
183
        if (isset($request['version'])) {
184
            if ($request['version'] == 2.0) {
185
                $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
186
            } else if ($request['version'] == 1.1) {
187
                $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
188
            } else {
189
                $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
190
            }
191
        }
192
193
        if (defined('CURLOPT_PROTOCOLS')) {
194
            $options[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
195
        }
196
197
        return $options;
198
    }
199
200
    private function applyMethod(array $request, array &$options)
201
    {
202
        if (isset($request['body'])) {
203
            $this->applyBody($request, $options);
204
            return;
205
        }
206
207
        switch ($request['http_method']) {
208
            case 'PUT':
209
            case 'POST':
210
                // See http://tools.ietf.org/html/rfc7230#section-3.3.2
211
                if (!Core::hasHeader($request, 'Content-Length')) {
212
                    $options[CURLOPT_HTTPHEADER][] = 'Content-Length: 0';
213
                }
214
                break;
215
            case 'HEAD':
216
                $options[CURLOPT_NOBODY] = true;
217
                unset(
218
                    $options[CURLOPT_WRITEFUNCTION],
219
                    $options[CURLOPT_READFUNCTION],
220
                    $options[CURLOPT_FILE],
221
                    $options[CURLOPT_INFILE]
222
                );
223
        }
224
    }
225
226
    private function applyBody(array $request, array &$options)
227
    {
228
        $contentLength = Core::firstHeader($request, 'Content-Length');
229
        $size = $contentLength !== null ? (int) $contentLength : null;
230
231
        // Send the body as a string if the size is less than 1MB OR if the
232
        // [client][curl][body_as_string] request value is set.
233
        if (($size !== null && $size < 1000000) ||
234
            isset($request['client']['curl']['body_as_string']) ||
235
            is_string($request['body'])
236
        ) {
237
            $options[CURLOPT_POSTFIELDS] = Core::body($request);
238
            // Don't duplicate the Content-Length header
239
            $this->removeHeader('Content-Length', $options);
240
            $this->removeHeader('Transfer-Encoding', $options);
241
        } else {
242
            $options[CURLOPT_UPLOAD] = true;
243
            if ($size !== null) {
244
                // Let cURL handle setting the Content-Length header
245
                $options[CURLOPT_INFILESIZE] = $size;
246
                $this->removeHeader('Content-Length', $options);
247
            }
248
            $this->addStreamingBody($request, $options);
249
        }
250
251
        // If the Expect header is not present, prevent curl from adding it
252
        if (!Core::hasHeader($request, 'Expect')) {
253
            $options[CURLOPT_HTTPHEADER][] = 'Expect:';
254
        }
255
256
        // cURL sometimes adds a content-type by default. Prevent this.
257
        if (!Core::hasHeader($request, 'Content-Type')) {
258
            $options[CURLOPT_HTTPHEADER][] = 'Content-Type:';
259
        }
260
    }
261
262
    private function addStreamingBody(array $request, array &$options)
263
    {
264
        $body = $request['body'];
265
266
        if ($body instanceof StreamInterface) {
267
            $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) {
268
                return (string) $body->read($length);
269
            };
270
            if (!isset($options[CURLOPT_INFILESIZE])) {
271
                if ($size = $body->getSize()) {
272
                    $options[CURLOPT_INFILESIZE] = $size;
273
                }
274
            }
275
        } elseif (is_resource($body)) {
276
            $options[CURLOPT_INFILE] = $body;
277
        } elseif ($body instanceof \Iterator) {
278
            $buf = '';
279
            $options[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body, &$buf) {
280
                if ($body->valid()) {
281
                    $buf .= $body->current();
282
                    $body->next();
283
                }
284
                $result = (string) substr($buf, 0, $length);
285
                $buf = substr($buf, $length);
286
                return $result;
287
            };
288
        } else {
289
            throw new \InvalidArgumentException('Invalid request body provided');
290
        }
291
    }
292
293
    private function applyHeaders(array $request, array &$options)
294
    {
295
        foreach ($options['_headers'] as $name => $values) {
296
            foreach ($values as $value) {
297
                $options[CURLOPT_HTTPHEADER][] = "$name: $value";
298
            }
299
        }
300
301
        // Remove the Accept header if one was not set
302
        if (!Core::hasHeader($request, 'Accept')) {
303
            $options[CURLOPT_HTTPHEADER][] = 'Accept:';
304
        }
305
    }
306
307
    /**
308
     * Takes an array of curl options specified in the 'curl' option of a
309
     * request's configuration array and maps them to CURLOPT_* options.
310
     *
311
     * This method is only called when a  request has a 'curl' config setting.
312
     *
313
     * @param array $config  Configuration array of custom curl option
314
     * @param array $options Array of existing curl options
315
     *
316
     * @return array Returns a new array of curl options
317
     */
318
    private function applyCustomCurlOptions(array $config, array $options)
319
    {
320
        $curlOptions = [];
321
        foreach ($config as $key => $value) {
322
            if (is_int($key)) {
323
                $curlOptions[$key] = $value;
324
            }
325
        }
326
327
        return $curlOptions + $options;
328
    }
329
330
    /**
331
     * Remove a header from the options array.
332
     *
333
     * @param string $name    Case-insensitive header to remove
334
     * @param array  $options Array of options to modify
335
     */
336
    private function removeHeader($name, array &$options)
337
    {
338
        foreach (array_keys($options['_headers']) as $key) {
339
            if (!strcasecmp($key, $name)) {
340
                unset($options['_headers'][$key]);
341
                return;
342
            }
343
        }
344
    }
345
346
    /**
347
     * Applies an array of request client options to a the options array.
348
     *
349
     * This method uses a large switch rather than double-dispatch to save on
350
     * high overhead of calling functions in PHP.
351
     */
352
    private function applyHandlerOptions(array $request, array &$options)
353
    {
354
        foreach ($request['client'] as $key => $value) {
355
            switch ($key) {
356
            // Violating PSR-4 to provide more room.
357
            case 'verify':
358
359
                if ($value === false) {
360
                    unset($options[CURLOPT_CAINFO]);
361
                    $options[CURLOPT_SSL_VERIFYHOST] = 0;
362
                    $options[CURLOPT_SSL_VERIFYPEER] = false;
363
                    continue;
364
                }
365
366
                $options[CURLOPT_SSL_VERIFYHOST] = 2;
367
                $options[CURLOPT_SSL_VERIFYPEER] = true;
368
369
                if (is_string($value)) {
370
                    $options[CURLOPT_CAINFO] = $value;
371
                    if (!file_exists($value)) {
372
                        throw new \InvalidArgumentException(
373
                            "SSL CA bundle not found: $value"
374
                        );
375
                    }
376
                }
377
                break;
378
379
            case 'decode_content':
380
381
                if ($value === false) {
382
                    continue;
383
                }
384
385
                $accept = Core::firstHeader($request, 'Accept-Encoding');
386
                if ($accept) {
387
                    $options[CURLOPT_ENCODING] = $accept;
388
                } else {
389
                    $options[CURLOPT_ENCODING] = '';
390
                    // Don't let curl send the header over the wire
391
                    $options[CURLOPT_HTTPHEADER][] = 'Accept-Encoding:';
392
                }
393
                break;
394
395
            case 'save_to':
396
397
                if (is_string($value)) {
398
                    if (!is_dir(dirname($value))) {
399
                        throw new \RuntimeException(sprintf(
400
                            'Directory %s does not exist for save_to value of %s',
401
                            dirname($value),
402
                            $value
403
                        ));
404
                    }
405
                    $value = new LazyOpenStream($value, 'w+');
406
                }
407
408
                if ($value instanceof StreamInterface) {
409
                    $options[CURLOPT_WRITEFUNCTION] =
410
                        function ($ch, $write) use ($value) {
411
                            return $value->write($write);
412
                        };
413
                } elseif (is_resource($value)) {
414
                    $options[CURLOPT_FILE] = $value;
415
                } else {
416
                    throw new \InvalidArgumentException('save_to must be a '
417
                        . 'GuzzleHttp\Stream\StreamInterface or resource');
418
                }
419
                break;
420
421
            case 'timeout':
422
423
                if (defined('CURLOPT_TIMEOUT_MS')) {
424
                    $options[CURLOPT_TIMEOUT_MS] = $value * 1000;
425
                } else {
426
                    $options[CURLOPT_TIMEOUT] = $value;
427
                }
428
                break;
429
430
            case 'connect_timeout':
431
432
                if (defined('CURLOPT_CONNECTTIMEOUT_MS')) {
433
                    $options[CURLOPT_CONNECTTIMEOUT_MS] = $value * 1000;
434
                } else {
435
                    $options[CURLOPT_CONNECTTIMEOUT] = $value;
436
                }
437
                break;
438
439
            case 'proxy':
440
441
                if (!is_array($value)) {
442
                    $options[CURLOPT_PROXY] = $value;
443
                } elseif (isset($request['scheme'])) {
444
                    $scheme = $request['scheme'];
445
                    if (isset($value[$scheme])) {
446
                        $options[CURLOPT_PROXY] = $value[$scheme];
447
                    }
448
                }
449
                break;
450
451
            case 'cert':
452
453
                if (is_array($value)) {
454
                    $options[CURLOPT_SSLCERTPASSWD] = $value[1];
455
                    $value = $value[0];
456
                }
457
458
                if (!file_exists($value)) {
459
                    throw new \InvalidArgumentException(
460
                        "SSL certificate not found: {$value}"
461
                    );
462
                }
463
464
                $options[CURLOPT_SSLCERT] = $value;
465
                break;
466
467
            case 'ssl_key':
468
469
                if (is_array($value)) {
470
                    $options[CURLOPT_SSLKEYPASSWD] = $value[1];
471
                    $value = $value[0];
472
                }
473
474
                if (!file_exists($value)) {
475
                    throw new \InvalidArgumentException(
476
                        "SSL private key not found: {$value}"
477
                    );
478
                }
479
480
                $options[CURLOPT_SSLKEY] = $value;
481
                break;
482
483
            case 'progress':
484
485
                if (!is_callable($value)) {
486
                    throw new \InvalidArgumentException(
487
                        'progress client option must be callable'
488
                    );
489
                }
490
491
                $options[CURLOPT_NOPROGRESS] = false;
492
                $options[CURLOPT_PROGRESSFUNCTION] =
493
                    function () use ($value) {
494
                        $args = func_get_args();
495
                        // PHP 5.5 pushed the handle onto the start of the args
496
                        if (is_resource($args[0])) {
497
                            array_shift($args);
498
                        }
499
                        call_user_func_array($value, $args);
500
                    };
501
                break;
502
503
            case 'debug':
504
505
                if ($value) {
506
                    $options[CURLOPT_STDERR] = Core::getDebugResource($value);
507
                    $options[CURLOPT_VERBOSE] = true;
508
                }
509
                break;
510
            }
511
        }
512
    }
513
514
    /**
515
     * This function ensures that a response was set on a transaction. If one
516
     * was not set, then the request is retried if possible. This error
517
     * typically means you are sending a payload, curl encountered a
518
     * "Connection died, retrying a fresh connect" error, tried to rewind the
519
     * stream, and then encountered a "necessary data rewind wasn't possible"
520
     * error, causing the request to be sent through curl_multi_info_read()
521
     * without an error status.
522
     */
523
    private static function retryFailedRewind(
524
        callable $handler,
525
        array $request,
526
        array $response
527
    ) {
528
        // If there is no body, then there is some other kind of issue. This
529
        // is weird and should probably never happen.
530
        if (!isset($request['body'])) {
531
            $response['err_message'] = 'No response was received for a request '
532
                . 'with no body. This could mean that you are saturating your '
533
                . 'network.';
534
            return self::createErrorResponse($handler, $request, $response);
535
        }
536
537
        if (!Core::rewindBody($request)) {
538
            $response['err_message'] = 'The connection unexpectedly failed '
539
                . 'without providing an error. The request would have been '
540
                . 'retried, but attempting to rewind the request body failed.';
541
            return self::createErrorResponse($handler, $request, $response);
542
        }
543
544
        // Retry no more than 3 times before giving up.
545
        if (!isset($request['curl']['retries'])) {
546
            $request['curl']['retries'] = 1;
547
        } elseif ($request['curl']['retries'] == 2) {
548
            $response['err_message'] = 'The cURL request was retried 3 times '
549
                . 'and did no succeed. cURL was unable to rewind the body of '
550
                . 'the request and subsequent retries resulted in the same '
551
                . 'error. Turn on the debug option to see what went wrong. '
552
                . 'See https://bugs.php.net/bug.php?id=47204 for more information.';
553
            return self::createErrorResponse($handler, $request, $response);
554
        } else {
555
            $request['curl']['retries']++;
556
        }
557
558
        return $handler($request);
559
    }
560
}
561