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

codeigniter4 / CodeIgniter4 / 29826081371

21 Jul 2026 11:25AM UTC coverage: 89.627% (-0.005%) from 89.632%
29826081371

push

github

michalsn
Merge remote-tracking branch 'upstream/develop' into 4.8

# Conflicts:
#	system/CLI/Console.php
#	system/Common.php
#	utils/phpstan-baseline/loader.neon
#	utils/phpstan-baseline/missingType.iterableValue.neon

74 of 79 new or added lines in 2 files covered. (93.67%)

25429 of 28372 relevant lines covered (89.63%)

232.42 hits per line

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

98.31
/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')) {
211✔
153
            throw HTTPException::forMissingCurl(); // @codeCoverageIgnore
154
        }
155

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

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

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

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

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

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

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

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

187
            foreach (array_unique($optShareConnection) as $opt) {
211✔
188
                curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt);
211✔
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;
192✔
202

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

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

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

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

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

215
        return $this->response;
185✔
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   = [];
99✔
227
        $this->headerMap = [];
99✔
228

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

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

235
        // Set the default options for next request
236
        $this->parseOptions($this->defaultOptions);
99✔
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
     * Convenience method for sending a QUERY request.
297
     *
298
     * @param array<string, mixed> $options
299
     */
300
    public function query(string $url, array $options = []): ResponseInterface
301
    {
302
        return $this->request(Method::QUERY, $url, $options);
4✔
303
    }
304

305
    /**
306
     * Set the HTTP Authentication.
307
     *
308
     * @param string $type basic or digest
309
     *
310
     * @return $this
311
     */
312
    public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic')
313
    {
314
        $this->config['auth'] = [$username, $password, $type];
4✔
315

316
        return $this;
4✔
317
    }
318

319
    /**
320
     * Set form data to be sent.
321
     *
322
     * @param bool $multipart Set TRUE if you are sending CURLFiles
323
     *
324
     * @return $this
325
     */
326
    public function setForm(array $params, bool $multipart = false)
327
    {
328
        if ($multipart) {
2✔
329
            $this->config['multipart'] = $params;
2✔
330
        } else {
331
            $this->config['form_params'] = $params;
2✔
332
        }
333

334
        return $this;
2✔
335
    }
336

337
    /**
338
     * Set JSON data to be sent.
339
     *
340
     * @param mixed $data
341
     *
342
     * @return $this
343
     */
344
    public function setJSON($data)
345
    {
346
        $this->config['json'] = $data;
2✔
347

348
        return $this;
2✔
349
    }
350

351
    /**
352
     * Sets the correct settings based on the options array
353
     * passed in.
354
     *
355
     * @return void
356
     */
357
    protected function parseOptions(array $options)
358
    {
359
        if (array_key_exists('baseURI', $options)) {
211✔
360
            $this->baseURI = new URI($options['baseURI'], true);
48✔
361
            unset($options['baseURI']);
48✔
362
        }
363

364
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
211✔
365
            foreach ($options['headers'] as $name => $value) {
6✔
366
                $this->setHeader($name, $value);
6✔
367
            }
368

369
            unset($options['headers']);
6✔
370
        }
371

372
        if (array_key_exists('delay', $options)) {
211✔
373
            // Convert from the milliseconds passed in
374
            // to the seconds that sleep requires.
375
            $this->delay = (float) $options['delay'] / 1000;
26✔
376
            unset($options['delay']);
26✔
377
        }
378

379
        if (array_key_exists('body', $options)) {
211✔
380
            $this->setBody($options['body']);
2✔
381
            unset($options['body']);
2✔
382
        }
383

384
        foreach ($options as $key => $value) {
211✔
385
            $this->config[$key] = $value;
116✔
386
        }
387
    }
388

389
    /**
390
     * If the $url is a relative URL, will attempt to create
391
     * a full URL by prepending $this->baseURI to it.
392
     */
393
    protected function prepareURL(string $url): string
