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

codeigniter4 / CodeIgniter4 / 37986190232

09 Oct 2026 08:19PM UTC coverage: 50.703% (-39.5%) from 90.183%
37986190232

Pull #10606

github

web-flow
Merge 2cebb1efa into 7822cde17
Pull Request #10606: fix: refresh REQUEST after URI GET normalization

17 of 30 new or added lines in 2 files covered. (56.67%)

10012 existing lines in 288 files now uncovered.

12871 of 25385 relevant lines covered (50.7%)

87.05 hits per line

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

7.91
/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 constructor's base URI, preserved independently of per-request changes.
53
     */
54
    private readonly URI $defaultBaseURI;
55

56
    /**
57
     * The setting values
58
     *
59
     * @var array<string, mixed>
60
     */
61
    protected $config;
62

63
    /**
64
     * The default setting values
65
     *
66
     * @var array<string, mixed>
67
     */
68
    protected $defaultConfig = [
69
        'timeout'         => 0.0,
70
        'connect_timeout' => 150,
71
        'debug'           => false,
72
        'verify'          => true,
73
    ];
74

75
    /**
76
     * Default values for when 'allow_redirects'
77
     * option is true.
78
     *
79
     * @var array<string, mixed>
80
     */
81
    protected $redirectDefaults = [
82
        'max'       => 5,
83
        'strict'    => true,
84
        'protocols' => [
85
            'http',
86
            'https',
87
        ],
88
    ];
89

90
    /**
91
     * The number of milliseconds to delay before
92
     * sending the request.
93
     *
94
     * @var float
95
     */
96
    protected $delay = 0.0;
97

98
    /**
99
     * The default options from the constructor. Applied to all requests.
100
     *
101
     * @var array<string, mixed>
102
     */
103
    private readonly array $defaultOptions;
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 bool $shareOptions;
112

113
    /**
114
     * The share connection instance.
115
     */
116
    protected ?CurlShareHandle $shareConnection = null;
117

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

133
        parent::__construct(Method::GET, $uri);
1 ✔
134

135
        $this->responseOrig = $response ?? new Response($config);
1 ✔
136
        // Remove the default Content-Type header.
137
        $this->responseOrig->removeHeader('Content-Type');
1 ✔
138

139
        $this->baseURI        = $uri->useRawQueryString();
1 ✔
140
        $this->defaultOptions = $options;
1 ✔
141

142
        $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions;
1 ✔
143

144
        $this->config = $this->defaultConfig;
1 ✔
145
        $this->parseOptions($options);
1 ✔
146
        $this->defaultBaseURI = clone $this->baseURI;
1 ✔
147

148
        // Share Connection
149
        $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property
1 ✔
150
            CURL_LOCK_DATA_CONNECT,
1 ✔
151
            CURL_LOCK_DATA_DNS,
1 ✔
152
        ];
153

154
        if ($optShareConnection !== []) {
1 ✔
155
            $this->shareConnection = curl_share_init();
1 ✔
156

157
            foreach (array_unique($optShareConnection) as $opt) {
1 ✔
158
                curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt);
1 ✔
159
            }
160
        }
161
    }
162

163
    /**
164
     * Sends an HTTP request to the specified $url. If this is a relative
165
     * URL, it will be merged with $this->baseURI to form a complete URL.
166
     *
167
     * @param string               $method  HTTP method
168
     * @param array<string, mixed> $options
169
     */
170
    public function request($method, string $url, array $options = []): ResponseInterface
171
    {
UNCOV
172
        $this->response = clone $this->responseOrig;
×
173

174
        try {
UNCOV
175
            $this->parseOptions($options);
×
176

UNCOV
177
            $url = $this->prepareURL($url);
×
178

UNCOV
179
            $method = esc(strip_tags($method));
×
180

UNCOV
181
            $this->send($method, $url);
×
182
        } finally {
UNCOV
183
            if ($this->shareOptions === false) {
×
UNCOV
184
                $this->resetOptions();
×
185
            }
186
        }
187

UNCOV
188
        return $this->response;
×
189
    }
