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

codeigniter4 / CodeIgniter4 / 25902734269

15 May 2026 05:51AM UTC coverage: 88.459% (+0.2%) from 88.299%
25902734269

Pull #10159

github

web-flow
Merge f0573f3e0 into 170b89a6e
Pull Request #10159: feat: Add support for callable TTLs in cache handlers

6 of 10 new or added lines in 3 files covered. (60.0%)

446 existing lines in 24 files now uncovered.

24114 of 27260 relevant lines covered (88.46%)

219.07 hits per line

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

98.5
/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
     * Default values for when 'retry' is enabled.
87
     *
88
     * @var array<string, bool|int|list<int>>
89
     */
90
    protected array $retryDefaults = [
91
        'max_retries'         => 3,
92
        'delay'               => 1000,
93
        'max_delay'           => 30_000,
94
        'status_codes'        => [429, 503, 504],
95
        'curl_errors'         => false,
96
        'respect_retry_after' => true,
97
    ];
98

99
    /**
100
     * cURL error numbers that may succeed on another attempt.
101
     *
102
     * @var list<int>
103
     */
104
    protected array $transientCurlErrors = [];
105

106
    /**
107
     * The number of milliseconds to delay before
108
     * sending the request.
109
     *
110
     * @var float
111
     */
112
    protected $delay = 0.0;
113

114
    /**
115
     * The last cURL error number.
116
     */
117
    protected int $lastCurlError = 0;
118

119
    /**
120
     * The default options from the constructor. Applied to all requests.
121
     */
122
    private readonly array $defaultOptions;
123

124
    /**
125
     * Whether share options between requests or not.
126
     *
127
     * If true, all the options won't be reset between requests.
128
     * It may cause an error request with unnecessary headers.
129
     */
130
    private readonly bool $shareOptions;
131

132
    /**
133
     * The share connection instance.
134
     */
135
    protected ?CurlShareHandle $shareConnection = null;
136

137
    /**
138
     * Takes an array of options to set the following possible class properties:
139
     *
140
     *  - baseURI
141
     *  - timeout
142
     *  - any other request options to use as defaults.
143
     *
144
     * @todo v4.8.0 Remove $config parameter since unused
145
     *
146
     * @param array<string, mixed> $options
147
     *
148
     * @phpstan-ignore-next-line constructor.unusedParameter
149
     */
150
    public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = [])
151
    {
152
        if (! function_exists('curl_version')) {
203✔
153
            throw HTTPException::forMissingCurl(); // @codeCoverageIgnore
154
        }
155

156
        $this->transientCurlErrors = [
203✔
157
            CURLE_COULDNT_RESOLVE_HOST,
203✔
158
            CURLE_COULDNT_CONNECT,
203✔
159
            CURLE_OPERATION_TIMEDOUT,
203✔
160
            CURLE_SEND_ERROR,
203✔
161
            CURLE_RECV_ERROR,
203✔
162
        ];
203✔
163

164
        parent::__construct(Method::GET, $uri);
203✔
165

166
        $this->responseOrig = $response ?? new Response();
203✔
167
        // Remove the default Content-Type header.
168
        $this->responseOrig->removeHeader('Content-Type');
203✔
169

170
        $this->baseURI        = $uri->useRawQueryString();
203✔
171
        $this->defaultOptions = $options;
203✔
172

173
        $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions;
203✔
174

175
        $this->config = $this->defaultConfig;
203✔
176
        $this->parseOptions($options);
203✔
177

178
        // Share Connection
179
        $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property
203✔
180
            CURL_LOCK_DATA_CONNECT,
203✔
181
            CURL_LOCK_DATA_DNS,
203✔
182
        ];
183

184
        if ($optShareConnection !== []) {
203✔
185
            $this->shareConnection = curl_share_init();
203✔
186

187
            foreach (array_unique($optShareConnection) as $opt) {
203✔
188
                curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt);
203✔
189
            }
190
        }
191
    }
192