394
    {
395
        // If it's a full URI, then we have nothing to do here...
396
        if (str_contains($url, '://')) {
194✔
397
            return $url;
104✔
398
        }
399

400
        $uri = $this->baseURI->resolveRelativeURI($url);
90✔
401

402
        // Create the string instead of casting to prevent baseURL muddling
403
        return URI::createURIString(
90✔
404
            $uri->getScheme(),
90✔
405
            $uri->getAuthority(),
90✔
406
            $uri->getPath(),
90✔
407
            $uri->getQuery(),
90✔
408
            $uri->getFragment(),
90✔
409
        );
90✔
410
    }
411

412
    /**
413
     * Fires the actual cURL request.
414
     *
415
     * @return ResponseInterface
416
     */
417
    public function send(string $method, string $url)
418
    {
419
        // Reset our curl options so we're on a fresh slate.
420
        $curlOptions = [];
194✔
421
        $config      = $this->config;
194✔
422
        $retry       = $this->normalizeRetryOption($config['retry'] ?? false);
194✔
423

424
        if (! empty($this->config['query']) && is_array($this->config['query'])) {
194✔
425
            // This is likely too naive a solution.
426
            // Should look into handling when $url already
427
            // has query vars on it.
428
            $url .= '?' . http_build_query($this->config['query']);
2✔
429
            unset($this->config['query']);
2✔
430
        }
431

432
        $curlOptions[CURLOPT_URL]            = $url;
194✔
433
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
194✔
434

435
        if ($this->shareConnection instanceof CurlShareHandle) {
194✔
436
            $curlOptions[CURLOPT_SHARE] = $this->shareConnection;
192✔
437
        }
438

439
        $curlOptions[CURLOPT_HEADER] = true;
194✔
440
        // Disable @file uploads in post data.
441
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
194✔
442

443
        $curlOptions = $this->setCURLOptions($curlOptions, $config);
194✔
444
        $curlOptions = $this->applyMethod($method, $curlOptions);
190✔
445
        $curlOptions = $this->applyRequestHeaders($curlOptions);
190✔
446

447
        if ($retry !== null) {
190✔
448
            $curlOptions[CURLOPT_FAILONERROR] = false;
15✔
449
        }
450

451
        // Do we need to delay this request?
452
        if ($this->delay > 0) {
190✔
453
            $this->sleep($this->delay);
24✔
454
        }
455

456
        if ($retry === null) {
190✔
457
            return $this->sendAttempt($curlOptions);
175✔
458
        }
459

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

462
        return $this->sendWithRetries($curlOptions, $retry, $httpErrors);
15✔
463
    }
464

465
    /**
466
     * Sends the request until it succeeds or retry attempts are exhausted.
467
     *
468
     * @param array<int, mixed>                 $curlOptions
469
     * @param array<string, bool|int|list<int>> $retry
470
     */
471
    protected function sendWithRetries(array $curlOptions, array $retry, bool $httpErrors): ResponseInterface
472
    {
473
        $attempt = 0;
15✔
474

475
        while (true) {
15✔
476
            $this->response = clone $this->responseOrig;
15✔
477

478
            try {
479
                $response = $this->sendAttempt($curlOptions);
15✔
480
            } catch (HTTPException $e) {
3✔
481
                if (! $this->shouldRetryCurlError($retry, $attempt)) {
3✔
482
                    throw $e;
2✔
483
                }
484

485
                $this->sleep($this->getRetryDelay($retry, $attempt) / 1000);
1✔
486
                $attempt++;
1✔
487

488
                continue;
1✔
489
            }
490

491
            if (! $this->shouldRetryResponse($response, $retry, $attempt)) {
13✔
492
                if ($httpErrors && $response->getStatusCode() >= 400) {
13✔
493
                    throw HTTPException::forCurlError((string) CURLE_HTTP_RETURNED_ERROR, 'The requested URL returned error: ' . $response->getStatusCode());
1✔
494
                }
495

496
                return $response;
12✔
497
            }
498

499
            $this->sleep($this->getRetryDelay($retry, $attempt, $response) / 1000);
11✔
500
            $attempt++;
11✔
501
        }
502
    }