190

191
    /**
192
     * Reset all options to default.
193
     *
194
     * @return void
195
     */
196
    protected function resetOptions()
197
    {
198
        // Reset headers
UNCOV
199
        $this->headers   = [];
×
UNCOV
200
        $this->headerMap = [];
×
201

202
        // Reset body
UNCOV
203
        $this->body = null;
×
204

205
        // Reset configs
UNCOV
206
        $this->config = $this->defaultConfig;
×
207

UNCOV
208
        $this->baseURI = clone $this->defaultBaseURI;
×
UNCOV
209
        $this->delay   = 0.0;
×
210

211
        // Set the default options for next request
UNCOV
212
        $this->parseOptions($this->defaultOptions);
×
213
    }
214

215
    /**
216
     * Convenience method for sending a GET request.
217
     *
218
     * @param array<string, mixed> $options
219
     */
220
    public function get(string $url, array $options = []): ResponseInterface
221
    {
UNCOV
222
        return $this->request(Method::GET, $url, $options);
×
223
    }
224

225
    /**
226
     * Convenience method for sending a DELETE request.
227
     *
228
     * @param array<string, mixed> $options
229
     */
230
    public function delete(string $url, array $options = []): ResponseInterface
231
    {
UNCOV
232
        return $this->request('DELETE', $url, $options);
×
233
    }
234

235
    /**
236
     * Convenience method for sending a HEAD request.
237
     *
238
     * @param array<string, mixed> $options
239
     */
240
    public function head(string $url, array $options = []): ResponseInterface
241
    {
UNCOV
242
        return $this->request('HEAD', $url, $options);
×
243
    }
244

245
    /**
246
     * Convenience method for sending an OPTIONS request.
247
     *
248
     * @param array<string, mixed> $options
249
     */
250
    public function options(string $url, array $options = []): ResponseInterface
251
    {
UNCOV
252
        return $this->request('OPTIONS', $url, $options);
×
253
    }
254

255
    /**
256
     * Convenience method for sending a PATCH request.
257
     *
258
     * @param array<string, mixed> $options
259
     */
260
    public function patch(string $url, array $options = []): ResponseInterface
261
    {
UNCOV
262
        return $this->request('PATCH', $url, $options);
×
263
    }
264

265
    /**
266
     * Convenience method for sending a POST request.
267
     *
268
     * @param array<string, mixed> $options
269
     */
270
    public function post(string $url, array $options = []): ResponseInterface
271
    {
UNCOV
272
        return $this->request(Method::POST, $url, $options);
×
273
    }
274

275
    /**
276
     * Convenience method for sending a PUT request.
277
     *
278
     * @param array<string, mixed> $options
279
     */
280
    public function put(string $url, array $options = []): ResponseInterface
281
    {
UNCOV
282
        return $this->request(Method::PUT, $url, $options);
×
283
    }
284

285
    /**
286
     * Set the HTTP Authentication.
287
     *
288
     * @param string $type basic or digest
289
     *
290
     * @return $this
291
     */
292
    public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic')
293
    {
UNCOV
294
        $this->config['auth'] = [$username, $password, $type];
×
295

UNCOV
296
        return $this;
×
297
    }
298

299
    /**
300
     * Set form data to be sent.
301
     *
302
     * @param bool                 $multipart Set TRUE if you are sending CURLFiles
303
     * @param array<string, mixed> $params
304
     *
305
     * @return $this
306
     */
307
    public function setForm(array $params, bool $multipart = false)
308
    {
UNCOV
309
        if ($multipart) {
×
UNCOV
310
            $this->config['multipart'] = $params;
×
311
        } else {
UNCOV
312
            $this->config['form_params'] = $params;
×
313
        }
314

UNCOV
315
        return $this;
×
316
    }
317

318
    /**
319
     * Set JSON data to be sent.
320
     *
321
     * @param mixed $data
322
     *
323
     * @return $this
324
     */
325
    public function setJSON($data)
