• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 15015773098

14 May 2025 08:18AM UTC coverage: 84.265% (+0.003%) from 84.262%
15015773098

Pull #9557

github

web-flow
Merge 8d5fe458a into ac89c61d8
Pull Request #9557: feat: share Connection & DNS Cache to `CURLRequest`

9 of 9 new or added lines in 1 file covered. (100.0%)

3 existing lines in 1 file now uncovered.

20832 of 24722 relevant lines covered (84.27%)

190.76 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.78
/system/HTTP/CURLRequest.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use CodeIgniter\Exceptions\InvalidArgumentException;
17
use CodeIgniter\HTTP\Exceptions\HTTPException;
18
use Config\App;
19
use Config\CURLRequest as ConfigCURLRequest;
20
use CurlShareHandle;
21

22
/**
23
 * A lightweight HTTP client for sending synchronous HTTP requests via cURL.
24
 *
25
 * @see \CodeIgniter\HTTP\CURLRequestTest
26
 */
27
class CURLRequest extends OutgoingRequest
28
{
29
    /**
30
     * The response object associated with this request
31
     *
32
     * @var ResponseInterface|null
33
     */
34
    protected $response;
35

36
    /**
37
     * The original response object associated with this request
38
     *
39
     * @var ResponseInterface|null
40
     */
41
    protected $responseOrig;
42

43
    /**
44
     * The URI associated with this request
45
     *
46
     * @var URI
47
     */
48
    protected $baseURI;
49

50
    /**
51
     * The setting values
52
     *
53
     * @var array
54
     */
55
    protected $config;
56

57
    /**
58
     * The default setting values
59
     *
60
     * @var array
61
     */
62
    protected $defaultConfig = [
63
        'timeout'         => 0.0,
64
        'connect_timeout' => 150,
65
        'debug'           => false,
66
        'verify'          => true,
67
    ];
68

69
    /**
70
     * Default values for when 'allow_redirects'
71
     * option is true.
72
     *
73
     * @var array
74
     */
75
    protected $redirectDefaults = [
76
        'max'       => 5,
77
        'strict'    => true,
78
        'protocols' => [
79
            'http',
80
            'https',
81
        ],
82
    ];
83

84
    /**
85
     * The number of milliseconds to delay before
86
     * sending the request.
87
     *
88
     * @var float
89
     */
90
    protected $delay = 0.0;
91

92
    /**
93
     * The default options from the constructor. Applied to all requests.
94
     */
95
    private readonly array $defaultOptions;
96

97
    /**
98
     * Whether share options between requests or not.
99
     *
100
     * If true, all the options won't be reset between requests.
101
     * It may cause an error request with unnecessary headers.
102
     */
103
    private readonly bool $shareOptions;
104

105
    /**
106
     * Whether share options between requests or not.
107
     *
108
     * If true, all the options won't be reset between requests.
109
     * It may cause an error request with unnecessary headers.
110
     */
111
    private readonly CurlShareHandle $shareConnection;
112

113
    /**
114
     * Takes an array of options to set the following possible class properties:
115
     *
116
     *  - baseURI
117
     *  - timeout
118
     *  - any other request options to use as defaults.
119
     *
120
     * @param array<string, mixed> $options
121
     */
122
    public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = [])
123
    {
124
        if (! function_exists('curl_version')) {
154✔
UNCOV
125
            throw HTTPException::forMissingCurl(); // @codeCoverageIgnore
×
126
        }
127

128
        parent::__construct(Method::GET, $uri);
154✔
129

130
        $this->responseOrig = $response ?? new Response($config);
154✔
131
        // Remove the default Content-Type header.
132
        $this->responseOrig->removeHeader('Content-Type');
154✔
133

134
        $this->baseURI        = $uri->useRawQueryString();
154✔
135
        $this->defaultOptions = $options;
154✔
136

137
        $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions ?? true;
154✔
138

139
        $this->config = $this->defaultConfig;
154✔
140
        $this->parseOptions($options);
154✔
141

142
        // Share Connection
143
        $this->shareConnection = curl_share_init();
154✔
144
        curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
154✔
145
        curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, CURL_LOCK_DATA_DNS);
