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

codeigniter4 / CodeIgniter4 / 28968418540

08 Jul 2026 07:04PM UTC coverage: 88.71% (+0.006%) from 88.704%
28968418540

push

github

web-flow
refactor(HTTP): split setCURLOptions into category helper methods to reduce complexity (#10339)

* refactor(HTTP): split setCURLOptions into category helper methods to reduce complexity

* fix: lazy-load CURL_HTTP_VERSION_3 constant only for HTTP/3.0

* fix: replace empty() with strict isset/bool check to satisfy PHPStan

57 of 58 new or added lines in 1 file covered. (98.28%)

22322 of 25163 relevant lines covered (88.71%)

213.98 hits per line

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

99.27
/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
use SensitiveParameter;
22

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

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

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

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

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

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

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

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

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

106
    /**
107
     * The share connection instance.
108
     */
109
    protected ?CurlShareHandle $shareConnection = null;
110

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

126
        parent::__construct(Method::GET, $uri);
189✔
127

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

132
        $this->baseURI        = $uri->useRawQueryString();
189✔
133
        $this->defaultOptions = $options;
189✔
134

135
        $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions;
189✔
136

137
        $this->config = $this->defaultConfig;
189✔
138
        $this->parseOptions($options);
189✔
139

140
        // Share Connection
141
        $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property
189✔
142
            CURL_LOCK_DATA_CONNECT,
189✔
143
            CURL_LOCK_DATA_DNS,
189✔
144
        ];
145

146
        if ($optShareConnection !== []) {
189✔
147
            $this->shareConnection = curl_share_init();
189✔
148

149
            foreach (array_unique($optShareConnection) as $opt) {
189✔
150
                curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt);
189✔
151
            }
152
        }
153
    }
154

155
    /**
156
     * Sends an HTTP request to the specified $url. If this is a relative
157
     * URL, it will be merged with $this->baseURI to form a complete URL.
158
     *
159
     * @param string $method HTTP method
160
     */
161
    public function request($method, string $url, array $options = []): ResponseInterface
162
    {
163
        $this->response = clone $this->responseOrig;
170✔
164

165
        $this->parseOptions($options);
170✔
166

167
        $url = $this->prepareURL($url);
170✔
168

169
        $method = esc(strip_tags($method));
170✔
170

171
        $this->send($method, $url);
170✔
172

173
        if ($this->shareOptions === false) {
166✔
174
            $this->resetOptions();
83✔
175
        }
176

177
        return $this->response;
166✔
178
    }
179

180
    /**
181
     * Reset all options to default.
182
     *
183
     * @return void
184
     */
185
    protected function resetOptions()
186
    {
187
        // Reset headers
188
        $this->headers   = [];
83✔
189
        $this->headerMap = [];
83✔
190

191
        // Reset body
192
        $this->body = null;
83✔
193

194
        // Reset configs
195
        $this->config = $this->defaultConfig;
83✔
196

197
        // Set the default options for next request
198
        $this->parseOptions($this->defaultOptions);
83✔
199
    }
200

201
    /**
202
     * Convenience method for sending a GET request.
203
     */
204
    public function get(string $url, array $options = []): ResponseInterface
205
    {
206
        return $this->request(Method::GET, $url, $options);
38✔
207
    }
208

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

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

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

233
    /**
234
     * Convenience method for sending a PATCH request.
235
     */
236
    public function patch(string $url, array $options = []): ResponseInterface
237
    {
238
        return $this->request('PATCH', $url, $options);
2✔
239
    }
240

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

249
    /**
250
     * Convenience method for sending a PUT request.
251
     */
252
    public function put(string $url, array $options = []): ResponseInterface
253
    {
254
        return $this->request(Method::PUT, $url, $options);
2✔
255
    }
256

257
    /**
258
     * Set the HTTP Authentication.
259
     *
260
     * @param string $type basic or digest
261
     *
262
     * @return $this
263
     */
264
    public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic')
265
    {
266
        $this->config['auth'] = [$username, $password, $type];
4✔
267

268
        return $this;
4✔
269
    }
270

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

286
        return $this;
2✔
287
    }
288

289
    /**
290
     * Set JSON data to be sent.
291
     *
292
     * @param mixed $data
293
     *
294
     * @return $this
295
     */
296
    public function setJSON($data)
297
    {
298
        $this->config['json'] = $data;
2✔
299

300
        return $this;
2✔
301
    }
302

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

316
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
189✔
317
            foreach ($options['headers'] as $name => $value) {
6✔
318
                $this->setHeader($name, $value);
6✔
319
            }
320

321
            unset($options['headers']);
6✔
322
        }
323

324
        if (array_key_exists('delay', $options)) {
189✔
325
            // Convert from the milliseconds passed in
326
            // to the seconds that sleep requires.
327
            $this->delay = (float) $options['delay'] / 1000;
26✔
328
            unset($options['delay']);
26✔
329
        }
330

331
        if (array_key_exists('body', $options)) {
189✔
332
            $this->setBody($options['body']);
2✔
333
            unset($options['body']);
2✔
334
        }
335

336
        foreach ($options as $key => $value) {
189✔
337
            $this->config[$key] = $value;
98✔
338
        }
339
    }
340

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

352
        $uri = $this->baseURI->resolveRelativeURI($url);
88✔
353

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

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

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

382
        $curlOptions[CURLOPT_URL]            = $url;
172✔
383
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
172✔
384

385
        if ($this->shareConnection instanceof CurlShareHandle) {
172✔
386
            $curlOptions[CURLOPT_SHARE] = $this->shareConnection;
170✔
387
        }
388

389
        $curlOptions[CURLOPT_HEADER] = true;
172✔
390
        // Disable @file uploads in post data.
391
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
172✔
392

393
        $curlOptions = $this->setCURLOptions($curlOptions, $this->config);
172✔
394
        $curlOptions = $this->applyMethod($method, $curlOptions);
168✔
395
        $curlOptions = $this->applyRequestHeaders($curlOptions);
168✔
396

397
        // Do we need to delay this request?
398
        if ($this->delay > 0) {
168✔
399
            usleep((int) $this->delay * 1_000_000);
24✔
400
        }
401

402
        $output = $this->sendRequest($curlOptions);
168✔
403

404
        // Set the string we want to break our response from
405
        $breakString = "\r\n\r\n";
168✔
406

407
        // Remove all intermediate responses
408
        $output = $this->removeIntermediateResponses($output, $breakString);
168✔
409

410
        // Split out our headers and body
411
        $break = strpos($output, $breakString);
168✔
412

413
        if ($break !== false) {
168✔
414
            // Our headers
415
            $headers = explode("\n", substr($output, 0, $break));
30✔
416

417
            $this->setResponseHeaders($headers);
30✔
418

419
            // Our body
420
            $body = substr($output, $break + 4);
30✔
421
            $this->response->setBody($body);
30✔
422
        } else {
423
            $this->response->setBody($output);
138✔
424
        }
425

426
        return $this->response;
168✔
427
    }
428

429
    /**
430
     * Adds $this->headers to the cURL request.
431
     */
432
    protected function applyRequestHeaders(array $curlOptions = []): array
433
    {
434
        if (empty($this->headers)) {
168✔
435
            return $curlOptions;
79✔
436
        }
437

438
        $set = [];
92✔
439

440
        foreach (array_keys($this->headers) as $name) {
92✔
441
            $set[] = $name . ': ' . $this->getHeaderLine($name);
92✔
442
        }
443

444
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
92✔
445

446
        return $curlOptions;
92✔
447
    }
448

449
    /**
450
     * Apply method
451
     */
452
    protected function applyMethod(string $method, array $curlOptions): array
453
    {
454
        $this->method                       = $method;
168✔
455
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
168✔
456

457
        $size = strlen($this->body ?? '');
168✔
458

459
        // Have content?
460
        if ($size > 0) {
168✔
461
            return $this->applyBody($curlOptions);
10✔
462
        }
463

464
        if ($method === Method::PUT || $method === Method::POST) {
159✔
465
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
466
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
55✔
467
                $this->setHeader('Content-Length', '0');
43✔
468
            }
469
        } elseif ($method === 'HEAD') {
108✔
470
            $curlOptions[CURLOPT_NOBODY] = 1;
2✔
471
        }
472

473
        return $curlOptions;
159✔
474
    }
475

476
    /**
477
     * Apply body
478
     */
479
    protected function applyBody(array $curlOptions = []): array
480
    {
481
        if (! empty($this->body)) {
10✔
482
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
10✔
483
        }
484

485
        return $curlOptions;
10✔
486
    }
487

488
    /**
489
     * Parses the header retrieved from the cURL response into
490
     * our Response object.
491
     *
492
     * @return void
493
     */
494
    protected function setResponseHeaders(array $headers = [])
495
    {
496
        foreach ($headers as $header) {
30✔
497
            if (($pos = strpos($header, ':')) !== false) {
30✔
498
                $title = trim(substr($header, 0, $pos));
26✔
499
                $value = trim(substr($header, $pos + 1));
26✔
500

501
                if ($this->response instanceof Response) {
26✔
502
                    $this->response->addHeader($title, $value);
26✔
503
                } else {
504
                    $this->response->setHeader($title, $value);
×
505
                }
506
            } elseif (str_starts_with($header, 'HTTP')) {
28✔
507
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches);
28✔
508

509
                if (isset($matches[1])) {
28✔
510
                    $this->response->setProtocolVersion($matches[1]);
28✔
511
                }
512

513
                if (isset($matches[2])) {
28✔
514
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? '');
28✔
515
                }
516
            }
517
        }
518
    }
519

520
    /**
521
     * Set CURL options
522
     *
523
     * @return array
524
     *
525
     * @throws InvalidArgumentException
526
     */
527
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
528
    {
529
        $curlOptions = $this->applyAuthOptions($curlOptions, $config);
176✔
530
        $curlOptions = $this->applySslOptions($curlOptions, $config);
176✔
531
        $curlOptions = $this->applyProxyOptions($curlOptions, $config);
172✔
532
        $curlOptions = $this->applyDebugOptions($curlOptions, $config);
172✔
533
        $curlOptions = $this->applyRedirectOptions($curlOptions, $config);
172✔
534
        $curlOptions = $this->applyConnectionOptions($curlOptions, $config);
172✔
535
        $curlOptions = $this->applyBodyOptions($curlOptions, $config);
172✔
536
        $curlOptions = $this->applyResponseOptions($curlOptions, $config);
172✔
537
        $curlOptions = $this->applyProtocolOptions($curlOptions, $config);
172✔
538

539
        return $this->applyClientOptions($curlOptions, $config);
172✔
540
    }
541

542
    /**
543
     * @param array<int, mixed>    $curlOptions
544
     * @param array<string, mixed> $config
545
     *
546
     * @return array<int, mixed>
547
     */
548
    private function applyAuthOptions(array $curlOptions, array $config): array
549
    {
550
        // Auth Headers
551
        if (isset($config['auth']) && is_array($config['auth']) && count($config['auth']) >= 2) {
176✔
552
            $curlOptions[CURLOPT_USERPWD]  = $config['auth'][0] . ':' . $config['auth'][1];
14✔
553
            $curlOptions[CURLOPT_HTTPAUTH] = (isset($config['auth'][2]) && $config['auth'][2] !== '' && strtolower($config['auth'][2]) === 'digest')
14✔
554
                ? CURLAUTH_DIGEST
6✔
555
                : CURLAUTH_BASIC;
10✔
556
        }
557

558
        return $curlOptions;
176✔
559
    }
560

561
    /**
562
     * @param array<int, mixed>    $curlOptions
563
     * @param array<string, mixed> $config
564
     *
565
     * @return array<int, mixed>
566
     */
567
    private function applySslOptions(array $curlOptions, array $config): array
568
    {
569
        // Certificate
570
        $cert = $config['cert'] ?? null;
176✔
571

572
        if ((bool) $cert) {
176✔
573
            if (is_array($cert)) {
8✔
574
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2✔
575
                $cert                               = $cert[0];
2✔
576
            }
577

578
            if (! is_file($cert)) {
8✔
579
                throw HTTPException::forSSLCertNotFound($cert);
2✔
580
            }
581

582
            $curlOptions[CURLOPT_SSLCERT] = $cert;
6✔
583
        }
584

585
        // SSL Verification
586
        if (isset($config['verify'])) {
174✔
587
            if (is_string($config['verify'])) {
172✔
588
                $file = realpath($config['verify']) ?: $config['verify'];
4✔
589

590
                if (! is_file($file)) {
4✔
591
                    throw HTTPException::forInvalidSSLKey($config['verify']);
2✔
592
                }
593

594
                $curlOptions[CURLOPT_CAINFO]         = $file;
2✔
595
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2✔
596
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2✔
597
            } elseif (is_bool($config['verify'])) {
168✔
598
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
168✔
599
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
168✔
600
            }
601
        }
602

603
        return $curlOptions;
172✔
604
    }
605

606
    /**
607
     * @param array<int, mixed>    $curlOptions
608
     * @param array<string, mixed> $config
609
     *
610
     * @return array<int, mixed>
611
     */
612
    private function applyProxyOptions(array $curlOptions, array $config): array
613
    {
614
        // Proxy
615
        if (isset($config['proxy'])) {
172✔
616
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
4✔
617
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
4✔
618
        }
619

620
        return $curlOptions;
172✔
621
    }
622

623
    /**
624
     * @param array<int, mixed>    $curlOptions
625
     * @param array<string, mixed> $config
626
     *
627
     * @return array<int, mixed>
628
     */
629
    private function applyDebugOptions(array $curlOptions, array $config): array
630
    {
631
        // Debug
632
        if ((bool) ($config['debug'] ?? false)) {
172✔
633
            $curlOptions[CURLOPT_VERBOSE] = 1;
6✔
634
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
6✔
635
        }
636

637
        return $curlOptions;
172✔
638
    }
639

640
    /**
641
     * @param array<int, mixed>    $curlOptions
642
     * @param array<string, mixed> $config
643
     *
644
     * @return array<int, mixed>
645
     */
646
    private function applyRedirectOptions(array $curlOptions, array $config): array
647
    {
648
        // Allow Redirects
649
        if (array_key_exists('allow_redirects', $config)) {
172✔
650
            $settings = $this->redirectDefaults;
16✔
651

652
            if (is_array($config['allow_redirects'])) {
16✔
653
                $settings = array_merge($settings, $config['allow_redirects']);
2✔
654
            }
655

656
            if ($config['allow_redirects'] === false) {
16✔
657
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
6✔
658
            } else {
659
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
12✔
660
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
12✔
661

662
                if ($settings['strict'] === true) {
12✔
663
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
12✔
664
                }
665

666
                $protocols = 0;
12✔
667

668
                foreach ($settings['protocols'] as $proto) {
12✔
669
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
12✔
670
                }
671

672
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
12✔
673
            }
674
        }
675

676
        return $curlOptions;
172✔
677
    }
678

679
    /**
680
     * @param array<int, mixed>    $curlOptions
681
     * @param array<string, mixed> $config
682
     *
683
     * @return array<int, mixed>
684
     */
685
    private function applyConnectionOptions(array $curlOptions, array $config): array
686
    {
687
        // DNS Cache Timeout
688
        if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) {
172✔
689
            $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout'];
14✔
690
        }
691

692
        // Fresh Connect (default true)
693
        $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect'])
172✔
694
            ? $config['fresh_connect']
4✔
695
            : true;
170✔
696

697
        // Timeout
698
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) ($config['timeout'] ?? 0) * 1000;
172✔
699

700
        // Connection Timeout
701
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) ($config['connect_timeout'] ?? 150) * 1000;
172✔
702

703
        // Resolve IP
704
        if (array_key_exists('force_ip_resolve', $config)) {
172✔
705
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
8✔
706
                'v4'    => CURL_IPRESOLVE_V4,
4✔
707
                'v6'    => CURL_IPRESOLVE_V6,
2✔
708
                default => CURL_IPRESOLVE_WHATEVER,
2✔
709
            };
8✔
710
        }
711

712
        return $curlOptions;
172✔
713
    }
714

715
    /**
716
     * @param array<int, mixed>    $curlOptions
717
     * @param array<string, mixed> $config
718
     *
719
     * @return array<int, mixed>
720
     */
721
    private function applyBodyOptions(array $curlOptions, array $config): array
722
    {
723
        // Post Data - application/x-www-form-urlencoded
724
        if (isset($config['form_params']) && is_array($config['form_params']) && $config['form_params'] !== []) {
172✔
725
            $postFields                      = http_build_query($config['form_params']);
12✔
726
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
12✔
727

728
            // Ensure content-length is set, since CURL doesn't seem to
729
            // calculate it when HTTPHEADER is set.
730
            $this->setHeader('Content-Length', (string) strlen($postFields));
12✔
731
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
12✔
732
        }
733

734
        // Post Data - multipart/form-data
735
        if (isset($config['multipart']) && is_array($config['multipart']) && $config['multipart'] !== []) {
172✔
736
            // setting the POSTFIELDS option automatically sets multipart
737
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
6✔
738
        }
739

740
        // JSON
741
        if (isset($config['json'])) {
172✔
742
            // Will be set as the body in `applyBody()`
743
            $json = json_encode($config['json']);
4✔
744
            $this->setBody($json);
4✔
745
            $this->setHeader('Content-Type', 'application/json');
4✔
746
            $this->setHeader('Content-Length', (string) strlen($json));
4✔
747
        }
748

749
        return $curlOptions;
172✔
750
    }
751

752
    /**
753
     * @param array<int, mixed>    $curlOptions
754
     * @param array<string, mixed> $config
755
     *
756
     * @return array<int, mixed>
757
     */
758
    private function applyResponseOptions(array $curlOptions, array $config): array
759
    {
760
        // Decode Content
761
        if ((bool) ($config['decode_content'] ?? false)) {
172✔
762
            $accept = $this->getHeaderLine('Accept-Encoding');
6✔
763

764
            if ($accept !== '') {
6✔
765
                $curlOptions[CURLOPT_ENCODING] = $accept;
2✔
766
            } else {
767
                $curlOptions[CURLOPT_ENCODING]   = '';
4✔
768
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
4✔
769
            }
770
        }
771

772
        // HTTP Errors
773
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
172✔
774

775
        return $curlOptions;
172✔
776
    }
777

778
    /**
779
     * @param array<int, mixed>    $curlOptions
780
     * @param array<string, mixed> $config
781
     *
782
     * @return array<int, mixed>
783
     */
784
    private function applyProtocolOptions(array $curlOptions, array $config): array
785
    {
786
        if (! isset($config['version']) || (bool) $config['version'] === false) {
172✔
787
            return $curlOptions;
162✔
788
        }
789

790
        $version = sprintf('%.1F', (float) $config['version']);
12✔
791

792
        if ($version === '3.0' && ! defined('CURL_HTTP_VERSION_3')) {
12✔
793
            define('CURL_HTTP_VERSION_3', 30);
1✔
794
        }
795

796
        $curlVersion = match ($version) {
12✔
797
            '1.0'   => CURL_HTTP_VERSION_1_0,
4✔
798
            '1.1'   => CURL_HTTP_VERSION_1_1,
4✔
799
            '2.0'   => CURL_HTTP_VERSION_2_0,
6✔
800
            '3.0'   => CURL_HTTP_VERSION_3,
2✔
NEW
801
            default => null,
×
802
        };
12✔
803

804
        if ($curlVersion !== null) {
12✔
805
            $curlOptions[CURLOPT_HTTP_VERSION] = $curlVersion;
12✔
806
        }
807

808
        return $curlOptions;
12✔
809
    }
810

811
    /**
812
     * @param array<int, mixed>    $curlOptions
813
     * @param array<string, mixed> $config
814
     *
815
     * @return array<int, mixed>
816
     */
817
    private function applyClientOptions(array $curlOptions, array $config): array
818
    {
819
        // Cookie
820
        if (isset($config['cookie'])) {
172✔
821
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
4✔
822
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
4✔
823
        }
824

825
        // User Agent
826
        if (isset($config['user_agent'])) {
172✔
827
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
6✔
828
        }
829

830
        return $curlOptions;
172✔
831
    }
832

833
    /**
834
     * Does the actual work of initializing cURL, setting the options,
835
     * and grabbing the output.
836
     *
837
     * @param array<int, mixed> $curlOptions
838
     *
839
     * @codeCoverageIgnore
840
     */
841
    protected function sendRequest(array $curlOptions = []): string
842
    {
843
        $ch = curl_init();
844

845
        curl_setopt_array($ch, $curlOptions);
846

847
        // Send the request and wait for a response.
848
        $output = curl_exec($ch);
849

850
        if ($output === false) {
851
            throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch));
852
        }
853

854
        return $output;
855
    }
856

857
    private function removeIntermediateResponses(string $output, string $breakString): string
858
    {
859
        while (true) {
168✔
860
            // Check if we should remove the current response
861
            if ($this->shouldRemoveCurrentResponse($output, $breakString)) {
168✔
862
                $breakStringPos = strpos($output, $breakString);
18✔
863
                if ($breakStringPos !== false) {
18✔
864
                    $output = substr($output, $breakStringPos + 4);
18✔
865

866
                    continue;
18✔
867
                }
868
            }
869

870
            // No more intermediate responses to remove
871
            break;
168✔
872
        }
873

874
        return $output;
168✔
875
    }
876

877
    /**
878
     * Check if the current response (at the beginning of output) should be removed.
879
     */
880
    private function shouldRemoveCurrentResponse(string $output, string $breakString): bool
881
    {
882
        // HTTP/x.x 1xx responses (Continue, Processing, etc.)
883
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) {
168✔
884
            return true;
8✔
885
        }
886

887
        // HTTP/x.x 200 Connection established (proxy responses)
888
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) {
168✔
889
            return true;
6✔
890
        }
891

892
        // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed
893
        $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false;
168✔
894
        if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) {
168✔
895
            // Check if there's a Location header
896
            $breakStringPos = strpos($output, $breakString);
4✔
897
            if ($breakStringPos !== false) {
4✔
898
                $headerSection = substr($output, 0, $breakStringPos);
4✔
899
                $headers       = explode("\n", $headerSection);
4✔
900

901
                foreach ($headers as $header) {
4✔
902
                    if (str_starts_with(strtolower($header), 'location:')) {
4✔
903
                        return true; // Found location header, this is a redirect to remove
2✔
904
                    }
905
                }
906
            }
907
        }
908

909
        // Digest auth challenges - only remove if there's another response after
910
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') {
168✔
911
            $breakStringPos = strpos($output, $breakString);
4✔
912
            if ($breakStringPos !== false) {
4✔
913
                $headerSection = substr($output, 0, $breakStringPos);
4✔
914
                if (str_contains($headerSection, 'WWW-Authenticate: Digest')) {
4✔
915
                    $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4);
4✔
916

917
                    return $nextBreakPos !== false; // Only remove if there's another response
4✔
918
                }
919
            }
920
        }
921

922
        return false;
168✔
923
    }
924
}
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