193
    /**
194
     * Sends an HTTP request to the specified $url. If this is a relative
195
     * URL, it will be merged with $this->baseURI to form a complete URL.
196
     *
197
     * @param string $method HTTP method
198
     */
199
    public function request($method, string $url, array $options = []): ResponseInterface
200
    {
201
        $this->response = clone $this->responseOrig;
188✔
202

203
        $this->parseOptions($options);
188✔
204

205
        $url = $this->prepareURL($url);
188✔
206

207
        $method = esc(strip_tags($method));
188✔
208

209
        $this->send($method, $url);
188✔
210

211
        if ($this->shareOptions === false) {
181✔
212
            $this->resetOptions();
97✔
213
        }
214

215
        return $this->response;
181✔
216
    }
217

218
    /**
219
     * Reset all options to default.
220
     *
221
     * @return void
222
     */
223
    protected function resetOptions()
224
    {
225
        // Reset headers
226
        $this->headers   = [];
97✔
227
        $this->headerMap = [];
97✔
228

229
        // Reset body
230
        $this->body = null;
97✔
231

232
        // Reset configs
233
        $this->config = $this->defaultConfig;
97✔
234

235
        // Set the default options for next request
236
        $this->parseOptions($this->defaultOptions);
97✔
237
    }
238

239
    /**
240
     * Convenience method for sending a GET request.
241
     */
242
    public function get(string $url, array $options = []): ResponseInterface
243
    {
244
        return $this->request(Method::GET, $url, $options);
56✔
245
    }
246

247
    /**
248
     * Convenience method for sending a DELETE request.
249
     */
250
    public function delete(string $url, array $options = []): ResponseInterface
251
    {
252
        return $this->request('DELETE', $url, $options);
2✔
253
    }
254

255
    /**
256
     * Convenience method for sending a HEAD request.
257
     */
258
    public function head(string $url, array $options = []): ResponseInterface
259
    {
260
        return $this->request('HEAD', $url, $options);
2✔
261
    }
262

263
    /**
264
     * Convenience method for sending an OPTIONS request.
265
     */
266
    public function options(string $url, array $options = []): ResponseInterface
267
    {
268
        return $this->request('OPTIONS', $url, $options);
2✔
269
    }
270

271
    /**
272
     * Convenience method for sending a PATCH request.
273
     */
274
    public function patch(string $url, array $options = []): ResponseInterface
275
    {
276
        return $this->request('PATCH', $url, $options);
2✔
277
    }
278

279
    /**
280
     * Convenience method for sending a POST request.
281
     */
282
    public function post(string $url, array $options = []): ResponseInterface
283
    {
284
        return $this->request(Method::POST, $url, $options);
16✔
285
    }
286

287
    /**
288
     * Convenience method for sending a PUT request.
289
     */
290
    public function put(string $url, array $options = []): ResponseInterface
291
    {
292
        return $this->request(Method::PUT, $url, $options);
2✔
293
    }
294

295
    /**
296
     * Set the HTTP Authentication.
297
     *
298
     * @param string $type basic or digest
299
     *
300
     * @return $this
301
     */
302
    public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic')
303
    {
304
        $this->config['auth'] = [$username, $password, $type];
4✔
305

306
        return $this;
4✔
307
    }
308

309
    /**
310
     * Set form data to be sent.
311
     *
312
     * @param bool $multipart Set TRUE if you are sending CURLFiles
313
     *
314
     * @return $this
315
     */
316
    public function setForm(array $params, bool $multipart = false)
317
    {
318
        if ($multipart) {
2✔
319
            $this->config['multipart'] = $params;
2✔
320
        } else {
321
            $this->config['form_params'] = $params;
2✔
322
        }
323

324
        return $this;
2✔
325
    }
326

327
    /**
328
     * Set JSON data to be sent.
329
     *
330
     * @param array|bool|float|int|object|string|null $data
331
     *
332
     * @return $this
333
     */
334
    public function setJSON($data)
335
    {
336
        $this->config['json'] = $data;
2✔
337

338
        return $this;
2✔
339
    }
340

341
    /**
342
     * Sets the correct settings based on the options array
343
     * passed in.
344
     *
345
     * @return void
346
     */
347
    protected function parseOptions(array $options)
348
    {
349
        if (array_key_exists('baseURI', $options)) {
203✔
350
            $this->baseURI = new URI($options['baseURI'], true);
48✔
351
            unset($options['baseURI']);
48✔
352
        }
353

354
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
203✔
355
            foreach ($options['headers'] as $name => $value) {
6✔
356
                $this->setHeader($name, $value);
6✔
357
            }
358

359
            unset($options['headers']);
6✔
360
        }
361

362
        if (array_key_exists('delay', $options)) {
203✔
363
            // Convert from the milliseconds passed in
364
            // to the seconds that sleep requires.
365
            $this->delay = (float) $options['delay'] / 1000;
26✔
366
            unset($options['delay']);
26✔
367
        }
368

369
        if (array_key_exists('body', $options)) {
203✔
370
            $this->setBody($options['body']);
2✔
371
            unset($options['body']);
2✔
372
        }
373

374
        foreach ($options as $key => $value) {
203✔
375
            $this->config[$key] = $value;
114✔
376
        }
377
    }
378

379
    /**
380
     * If the $url is a relative URL, will attempt to create
381
     * a full URL by prepending $this->baseURI to it.
382
     */
383
    protected function prepareURL(string $url): string
384
    {
385
        // If it's a full URI, then we have nothing to do here...
386
        if (str_contains($url, '://')) {
190✔
387
            return $url;
100✔
388
        }
389

390
        $uri = $this->baseURI->resolveRelativeURI($url);
90✔
391

392
        // Create the string instead of casting to prevent baseURL muddling
393
        return URI::createURIString(
90✔
394
            $uri->getScheme(),
90✔
395
            $uri->getAuthority(),
90✔
396
            $uri->getPath(),
90✔
397
            $uri->getQuery(),
90✔
398
            $uri->getFragment(),
90✔
399
        );
90✔
400
    }
401

402
    /**
403
     * Fires the actual cURL request.
404
     *
405
     * @return ResponseInterface
406
     */
407
    public function send(string $method, string $url)
408
    {
409
        // Reset our curl options so we're on a fresh slate.
410
        $curlOptions = [];
190✔
411
        $config      = $this->config;
190✔
412
        $retry       = $this->normalizeRetryOption($config['retry'] ?? false);
190✔
413

414
        if (! empty($this->config['query']) && is_array($this->config['query'])) {
190✔
415
            // This is likely too naive a solution.
416
            // Should look into handling when $url already
417
            // has query vars on it.
418
            $url .= '?' . http_build_query($this->config['query']);
2✔
419
            unset($this->config['query']);
2✔
420
        }
421

422
        $curlOptions[CURLOPT_URL]            = $url;
190✔
423
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
190✔
424

425
        if ($this->shareConnection instanceof CurlShareHandle) {
190✔
426
            $curlOptions[CURLOPT_SHARE] = $this->shareConnection;
188✔
427
        }
428

429
        $curlOptions[CURLOPT_HEADER] = true;
190✔
430
        // Disable @file uploads in post data.
431
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
190✔
432

433
        $curlOptions = $this->setCURLOptions($curlOptions, $config);
190✔
434
        $curlOptions = $this->applyMethod($method, $curlOptions);
186✔
435
        $curlOptions = $this->applyRequestHeaders($curlOptions);
186✔
436

437
        if ($retry !== null) {
186✔
438
            $curlOptions[CURLOPT_FAILONERROR] = false;
15✔
439
        }
440

441
        // Do we need to delay this request?
442
        if ($this->delay > 0) {
186✔
443
            $this->sleep($this->delay);
24✔
444
        }
445

446
        if ($retry === null) {
186✔
447
            return $this->sendAttempt($curlOptions);
171✔
448
        }
449

450
        $httpErrors = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
15✔
451

452
        return $this->sendWithRetries($curlOptions, $retry, $httpErrors);
15✔
453
    }
454

455
    /**
456
     * Sends the request until it succeeds or retry attempts are exhausted.
457
     *
458
     * @param array<int, mixed>                 $curlOptions
459
     * @param array<string, bool|int|list<int>> $retry
460
     */
461
    protected function sendWithRetries(array $curlOptions, array $retry, bool $httpErrors): ResponseInterface
462
    {
463
        $attempt = 0;
15✔
464

465
        while (true) {
15✔
466
            $this->response = clone $this->responseOrig;
15✔
467

468
            try {
469
                $response = $this->sendAttempt($curlOptions);
15✔
470
            } catch (HTTPException $e) {
3✔
471
                if (! $this->shouldRetryCurlError($retry, $attempt)) {
3✔
472
                    throw $e;
2✔
473
                }
474

475
                $this->sleep($this->getRetryDelay($retry, $attempt) / 1000);
1✔
476
                $attempt++;
1✔
477

478
                continue;
1✔
479
            }
480

481
            if (! $this->shouldRetryResponse($response, $retry, $attempt)) {
13✔
482
                if ($httpErrors && $response->getStatusCode() >= 400) {
13✔
483
                    throw HTTPException::forCurlError((string) CURLE_HTTP_RETURNED_ERROR, 'The requested URL returned error: ' . $response->getStatusCode());
1✔
484
                }
485

486
                return $response;
12✔
487
            }
488

489
            $this->sleep($this->getRetryDelay($retry, $attempt, $response) / 1000);
11✔
490
            $attempt++;
11✔
491
        }
492
    }
493

494
    /**
495
     * Sends a single cURL request attempt and populates the response.
496
     *
497
     * @param array<int, mixed> $curlOptions
498
     */
499
    protected function sendAttempt(array $curlOptions): ResponseInterface
500
    {
501
        $this->lastCurlError = 0;
186✔
502

503
        $output = $this->sendRequest($curlOptions);
186✔
504

505
        // Set the string we want to break our response from
506
        $breakString = "\r\n\r\n";
184✔
507

508
        // Remove all intermediate responses
509
        $output = $this->removeIntermediateResponses($output, $breakString);
184✔
510

511
        // Split out our headers and body
512
        $break = strpos($output, $breakString);
184✔
513

514
        if ($break !== false) {
184✔
515
            // Our headers
516
            $headers = explode("\n", substr($output, 0, $break));
44✔
517

518
            $this->setResponseHeaders($headers);
44✔
519

520
            // Our body
521
            $body = substr($output, $break + 4);
44✔
522
            $this->response->setBody($body);
44✔
523
        } else {
524
            $this->response->setBody($output);
140✔
525
        }
526

527
        return $this->response;
184✔
528
    }
529

530
    /**
531
     * Normalizes the retry option into retry settings.
532
     *
533
     * @return array<string, bool|int|list<int>>|null
534
     */
535
    protected function normalizeRetryOption(mixed $retry): ?array
536
    {
537
        if (in_array($retry, [false, null, 0], true)) {
190✔
538
            return null;
174✔
539
        }
540

541
        $config = $this->retryDefaults;
16✔
542

543
        if (is_int($retry)) {
16✔
544
            $config['max_retries'] = $retry;
5✔
545
        } elseif (is_array($retry)) {
11✔
546
            $config = array_merge($config, $retry);
11✔
547
        } else {
UNCOV
548
            return null;
×
549
        }
550

551
        $config['max_retries'] = max(0, (int) $config['max_retries']);
16✔
552

553
        if ($config['max_retries'] === 0) {
16✔
554
            return null;
1✔
555
        }
556

557
        $config['delay']               = $this->normalizeRetryDelay($config['delay']);
15✔
558
        $config['max_delay']           = max(0, (int) $config['max_delay']);
15✔
559
        $config['status_codes']        = array_map(intval(...), (array) $config['status_codes']);
15✔
560
        $config['curl_errors']         = (bool) $config['curl_errors'];
15✔
561
        $config['respect_retry_after'] = (bool) $config['respect_retry_after'];
15✔
562

563
        return $config;
15✔
564
    }
565

566
    /**
567
     * Normalizes the retry delay setting.
568
     *
569
     * @return int|list<int>
570
     */
571
    protected function normalizeRetryDelay(mixed $delay): array|int
572
    {
573
        if (is_array($delay)) {
15✔
574
            return array_map(static fn ($value): int => max(0, (int) $value), $delay);
1✔
575
        }
576

577
        return max(0, (int) $delay);
14✔
578
    }
579

580
    /**
581
     * Determines whether a response should be retried.
582
     *
583
     * @param array<string, bool|int|list<int>> $retry
584
     */
585
    protected function shouldRetryResponse(ResponseInterface $response, array $retry, int $attempt): bool
586
    {
587
        if ($attempt >= $retry['max_retries']) {
13✔
588
            return false;
11✔
589
        }
590

591
        return in_array($response->getStatusCode(), $retry['status_codes'], true);
12✔
592
    }
593

594
    /**
595
     * Determines whether a cURL error should be retried.
596
     *
597
     * @param array<string, bool|int|list<int>> $retry
598
     */
599
    protected function shouldRetryCurlError(array $retry, int $attempt): bool
600
    {
601
        if ($attempt >= $retry['max_retries'] || $retry['curl_errors'] === false) {
3✔
602
            return false;
1✔
603
        }
604

605
        return in_array($this->lastCurlError, $this->transientCurlErrors, true);
2✔
606
    }
607

608
    /**
609
     * Returns the delay before the next retry attempt.
610
     *
611
     * @param array<string, bool|int|list<int>> $retry
612
     */
613
    protected function getRetryDelay(array $retry, int $attempt, ?ResponseInterface $response = null): int
614
    {
615
        if ($response instanceof ResponseInterface && $retry['respect_retry_after'] === true) {
12✔
616
            $retryAfter = $this->getRetryAfterDelay($response);
10✔
617

618
            if ($retryAfter !== null) {
10✔
619
                return $this->limitRetryDelay($retryAfter * 1000, $retry);
3✔
620
            }
621
        }
622

623
        $delay = $retry['delay'];
9✔
624

625
        if (is_array($delay)) {
9✔
626
            $lastDelay = $delay[array_key_last($delay)] ?? 0;
1✔
627

628
            return $this->limitRetryDelay((int) ($delay[$attempt] ?? $lastDelay), $retry);
1✔
629
        }
630

631
        return $this->limitRetryDelay((int) $delay, $retry);
8✔
632
    }
633

634
    /**
635
     * Caps the retry delay when configured.
636
     *
637
     * @param array<string, bool|int|list<int>> $retry
638
     */
639
    protected function limitRetryDelay(int $delay, array $retry): int
640
    {
641
        $maxDelay = (int) $retry['max_delay'];
12✔
642

643
        if ($maxDelay === 0) {
12✔
UNCOV
644
            return $delay;
×
645
        }
646

647
        return min($delay, $maxDelay);
12✔
648
    }
649

650
    /**
651
     * Returns the delay from a Retry-After header in seconds.
652
     */
653
    protected function getRetryAfterDelay(ResponseInterface $response): ?int
654
    {
655
        $retryAfter = $response->getHeaderLine('Retry-After');
10✔
656

657
        if ($retryAfter === '') {
10✔
658
            return null;
7✔
659
        }
660

661
        if (ctype_digit($retryAfter)) {
3✔
662
            return (int) $retryAfter;
2✔
663
        }
664

665
        $timestamp = strtotime($retryAfter);
1✔
666

667
        if ($timestamp === false) {
1✔
UNCOV
668
            return null;
×
669
        }
670

671
        return max(0, $timestamp - time());
1✔
672
    }
673

674
    /**
675
     * Sleeps for the configured number of seconds.
676
     */
677
    protected function sleep(float $seconds): void
678
    {
UNCOV
679
        usleep((int) ($seconds * 1_000_000));
×
680
    }
681

682
    /**
683
     * Adds $this->headers to the cURL request.
684
     */
685
    protected function applyRequestHeaders(array $curlOptions = []): array
686
    {
687
        if (empty($this->headers)) {
186✔
688
            return $curlOptions;
95✔
689
        }
690

691
        $set = [];
94✔
692

693
        foreach (array_keys($this->headers) as $name) {
94✔
694
            $set[] = $name . ': ' . $this->getHeaderLine($name);
94✔
695
        }
696

697
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
94✔
698

699
        return $curlOptions;
94✔
700
    }
701

702
    /**
703
     * Apply method
704
     */
705
    protected function applyMethod(string $method, array $curlOptions): array
706
    {
707
        $this->method                       = $method;
186✔
708
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
186✔
709

710
        $size = strlen($this->body ?? '');
186✔
711

712
        // Have content?
713
        if ($size > 0) {
186✔
714
            return $this->applyBody($curlOptions);
10✔
715
        }
716

717
        if ($method === Method::PUT || $method === Method::POST) {
177✔
718
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
719
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
55✔
720
                $this->setHeader('Content-Length', '0');
43✔
721
            }
722
        } elseif ($method === 'HEAD') {
126✔
723
            $curlOptions[CURLOPT_NOBODY] = 1;
2✔
724
        }
725

726
        return $curlOptions;
177✔
727
    }
728

729
    /**
730
     * Apply body
731
     */
732
    protected function applyBody(array $curlOptions = []): array
733
    {
734
        if (! empty($this->body)) {
10✔
735
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
10✔
736
        }
737

738
        return $curlOptions;
10✔
739
    }
740

741
    /**
742
     * Parses the header retrieved from the cURL response into
743
     * our Response object.
744
     *
745
     * @return void
746
     */
747
    protected function setResponseHeaders(array $headers = [])
748
    {
749
        foreach ($headers as $header) {
44✔
750
            if (($pos = strpos($header, ':')) !== false) {
44✔
751
                $title = trim(substr($header, 0, $pos));
30✔
752
                $value = trim(substr($header, $pos + 1));
30✔
753

754
                if ($this->response instanceof Response) {
30✔
755
                    $this->response->addHeader($title, $value);
30✔
756
                } else {
UNCOV
757
                    $this->response->setHeader($title, $value);
×
758
                }
759
            } elseif (str_starts_with($header, 'HTTP')) {
42✔
760
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches);
42✔
761

762
                if (isset($matches[1])) {
42✔
763
                    $this->response->setProtocolVersion($matches[1]);
42✔
764
                }
765

766
                if (isset($matches[2])) {
42✔
767
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? '');
42✔
768
                }
769
            }
770
        }
771
    }
772

773
    /**
774
     * Set CURL options
775
     *
776
     * @return array
777
     *
778
     * @throws InvalidArgumentException
779
     */
780
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
781
    {
782
        // Auth Headers
783
        if (! empty($config['auth'])) {
190✔
784
            $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1];
10✔
785

786
            if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') {
10✔
787
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST;
4✔
788
            } else {
789
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
6✔
790
            }
791
        }
792

793
        // Certificate
794
        if (! empty($config['cert'])) {
190✔
795
            $cert = $config['cert'];
6✔
796

797
            if (is_array($cert)) {
6✔
798
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2✔
799
                $cert                               = $cert[0];
2✔
800
            }
801

802
            if (! is_file($cert)) {
6✔
803
                throw HTTPException::forSSLCertNotFound($cert);
2✔
804
            }
805

806
            $curlOptions[CURLOPT_SSLCERT] = $cert;
4✔
807
        }
808

809
        // SSL Verification
810
        if (isset($config['verify'])) {
188✔
811
            if (is_string($config['verify'])) {
188✔
812
                $file = realpath($config['verify']) ?: $config['verify'];
4✔
813

814
                if (! is_file($file)) {
4✔
815
                    throw HTTPException::forInvalidSSLKey($config['verify']);
2✔
816
                }
817

818
                $curlOptions[CURLOPT_CAINFO]         = $file;
2✔
819
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2✔
820
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2✔
821
            } elseif (is_bool($config['verify'])) {
184✔
822
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
184✔
823
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
184✔
824
            }
825
        }
826

827
        // Proxy
828
        if (isset($config['proxy'])) {
186✔
829
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
2✔
830
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
2✔
831
        }
832

833
        // Debug
834
        if ($config['debug']) {
186✔
835
            $curlOptions[CURLOPT_VERBOSE] = 1;
4✔
836
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
4✔
837
        }
838

839
        // Decode Content
840
        if (! empty($config['decode_content'])) {
186✔
841
            $accept = $this->getHeaderLine('Accept-Encoding');
4✔
842

843
            if ($accept !== '') {
4✔
844
                $curlOptions[CURLOPT_ENCODING] = $accept;
2✔
845
            } else {
846
                $curlOptions[CURLOPT_ENCODING]   = '';
2✔
847
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
2✔
848
            }
849
        }
850

851
        // Allow Redirects
852
        if (array_key_exists('allow_redirects', $config)) {
186✔
853
            $settings = $this->redirectDefaults;
14✔
854

855
            if (is_array($config['allow_redirects'])) {
14✔
856
                $settings = array_merge($settings, $config['allow_redirects']);
2✔
857
            }
858

859
            if ($config['allow_redirects'] === false) {
14✔
860
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
4✔
861
            } else {
862
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
10✔
863
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
10✔
864

865
                if ($settings['strict'] === true) {
10✔
866
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
10✔
867
                }
868

869
                $protocols = 0;
10✔
870

871
                foreach ($settings['protocols'] as $proto) {
10✔
872
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
10✔
873
                }
874

875
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
10✔
876
            }
877
        }
878

879
        // DNS Cache Timeout
880
        if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) {
186✔
881
            $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout'];
12✔
882
        }
883

884
        // Fresh Connect (default true)
885
        $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect'])
186✔
886
            ? $config['fresh_connect']
2✔
887
            : true;
184✔
888

889
        // Timeout
890
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000;
186✔
891

892
        // Connection Timeout
893
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000;
186✔
894

895
        // Post Data - application/x-www-form-urlencoded
896
        if (! empty($config['form_params']) && is_array($config['form_params'])) {
186✔
897
            $postFields                      = http_build_query($config['form_params']);
10✔
898
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
10✔
899

900
            // Ensure content-length is set, since CURL doesn't seem to
901
            // calculate it when HTTPHEADER is set.
902
            $this->setHeader('Content-Length', (string) strlen($postFields));
10✔
903
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
10✔
904
        }
905

906
        // Post Data - multipart/form-data
907
        if (! empty($config['multipart']) && is_array($config['multipart'])) {
186✔
908
            // setting the POSTFIELDS option automatically sets multipart
909
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
4✔
910
        }
911

912
        // HTTP Errors
913
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
186✔
914

915
        // JSON
916
        if (isset($config['json'])) {
186✔
917
            // Will be set as the body in `applyBody()`
918
            $json = json_encode($config['json']);
4✔
919
            $this->setBody($json);
4✔
920
            $this->setHeader('Content-Type', 'application/json');
4✔
921
            $this->setHeader('Content-Length', (string) strlen($json));
4✔
922
        }
923

924
        // Resolve IP
925
        if (array_key_exists('force_ip_resolve', $config)) {
186✔
926
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
6✔
927
                'v4'    => CURL_IPRESOLVE_V4,
2✔
928
                'v6'    => CURL_IPRESOLVE_V6,
2✔
929
                default => CURL_IPRESOLVE_WHATEVER,
2✔
930
            };
6✔
931
        }
932

933
        // version
934
        if (! empty($config['version'])) {
186✔
935
            $version = sprintf('%.1F', $config['version']);
10✔
936
            if ($version === '1.0') {
10✔
937
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
4✔
938
            } elseif ($version === '1.1') {
8✔
939
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
4✔
940
            } elseif ($version === '2.0') {
6✔
941
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
4✔
942
            } elseif ($version === '3.0') {
2✔
943
                if (! defined('CURL_HTTP_VERSION_3')) {
2✔
944
                    define('CURL_HTTP_VERSION_3', 30);
1✔
945
                }
946

947
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_3;
2✔
948
            }
949
        }
950

951
        // Cookie
952
        if (isset($config['cookie'])) {
186✔
953
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
2✔
954
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
2✔
955
        }
956

957
        // User Agent
958
        if (isset($config['user_agent'])) {
186✔
959
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
4✔
960
        }
961

962
        return $curlOptions;
186✔
963
    }
964

965
    /**
966
     * Does the actual work of initializing cURL, setting the options,
967
     * and grabbing the output.
968
     *
969
     * @param array<int, mixed> $curlOptions
970
     *
971
     * @codeCoverageIgnore
972
     */
973
    protected function sendRequest(array $curlOptions = []): string
974
    {
975
        $ch = curl_init();
976

977
        curl_setopt_array($ch, $curlOptions);
978

979
        // Send the request and wait for a response.
980
        $output = curl_exec($ch);
981

982
        if ($output === false) {
983
            $this->lastCurlError = curl_errno($ch);
984

985
            throw HTTPException::forCurlError((string) $this->lastCurlError, curl_error($ch));
986
        }
987

988
        return $output;
989
    }
990

991
    private function removeIntermediateResponses(string $output, string $breakString): string
992
    {
993
        while (true) {
184✔
994
            // Check if we should remove the current response
995
            if ($this->shouldRemoveCurrentResponse($output, $breakString)) {
184✔
996
                $breakStringPos = strpos($output, $breakString);
18✔
997
                if ($breakStringPos !== false) {
18✔
998
                    $output = substr($output, $breakStringPos + 4);
18✔
999

1000
                    continue;
18✔
1001
                }
1002
            }
1003

1004
            // No more intermediate responses to remove
1005
            break;
184✔
1006
        }
1007

1008
        return $output;
184✔
1009
    }
1010

1011
    /**
1012
     * Check if the current response (at the beginning of output) should be removed.
1013
     */
1014
    private function shouldRemoveCurrentResponse(string $output, string $breakString): bool
1015
    {
1016
        // HTTP/x.x 1xx responses (Continue, Processing, etc.)
1017
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) {
184✔
1018
            return true;
8✔
1019
        }
1020

1021
        // HTTP/x.x 200 Connection established (proxy responses)
1022
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) {
184✔
1023
            return true;
6✔
1024
        }
1025

1026
        // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed
1027
        $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false;
184✔
1028
        if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) {
184✔
1029
            // Check if there's a Location header
1030
            $breakStringPos = strpos($output, $breakString);
4✔
1031
            if ($breakStringPos !== false) {
4✔
1032
                $headerSection = substr($output, 0, $breakStringPos);
4✔
1033
                $headers       = explode("\n", $headerSection);
4✔
1034

1035
                foreach ($headers as $header) {
4✔
1036
                    if (str_starts_with(strtolower($header), 'location:')) {
4✔
1037
                        return true; // Found location header, this is a redirect to remove
2✔
1038
                    }
1039
                }
1040
            }
1041
        }
1042

1043
        // Digest auth challenges - only remove if there's another response after
1044
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') {
184✔
1045
            $breakStringPos = strpos($output, $breakString);
4✔
1046
            if ($breakStringPos !== false) {
4✔
1047
                $headerSection = substr($output, 0, $breakStringPos);
4✔
1048
                if (str_contains($headerSection, 'WWW-Authenticate: Digest')) {
4✔
1049
                    $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4);
4✔
1050

1051
                    return $nextBreakPos !== false; // Only remove if there's another response
4✔
1052
                }
1053
            }
1054
        }
1055

1056
        return false;
184✔
1057
    }
1058
}
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