154✔
146
    }
147

148
    /**
149
     * Sends an HTTP request to the specified $url. If this is a relative
150
     * URL, it will be merged with $this->baseURI to form a complete URL.
151
     *
152
     * @param string $method HTTP method
153
     */
154
    public function request($method, string $url, array $options = []): ResponseInterface
155
    {
156
        $this->response = clone $this->responseOrig;
140✔
157

158
        $this->parseOptions($options);
140✔
159

160
        $url = $this->prepareURL($url);
140✔
161

162
        $method = esc(strip_tags($method));
140✔
163

164
        $this->send($method, $url);
140✔
165

166
        if ($this->shareOptions === false) {
136✔
167
            $this->resetOptions();
69✔
168
        }
169

170
        return $this->response;
136✔
171
    }
172

173
    /**
174
     * Reset all options to default.
175
     *
176
     * @return void
177
     */
178
    protected function resetOptions()
179
    {
180
        // Reset headers
181
        $this->headers   = [];
69✔
182
        $this->headerMap = [];
69✔
183

184
        // Reset body
185
        $this->body = null;
69✔
186

187
        // Reset configs
188
        $this->config = $this->defaultConfig;
69✔
189

190
        // Set the default options for next request
191
        $this->parseOptions($this->defaultOptions);
69✔
192
    }
193

194
    /**
195
     * Convenience method for sending a GET request.
196
     */
197
    public function get(string $url, array $options = []): ResponseInterface
198
    {
199
        return $this->request(Method::GET, $url, $options);
38✔
200
    }
201

202
    /**
203
     * Convenience method for sending a DELETE request.
204
     */
205
    public function delete(string $url, array $options = []): ResponseInterface
206
    {
207
        return $this->request('DELETE', $url, $options);
2✔
208
    }
209

210
    /**
211
     * Convenience method for sending a HEAD request.
212
     */
213
    public function head(string $url, array $options = []): ResponseInterface
214
    {
215
        return $this->request('HEAD', $url, $options);
2✔
216
    }
217

218
    /**
219
     * Convenience method for sending an OPTIONS request.
220
     */
221
    public function options(string $url, array $options = []): ResponseInterface
222
    {
223
        return $this->request('OPTIONS', $url, $options);
2✔
224
    }
225

226
    /**
227
     * Convenience method for sending a PATCH request.
228
     */
229
    public function patch(string $url, array $options = []): ResponseInterface
230
    {
231
        return $this->request('PATCH', $url, $options);
2✔
232
    }
233

234
    /**
235
     * Convenience method for sending a POST request.
236
     */
237
    public function post(string $url, array $options = []): ResponseInterface
238
    {
239
        return $this->request(Method::POST, $url, $options);
16✔
240
    }
241

242
    /**
243
     * Convenience method for sending a PUT request.
244
     */
245
    public function put(string $url, array $options = []): ResponseInterface
246
    {
247
        return $this->request(Method::PUT, $url, $options);
2✔
248
    }
249

250
    /**
251
     * Set the HTTP Authentication.
252
     *
253
     * @param string $type basic or digest
254
     *
255
     * @return $this
256
     */
257
    public function setAuth(string $username, string $password, string $type = 'basic')
258
    {
259
        $this->config['auth'] = [
4✔
260
            $username,
4✔
261
            $password,
4✔
262
            $type,
4✔
263
        ];
4✔
264

265
        return $this;
4✔
266
    }
267

268
    /**
269
     * Set form data to be sent.
270
     *
271
     * @param bool $multipart Set TRUE if you are sending CURLFiles
272
     *
273
     * @return $this
274
     */
275
    public function setForm(array $params, bool $multipart = false)