326
    {
UNCOV
327
        $this->config['json'] = $data;
×
328

UNCOV
329
        return $this;
×
330
    }
331

332
    /**
333
     * Sets the correct settings based on the options array
334
     * passed in.
335
     *
336
     * @param array<string, mixed> $options
337
     *
338
     * @return void
339
     */
340
    protected function parseOptions(array $options)
341
    {
342
        if (array_key_exists('baseURI', $options)) {
1 ✔
UNCOV
343
            $this->baseURI = $this->baseURI->setURI($options['baseURI']);
×
UNCOV
344
            unset($options['baseURI']);
×
345
        }
346

347
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
1 ✔
UNCOV
348
            foreach ($options['headers'] as $name => $value) {
×
UNCOV
349
                $this->setHeader($name, $value);
×
350
            }
351

UNCOV
352
            unset($options['headers']);
×
353
        }
354

355
        if (array_key_exists('delay', $options)) {
1 ✔
356
            // Convert from the milliseconds passed in
357
            // to the seconds that sleep requires.
UNCOV
358
            $this->delay = (float) $options['delay'] / 1000;
×
UNCOV
359
            unset($options['delay']);
×
360
        }
361

362
        if (array_key_exists('body', $options)) {
1 ✔
UNCOV
363
            $this->setBody($options['body']);
×
UNCOV
364
            unset($options['body']);
×
365
        }
366

367
        foreach ($options as $key => $value) {
1 ✔
UNCOV
368
            $this->config[$key] = $value;
×
369
        }
370
    }
371

372
    /**
373
     * If the $url is a relative URL, will attempt to create
374
     * a full URL by prepending $this->baseURI to it.
375
     */
376
    protected function prepareURL(string $url): string
377
    {
378
        // If it's a full URI, then we have nothing to do here...
UNCOV
379
        if (str_contains($url, '://')) {
×
UNCOV
380
            return $url;
×
381
        }
382

UNCOV
383
        $uri = $this->baseURI->resolveRelativeURI($url);
×
384

385
        // Create the string instead of casting to prevent baseURL muddling
UNCOV
386
        return URI::createURIString(
×
UNCOV
387
            $uri->getScheme(),
×
UNCOV
388
            $uri->getAuthority(),
×
UNCOV
389
            $uri->getPath(),
×
UNCOV
390
            $uri->getQuery(),
×
UNCOV
391
            $uri->getFragment(),
×
UNCOV
392
        );
×
393
    }
394

395
    /**
396
     * Fires the actual cURL request.
397
     *
398
     * @return ResponseInterface
399
     */
400
    public function send(string $method, string $url)
401
    {
402
        // Reset our curl options so we're on a fresh slate.
UNCOV
403
        $curlOptions = [];
×
404

UNCOV
405
        $query = $this->config['query'] ?? [];
×
406

UNCOV
407
        if (is_array($query) && $query !== []) {
×
408
            // This is likely too naive a solution.
409
            // Should look into handling when $url already
410
            // has query vars on it.
UNCOV
411
            $url .= '?' . http_build_query($query);
×
UNCOV
412
            unset($this->config['query']);
×
413
        }
414

UNCOV
415
        $curlOptions[CURLOPT_URL]            = $url;
×
UNCOV
416
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
×
417

UNCOV
418
        if ($this->shareConnection instanceof CurlShareHandle) {
×
UNCOV
419
            $curlOptions[CURLOPT_SHARE] = $this->shareConnection;
×
420
        }
421

UNCOV
422
        $curlOptions[CURLOPT_HEADER] = true;
×
423
        // Disable @file uploads in post data.
UNCOV
424
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
×
425

UNCOV
426
        $curlOptions = $this->setCURLOptions($curlOptions, $this->config);
×
UNCOV
427
        $curlOptions = $this->applyMethod($method, $curlOptions);
×
UNCOV
428
        $curlOptions = $this->applyRequestHeaders($curlOptions);
×
429

430
        // Do we need to delay this request?
UNCOV
431
        if ($this->delay > 0) {
×
UNCOV
432
            usleep((int) $this->delay * 1_000_000);
×
433
        }
434

UNCOV
435
        $output = $this->sendRequest($curlOptions);
×
436

437
        // Set the string we want to break our response from
UNCOV
438
        $breakString = "\r\n\r\n";
×
439

440
        // Remove all intermediate responses
UNCOV
441
        $output = $this->removeIntermediateResponses($output, $breakString);
×
442

443
        // Split out our headers and body
UNCOV
444
        $break = strpos($output, $breakString);
×
445

UNCOV
446
        if ($break !== false) {
×
447
            // Our headers
UNCOV
448
            $headers = explode("\n", substr($output, 0, $break));
×
449

UNCOV
450
            $this->setResponseHeaders($headers);
×
451

452
            // Our body
UNCOV
453
            $body = substr($output, $break + 4);
×
UNCOV
454
            $this->response->setBody($body);
×
455
        } else {
UNCOV
456
            $this->response->setBody($output);
×
457
        }
458

UNCOV
459
        return $this->response;
×
460
    }