503

504
    /**
505
     * Sends a single cURL request attempt and populates the response.
506
     *
507
     * @param array<int, mixed> $curlOptions
508
     */
509
    protected function sendAttempt(array $curlOptions): ResponseInterface
510
    {
511
        $this->lastCurlError = 0;
190✔
512

513
        $output = $this->sendRequest($curlOptions);
190✔
514

515
        // Set the string we want to break our response from
516
        $breakString = "\r\n\r\n";
188✔
517

518
        // Remove all intermediate responses
519
        $output = $this->removeIntermediateResponses($output, $breakString);
188✔
520

521
        // Split out our headers and body
522
        $break = strpos($output, $breakString);
188✔
523

524
        if ($break !== false) {
188✔
525
            // Our headers
526
            $headers = explode("\n", substr($output, 0, $break));
44✔
527

528
            $this->setResponseHeaders($headers);
44✔
529

530
            // Our body
531
            $body = substr($output, $break + 4);
44✔
532
            $this->response->setBody($body);
44✔
533
        } else {
534
            $this->response->setBody($output);
144✔
535
        }
536

537
        return $this->response;
188✔
538
    }
539

540
    /**
541
     * Normalizes the retry option into retry settings.
542
     *
543
     * @return array<string, bool|int|list<int>>|null
544
     */
545
    protected function normalizeRetryOption(mixed $retry): ?array
546
    {
547
        if (in_array($retry, [false, null, 0], true)) {
194✔
548
            return null;
178✔
549
        }
550

551
        $config = $this->retryDefaults;
16✔
552

553
        if (is_int($retry)) {
16✔
554
            $config['max_retries'] = $retry;
5✔
555
        } elseif (is_array($retry)) {
11✔
556
            $config = array_merge($config, $retry);
11✔
557
        } else {
558
            return null;
×
559
        }
560

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

563
        if ($config['max_retries'] === 0) {
16✔
564
            return null;
1✔
565
        }
566

567
        $config['delay']               = $this->normalizeRetryDelay($config['delay']);
15✔
568
        $config['max_delay']           = max(0, (int) $config['max_delay']);
15✔
569
        $config['status_codes']        = array_map(intval(...), (array) $config['status_codes']);
15✔
570
        $config['curl_errors']         = (bool) $config['curl_errors'];
15✔
571
        $config['respect_retry_after'] = (bool) $config['respect_retry_after'];
15✔
572

573
        return $config;
15✔
574
    }
575

576
    /**
577
     * Normalizes the retry delay setting.
578
     *
579
     * @return int|list<int>
580
     */
581
    protected function normalizeRetryDelay(mixed $delay): array|int
582
    {
583
        if (is_array($delay)) {
15✔
584
            return array_map(static fn ($value): int => max(0, (int) $value), $delay);
1✔
585
        }
586

587
        return max(0, (int) $delay);
14✔
588
    }
589

590
    /**
591
     * Determines whether a response should be retried.
592
     *
593
     * @param array<string, bool|int|list<int>> $retry
594
     */
595
    protected function shouldRetryResponse(ResponseInterface $response, array $retry, int $attempt): bool
596
    {
597
        if ($attempt >= $retry['max_retries']) {
13✔
598
            return false;
11✔
599
        }
600

601
        return in_array($response->getStatusCode(), $retry['status_codes'], true);
12✔
602
    }
603

604
    /**
605
     * Determines whether a cURL error should be retried.
606
     *
607
     * @param array<string, bool|int|list<int>> $retry
608
     */
609
    protected function shouldRetryCurlError(array $retry, int $attempt): bool
610
    {
611
        if ($attempt >= $retry['max_retries'] || $retry['curl_errors'] === false) {
3✔
612
            return false;
1✔
613
        }
614

615
        return in_array($this->lastCurlError, $this->transientCurlErrors, true);
2✔
616
    }
617

618
    /**
619
     * Returns the delay before the next retry attempt.
620
     *
621
     * @param array<string, bool|int|list<int>> $retry
622
     */
623
    protected function getRetryDelay(array $retry, int $attempt, ?ResponseInterface $response = null): int
624
    {
625
        if ($response instanceof ResponseInterface && $retry['respect_retry_after'] === true) {
12✔
626
            $retryAfter = $this->getRetryAfterDelay($response);
10✔
627

628
            if ($retryAfter !== null) {
10✔
629
                return $this->limitRetryDelay($retryAfter * 1000, $retry);
3✔
630
            }
631
        }
632

633
        $delay = $retry['delay'];
9✔
634

635
        if (is_array($delay)) {
9✔
636
            $lastDelay = $delay[array_key_last($delay)] ?? 0;
1✔
637

638
            return $this->limitRetryDelay((int) ($delay[$attempt] ?? $lastDelay), $retry);
1✔
639
        }
640

641
        return $this->limitRetryDelay((int) $delay, $retry);
8✔
642
    }
643

644
    /**
645
     * Caps the retry delay when configured.
646
     *
647
     * @param array<string, bool|int|list<int>> $retry
648
     */
649
    protected function limitRetryDelay(int $delay, array $retry): int
650
    {
651
        $maxDelay = (int) $retry['max_delay'];
12✔
652

653
        if ($maxDelay === 0) {
12✔
654
            return $delay;
×
655
        }
656

657
        return min($delay, $maxDelay);
12✔
658
    }
659

660
    /**
661
     * Returns the delay from a Retry-After header in seconds.
662
     */
663
    protected function getRetryAfterDelay(ResponseInterface $response): ?int
664
    {
665
        $retryAfter = $response->getHeaderLine('Retry-After');
10✔
666

667
        if ($retryAfter === '') {
10✔
668
            return null;
7✔
669
        }
670

671
        if (ctype_digit($retryAfter)) {
3✔
672
            return (int) $retryAfter;
2✔
673
        }
674

675
        $timestamp = strtotime($retryAfter);
1✔
676

677
        if ($timestamp === false) {
1✔
678
            return null;
×
679
        }
680

681
        return max(0, $timestamp - time());
1✔
682
    }
683

684
    /**
685
     * Sleeps for the configured number of seconds.
686
     */
687
    protected function sleep(float $seconds): void
688
    {
689
        usleep((int) ($seconds * 1_000_000));
×
690
    }
691

692
    /**
693
     * Adds $this->headers to the cURL request.
694
     */
695
    protected function applyRequestHeaders(array $curlOptions = []): array
696
    {
697
        if (empty($this->headers)) {
190✔
698
            return $curlOptions;
97✔
699
        }
700

701
        $set = [];
96✔
702

703
        foreach (array_keys($this->headers) as $name) {
96✔
704
            $set[] = $name . ': ' . $this->getHeaderLine($name);
96✔
705
        }
706

707
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
96✔
708

709
        return $curlOptions;
96✔
710
    }
711

712
    /**
713
     * Apply method
714
     */
715
    protected function applyMethod(string $method, array $curlOptions): array
716
    {
717
        $this->method                       = $method;
190✔
718
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
190✔
719

720
        $size = strlen($this->body ?? '');
190✔
721

722
        // Have content?
723
        if ($size > 0) {
190✔
724
            return $this->applyBody($curlOptions);
12✔
725
        }
726

727
        if ($method === Method::PUT || $method === Method::POST) {
179✔
728
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
729
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
55✔
730
                $this->setHeader('Content-Length', '0');
43✔
731
            }
732
        } elseif ($method === 'HEAD') {
128✔
733
            $curlOptions[CURLOPT_NOBODY] = 1;
2✔
734
        }
735

736
        return $curlOptions;
179✔
737
    }
738

739
    /**
740
     * Apply body
741
     */
742
    protected function applyBody(array $curlOptions = []): array
743
    {
744
        if (! empty($this->body)) {
12✔
745
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
12✔
746
        }
747

748
        return $curlOptions;
12✔
749
    }
750

751
    /**
752
     * Parses the header retrieved from the cURL response into
753
     * our Response object.
754
     *
755
     * @return void
756
     */
757
    protected function setResponseHeaders(array $headers = [])
758
    {
759
        foreach ($headers as $header) {
44✔
760
            if (($pos = strpos($header, ':')) !== false) {
44✔
761
                $title = trim(substr($header, 0, $pos));
30✔
762
                $value = trim(substr($header, $pos + 1));
30✔
763

764
                if ($this->response instanceof Response) {
30✔
765
                    $this->response->addHeader($title, $value);
30✔
766
                } else {
767
                    $this->response->setHeader($title, $value);
×
768
                }
769
            } elseif (str_starts_with($header, 'HTTP')) {
42✔
770
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches);
42✔
771

772
                if (isset($matches[1])) {
42✔
773
                    $this->response->setProtocolVersion($matches[1]);
42✔
774
                }
775

776
                if (isset($matches[2])) {
42✔
777
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? '');
42✔
778
                }
779
            }
780
        }
781
    }
782

783
    /**
784
     * Set CURL options
785
     *
786
     * @return array
787
     *
788
     * @throws InvalidArgumentException
789
     */
790
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
791
    {
792
        $curlOptions = $this->applyAuthOptions($curlOptions, $config);
198✔
793
        $curlOptions = $this->applySslOptions($curlOptions, $config);
198✔
794
        $curlOptions = $this->applyProxyOptions($curlOptions, $config);
194✔
795
        $curlOptions = $this->applyDebugOptions($curlOptions, $config);
194✔
796
        $curlOptions = $this->applyRedirectOptions($curlOptions, $config);
194✔
797
        $curlOptions = $this->applyConnectionOptions($curlOptions, $config);
194✔
798
        $curlOptions = $this->applyBodyOptions($curlOptions, $config);
194✔
799
        $curlOptions = $this->applyResponseOptions($curlOptions, $config);
194✔
800
        $curlOptions = $this->applyProtocolOptions($curlOptions, $config);
194✔
801

802
        return $this->applyClientOptions($curlOptions, $config);
194✔
803
    }
804

805
    /**
806
     * @param array<int, mixed>    $curlOptions
807
     * @param array<string, mixed> $config
808
     *
809
     * @return array<int, mixed>
810
     */
811
    private function applyAuthOptions(array $curlOptions, array $config): array
812
    {
813
        // Auth Headers
814
        if (isset($config['auth']) && is_array($config['auth']) && count($config['auth']) >= 2) {
198✔
815
            $curlOptions[CURLOPT_USERPWD]  = $config['auth'][0] . ':' . $config['auth'][1];
14✔
816
            $curlOptions[CURLOPT_HTTPAUTH] = (isset($config['auth'][2]) && $config['auth'][2] !== '' && strtolower($config['auth'][2]) === 'digest')
14✔
817
                ? CURLAUTH_DIGEST
6✔
818
                : CURLAUTH_BASIC;
10✔
819
        }
820

821
        return $curlOptions;
198✔
822
    }
823

824
    /**
825
     * @param array<int, mixed>    $curlOptions
826
     * @param array<string, mixed> $config
827
     *
828
     * @return array<int, mixed>
829
     */
830
    private function applySslOptions(array $curlOptions, array $config): array
831
    {
832
        // Certificate
833
        $cert = $config['cert'] ?? null;
198✔
834

835
        if ((bool) $cert) {
198✔
836
            if (is_array($cert)) {
8✔
837
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2✔
838
                $cert                               = $cert[0];
2✔
839
            }
840

841
            if (! is_file($cert)) {
8✔
842
                throw HTTPException::forSSLCertNotFound($cert);
2✔
843
            }
844

845
            $curlOptions[CURLOPT_SSLCERT] = $cert;
6✔
846
        }
847

848
        // SSL Verification
849
        if (isset($config['verify'])) {
196✔
850
            if (is_string($config['verify'])) {
194✔
851
                $file = realpath($config['verify']) ?: $config['verify'];
4✔
852

853
                if (! is_file($file)) {
4✔
854
                    throw HTTPException::forInvalidSSLKey($config['verify']);
2✔
855
                }
856

857
                $curlOptions[CURLOPT_CAINFO]         = $file;
2✔
858
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2✔
859
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2✔
860
            } elseif (is_bool($config['verify'])) {
190✔
861
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
190✔
862
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
190✔
863
            }
864
        }
865

866
        return $curlOptions;
194✔
867
    }
868

869
    /**
870
     * @param array<int, mixed>    $curlOptions
871
     * @param array<string, mixed> $config
872
     *
873
     * @return array<int, mixed>
874
     */
875
    private function applyProxyOptions(array $curlOptions, array $config): array
876
    {
877
        // Proxy
878
        if (isset($config['proxy'])) {
194✔
879
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
4✔
880
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
4✔
881
        }
882

883
        return $curlOptions;
194✔
884
    }
885

886
    /**
887
     * @param array<int, mixed>    $curlOptions
888
     * @param array<string, mixed> $config
889
     *
890
     * @return array<int, mixed>
891
     */
892
    private function applyDebugOptions(array $curlOptions, array $config): array
893
    {
894
        // Debug
895
        if ((bool) ($config['debug'] ?? false)) {
194✔
896
            $curlOptions[CURLOPT_VERBOSE] = 1;
6✔
897
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
6✔
898
        }
899

900
        return $curlOptions;
194✔
901
    }
902

903
    /**
904
     * @param array<int, mixed>    $curlOptions
905
     * @param array<string, mixed> $config
906
     *
907
     * @return array<int, mixed>
908
     */
909
    private function applyRedirectOptions(array $curlOptions, array $config): array
910
    {
911
        // Allow Redirects
912
        if (array_key_exists('allow_redirects', $config)) {
194✔
913
            $settings = $this->redirectDefaults;
16✔
914

915
            if (is_array($config['allow_redirects'])) {
16✔
916
                $settings = array_merge($settings, $config['allow_redirects']);
2✔
917
            }
918

919
            if ($config['allow_redirects'] === false) {
16✔
920
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
6✔
921
            } else {
922
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
12✔
923
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
12✔
924

925
                if ($settings['strict'] === true) {
12✔
926
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
12✔
927
                }
928

929
                $protocols = 0;
12✔
930

931
                foreach ($settings['protocols'] as $proto) {
12✔
932
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
12✔
933
                }
934

935
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
12✔
936
            }
937
        }
938

939
        return $curlOptions;
194✔
940
    }
941

942
    /**
943
     * @param array<int, mixed>    $curlOptions
944
     * @param array<string, mixed> $config
945
     *
946
     * @return array<int, mixed>
947
     */
948
    private function applyConnectionOptions(array $curlOptions, array $config): array
949
    {
950
        // DNS Cache Timeout
951
        if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) {
194✔
952
            $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout'];
14✔
953
        }
954

955
        // Fresh Connect (default true)
956
        $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect'])
194✔
957
            ? $config['fresh_connect']
4✔
958
            : true;
192✔
959

960
        // Timeout
961
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) ($config['timeout'] ?? 0) * 1000;
194✔
962

963
        // Connection Timeout
964
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) ($config['connect_timeout'] ?? 150) * 1000;
194✔
965

966
        // Resolve IP
967
        if (array_key_exists('force_ip_resolve', $config)) {
194✔
968
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
8✔
969
                'v4'    => CURL_IPRESOLVE_V4,
4✔
970
                'v6'    => CURL_IPRESOLVE_V6,
2✔
971
                default => CURL_IPRESOLVE_WHATEVER,
2✔
972
            };
8✔
973
        }
974

975
        return $curlOptions;
194✔
976
    }
977

978
    /**
979
     * @param array<int, mixed>    $curlOptions
980
     * @param array<string, mixed> $config
981
     *
982
     * @return array<int, mixed>
983
     */
984
    private function applyBodyOptions(array $curlOptions, array $config): array
985
    {
986
        // Post Data - application/x-www-form-urlencoded
987
        if (isset($config['form_params']) && is_array($config['form_params']) && $config['form_params'] !== []) {
194✔
988
            $postFields                      = http_build_query($config['form_params']);
12✔
989
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
12✔
990

991
            // Ensure content-length is set, since CURL doesn't seem to
992
            // calculate it when HTTPHEADER is set.
993
            $this->setHeader('Content-Length', (string) strlen($postFields));
12✔
994
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
12✔
995
        }
996

997
        // Post Data - multipart/form-data
998
        if (isset($config['multipart']) && is_array($config['multipart']) && $config['multipart'] !== []) {
194✔
999
            // setting the POSTFIELDS option automatically sets multipart
1000
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
6✔
1001
        }
1002

1003
        // JSON
1004
        if (isset($config['json'])) {
194✔
1005
            // Will be set as the body in `applyBody()`
1006
            $json = json_encode($config['json']);
6✔
1007
            $this->setBody($json);
6✔
1008
            $this->setHeader('Content-Type', 'application/json');
6✔
1009
            $this->setHeader('Content-Length', (string) strlen($json));
6✔
1010
        }
1011

1012
        return $curlOptions;
194✔
1013
    }
1014

1015
    /**
1016
     * @param array<int, mixed>    $curlOptions
1017
     * @param array<string, mixed> $config
1018
     *
1019
     * @return array<int, mixed>
1020
     */
1021
    private function applyResponseOptions(array $curlOptions, array $config): array
1022
    {
1023
        // Decode Content
1024
        if ((bool) ($config['decode_content'] ?? false)) {
194✔
1025
            $accept = $this->getHeaderLine('Accept-Encoding');
6✔
1026

1027
            if ($accept !== '') {
6✔
1028
                $curlOptions[CURLOPT_ENCODING] = $accept;
2✔
1029
            } else {
1030
                $curlOptions[CURLOPT_ENCODING]   = '';
4✔
1031
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
4✔
1032
            }
1033
        }
1034

1035
        // HTTP Errors
1036
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
194✔
1037

1038
        return $curlOptions;
194✔
1039
    }
1040

1041
    /**
1042
     * @param array<int, mixed>    $curlOptions
1043
     * @param array<string, mixed> $config
1044
     *
1045
     * @return array<int, mixed>
1046
     */
1047
    private function applyProtocolOptions(array $curlOptions, array $config): array
1048
    {
1049
        if (! isset($config['version']) || (bool) $config['version'] === false) {
194✔
1050
            return $curlOptions;
184✔
1051
        }
1052

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

1055
        if ($version === '3.0' && ! defined('CURL_HTTP_VERSION_3')) {
12✔
1056
            define('CURL_HTTP_VERSION_3', 30);
1✔
1057
        }
1058

1059
        $curlVersion = match ($version) {
12✔
1060
            '1.0'   => CURL_HTTP_VERSION_1_0,
4✔
1061
            '1.1'   => CURL_HTTP_VERSION_1_1,
4✔
1062
            '2.0'   => CURL_HTTP_VERSION_2_0,
6✔
1063
            '3.0'   => CURL_HTTP_VERSION_3,
2✔
NEW
1064
            default => null,
×
1065
        };
12✔
1066

1067
        if ($curlVersion !== null) {
12✔
1068
            $curlOptions[CURLOPT_HTTP_VERSION] = $curlVersion;
12✔
1069
        }
1070

1071
        return $curlOptions;
12✔
1072
    }
1073

1074
    /**
1075
     * @param array<int, mixed>    $curlOptions
1076
     * @param array<string, mixed> $config
1077
     *
1078
     * @return array<int, mixed>
1079
     */
1080
    private function applyClientOptions(array $curlOptions, array $config): array
1081
    {
1082
        // Cookie
1083
        if (isset($config['cookie'])) {
194✔
1084
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
4✔
1085
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
4✔
1086
        }
1087

1088
        // User Agent
1089
        if (isset($config['user_agent'])) {
194✔
1090
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
6✔
1091
        }
1092

1093
        return $curlOptions;
194✔
1094
    }
1095

1096
    /**
1097
     * Does the actual work of initializing cURL, setting the options,
1098
     * and grabbing the output.
1099
     *
1100
     * @param array<int, mixed> $curlOptions
1101
     *
1102
     * @codeCoverageIgnore
1103
     */
1104
    protected function sendRequest(array $curlOptions = []): string
1105
    {
1106
        $ch = curl_init();
1107

1108
        curl_setopt_array($ch, $curlOptions);
1109

1110
        // Send the request and wait for a response.
1111
        $output = curl_exec($ch);
1112

1113
        if ($output === false) {
1114
            $this->lastCurlError = curl_errno($ch);
1115

1116
            throw HTTPException::forCurlError((string) $this->lastCurlError, curl_error($ch));
1117
        }
1118

1119
        return $output;
1120
    }
1121

1122
    private function removeIntermediateResponses(string $output, string $breakString): string
1123
    {
1124
        while (true) {
188✔
1125
            // Check if we should remove the current response
1126
            if ($this->shouldRemoveCurrentResponse($output, $breakString)) {
188✔
1127
                $breakStringPos = strpos($output, $breakString);
18✔
1128
                if ($breakStringPos !== false) {
18✔
1129
                    $output = substr($output, $breakStringPos + 4);
18✔
1130

1131
                    continue;
18✔
1132
                }
1133
            }
1134

1135
            // No more intermediate responses to remove
1136
            break;
188✔
1137
        }
1138

1139
        return $output;
188✔
1140
    }
1141

1142
    /**
1143
     * Check if the current response (at the beginning of output) should be removed.
1144
     */
1145
    private function shouldRemoveCurrentResponse(string $output, string $breakString): bool
1146
    {
1147
        // HTTP/x.x 1xx responses (Continue, Processing, etc.)
1148
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) {
188✔
1149
            return true;
8✔
1150
        }
1151

1152
        // HTTP/x.x 200 Connection established (proxy responses)
1153
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) {
188✔
1154
            return true;
6✔
1155
        }
1156

1157
        // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed
1158
        $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false;
188✔
1159
        if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) {
188✔
1160
            // Check if there's a Location header
1161
            $breakStringPos = strpos($output, $breakString);
4✔
1162
            if ($breakStringPos !== false) {
4✔
1163
                $headerSection = substr($output, 0, $breakStringPos);
4✔
1164
                $headers       = explode("\n", $headerSection);
4✔
1165

1166
                foreach ($headers as $header) {
4✔
1167
                    if (str_starts_with(strtolower($header), 'location:')) {
4✔
1168
                        return true; // Found location header, this is a redirect to remove
2✔
1169
                    }
1170
                }
1171
            }
1172
        }
1173

1174
        // Digest auth challenges - only remove if there's another response after
1175
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') {
188✔
1176
            $breakStringPos = strpos($output, $breakString);
4✔
1177
            if ($breakStringPos !== false) {
4✔
1178
                $headerSection = substr($output, 0, $breakStringPos);
4✔
1179
                if (str_contains($headerSection, 'WWW-Authenticate: Digest')) {
4✔
1180
                    $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4);
4✔
1181

1182
                    return $nextBreakPos !== false; // Only remove if there's another response
4✔
1183
                }
1184
            }
1185
        }
1186

1187
        return false;
188✔
1188
    }
1189
}
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