276
    {
277
        if ($multipart) {
2✔
278
            $this->config['multipart'] = $params;
2✔
279
        } else {
280
            $this->config['form_params'] = $params;
2✔
281
        }
282

283
        return $this;
2✔
284
    }
285

286
    /**
287
     * Set JSON data to be sent.
288
     *
289
     * @param array|bool|float|int|object|string|null $data
290
     *
291
     * @return $this
292
     */
293
    public function setJSON($data)
294
    {
295
        $this->config['json'] = $data;
2✔
296

297
        return $this;
2✔
298
    }
299

300
    /**
301
     * Sets the correct settings based on the options array
302
     * passed in.
303
     *
304
     * @return void
305
     */
306
    protected function parseOptions(array $options)
307
    {
308
        if (array_key_exists('baseURI', $options)) {
155✔
309
            $this->baseURI = $this->baseURI->setURI($options['baseURI']);
44✔
310
            unset($options['baseURI']);
44✔
311
        }
312

313
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
155✔
314
            foreach ($options['headers'] as $name => $value) {
6✔
315
                $this->setHeader($name, $value);
6✔
316
            }
317

318
            unset($options['headers']);
6✔
319
        }
320

321
        if (array_key_exists('delay', $options)) {
155✔
322
            // Convert from the milliseconds passed in
323
            // to the seconds that sleep requires.
324
            $this->delay = (float) $options['delay'] / 1000;
24✔
325
            unset($options['delay']);
24✔
326
        }
327

328
        if (array_key_exists('body', $options)) {
155✔
329
            $this->setBody($options['body']);
2✔
330
            unset($options['body']);
2✔
331
        }
332

333
        foreach ($options as $key => $value) {
155✔
334
            $this->config[$key] = $value;
76✔
335
        }
336
    }
337

338
    /**
339
     * If the $url is a relative URL, will attempt to create
340
     * a full URL by prepending $this->baseURI to it.
341
     */
342
    protected function prepareURL(string $url): string
343
    {
344
        // If it's a full URI, then we have nothing to do here...
345
        if (str_contains($url, '://')) {
142✔
346
            return $url;
74✔
347
        }
348

349
        $uri = $this->baseURI->resolveRelativeURI($url);
68✔
350

351
        // Create the string instead of casting to prevent baseURL muddling
352
        return URI::createURIString(
68✔
353
            $uri->getScheme(),
68✔
354
            $uri->getAuthority(),
68✔
355
            $uri->getPath(),
68✔
356
            $uri->getQuery(),
68✔
357
            $uri->getFragment(),
68✔
358
        );
68✔
359
    }
360

361
    /**
362
     * Fires the actual cURL request.
363
     *
364
     * @return ResponseInterface
365
     */
366
    public function send(string $method, string $url)
367
    {
368
        // Reset our curl options so we're on a fresh slate.
369
        $curlOptions = [];
142✔
370

371
        if (! empty($this->config['query']) && is_array($this->config['query'])) {
142✔
372
            // This is likely too naive a solution.
373
            // Should look into handling when $url already
374
            // has query vars on it.
375
            $url .= '?' . http_build_query($this->config['query']);
2✔
376
            unset($this->config['query']);
2✔
377
        }
378

379
        $curlOptions[CURLOPT_URL]            = $url;
142✔
380
        $curlOptions[CURLOPT_SHARE]          = $this->shareConnection;
142✔
381
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
142✔
382
        $curlOptions[CURLOPT_HEADER]         = true;
142✔
383
        $curlOptions[CURLOPT_FRESH_CONNECT]  = true;
142✔
384
        // Disable @file uploads in post data.
385
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
142✔
386

387
        $curlOptions = $this->setCURLOptions($curlOptions, $this->config);
142✔
388
        $curlOptions = $this->applyMethod($method, $curlOptions);
138✔
389
        $curlOptions = $this->applyRequestHeaders($curlOptions);
138✔
390

391
        // Do we need to delay this request?
392
        if ($this->delay > 0) {
138✔
393
            usleep((int) $this->delay * 1_000_000);
22✔
394
        }
395

396
        $output = $this->sendRequest($curlOptions);
138✔
397

398
        // Set the string we want to break our response from
399
        $breakString = "\r\n\r\n";
138✔
400

401
        if (isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false) {
138✔
402
            $output = $this->handleRedirectHeaders($output, $breakString);
10✔
403
        }
404

405
        while (str_starts_with($output, 'HTTP/1.1 100 Continue')) {
138✔
406
            $output = substr($output, strpos($output, $breakString) + 4);
6✔
407
        }
408

409
        if (preg_match('/HTTP\/\d\.\d 200 Connection established/i', $output)) {
138✔
410
            $output = substr($output, strpos($output, $breakString) + 4);
4✔
411
        }
412

413
        // If request and response have Digest
414
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest' && str_contains($output, 'WWW-Authenticate: Digest')) {
138✔
415
            $output = substr($output, strpos($output, $breakString) + 4);
4✔
416
        }