461

462
    /**
463
     * Adds $this->headers to the cURL request.
464
     *
465
     * @param array<int, mixed> $curlOptions
466
     *
467
     * @return array<int, mixed>
468
     */
469
    protected function applyRequestHeaders(array $curlOptions = []): array
470
    {
UNCOV
471
        if ($this->headers === []) {
×
UNCOV
472
            return $curlOptions;
×
473
        }
474

UNCOV
475
        $set = [];
×
476

UNCOV
477
        foreach (array_keys($this->headers) as $name) {
×
UNCOV
478
            $set[] = $name . ': ' . $this->getHeaderLine($name);
×
479
        }
480

UNCOV
481
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
×
482

UNCOV
483
        return $curlOptions;
×
484
    }
485

486
    /**
487
     * Apply method
488
     *
489
     * @param array<int, mixed> $curlOptions
490
     *
491
     * @return array<int, mixed>
492
     */
493
    protected function applyMethod(string $method, array $curlOptions): array
494
    {
UNCOV
495
        $this->method                       = $method;
×
UNCOV
496
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
×
497

UNCOV
498
        $size = strlen($this->body ?? '');
×
499

500
        // Have content?
UNCOV
501
        if ($size > 0) {
×
UNCOV
502
            return $this->applyBody($curlOptions);
×
503
        }
504

UNCOV
505
        if ($method === Method::PUT || $method === Method::POST) {
×
506
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
UNCOV
507
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
×
UNCOV
508
                $this->setHeader('Content-Length', '0');
×
509
            }
UNCOV
510
        } elseif ($method === 'HEAD') {
×
UNCOV
511
            $curlOptions[CURLOPT_NOBODY] = 1;
×
512
        }
513

UNCOV
514
        return $curlOptions;
×
515
    }
516

517
    /**
518
     * Apply body
519
     *
520
     * @param array<int, mixed> $curlOptions
521
     *
522
     * @return array<int, mixed>
523
     */
524
    protected function applyBody(array $curlOptions = []): array
525
    {
UNCOV
526
        if (! in_array($this->body, [null, '', '0'], true)) {
×
UNCOV
527
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
×
528
        }
529

UNCOV
530
        return $curlOptions;
×
531
    }
532

533
    /**
534
     * Parses the header retrieved from the cURL response into
535
     * our Response object.
536
     *
537
     * @param list<string> $headers
538
     *
539
     * @return void
540
     */
541
    protected function setResponseHeaders(array $headers = [])
542
    {
UNCOV
543
        foreach ($headers as $header) {
×
UNCOV
544
            if (($pos = strpos($header, ':')) !== false) {
×
UNCOV
545
                $title = trim(substr($header, 0, $pos));
×
UNCOV
546
                $value = trim(substr($header, $pos + 1));
×
547

UNCOV
548
                if ($this->response instanceof Response) {
×
UNCOV
549
                    $this->response->addHeader($title, $value);
×
550
                } else {
551
                    $this->response->setHeader($title, $value);
×
552
                }
UNCOV
553
            } elseif (str_starts_with($header, 'HTTP')) {
×
UNCOV
554
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches);
×
555

UNCOV
556
                if (isset($matches[1])) {
×
UNCOV
557
                    $this->response->setProtocolVersion($matches[1]);
×
558
                }
559

UNCOV
560
                if (isset($matches[2])) {
×
UNCOV
561
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? '');
×
562
                }
563
            }
564
        }
565
    }
566

567
    /**
568
     * Set CURL options
569
     *
570
     * @param array<int, mixed>    $curlOptions
571
     * @param array<string, mixed> $config
572
     *
573
     * @return array<int, mixed>
574
     *
575
     * @throws InvalidArgumentException
576
     */
577
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
578
    {
UNCOV
579
        $curlOptions = $this->applyAuthOptions($curlOptions, $config);
×
UNCOV
580
        $curlOptions = $this->applySslOptions($curlOptions, $config);
×
UNCOV
581
        $curlOptions = $this->applyProxyOptions($curlOptions, $config);
×
UNCOV
582
        $curlOptions = $this->applyDebugOptions($curlOptions, $config);
×
UNCOV
583
        $curlOptions = $this->applyRedirectOptions($curlOptions, $config);
×
UNCOV
584
        $curlOptions = $this->applyConnectionOptions($curlOptions, $config);
×
UNCOV
585
        $curlOptions = $this->applyBodyOptions($curlOptions, $config);
×
UNCOV
586
        $curlOptions = $this->applyResponseOptions($curlOptions, $config);
×
UNCOV
587
        $curlOptions = $this->applyProtocolOptions($curlOptions, $config);
×
588

UNCOV
589
        return $this->applyClientOptions($curlOptions, $config);
×
590
    }
591

592
    /**
593
     * @param array<int, mixed>    $curlOptions
594
     * @param array<string, mixed> $config
595
     *
596
     * @return array<int, mixed>
597
     */
598
    private function applyAuthOptions(array $curlOptions, array $config): array
599
    {
600
        // Auth Headers
UNCOV
601
        if (isset($config['auth']) && is_array($config['auth']) && count($config['auth']) >= 2) {
×
UNCOV
602
            $curlOptions[CURLOPT_USERPWD]  = $config['auth'][0] . ':' . $config['auth'][1];
×
UNCOV
603
            $curlOptions[CURLOPT_HTTPAUTH] = (isset($config['auth'][2]) && $config['auth'][2] !== '' && strtolower($config['auth'][2]) === 'digest')
×
UNCOV
604
                ? CURLAUTH_DIGEST
×
UNCOV
605
                : CURLAUTH_BASIC;
×
606
        }
607

UNCOV
608
        return $curlOptions;
×
609
    }
610

611
    /**
612
     * @param array<int, mixed>    $curlOptions
613
     * @param array<string, mixed> $config
614
     *
615
     * @return array<int, mixed>
616
     */
617
    private function applySslOptions(array $curlOptions, array $config): array
618
    {
619
        // Certificate
UNCOV
620
        $cert = $config['cert'] ?? null;
×
621

UNCOV
622
        if ((bool) $cert) {
×
UNCOV
623
            if (is_array($cert)) {
×
UNCOV
624
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
×
UNCOV
625
                $cert                               = $cert[0];
×
626
            }
627

UNCOV
628
            if (! is_file($cert)) {
×
UNCOV
629
                throw HTTPException::forSSLCertNotFound($cert);
×
630
            }
631

UNCOV
632
            $curlOptions[CURLOPT_SSLCERT] = $cert;
×
633
        }
634

635
        // SSL Verification
UNCOV
636
        if (isset($config['verify'])) {
×
UNCOV
637
            if (is_string($config['verify'])) {
×
UNCOV
638
                $realPath = realpath($config['verify']);
×
UNCOV
639
                $file     = $realPath === false ? $config['verify'] : $realPath;
×
640

UNCOV
641
                if (! is_file($file)) {
×
UNCOV
642
                    throw HTTPException::forInvalidSSLKey($config['verify']);
×
643
                }
644

UNCOV
645
                $curlOptions[CURLOPT_CAINFO]         = $file;
×
UNCOV
646
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
×
UNCOV
647
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
×
UNCOV
648
            } elseif (is_bool($config['verify'])) {
×
UNCOV
649
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
×
UNCOV
650
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
×
651
            }
652
        }
653

UNCOV
654
        return $curlOptions;
×
655
    }
656

657
    /**
658
     * @param array<int, mixed>    $curlOptions
659
     * @param array<string, mixed> $config
660
     *
661
     * @return array<int, mixed>
662
     */
663
    private function applyProxyOptions(array $curlOptions, array $config): array
664
    {
665
        // Proxy
UNCOV
666
        if (isset($config['proxy'])) {
×
UNCOV
667
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
×
UNCOV
668
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
×
669
        }
670

UNCOV
671
        return $curlOptions;
×
672
    }
673

674
    /**
675
     * @param array<int, mixed>    $curlOptions
676
     * @param array<string, mixed> $config
677
     *
678
     * @return array<int, mixed>
679
     */
680
    private function applyDebugOptions(array $curlOptions, array $config): array
681
    {
682
        // Debug
UNCOV
683
        if ((bool) ($config['debug'] ?? false)) {
×
UNCOV
684
            $curlOptions[CURLOPT_VERBOSE] = 1;
×
UNCOV
685
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
×
686
        }
687

UNCOV
688
        return $curlOptions;
×
689
    }
690

691
    /**
692
     * @param array<int, mixed>    $curlOptions
693
     * @param array<string, mixed> $config
694
     *
695
     * @return array<int, mixed>
696
     */
697
    private function applyRedirectOptions(array $curlOptions, array $config): array
698
    {
699
        // Allow Redirects
UNCOV
700
        if (array_key_exists('allow_redirects', $config)) {
×
UNCOV
701
            $settings = $this->redirectDefaults;
×
702

UNCOV
703
            if (is_array($config['allow_redirects'])) {
×
UNCOV
704
                $settings = array_merge($settings, $config['allow_redirects']);
×
705
            }
706

UNCOV
707
            if ($config['allow_redirects'] === false) {
×
UNCOV
708
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
×
709
            } else {
UNCOV
710
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
×
UNCOV
711
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
×
712

UNCOV
713
                if ($settings['strict'] === true) {
×
UNCOV
714
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
×
715
                }
716

UNCOV
717
                $protocols = 0;
×
718

UNCOV
719
                foreach ($settings['protocols'] as $proto) {
×
UNCOV
720
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
×
721
                }
722

UNCOV
723
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
×
724
            }
725
        }
726

UNCOV
727
        return $curlOptions;
×
728
    }
729

730
    /**
731
     * @param array<int, mixed>    $curlOptions
732
     * @param array<string, mixed> $config
733
     *
734
     * @return array<int, mixed>
735
     */
736
    private function applyConnectionOptions(array $curlOptions, array $config): array
737
    {
738
        // DNS Cache Timeout
UNCOV
739
        if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) {
×
UNCOV
740
            $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout'];
×
741
        }
742

743
        // Fresh Connect (default true)
UNCOV
744
        $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect'])
×
UNCOV
745
            ? $config['fresh_connect']
×
UNCOV
746
            : true;
×
747

748
        // Timeout
UNCOV
749
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) ($config['timeout'] ?? 0) * 1000;
×
750

751
        // Connection Timeout
UNCOV
752
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) ($config['connect_timeout'] ?? 150) * 1000;
×
753

754
        // Resolve IP
UNCOV
755
        if (array_key_exists('force_ip_resolve', $config)) {
×
UNCOV
756
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
×
UNCOV
757
                'v4'    => CURL_IPRESOLVE_V4,
×
UNCOV
758
                'v6'    => CURL_IPRESOLVE_V6,
×
UNCOV
759
                default => CURL_IPRESOLVE_WHATEVER,
×
UNCOV
760
            };
×
761
        }
762

UNCOV
763
        return $curlOptions;
×
764
    }
765

766
    /**
767
     * @param array<int, mixed>    $curlOptions
768
     * @param array<string, mixed> $config
769
     *
770
     * @return array<int, mixed>
771
     */
772
    private function applyBodyOptions(array $curlOptions, array $config): array
773
    {
774
        // Post Data - application/x-www-form-urlencoded
UNCOV
775
        if (isset($config['form_params']) && is_array($config['form_params']) && $config['form_params'] !== []) {
×
UNCOV
776
            $postFields                      = http_build_query($config['form_params']);
×
UNCOV
777
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
×
778

779
            // Ensure content-length is set, since CURL doesn't seem to
780
            // calculate it when HTTPHEADER is set.
UNCOV
781
            $this->setHeader('Content-Length', (string) strlen($postFields));
×
UNCOV
782
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
×
783
        }
784

785
        // Post Data - multipart/form-data
UNCOV
786
        if (isset($config['multipart']) && is_array($config['multipart']) && $config['multipart'] !== []) {
×
787
            // setting the POSTFIELDS option automatically sets multipart
UNCOV
788
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
×
789
        }
790

791
        // JSON
UNCOV
792
        if (isset($config['json'])) {
×
793
            // Will be set as the body in `applyBody()`
UNCOV
794
            $json = json_encode($config['json']);
×
UNCOV
795
            $this->setBody($json);
×
UNCOV
796
            $this->setHeader('Content-Type', 'application/json');
×
UNCOV
797
            $this->setHeader('Content-Length', (string) strlen($json));
×
798
        }
799

UNCOV
800
        return $curlOptions;
×
801
    }
802

803
    /**
804
     * @param array<int, mixed>    $curlOptions
805
     * @param array<string, mixed> $config
806
     *
807
     * @return array<int, mixed>
808
     */
809
    private function applyResponseOptions(array $curlOptions, array $config): array
810
    {
811
        // Decode Content
UNCOV
812
        if ((bool) ($config['decode_content'] ?? false)) {
×
UNCOV
813
            $accept = $this->getHeaderLine('Accept-Encoding');
×
814

UNCOV
815
            if ($accept !== '') {
×
UNCOV
816
                $curlOptions[CURLOPT_ENCODING] = $accept;
×
817
            } else {
UNCOV
818
                $curlOptions[CURLOPT_ENCODING]   = '';
×
UNCOV
819
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
×
820
            }
821
        }
822

823
        // HTTP Errors
UNCOV
824
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
×
825

UNCOV
826
        return $curlOptions;
×
827
    }
828

829
    /**
830
     * @param array<int, mixed>    $curlOptions
831
     * @param array<string, mixed> $config
832
     *
833
     * @return array<int, mixed>
834
     */
835
    private function applyProtocolOptions(array $curlOptions, array $config): array
836
    {
UNCOV
837
        if (! isset($config['version']) || (bool) $config['version'] === false) {
×
UNCOV
838
            return $curlOptions;
×
839
        }
840

UNCOV
841
        $version = sprintf('%.1F', (float) $config['version']);
×
842

UNCOV
843
        if ($version === '3.0' && ! defined('CURL_HTTP_VERSION_3')) {
×
UNCOV
844
            define('CURL_HTTP_VERSION_3', 30);
×
845
        }
846

UNCOV
847
        $curlVersion = match ($version) {
×
UNCOV
848
            '1.0'   => CURL_HTTP_VERSION_1_0,
×
UNCOV
849
            '1.1'   => CURL_HTTP_VERSION_1_1,
×
UNCOV
850
            '2.0'   => CURL_HTTP_VERSION_2_0,
×
UNCOV
851
            '3.0'   => CURL_HTTP_VERSION_3,
×
852
            default => null,
×
UNCOV
853
        };
×
854

UNCOV
855
        if ($curlVersion !== null) {
×
UNCOV
856
            $curlOptions[CURLOPT_HTTP_VERSION] = $curlVersion;
×
857
        }
858

UNCOV
859
        return $curlOptions;
×
860
    }
861

862
    /**
863
     * @param array<int, mixed>    $curlOptions
864
     * @param array<string, mixed> $config
865
     *
866
     * @return array<int, mixed>
867
     */
868
    private function applyClientOptions(array $curlOptions, array $config): array
869
    {
870
        // Cookie
UNCOV
871
        if (isset($config['cookie'])) {
×
UNCOV
872
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
×
UNCOV
873
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
×
874
        }
875

876
        // User Agent
UNCOV
877
        if (isset($config['user_agent'])) {
×
UNCOV
878
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
×
879
        }
880

UNCOV
881
        return $curlOptions;
×
882
    }
883

884
    /**
885
     * Does the actual work of initializing cURL, setting the options,
886
     * and grabbing the output.
887
     *
888
     * @param array<int, mixed> $curlOptions
889
     *
890
     * @codeCoverageIgnore
891
     */
892
    protected function sendRequest(array $curlOptions = []): string
893
    {
894
        $ch = curl_init();
895

896
        curl_setopt_array($ch, $curlOptions);
897

898
        // Send the request and wait for a response.
899
        $output = curl_exec($ch);
900

901
        if ($output === false) {
902
            throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch));
903
        }
904

905
        return $output;
906
    }
907

908
    private function removeIntermediateResponses(string $output, string $breakString): string
909
    {
UNCOV
910
        while (true) {
×
911
            // Check if we should remove the current response
UNCOV
912
            if ($this->shouldRemoveCurrentResponse($output, $breakString)) {
×
UNCOV
913
                $breakStringPos = strpos($output, $breakString);
×
UNCOV
914
                if ($breakStringPos !== false) {
×
UNCOV
915
                    $output = substr($output, $breakStringPos + 4);
×
916

UNCOV
917
                    continue;
×
918
                }
919
            }
920

921
            // No more intermediate responses to remove
UNCOV
922
            break;
×
923
        }
924

UNCOV
925
        return $output;
×
926
    }
927

928
    /**
929
     * Check if the current response (at the beginning of output) should be removed.
930
     */
931
    private function shouldRemoveCurrentResponse(string $output, string $breakString): bool
932
    {
933
        // HTTP/x.x 1xx responses (Continue, Processing, etc.)
UNCOV
934
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) {
×
UNCOV
935
            return true;
×
936
        }
937

938
        // HTTP/x.x 200 Connection established (proxy responses)
UNCOV
939
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) {
×
UNCOV
940
            return true;
×
941
        }
942

943
        // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed
UNCOV
944
        $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false;
×
UNCOV
945
        if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) {
×
946
            // Check if there's a Location header
UNCOV
947
            $breakStringPos = strpos($output, $breakString);
×
UNCOV
948
            if ($breakStringPos !== false) {
×
UNCOV
949
                $headerSection = substr($output, 0, $breakStringPos);
×
UNCOV
950
                $headers       = explode("\n", $headerSection);
×
951

UNCOV
952
                foreach ($headers as $header) {
×
UNCOV
953
                    if (str_starts_with(strtolower($header), 'location:')) {
×
UNCOV
954
                        return true; // Found location header, this is a redirect to remove
×
955
                    }
956
                }
957
            }
958
        }
959

960
        // Digest auth challenges - only remove if there's another response after
UNCOV
961
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') {
×
UNCOV
962
            $breakStringPos = strpos($output, $breakString);
×
UNCOV
963
            if ($breakStringPos !== false) {
×
UNCOV
964
                $headerSection = substr($output, 0, $breakStringPos);
×
UNCOV
965
                if (str_contains($headerSection, 'WWW-Authenticate: Digest')) {
×
UNCOV
966
                    $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4);
×
967

UNCOV
968
                    return $nextBreakPos !== false; // Only remove if there's another response
×
969
                }
970
            }
971
        }
972

UNCOV
973
        return false;
×
974
    }
975
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc