Completed
Push — master ( 21f5dd...5d340a )
by Márk
03:26 queued 01:34
created

CurlFactory::applyHeaders()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 10
nc 8
nop 2
dl 0
loc 20
rs 8.8571
c 0
b 0
f 0
1
<?php
2
namespace GuzzleHttp\Handler;
3
4
use GuzzleHttp\Exception\RequestException;
5
use GuzzleHttp\Exception\ConnectException;
6
use GuzzleHttp\Promise\FulfilledPromise;
7
use GuzzleHttp\Psr7;
8
use GuzzleHttp\Psr7\LazyOpenStream;
9
use GuzzleHttp\TransferStats;
10
use Psr\Http\Message\RequestInterface;
11
12
/**
13
 * Creates curl resources from a request
14
 */
15
class CurlFactory implements CurlFactoryInterface
16
{
17
    /** @var array */
18
    private $handles = [];
19
20
    /** @var int Total number of idle handles to keep in cache */
21
    private $maxHandles;
22
23
    /**
24
     * @param int $maxHandles Maximum number of idle handles.
25
     */
26
    public function __construct($maxHandles)
27
    {
28
        $this->maxHandles = $maxHandles;
29
    }
30
31
    public function create(RequestInterface $request, array $options)
32
    {
33
        if (isset($options['curl']['body_as_string'])) {
34
            $options['_body_as_string'] = $options['curl']['body_as_string'];
35
            unset($options['curl']['body_as_string']);
36
        }
37
38
        $easy = new EasyHandle;
39
        $easy->request = $request;
40
        $easy->options = $options;
41
        $conf = $this->getDefaultConf($easy);
42
        $this->applyMethod($easy, $conf);
43
        $this->applyHandlerOptions($easy, $conf);
44
        $this->applyHeaders($easy, $conf);
45
        unset($conf['_headers']);
46
47
        // Add handler options from the request configuration options
48
        if (isset($options['curl'])) {
49
            $conf = array_replace($conf, $options['curl']);
50
        }
51
52
        $conf[CURLOPT_HEADERFUNCTION] = $this->createHeaderFn($easy);
53
        $easy->handle = $this->handles
54
            ? array_pop($this->handles)
55
            : curl_init();
56
        curl_setopt_array($easy->handle, $conf);
57
58
        return $easy;
59
    }
60
61
    public function release(EasyHandle $easy)
62
    {
63
        $resource = $easy->handle;
64
        unset($easy->handle);
65
66
        if (count($this->handles) >= $this->maxHandles) {
67
            curl_close($resource);
68
        } else {
69
            // Remove all callback functions as they can hold onto references
70
            // and are not cleaned up by curl_reset. Using curl_setopt_array
71
            // does not work for some reason, so removing each one
72
            // individually.
73
            curl_setopt($resource, CURLOPT_HEADERFUNCTION, null);
74
            curl_setopt($resource, CURLOPT_READFUNCTION, null);
75
            curl_setopt($resource, CURLOPT_WRITEFUNCTION, null);
76
            curl_setopt($resource, CURLOPT_PROGRESSFUNCTION, null);
77
            curl_reset($resource);
78
            $this->handles[] = $resource;
79
        }
80
    }
81
82
    /**
83
     * Completes a cURL transaction, either returning a response promise or a
84
     * rejected promise.
85
     *
86
     * @param callable             $handler
87
     * @param EasyHandle           $easy
88
     * @param CurlFactoryInterface $factory Dictates how the handle is released
89
     *
90
     * @return \GuzzleHttp\Promise\PromiseInterface
91
     */
92
    public static function finish(
93
        callable $handler,
94
        EasyHandle $easy,
95
        CurlFactoryInterface $factory
96
    ) {
97
        if (isset($easy->options['on_stats'])) {
98
            self::invokeStats($easy);
99
        }
100
101
        if (!$easy->response || $easy->errno) {
102
            return self::finishError($handler, $easy, $factory);
103
        }
104
105
        // Return the response if it is present and there is no error.
106
        $factory->release($easy);
107
108
        // Rewind the body of the response if possible.
109
        $body = $easy->response->getBody();
110
        if ($body->isSeekable()) {
111
            $body->rewind();
112
        }
113
114
        return new FulfilledPromise($easy->response);
115
    }
116
117
    private static function invokeStats(EasyHandle $easy)
118
    {
119
        $curlStats = curl_getinfo($easy->handle);
120
        $stats = new TransferStats(
121
            $easy->request,
122
            $easy->response,
123
            $curlStats['total_time'],
124
            $easy->errno,
125
            $curlStats
126
        );
127
        call_user_func($easy->options['on_stats'], $stats);
128
    }
129
130
    private static function finishError(
131
        callable $handler,
132
        EasyHandle $easy,
133
        CurlFactoryInterface $factory
134
    ) {
135
        // Get error information and release the handle to the factory.
136
        $ctx = [
137
            'errno' => $easy->errno,
138
            'error' => curl_error($easy->handle),
139
        ] + curl_getinfo($easy->handle);
140
        $factory->release($easy);
141
142
        // Retry when nothing is present or when curl failed to rewind.
143
        if (empty($easy->options['_err_message'])
144
            && (!$easy->errno || $easy->errno == 65)
145
        ) {
146
            return self::retryFailedRewind($handler, $easy, $ctx);
147
        }
148
149
        return self::createRejection($easy, $ctx);
150
    }
151
152
    private static function createRejection(EasyHandle $easy, array $ctx)
153
    {
154
        static $connectionErrors = [
155
            CURLE_OPERATION_TIMEOUTED  => true,
156
            CURLE_COULDNT_RESOLVE_HOST => true,
157
            CURLE_COULDNT_CONNECT      => true,
158
            CURLE_SSL_CONNECT_ERROR    => true,
159
            CURLE_GOT_NOTHING          => true,
160
        ];
161
162
        // If an exception was encountered during the onHeaders event, then
163
        // return a rejected promise that wraps that exception.
164
        if ($easy->onHeadersException) {
165
            return \GuzzleHttp\Promise\rejection_for(
166
                new RequestException(
167
                    'An error was encountered during the on_headers event',
168
                    $easy->request,
169
                    $easy->response,
170
                    $easy->onHeadersException,
171
                    $ctx
172
                )
173
            );
174
        }
175
176
        $message = sprintf(
177
            'cURL error %s: %s (%s)',
178
            $ctx['errno'],
179
            $ctx['error'],
180
            'see http://curl.haxx.se/libcurl/c/libcurl-errors.html'
181
        );
182
183
        // Create a connection exception if it was a specific error code.
184
        $error = isset($connectionErrors[$easy->errno])
185
            ? new ConnectException($message, $easy->request, null, $ctx)
186
            : new RequestException($message, $easy->request, $easy->response, null, $ctx);
187
188
        return \GuzzleHttp\Promise\rejection_for($error);
189
    }
190
191
    private function getDefaultConf(EasyHandle $easy)
192
    {
193
        $conf = [
194
            '_headers'             => $easy->request->getHeaders(),
195
            CURLOPT_CUSTOMREQUEST  => $easy->request->getMethod(),
196
            CURLOPT_URL            => (string) $easy->request->getUri()->withFragment(''),
197
            CURLOPT_RETURNTRANSFER => false,
198
            CURLOPT_HEADER         => false,
199
            CURLOPT_CONNECTTIMEOUT => 150,
200
        ];
201
202
        if (defined('CURLOPT_PROTOCOLS')) {
203
            $conf[CURLOPT_PROTOCOLS] = CURLPROTO_HTTP | CURLPROTO_HTTPS;
204
        }
205
206
        $version = $easy->request->getProtocolVersion();
207
        if ($version == 1.1) {
208
            $conf[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
209
        } elseif ($version == 2.0) {
210
            $conf[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
211
        } else {
212
            $conf[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
213
        }
214
215
        return $conf;
216
    }
217
218
    private function applyMethod(EasyHandle $easy, array &$conf)
219
    {
220
        $body = $easy->request->getBody();
221
        $size = $body->getSize();
222
223
        if ($size === null || $size > 0) {
224
            $this->applyBody($easy->request, $easy->options, $conf);
225
            return;
226
        }
227
228
        $method = $easy->request->getMethod();
229
        if ($method === 'PUT' || $method === 'POST') {
230
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
231
            if (!$easy->request->hasHeader('Content-Length')) {
232
                $conf[CURLOPT_HTTPHEADER][] = 'Content-Length: 0';
233
            }
234
        } elseif ($method === 'HEAD') {
235
            $conf[CURLOPT_NOBODY] = true;
236
            unset(
237
                $conf[CURLOPT_WRITEFUNCTION],
238
                $conf[CURLOPT_READFUNCTION],
239
                $conf[CURLOPT_FILE],
240
                $conf[CURLOPT_INFILE]
241
            );
242
        }
243
    }
244
245
    private function applyBody(RequestInterface $request, array $options, array &$conf)
246
    {
247
        $size = $request->hasHeader('Content-Length')
248
            ? (int) $request->getHeaderLine('Content-Length')
249
            : null;
250
251
        // Send the body as a string if the size is less than 1MB OR if the
252
        // [curl][body_as_string] request value is set.
253
        if (($size !== null && $size < 1000000) ||
254
            !empty($options['_body_as_string'])
255
        ) {
256
            $conf[CURLOPT_POSTFIELDS] = (string) $request->getBody();
257
            // Don't duplicate the Content-Length header
258
            $this->removeHeader('Content-Length', $conf);
259
            $this->removeHeader('Transfer-Encoding', $conf);
260
        } else {
261
            $conf[CURLOPT_UPLOAD] = true;
262
            if ($size !== null) {
263
                $conf[CURLOPT_INFILESIZE] = $size;
264
                $this->removeHeader('Content-Length', $conf);
265
            }
266
            $body = $request->getBody();
267
            if ($body->isSeekable()) {
268
                $body->rewind();
269
            }
270
            $conf[CURLOPT_READFUNCTION] = function ($ch, $fd, $length) use ($body) {
271
                return $body->read($length);
272
            };
273
        }
274
275
        // If the Expect header is not present, prevent curl from adding it
276
        if (!$request->hasHeader('Expect')) {
277
            $conf[CURLOPT_HTTPHEADER][] = 'Expect:';
278
        }
279
280
        // cURL sometimes adds a content-type by default. Prevent this.
281
        if (!$request->hasHeader('Content-Type')) {
282
            $conf[CURLOPT_HTTPHEADER][] = 'Content-Type:';
283
        }
284
    }
285
286
    private function applyHeaders(EasyHandle $easy, array &$conf)
287
    {
288
        foreach ($conf['_headers'] as $name => $values) {
289
            foreach ($values as $value) {
290
                $value = (string) $value;
291
                if ($value === '') {
292
                    // cURL requires a special format for empty headers.
293
                    // See https://github.com/guzzle/guzzle/issues/1882 for more details.
294
                    $conf[CURLOPT_HTTPHEADER][] = "$name;";
295
                } else {
296
                    $conf[CURLOPT_HTTPHEADER][] = "$name: $value";
297
                }
298
            }
299
        }
300
301
        // Remove the Accept header if one was not set
302
        if (!$easy->request->hasHeader('Accept')) {
303
            $conf[CURLOPT_HTTPHEADER][] = 'Accept:';
304
        }
305
    }
306
307
    /**
308
     * Remove a header from the options array.
309
     *
310
     * @param string $name    Case-insensitive header to remove
311
     * @param array  $options Array of options to modify
312
     */
313
    private function removeHeader($name, array &$options)
314
    {
315
        foreach (array_keys($options['_headers']) as $key) {
316
            if (!strcasecmp($key, $name)) {
317
                unset($options['_headers'][$key]);
318
                return;
319
            }
320
        }
321
    }
322
323
    private function applyHandlerOptions(EasyHandle $easy, array &$conf)
324
    {
325
        $options = $easy->options;
326
        if (isset($options['verify'])) {
327
            if ($options['verify'] === false) {
328
                unset($conf[CURLOPT_CAINFO]);
329
                $conf[CURLOPT_SSL_VERIFYHOST] = 0;
330
                $conf[CURLOPT_SSL_VERIFYPEER] = false;
331
            } else {
332
                $conf[CURLOPT_SSL_VERIFYHOST] = 2;
333
                $conf[CURLOPT_SSL_VERIFYPEER] = true;
334
                if (is_string($options['verify'])) {
335
                    // Throw an error if the file/folder/link path is not valid or doesn't exist.
336
                    if (!file_exists($options['verify'])) {
337
                        throw new \InvalidArgumentException(
338
                            "SSL CA bundle not found: {$options['verify']}"
339
                        );
340
                    }
341
                    // If it's a directory or a link to a directory use CURLOPT_CAPATH.
342
                    // If not, it's probably a file, or a link to a file, so use CURLOPT_CAINFO.
343
                    if (is_dir($options['verify']) ||
344
                        (is_link($options['verify']) && is_dir(readlink($options['verify'])))) {
345
                        $conf[CURLOPT_CAPATH] = $options['verify'];
346
                    } else {
347
                        $conf[CURLOPT_CAINFO] = $options['verify'];
348
                    }
349
                }
350
            }
351
        }
352
353
        if (!empty($options['decode_content'])) {
354
            $accept = $easy->request->getHeaderLine('Accept-Encoding');
355
            if ($accept) {
356
                $conf[CURLOPT_ENCODING] = $accept;
357
            } else {
358
                $conf[CURLOPT_ENCODING] = '';
359
                // Don't let curl send the header over the wire
360
                $conf[CURLOPT_HTTPHEADER][] = 'Accept-Encoding:';
361
            }
362
        }
363
364
        if (isset($options['sink'])) {
365
            $sink = $options['sink'];
366
            if (!is_string($sink)) {
367
                $sink = \GuzzleHttp\Psr7\stream_for($sink);
368
            } elseif (!is_dir(dirname($sink))) {
369
                // Ensure that the directory exists before failing in curl.
370
                throw new \RuntimeException(sprintf(
371
                    'Directory %s does not exist for sink value of %s',
372
                    dirname($sink),
373
                    $sink
374
                ));
375
            } else {
376
                $sink = new LazyOpenStream($sink, 'w+');
377
            }
378
            $easy->sink = $sink;
379
            $conf[CURLOPT_WRITEFUNCTION] = function ($ch, $write) use ($sink) {
380
                return $sink->write($write);
381
            };
382
        } else {
383
            // Use a default temp stream if no sink was set.
384
            $conf[CURLOPT_FILE] = fopen('php://temp', 'w+');
385
            $easy->sink = Psr7\stream_for($conf[CURLOPT_FILE]);
386
        }
387
        $timeoutRequiresNoSignal = false;
388 View Code Duplication
        if (isset($options['timeout'])) {
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...
389
            $timeoutRequiresNoSignal |= $options['timeout'] < 1;
390
            $conf[CURLOPT_TIMEOUT_MS] = $options['timeout'] * 1000;
391
        }
392
393
        // CURL default value is CURL_IPRESOLVE_WHATEVER
394
        if (isset($options['force_ip_resolve'])) {
395
            if ('v4' === $options['force_ip_resolve']) {
396
                $conf[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V4;
397
            } elseif ('v6' === $options['force_ip_resolve']) {
398
                $conf[CURLOPT_IPRESOLVE] = CURL_IPRESOLVE_V6;
399
            }
400
        }
401
402 View Code Duplication
        if (isset($options['connect_timeout'])) {
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...
403
            $timeoutRequiresNoSignal |= $options['connect_timeout'] < 1;
404
            $conf[CURLOPT_CONNECTTIMEOUT_MS] = $options['connect_timeout'] * 1000;
405
        }
406
407
        if ($timeoutRequiresNoSignal && strtoupper(substr(PHP_OS, 0, 3)) !== 'WIN') {
408
            $conf[CURLOPT_NOSIGNAL] = true;
409
        }
410
411
        if (isset($options['proxy'])) {
412
            if (!is_array($options['proxy'])) {
413
                $conf[CURLOPT_PROXY] = $options['proxy'];
414
            } else {
415
                $scheme = $easy->request->getUri()->getScheme();
416
                if (isset($options['proxy'][$scheme])) {
417
                    $host = $easy->request->getUri()->getHost();
418
                    if (!isset($options['proxy']['no']) ||
419
                        !\GuzzleHttp\is_host_in_noproxy($host, $options['proxy']['no'])
420
                    ) {
421
                        $conf[CURLOPT_PROXY] = $options['proxy'][$scheme];
422
                    }
423
                }
424
            }
425
        }
426
427 View Code Duplication
        if (isset($options['cert'])) {
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...
428
            $cert = $options['cert'];
429
            if (is_array($cert)) {
430
                $conf[CURLOPT_SSLCERTPASSWD] = $cert[1];
431
                $cert = $cert[0];
432
            }
433
            if (!file_exists($cert)) {
434
                throw new \InvalidArgumentException(
435
                    "SSL certificate not found: {$cert}"
436
                );
437
            }
438
            $conf[CURLOPT_SSLCERT] = $cert;
439
        }
440
441 View Code Duplication
        if (isset($options['ssl_key'])) {
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...
442
            $sslKey = $options['ssl_key'];
443
            if (is_array($sslKey)) {
444
                $conf[CURLOPT_SSLKEYPASSWD] = $sslKey[1];
445
                $sslKey = $sslKey[0];
446
            }
447
            if (!file_exists($sslKey)) {
448
                throw new \InvalidArgumentException(
449
                    "SSL private key not found: {$sslKey}"
450
                );
451
            }
452
            $conf[CURLOPT_SSLKEY] = $sslKey;
453
        }
454
455
        if (isset($options['progress'])) {
456
            $progress = $options['progress'];
457
            if (!is_callable($progress)) {
458
                throw new \InvalidArgumentException(
459
                    'progress client option must be callable'
460
                );
461
            }
462
            $conf[CURLOPT_NOPROGRESS] = false;
463
            $conf[CURLOPT_PROGRESSFUNCTION] = function () use ($progress) {
464
                $args = func_get_args();
465
                // PHP 5.5 pushed the handle onto the start of the args
466
                if (is_resource($args[0])) {
467
                    array_shift($args);
468
                }
469
                call_user_func_array($progress, $args);
470
            };
471
        }
472
473
        if (!empty($options['debug'])) {
474
            $conf[CURLOPT_STDERR] = \GuzzleHttp\debug_resource($options['debug']);
475
            $conf[CURLOPT_VERBOSE] = true;
476
        }
477
    }
478
479
    /**
480
     * This function ensures that a response was set on a transaction. If one
481
     * was not set, then the request is retried if possible. This error
482
     * typically means you are sending a payload, curl encountered a
483
     * "Connection died, retrying a fresh connect" error, tried to rewind the
484
     * stream, and then encountered a "necessary data rewind wasn't possible"
485
     * error, causing the request to be sent through curl_multi_info_read()
486
     * without an error status.
487
     */
488
    private static function retryFailedRewind(
489
        callable $handler,
490
        EasyHandle $easy,
491
        array $ctx
492
    ) {
493
        try {
494
            // Only rewind if the body has been read from.
495
            $body = $easy->request->getBody();
496
            if ($body->tell() > 0) {
497
                $body->rewind();
498
            }
499
        } catch (\RuntimeException $e) {
500
            $ctx['error'] = 'The connection unexpectedly failed without '
501
                . 'providing an error. The request would have been retried, '
502
                . 'but attempting to rewind the request body failed. '
503
                . 'Exception: ' . $e;
504
            return self::createRejection($easy, $ctx);
505
        }
506
507
        // Retry no more than 3 times before giving up.
508
        if (!isset($easy->options['_curl_retries'])) {
509
            $easy->options['_curl_retries'] = 1;
510
        } elseif ($easy->options['_curl_retries'] == 2) {
511
            $ctx['error'] = 'The cURL request was retried 3 times '
512
                . 'and did not succeed. The most likely reason for the failure '
513
                . 'is that cURL was unable to rewind the body of the request '
514
                . 'and subsequent retries resulted in the same error. Turn on '
515
                . 'the debug option to see what went wrong. See '
516
                . 'https://bugs.php.net/bug.php?id=47204 for more information.';
517
            return self::createRejection($easy, $ctx);
518
        } else {
519
            $easy->options['_curl_retries']++;
520
        }
521
522
        return $handler($easy->request, $easy->options);
523
    }
524
525
    private function createHeaderFn(EasyHandle $easy)
526
    {
527
        if (isset($easy->options['on_headers'])) {
528
            $onHeaders = $easy->options['on_headers'];
529
530
            if (!is_callable($onHeaders)) {
531
                throw new \InvalidArgumentException('on_headers must be callable');
532
            }
533
        } else {
534
            $onHeaders = null;
535
        }
536
537
        return function ($ch, $h) use (
538
            $onHeaders,
539
            $easy,
540
            &$startingResponse
541
        ) {
542
            $value = trim($h);
543
            if ($value === '') {
544
                $startingResponse = true;
545
                $easy->createResponse();
546
                if ($onHeaders !== null) {
547
                    try {
548
                        $onHeaders($easy->response);
549
                    } catch (\Exception $e) {
550
                        // Associate the exception with the handle and trigger
551
                        // a curl header write error by returning 0.
552
                        $easy->onHeadersException = $e;
553
                        return -1;
554
                    }
555
                }
556
            } elseif ($startingResponse) {
557
                $startingResponse = false;
558
                $easy->headers = [$value];
559
            } else {
560
                $easy->headers[] = $value;
561
            }
562
            return strlen($h);
563
        };
564
    }
565
}
566