417

418
        // Split out our headers and body
419
        $break = strpos($output, $breakString);
138✔
420

421
        if ($break !== false) {
138✔
422
            // Our headers
423
            $headers = explode("\n", substr($output, 0, $break));
28✔
424

425
            $this->setResponseHeaders($headers);
28✔
426

427
            // Our body
428
            $body = substr($output, $break + 4);
28✔
429
            $this->response->setBody($body);
28✔
430
        } else {
431
            $this->response->setBody($output);
110✔
432
        }
433

434
        return $this->response;
138✔
435
    }
436

437
    /**
438
     * Adds $this->headers to the cURL request.
439
     */
440
    protected function applyRequestHeaders(array $curlOptions = []): array
441
    {
442
        if (empty($this->headers)) {
138✔
443
            return $curlOptions;
69✔
444
        }
445

446
        $set = [];
72✔
447

448
        foreach (array_keys($this->headers) as $name) {
72✔
449
            $set[] = $name . ': ' . $this->getHeaderLine($name);
72✔
450
        }
451

452
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
72✔
453

454
        return $curlOptions;
72✔
455
    }
456

457
    /**
458
     * Apply method
459
     */
460
    protected function applyMethod(string $method, array $curlOptions): array
461
    {
462
        $this->method                       = $method;
138✔
463
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
138✔
464

465
        $size = strlen($this->body ?? '');
138✔
466

467
        // Have content?
468
        if ($size > 0) {
138✔
469
            return $this->applyBody($curlOptions);
10✔
470
        }
471

472
        if ($method === Method::PUT || $method === Method::POST) {
129✔
473
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
474
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
37✔
475
                $this->setHeader('Content-Length', '0');
25✔
476
            }
477
        } elseif ($method === 'HEAD') {
96✔
478
            $curlOptions[CURLOPT_NOBODY] = 1;
2✔
479
        }
480

481
        return $curlOptions;
129✔
482
    }
483

484
    /**
485
     * Apply body
486
     */
487
    protected function applyBody(array $curlOptions = []): array
488
    {
489
        if (! empty($this->body)) {
10✔
490
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
10✔
491
        }
492

493
        return $curlOptions;
10✔
494
    }
495

496
    /**
497
     * Parses the header retrieved from the cURL response into
498
     * our Response object.
499
     *
500
     * @return void
501
     */
502
    protected function setResponseHeaders(array $headers = [])
503
    {
504
        foreach ($headers as $header) {
28✔
505
            if (($pos = strpos($header, ':')) !== false) {
28✔
506
                $title = trim(substr($header, 0, $pos));
24✔
507
                $value = trim(substr($header, $pos + 1));
24✔
508

509
                if ($this->response instanceof Response) {
24✔
510
                    $this->response->addHeader($title, $value);
24✔
511
                } else {
UNCOV
512
                    $this->response->setHeader($title, $value);
×
513
                }
514
            } elseif (str_starts_with($header, 'HTTP')) {
26✔
515
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+) (.+)#', $header, $matches);
26✔
516

517
                if (isset($matches[1])) {
26✔
518
                    $this->response->setProtocolVersion($matches[1]);
22✔
519
                }
520

521
                if (isset($matches[2])) {
26✔
522
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? null);
22✔
523
                }
524
            }
525
        }
526
    }
527

528
    /**
529
     * Set CURL options
530
     *
531
     * @return array
532
     *
533
     * @throws InvalidArgumentException
534
     */
535
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
536
    {
537
        // Auth Headers
538
        if (! empty($config['auth'])) {
142✔
539
            $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1];
10✔
540

541
            if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') {
10✔
542
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST;
4✔
543
            } else {
544
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
6✔
545
            }
546
        }
547

548
        // Certificate
549
        if (! empty($config['cert'])) {
142✔
550
            $cert = $config['cert'];
6✔
551

552
            if (is_array($cert)) {
6✔
553
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2✔
554
                $cert                               = $cert[0];
2✔
555
            }
556

557
            if (! is_file($cert)) {
6✔
558
                throw HTTPException::forSSLCertNotFound($cert);
2✔
559
            }
560

561
            $curlOptions[CURLOPT_SSLCERT] = $cert;
4✔
562
        }
563

564
        // SSL Verification
565
        if (isset($config['verify'])) {
140✔
566
            if (is_string($config['verify'])) {
140✔
567
                $file = realpath($config['verify']) ?: $config['verify'];
4✔
568

569
                if (! is_file($file)) {
4✔
570
                    throw HTTPException::forInvalidSSLKey($config['verify']);
2✔
571
                }
572

573
                $curlOptions[CURLOPT_CAINFO]         = $file;
2✔
574
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2✔
575
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2✔
576
            } elseif (is_bool($config['verify'])) {
136✔
577
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
136✔
578
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
136✔
579
            }
580
        }
581

582
        // Proxy
583
        if (isset($config['proxy'])) {
138✔
584
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
2✔
585
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
2✔
586
        }
587

588
        // Debug
589
        if ($config['debug']) {
138✔
590
            $curlOptions[CURLOPT_VERBOSE] = 1;
4✔
591
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
4✔
592
        }
593

594
        // Decode Content
595
        if (! empty($config['decode_content'])) {
138✔
596
            $accept = $this->getHeaderLine('Accept-Encoding');
4✔
597

598
            if ($accept !== '') {
4✔
599
                $curlOptions[CURLOPT_ENCODING] = $accept;
2✔
600
            } else {
601
                $curlOptions[CURLOPT_ENCODING]   = '';
2✔
602
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
2✔
603
            }
604
        }
605

606
        // Allow Redirects
607
        if (array_key_exists('allow_redirects', $config)) {
138✔
608
            $settings = $this->redirectDefaults;
12✔
609

610
            if (is_array($config['allow_redirects'])) {
12✔
611
                $settings = array_merge($settings, $config['allow_redirects']);
2✔
612
            }
613

614
            if ($config['allow_redirects'] === false) {
12✔
615
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
2✔
616
            } else {
617
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
10✔
618
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
10✔
619

620
                if ($settings['strict'] === true) {
10✔
621
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
10✔
622
                }
623

624
                $protocols = 0;
10✔
625

626
                foreach ($settings['protocols'] as $proto) {
10✔
627
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
10✔
628
                }
629

630
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
10✔
631
            }
632
        }
633

634
        // Timeout
635
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000;
138✔
636

637
        // Connection Timeout
638
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000;
138✔
639

640
        // Post Data - application/x-www-form-urlencoded
641
        if (! empty($config['form_params']) && is_array($config['form_params'])) {
138✔
642
            $postFields                      = http_build_query($config['form_params']);
10✔
643
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
10✔
644

645
            // Ensure content-length is set, since CURL doesn't seem to
646
            // calculate it when HTTPHEADER is set.
647
            $this->setHeader('Content-Length', (string) strlen($postFields));
10✔
648
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
10✔
649
        }
650

651
        // Post Data - multipart/form-data
652
        if (! empty($config['multipart']) && is_array($config['multipart'])) {
138✔
653
            // setting the POSTFIELDS option automatically sets multipart
654
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
4✔
655
        }
656

657
        // HTTP Errors
658
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
138✔
659

660
        // JSON
661
        if (isset($config['json'])) {
138✔
662
            // Will be set as the body in `applyBody()`
663
            $json = json_encode($config['json']);
4✔
664
            $this->setBody($json);
4✔
665
            $this->setHeader('Content-Type', 'application/json');
4✔
666
            $this->setHeader('Content-Length', (string) strlen($json));
4✔
667
        }
668

669
        // Resolve IP
670
        if (array_key_exists('force_ip_resolve', $config)) {
138✔
671
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
6✔
672
                'v4'    => CURL_IPRESOLVE_V4,
2✔
673
                'v6'    => CURL_IPRESOLVE_V6,
2✔
674
                default => CURL_IPRESOLVE_WHATEVER,
2✔
675
            };
6✔
676
        }
677

678
        // version
679
        if (! empty($config['version'])) {
138✔
680
            $version = sprintf('%.1F', $config['version']);
10✔
681
            if ($version === '1.0') {
10✔
682
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
4✔
683
            } elseif ($version === '1.1') {
8✔
684
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
4✔
685
            } elseif ($version === '2.0') {
6✔
686
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
4✔
687
            } elseif ($version === '3.0') {
2✔
688
                if (! defined('CURL_HTTP_VERSION_3')) {
2✔
689
                    define('CURL_HTTP_VERSION_3', 30);
1✔
690
                }
691

692
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_3;
2✔
693
            }
694
        }
695

696
        // Cookie
697
        if (isset($config['cookie'])) {
138✔
698
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
2✔
699
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
2✔
700
        }
701

702
        // User Agent
703
        if (isset($config['user_agent'])) {
138✔
704
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
4✔
705
        }
706

707
        return $curlOptions;
138✔
708
    }
709

710
    /**
711
     * Does the actual work of initializing cURL, setting the options,
712
     * and grabbing the output.
713
     *
714
     * @codeCoverageIgnore
715
     */
716
    protected function sendRequest(array $curlOptions = []): string
717
    {
718
        $ch = curl_init();
2✔
719

720
        curl_setopt_array($ch, $curlOptions);
2✔
721

722
        // Send the request and wait for a response.
723
        $output = curl_exec($ch);
2✔
724

725
        if ($output === false) {
2✔
UNCOV
726
            throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch));
×
727
        }
728

729
        curl_close($ch);
2✔
730

731
        return $output;
2✔
732
    }
733

734
    private function handleRedirectHeaders(string $output, string $breakString): string
735
    {
736
        // Strip out multiple redirect header sections
737
        while (preg_match('/^HTTP\/\d(?:\.\d)? 3\d\d/', $output)) {
10✔
738
            $breakStringPos        = strpos($output, $breakString);
4✔
739
            $redirectHeaderSection = substr($output, 0, $breakStringPos);
4✔
740
            $redirectHeaders       = explode("\n", $redirectHeaderSection);
4✔
741
            $locationHeaderFound   = false;
4✔
742

743
            foreach ($redirectHeaders as $header) {
4✔
744
                if (str_starts_with(strtolower($header), 'location:')) {
4✔
745
                    $locationHeaderFound = true;
2✔
746
                    break;
2✔
747
                }
748
            }
749

750
            if ($locationHeaderFound) {
4✔
751
                $output = substr($output, $breakStringPos + 4);
2✔
752
            } else {
753
                break;
2✔
754
            }
755
        }
756

757
        return $output;
10✔
758
    }
759
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc