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

codeigniter4 / CodeIgniter4 / 37962869632

09 Oct 2026 04:57PM UTC coverage: 90.806% (+0.09%) from 90.718%
37962869632

push

github

web-flow
test: adjust `CURLRequestExceptionTest` to 4.8's base URI handling (#10612)

26034 of 28670 relevant lines covered (90.81%)

250.84 hits per line

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

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

104
    /**
105
     * cURL error numbers that may succeed on another attempt.
106
     *
107
     * @var list<int>
108
     */
109
    protected array $transientCurlErrors = [];
110

111
    /**
112
     * The number of milliseconds to delay before
113
     * sending the request.
114
     *
115
     * @var float
116
     */
117
    protected $delay = 0.0;
118

119
    /**
120
     * The last cURL error number.
121
     */
122
    protected int $lastCurlError = 0;
123

124
    /**
125
     * The default options from the constructor. Applied to all requests.
126
     *
127
     * @var array<string, mixed>
128
     */
129
    private readonly array $defaultOptions;
130

131
    /**
132
     * Whether share options between requests or not.
133
     *
134
     * If true, all the options won't be reset between requests.
135
     * It may cause an error request with unnecessary headers.
136
     */
137
    private readonly bool $shareOptions;
138

139
    /**
140
     * The share connection instance.
141
     */
142
    protected ?CurlShareHandle $shareConnection = null;
143

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

163
        $this->transientCurlErrors = [
257 ✔
164
            CURLE_COULDNT_RESOLVE_HOST,
257 ✔
165
            CURLE_COULDNT_CONNECT,
257 ✔
166
            CURLE_OPERATION_TIMEDOUT,
257 ✔
167
            CURLE_SEND_ERROR,
257 ✔
168
            CURLE_RECV_ERROR,
257 ✔
169
        ];
257 ✔
170

171
        parent::__construct(Method::GET, $uri);
257 ✔
172

173
        $this->responseOrig = $response ?? new Response();
257 ✔
174
        // Remove the default Content-Type header.
175
        $this->responseOrig->removeHeader('Content-Type');
257 ✔
176

177
        $this->baseURI        = $uri->useRawQueryString();
257 ✔
178
        $this->defaultOptions = $options;
257 ✔
179

180
        $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions;
257 ✔
181

182
        $this->config = $this->defaultConfig;
257 ✔
183
        $this->parseOptions($options);
257 ✔
184
        $this->defaultBaseURI = clone $this->baseURI;
257 ✔
185

186
        // Share Connection
187
        $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property
257 ✔
188
            CURL_LOCK_DATA_CONNECT,
257 ✔
189
            CURL_LOCK_DATA_DNS,
257 ✔
190
        ];
191

192
        if ($optShareConnection !== []) {
257 ✔
193
            $this->shareConnection = curl_share_init();
257 ✔
194

195
            foreach (array_unique($optShareConnection) as $opt) {
257 ✔
196
                curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt);
257 ✔
197
            }
198
        }
199
    }
200

201
    /**
202
     * Sends an HTTP request to the specified $url. If this is a relative
203
     * URL, it will be merged with $this->baseURI to form a complete URL.
204
     *
205
     * @param string               $method  HTTP method
206
     * @param array<string, mixed> $options
207
     */
208
    public function request($method, string $url, array $options = []): ResponseInterface
209
    {
210
        $this->response = clone $this->responseOrig;
238 ✔
211

212
        try {
213
            $this->parseOptions($options);
238 ✔
214

215
            $url = $this->prepareURL($url);
238 ✔
216

217
            $method = esc(strip_tags($method));
238 ✔
218

219
            $this->send($method, $url);
238 ✔
220
        } finally {
221
            if ($this->shareOptions === false) {
238 ✔
222
                $this->resetOptions();
238 ✔
223
            }
224
        }
225

226
        return $this->response;
230 ✔
227
    }
228

229
    /**
230
     * Reset all options to default.
231
     *
232
     * @return void
233
     */
234
    protected function resetOptions()
235
    {
236
        // Reset headers
237
        $this->headers   = [];
147 ✔
238
        $this->headerMap = [];
147 ✔
239

240
        // Reset body
241
        $this->body = null;
147 ✔
242

243
        // Reset configs
244
        $this->config = $this->defaultConfig;
147 ✔
245

246
        $this->baseURI = clone $this->defaultBaseURI;
147 ✔
247
        $this->delay   = 0.0;
147 ✔
248

249
        // Set the default options for next request
250
        $this->parseOptions($this->defaultOptions);
147 ✔
251
    }
252

253
    /**
254
     * Convenience method for sending a GET request.
255
     *
256
     * @param array<string, mixed> $options
257
     */
258
    public function get(string $url, array $options = []): ResponseInterface
259
    {
260
        return $this->request(Method::GET, $url, $options);
102 ✔
261
    }
262

263
    /**
264
     * Convenience method for sending a DELETE request.
265
     *
266
     * @param array<string, mixed> $options
267
     */
268
    public function delete(string $url, array $options = []): ResponseInterface
269
    {
270
        return $this->request('DELETE', $url, $options);
2 ✔
271
    }
272

273
    /**
274
     * Convenience method for sending a HEAD request.
275
     *
276
     * @param array<string, mixed> $options
277
     */
278
    public function head(string $url, array $options = []): ResponseInterface
279
    {
280
        return $this->request('HEAD', $url, $options);
2 ✔
281
    }
282

283
    /**
284
     * Convenience method for sending an OPTIONS request.
285
     *
286
     * @param array<string, mixed> $options
287
     */
288
    public function options(string $url, array $options = []): ResponseInterface
289
    {
290
        return $this->request('OPTIONS', $url, $options);
2 ✔
291
    }
292

293
    /**
294
     * Convenience method for sending a PATCH request.
295
     *
296
     * @param array<string, mixed> $options
297
     */
298
    public function patch(string $url, array $options = []): ResponseInterface
299
    {
300
        return $this->request('PATCH', $url, $options);
2 ✔
301
    }
302

303
    /**
304
     * Convenience method for sending a POST request.
305
     *
306
     * @param array<string, mixed> $options
307
     */
308
    public function post(string $url, array $options = []): ResponseInterface
309
    {
310
        return $this->request(Method::POST, $url, $options);
54 ✔
311
    }
312

313
    /**
314
     * Convenience method for sending a PUT request.
315
     *
316
     * @param array<string, mixed> $options
317
     */
318
    public function put(string $url, array $options = []): ResponseInterface
319
    {
320
        return $this->request(Method::PUT, $url, $options);
2 ✔
321
    }
322

323
    /**
324
     * Convenience method for sending a QUERY request.
325
     *
326
     * @param array<string, mixed> $options
327
     */
328
    public function query(string $url, array $options = []): ResponseInterface
329
    {
330
        return $this->request(Method::QUERY, $url, $options);
4 ✔
331
    }
332

333
    /**
334
     * Set the HTTP Authentication.
335
     *
336
     * @param string $type basic or digest
337
     *
338
     * @return $this
339
     */
340
    public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic')
341
    {
342
        $this->config['auth'] = [$username, $password, $type];
11 ✔
343

344
        return $this;
11 ✔
345
    }
346

347
    /**
348
     * Set form data to be sent.
349
     *
350
     * @param bool                 $multipart Set TRUE if you are sending CURLFiles
351
     * @param array<string, mixed> $params
352
     *
353
     * @return $this
354
     */
355
    public function setForm(array $params, bool $multipart = false)
356
    {
357
        if ($multipart) {
16 ✔
358
            $this->config['multipart'] = $params;
9 ✔
359
        } else {
360
            $this->config['form_params'] = $params;
9 ✔
361
        }
362

363
        return $this;
16 ✔
364
    }
365

366
    /**
367
     * Set JSON data to be sent.
368
     *
369
     * @param mixed $data
370
     *
371
     * @return $this
372
     */
373
    public function setJSON($data)
374
    {
375
        $this->config['json'] = $data;
9 ✔
376

377
        return $this;
9 ✔
378
    }
379

380
    /**
381
     * Sets the correct settings based on the options array
382
     * passed in.
383
     *
384
     * @param array<string, mixed> $options
385
     *
386
     * @return void
387
     */
388
    protected function parseOptions(array $options)
389
    {
390
        if (array_key_exists('baseURI', $options)) {
257 ✔
391
            $this->baseURI = new URI($options['baseURI'], true);
61 ✔
392
            unset($options['baseURI']);
56 ✔
393
        }
394

395
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
257 ✔
396
            foreach ($options['headers'] as $name => $value) {
14 ✔
397
                $this->setHeader($name, $value);
14 ✔
398
            }
399

400
            unset($options['headers']);
9 ✔
401
        }
402

403
        if (array_key_exists('delay', $options)) {
257 ✔
404
            // Convert from the milliseconds passed in
405
            // to the seconds that sleep requires.
406
            $this->delay = (float) $options['delay'] / 1000;
34 ✔
407
            unset($options['delay']);
34 ✔
408
        }
409

410
        if (array_key_exists('body', $options)) {
257 ✔
411
            $this->setBody($options['body']);
5 ✔
412
            unset($options['body']);
5 ✔
413
        }
414

415
        foreach ($options as $key => $value) {
257 ✔
416
            $this->config[$key] = $value;
128 ✔
417
        }
418
    }
419

420
    /**
421
     * If the $url is a relative URL, will attempt to create
422
     * a full URL by prepending $this->baseURI to it.
423
     */
424
    protected function prepareURL(string $url): string
425
    {
426
        // If it's a full URI, then we have nothing to do here...
427
        if (str_contains($url, '://')) {
240 ✔
428
            return $url;
142 ✔
429
        }
430

431
        $uri = $this->baseURI->resolveRelativeURI($url);
98 ✔
432

433
        // Create the string instead of casting to prevent baseURL muddling
434
        return URI::createURIString(
98 ✔
435
            $uri->getScheme(),
98 ✔
436
            $uri->getAuthority(),
98 ✔
437
            $uri->getPath(),
98 ✔
438
            $uri->getQuery(),
98 ✔
439
            $uri->getFragment(),
98 ✔
440
        );
98 ✔
441
    }
442

443
    /**
444
     * Fires the actual cURL request.
445
     *
446
     * @return ResponseInterface
447
     */
448
    public function send(string $method, string $url)
449
    {
450
        // Reset our curl options so we're on a fresh slate.
451
        $curlOptions = [];
240 ✔
452
        $config      = $this->config;
240 ✔
453
        $retry       = $this->normalizeRetryOption($config['retry'] ?? false);
240 ✔
454

455
        $query = $this->config['query'] ?? [];
240 ✔
456

457
        if (is_array($query) && $query !== []) {
240 ✔
458
            // This is likely too naive a solution.
459
            // Should look into handling when $url already
460
            // has query vars on it.
461
            $url .= '?' . http_build_query($query);
2 ✔
462
            unset($this->config['query']);
2 ✔
463
        }
464

465
        $curlOptions[CURLOPT_URL]            = $url;
240 ✔
466
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
240 ✔
467

468
        if ($this->shareConnection instanceof CurlShareHandle) {
240 ✔
469
            $curlOptions[CURLOPT_SHARE] = $this->shareConnection;
238 ✔
470
        }
471

472
        $curlOptions[CURLOPT_HEADER] = true;
240 ✔
473
        // Disable @file uploads in post data.
474
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
240 ✔
475

476
        $curlOptions = $this->setCURLOptions($curlOptions, $config);
240 ✔
477
        $curlOptions = $this->applyMethod($method, $curlOptions);
236 ✔
478
        $curlOptions = $this->applyRequestHeaders($curlOptions);
236 ✔
479

480
        if ($retry !== null) {
236 ✔
481
            $curlOptions[CURLOPT_FAILONERROR] = false;
15 ✔
482
        }
483

484
        // Do we need to delay this request?
485
        if ($this->delay > 0) {
236 ✔
486
            $this->sleep($this->delay);
32 ✔
487
        }
488

489
        if ($retry === null) {
236 ✔
490
            return $this->sendAttempt($curlOptions);
221 ✔
491
        }
492

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

495
        return $this->sendWithRetries($curlOptions, $retry, $httpErrors);
15 ✔
496
    }
497

498
    /**
499
     * Sends the request until it succeeds or retry attempts are exhausted.
500
     *
501
     * @param array<int, mixed>                 $curlOptions
502
     * @param array<string, bool|int|list<int>> $retry
503
     */
504
    protected function sendWithRetries(array $curlOptions, array $retry, bool $httpErrors): ResponseInterface
505
    {
506
        $attempt = 0;
15 ✔
507

508
        while (true) {
15 ✔
509
            $this->response = clone $this->responseOrig;
15 ✔
510

511
            try {
512
                $response = $this->sendAttempt($curlOptions);
15 ✔
513
            } catch (HTTPException $e) {
3 ✔
514
                if (! $this->shouldRetryCurlError($retry, $attempt)) {
3 ✔
515
                    throw $e;
2 ✔
516
                }
517

518
                $this->sleep($this->getRetryDelay($retry, $attempt) / 1000);
1 ✔
519
                $attempt++;
1 ✔
520

521
                continue;
1 ✔
522
            }
523

524
            if (! $this->shouldRetryResponse($response, $retry, $attempt)) {
13 ✔
525
                if ($httpErrors && $response->getStatusCode() >= 400) {
13 ✔
526
                    throw HTTPException::forCurlError((string) CURLE_HTTP_RETURNED_ERROR, 'The requested URL returned error: ' . $response->getStatusCode());
1 ✔
527
                }
528

529
                return $response;
12 ✔
530
            }
531

532
            $this->sleep($this->getRetryDelay($retry, $attempt, $response) / 1000);
11 ✔
533
            $attempt++;
11 ✔
534
        }
535
    }
536

537
    /**
538
     * Sends a single cURL request attempt and populates the response.
539
     *
540
     * @param array<int, mixed> $curlOptions
541
     */
542
    protected function sendAttempt(array $curlOptions): ResponseInterface
543
    {
544
        $this->lastCurlError = 0;
236 ✔
545

546
        $output = $this->sendRequest($curlOptions);
236 ✔
547

548
        // Set the string we want to break our response from
549
        $breakString = "\r\n\r\n";
233 ✔
550

551
        // Remove all intermediate responses
552
        $output = $this->removeIntermediateResponses($output, $breakString);
233 ✔
553

554
        // Split out our headers and body
555
        $break = strpos($output, $breakString);
233 ✔
556

557
        if ($break !== false) {
233 ✔
558
            // Our headers
559
            $headers = explode("\n", substr($output, 0, $break));
54 ✔
560

561
            $this->setResponseHeaders($headers);
54 ✔
562

563
            // Our body
564
            $body = substr($output, $break + 4);
44 ✔
565
            $this->response->setBody($body);
44 ✔
566
        } else {
567
            $this->response->setBody($output);
189 ✔
568
        }
569

570
        return $this->response;
233 ✔
571
    }
572

573
    /**
574
     * Normalizes the retry option into retry settings.
575
     *
576
     * @return array<string, bool|int|list<int>>|null
577
     */
578
    protected function normalizeRetryOption(mixed $retry): ?array
579
    {
580
        if (in_array($retry, [false, null, 0], true)) {
240 ✔
581
            return null;
224 ✔
582
        }
583

584
        $config = $this->retryDefaults;
16 ✔
585

586
        if (is_int($retry)) {
16 ✔
587
            $config['max_retries'] = $retry;
5 ✔
588
        } elseif (is_array($retry)) {
11 ✔
589
            $config = array_merge($config, $retry);
11 ✔
590
        } else {
591
            return null;
×
592
        }
593

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

596
        if ($config['max_retries'] === 0) {
16 ✔
597
            return null;
1 ✔
598
        }
599

600
        $config['delay']               = $this->normalizeRetryDelay($config['delay']);
15 ✔
601
        $config['max_delay']           = max(0, (int) $config['max_delay']);
15 ✔
602
        $config['status_codes']        = array_map(intval(...), (array) $config['status_codes']);
15 ✔
603
        $config['curl_errors']         = (bool) $config['curl_errors'];
15 ✔
604
        $config['respect_retry_after'] = (bool) $config['respect_retry_after'];
15 ✔
605

606
        return $config;
15 ✔
607
    }
608

609
    /**
610
     * Normalizes the retry delay setting.
611
     *
612
     * @return int|list<int>
613
     */
614
    protected function normalizeRetryDelay(mixed $delay): array|int
615
    {
616
        if (is_array($delay)) {
15 ✔
617
            return array_map(static fn ($value): int => max(0, (int) $value), $delay);
1 ✔
618
        }
619

620
        return max(0, (int) $delay);
14 ✔
621
    }
622

623
    /**
624
     * Determines whether a response should be retried.
625
     *
626
     * @param array<string, bool|int|list<int>> $retry
627
     */
628
    protected function shouldRetryResponse(ResponseInterface $response, array $retry, int $attempt): bool
629
    {
630
        if ($attempt >= $retry['max_retries']) {
13 ✔
631
            return false;
11 ✔
632
        }
633

634
        return in_array($response->getStatusCode(), $retry['status_codes'], true);
12 ✔
635
    }
636

637
    /**
638
     * Determines whether a cURL error should be retried.
639
     *
640
     * @param array<string, bool|int|list<int>> $retry
641
     */
642
    protected function shouldRetryCurlError(array $retry, int $attempt): bool
643
    {
644
        if ($attempt >= $retry['max_retries'] || $retry['curl_errors'] === false) {
3 ✔
645
            return false;
1 ✔
646
        }
647

648
        return in_array($this->lastCurlError, $this->transientCurlErrors, true);
2 ✔
649
    }
650

651
    /**
652
     * Returns the delay before the next retry attempt.
653
     *
654
     * @param array<string, bool|int|list<int>> $retry
655
     */
656
    protected function getRetryDelay(array $retry, int $attempt, ?ResponseInterface $response = null): int
657
    {
658
        if ($response instanceof ResponseInterface && $retry['respect_retry_after'] === true) {
12 ✔
659
            $retryAfter = $this->getRetryAfterDelay($response);
10 ✔
660

661
            if ($retryAfter !== null) {
10 ✔
662
                return $this->limitRetryDelay($retryAfter * 1000, $retry);
3 ✔
663
            }
664
        }
665

666
        $delay = $retry['delay'];
9 ✔
667

668
        if (is_array($delay)) {
9 ✔
669
            $lastDelay = $delay[array_key_last($delay)] ?? 0;
1 ✔
670

671
            return $this->limitRetryDelay((int) ($delay[$attempt] ?? $lastDelay), $retry);
1 ✔
672
        }
673

674
        return $this->limitRetryDelay((int) $delay, $retry);
8 ✔
675
    }
676

677
    /**
678
     * Caps the retry delay when configured.
679
     *
680
     * @param array<string, bool|int|list<int>> $retry
681
     */
682
    protected function limitRetryDelay(int $delay, array $retry): int
683
    {
684
        $maxDelay = (int) $retry['max_delay'];
12 ✔
685

686
        if ($maxDelay === 0) {
12 ✔
687
            return $delay;
×
688
        }
689

690
        return min($delay, $maxDelay);
12 ✔
691
    }
692

693
    /**
694
     * Returns the delay from a Retry-After header in seconds.
695
     */
696
    protected function getRetryAfterDelay(ResponseInterface $response): ?int
697
    {
698
        $retryAfter = $response->getHeaderLine('Retry-After');
10 ✔
699

700
        if ($retryAfter === '') {
10 ✔
701
            return null;
7 ✔
702
        }
703

704
        if (ctype_digit($retryAfter)) {
3 ✔
705
            return (int) $retryAfter;
2 ✔
706
        }
707

708
        $timestamp = strtotime($retryAfter);
1 ✔
709

710
        if ($timestamp === false) {
1 ✔
711
            return null;
×
712
        }
713

714
        return max(0, $timestamp - time());
1 ✔
715
    }
716

717
    /**
718
     * Sleeps for the configured number of seconds.
719
     */
720
    protected function sleep(float $seconds): void
721
    {
722
        usleep((int) ($seconds * 1_000_000));
×
723
    }
724

725
    /**
726
     * Adds $this->headers to the cURL request.
727
     *
728
     * @param array<int, mixed> $curlOptions
729
     *
730
     * @return array<int, mixed>
731
     */
732
    protected function applyRequestHeaders(array $curlOptions = []): array
733
    {
734
        if ($this->headers === []) {
236 ✔
735
            return $curlOptions;
141 ✔
736
        }
737

738
        $set = [];
115 ✔
739

740
        foreach (array_keys($this->headers) as $name) {
115 ✔
741
            $set[] = $name . ': ' . $this->getHeaderLine($name);
115 ✔
742
        }
743

744
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
115 ✔
745

746
        return $curlOptions;
115 ✔
747
    }
748

749
    /**
750
     * Apply method
751
     *
752
     * @param array<int, mixed> $curlOptions
753
     *
754
     * @return array<int, mixed>
755
     */
756
    protected function applyMethod(string $method, array $curlOptions): array
757
    {
758
        $this->method                       = $method;
236 ✔
759
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
236 ✔
760

761
        $size = strlen($this->body ?? '');
236 ✔
762

763
        // Have content?
764
        if ($size > 0) {
236 ✔
765
            return $this->applyBody($curlOptions);
20 ✔
766
        }
767

768
        if ($method === Method::PUT || $method === Method::POST) {
224 ✔
769
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
770
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
64 ✔
771
                $this->setHeader('Content-Length', '0');
46 ✔
772
            }
773
        } elseif ($method === 'HEAD') {
173 ✔
774
            $curlOptions[CURLOPT_NOBODY] = 1;
2 ✔
775
        }
776

777
        return $curlOptions;
224 ✔
778
    }
779

780
    /**
781
     * Apply body
782
     *
783
     * @param array<int, mixed> $curlOptions
784
     *
785
     * @return array<int, mixed>
786
     */
787
    protected function applyBody(array $curlOptions = []): array
788
    {
789
        if (! in_array($this->body, [null, '', '0'], true)) {
20 ✔
790
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
20 ✔
791
        }
792

793
        return $curlOptions;
20 ✔
794
    }
795

796
    /**
797
     * Parses the header retrieved from the cURL response into
798
     * our Response object.
799
     *
800
     * @param list<string> $headers
801
     *
802
     * @return void
803
     */
804
    protected function setResponseHeaders(array $headers = [])
805
    {
806
        foreach ($headers as $header) {
54 ✔
807
            if (($pos = strpos($header, ':')) !== false) {
54 ✔
808
                $title = trim(substr($header, 0, $pos));
35 ✔
809
                $value = trim(substr($header, $pos + 1));
35 ✔
810

811
                if ($this->response instanceof Response) {
35 ✔
812
                    $this->response->addHeader($title, $value);
35 ✔
813
                } else {
814
                    $this->response->setHeader($title, $value);
×
815
                }
816
            } elseif (str_starts_with($header, 'HTTP')) {
52 ✔
817
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches);
52 ✔
818

819
                if (isset($matches[1])) {
52 ✔
820
                    $this->response->setProtocolVersion($matches[1]);
52 ✔
821
                }
822

823
                if (isset($matches[2])) {
52 ✔
824
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? '');
52 ✔
825
                }
826
            }
827
        }
828
    }
829

830
    /**
831
     * Set CURL options
832
     *
833
     * @param array<int, mixed>    $curlOptions
834
     * @param array<string, mixed> $config
835
     *
836
     * @return array<int, mixed>
837
     *
838
     * @throws InvalidArgumentException
839
     */
840
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
841
    {
842
        $curlOptions = $this->applyAuthOptions($curlOptions, $config);
244 ✔
843
        $curlOptions = $this->applySslOptions($curlOptions, $config);
244 ✔
844
        $curlOptions = $this->applyProxyOptions($curlOptions, $config);
240 ✔
845
        $curlOptions = $this->applyDebugOptions($curlOptions, $config);
240 ✔
846
        $curlOptions = $this->applyRedirectOptions($curlOptions, $config);
240 ✔
847
        $curlOptions = $this->applyConnectionOptions($curlOptions, $config);
240 ✔
848
        $curlOptions = $this->applyBodyOptions($curlOptions, $config);
240 ✔
849
        $curlOptions = $this->applyResponseOptions($curlOptions, $config);
240 ✔
850
        $curlOptions = $this->applyProtocolOptions($curlOptions, $config);
240 ✔
851

852
        return $this->applyClientOptions($curlOptions, $config);
240 ✔
853
    }
854

855
    /**
856
     * @param array<int, mixed>    $curlOptions
857
     * @param array<string, mixed> $config
858
     *
859
     * @return array<int, mixed>
860
     */
861
    private function applyAuthOptions(array $curlOptions, array $config): array
862
    {
863
        // Auth Headers
864
        if (isset($config['auth']) && is_array($config['auth']) && count($config['auth']) >= 2) {
244 ✔
865
            $curlOptions[CURLOPT_USERPWD]  = $config['auth'][0] . ':' . $config['auth'][1];
20 ✔
866
            $curlOptions[CURLOPT_HTTPAUTH] = (isset($config['auth'][2]) && $config['auth'][2] !== '' && strtolower($config['auth'][2]) === 'digest')
20 ✔
867
                ? CURLAUTH_DIGEST
6 ✔
868
                : CURLAUTH_BASIC;
16 ✔
869
        }
870

871
        return $curlOptions;
244 ✔
872
    }
873

874
    /**
875
     * @param array<int, mixed>    $curlOptions
876
     * @param array<string, mixed> $config
877
     *
878
     * @return array<int, mixed>
879
     */
880
    private function applySslOptions(array $curlOptions, array $config): array
881
    {
882
        // Certificate
883
        $cert = $config['cert'] ?? null;
244 ✔
884

885
        if ((bool) $cert) {
244 ✔
886
            if (is_array($cert)) {
14 ✔
887
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2 ✔
888
                $cert                               = $cert[0];
2 ✔
889
            }
890

891
            if (! is_file($cert)) {
14 ✔
892
                throw HTTPException::forSSLCertNotFound($cert);
8 ✔
893
            }
894

895
            $curlOptions[CURLOPT_SSLCERT] = $cert;
6 ✔
896
        }
897

898
        // SSL Verification
899
        if (isset($config['verify'])) {
242 ✔
900
            if (is_string($config['verify'])) {
240 ✔
901
                $realPath = realpath($config['verify']);
9 ✔
902
                $file     = $realPath === false ? $config['verify'] : $realPath;
9 ✔
903

904
                if (! is_file($file)) {
9 ✔
905
                    throw HTTPException::forInvalidSSLKey($config['verify']);
7 ✔
906
                }
907

908
                $curlOptions[CURLOPT_CAINFO]         = $file;
2 ✔
909
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2 ✔
910
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2 ✔
911
            } elseif (is_bool($config['verify'])) {
236 ✔
912
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
236 ✔
913
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
236 ✔
914
            }
915
        }
916

917
        return $curlOptions;
240 ✔
918
    }
919

920
    /**
921
     * @param array<int, mixed>    $curlOptions
922
     * @param array<string, mixed> $config
923
     *
924
     * @return array<int, mixed>
925
     */
926
    private function applyProxyOptions(array $curlOptions, array $config): array
927
    {
928
        // Proxy
929
        if (isset($config['proxy'])) {
240 ✔
930
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
5 ✔
931
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
5 ✔
932
        }
933

934
        return $curlOptions;
240 ✔
935
    }
936

937
    /**
938
     * @param array<int, mixed>    $curlOptions
939
     * @param array<string, mixed> $config
940
     *
941
     * @return array<int, mixed>
942
     */
943
    private function applyDebugOptions(array $curlOptions, array $config): array
944
    {
945
        // Debug
946
        if ((bool) ($config['debug'] ?? false)) {
240 ✔
947
            $curlOptions[CURLOPT_VERBOSE] = 1;
6 ✔
948
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
6 ✔
949
        }
950

951
        return $curlOptions;
240 ✔
952
    }
953

954
    /**
955
     * @param array<int, mixed>    $curlOptions
956
     * @param array<string, mixed> $config
957
     *
958
     * @return array<int, mixed>
959
     */
960
    private function applyRedirectOptions(array $curlOptions, array $config): array
961
    {
962
        // Allow Redirects
963
        if (array_key_exists('allow_redirects', $config)) {
240 ✔
964
            $settings = $this->redirectDefaults;
16 ✔
965

966
            if (is_array($config['allow_redirects'])) {
16 ✔
967
                $settings = array_merge($settings, $config['allow_redirects']);
2 ✔
968
            }
969

970
            if ($config['allow_redirects'] === false) {
16 ✔
971
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
6 ✔
972
            } else {
973
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
12 ✔
974
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
12 ✔
975

976
                if ($settings['strict'] === true) {
12 ✔
977
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
12 ✔
978
                }
979

980
                $protocols = 0;
12 ✔
981

982
                foreach ($settings['protocols'] as $proto) {
12 ✔
983
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
12 ✔
984
                }
985

986
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
12 ✔
987
            }
988
        }
989

990
        return $curlOptions;
240 ✔
991
    }
992

993
    /**
994
     * @param array<int, mixed>    $curlOptions
995
     * @param array<string, mixed> $config
996
     *
997
     * @return array<int, mixed>
998
     */
999
    private function applyConnectionOptions(array $curlOptions, array $config): array
1000
    {
1001
        // DNS Cache Timeout
1002
        if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) {
240 ✔
1003
            $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout'];
14 ✔
1004
        }
1005

1006
        // Fresh Connect (default true)
1007
        $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect'])
240 ✔
1008
            ? $config['fresh_connect']
4 ✔
1009
            : true;
238 ✔
1010

1011
        // Timeout
1012
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) ($config['timeout'] ?? 0) * 1000;
240 ✔
1013

1014
        // Connection Timeout
1015
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) ($config['connect_timeout'] ?? 150) * 1000;
240 ✔
1016

1017
        // Resolve IP
1018
        if (array_key_exists('force_ip_resolve', $config)) {
240 ✔
1019
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
8 ✔
1020
                'v4'    => CURL_IPRESOLVE_V4,
4 ✔
1021
                'v6'    => CURL_IPRESOLVE_V6,
2 ✔
1022
                default => CURL_IPRESOLVE_WHATEVER,
2 ✔
1023
            };
8 ✔
1024
        }
1025

1026
        return $curlOptions;
240 ✔
1027
    }
1028

1029
    /**
1030
     * @param array<int, mixed>    $curlOptions
1031
     * @param array<string, mixed> $config
1032
     *
1033
     * @return array<int, mixed>
1034
     */
1035
    private function applyBodyOptions(array $curlOptions, array $config): array
1036
    {
1037
        // Post Data - application/x-www-form-urlencoded
1038
        if (isset($config['form_params']) && is_array($config['form_params']) && $config['form_params'] !== []) {
240 ✔
1039
            $postFields                      = http_build_query($config['form_params']);
15 ✔
1040
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
15 ✔
1041

1042
            // Ensure content-length is set, since CURL doesn't seem to
1043
            // calculate it when HTTPHEADER is set.
1044
            $this->setHeader('Content-Length', (string) strlen($postFields));
15 ✔
1045
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
15 ✔
1046
        }
1047

1048
        // Post Data - multipart/form-data
1049
        if (isset($config['multipart']) && is_array($config['multipart']) && $config['multipart'] !== []) {
240 ✔
1050
            // setting the POSTFIELDS option automatically sets multipart
1051
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
9 ✔
1052
        }
1053

1054
        // JSON
1055
        if (isset($config['json'])) {
240 ✔
1056
            // Will be set as the body in `applyBody()`
1057
            $json = json_encode($config['json']);
9 ✔
1058
            $this->setBody($json);
9 ✔
1059
            $this->setHeader('Content-Type', 'application/json');
9 ✔
1060
            $this->setHeader('Content-Length', (string) strlen($json));
9 ✔
1061
        }
1062

1063
        return $curlOptions;
240 ✔
1064
    }
1065

1066
    /**
1067
     * @param array<int, mixed>    $curlOptions
1068
     * @param array<string, mixed> $config
1069
     *
1070
     * @return array<int, mixed>
1071
     */
1072
    private function applyResponseOptions(array $curlOptions, array $config): array
1073
    {
1074
        // Decode Content
1075
        if ((bool) ($config['decode_content'] ?? false)) {
240 ✔
1076
            $accept = $this->getHeaderLine('Accept-Encoding');
6 ✔
1077

1078
            if ($accept !== '') {
6 ✔
1079
                $curlOptions[CURLOPT_ENCODING] = $accept;
2 ✔
1080
            } else {
1081
                $curlOptions[CURLOPT_ENCODING]   = '';
4 ✔
1082
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
4 ✔
1083
            }
1084
        }
1085

1086
        // HTTP Errors
1087
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
240 ✔
1088

1089
        return $curlOptions;
240 ✔
1090
    }
1091

1092
    /**
1093
     * @param array<int, mixed>    $curlOptions
1094
     * @param array<string, mixed> $config
1095
     *
1096
     * @return array<int, mixed>
1097
     */
1098
    private function applyProtocolOptions(array $curlOptions, array $config): array
1099
    {
1100
        if (! isset($config['version']) || (bool) $config['version'] === false) {
240 ✔
1101
            return $curlOptions;
230 ✔
1102
        }
1103

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

1106
        if ($version === '3.0' && ! defined('CURL_HTTP_VERSION_3')) {
12 ✔
1107
            define('CURL_HTTP_VERSION_3', 30);
1 ✔
1108
        }
1109

1110
        $curlVersion = match ($version) {
12 ✔
1111
            '1.0'   => CURL_HTTP_VERSION_1_0,
4 ✔
1112
            '1.1'   => CURL_HTTP_VERSION_1_1,
4 ✔
1113
            '2.0'   => CURL_HTTP_VERSION_2_0,
6 ✔
1114
            '3.0'   => CURL_HTTP_VERSION_3,
2 ✔
1115
            default => null,
×
1116
        };
12 ✔
1117

1118
        if ($curlVersion !== null) {
12 ✔
1119
            $curlOptions[CURLOPT_HTTP_VERSION] = $curlVersion;
12 ✔
1120
        }
1121

1122
        return $curlOptions;
12 ✔
1123
    }
1124

1125
    /**
1126
     * @param array<int, mixed>    $curlOptions
1127
     * @param array<string, mixed> $config
1128
     *
1129
     * @return array<int, mixed>
1130
     */
1131
    private function applyClientOptions(array $curlOptions, array $config): array
1132
    {
1133
        // Cookie
1134
        if (isset($config['cookie'])) {
240 ✔
1135
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
4 ✔
1136
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
4 ✔
1137
        }
1138

1139
        // User Agent
1140
        if (isset($config['user_agent'])) {
240 ✔
1141
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
7 ✔
1142
        }
1143

1144
        return $curlOptions;
240 ✔
1145
    }
1146

1147
    /**
1148
     * Does the actual work of initializing cURL, setting the options,
1149
     * and grabbing the output.
1150
     *
1151
     * @param array<int, mixed> $curlOptions
1152
     *
1153
     * @codeCoverageIgnore
1154
     */
1155
    protected function sendRequest(array $curlOptions = []): string
1156
    {
1157
        $ch = curl_init();
1158

1159
        curl_setopt_array($ch, $curlOptions);
1160

1161
        // Send the request and wait for a response.
1162
        $output = curl_exec($ch);
1163

1164
        if ($output === false) {
1165
            $this->lastCurlError = curl_errno($ch);
1166

1167
            throw HTTPException::forCurlError((string) $this->lastCurlError, curl_error($ch));
1168
        }
1169

1170
        return $output;
1171
    }
1172

1173
    private function removeIntermediateResponses(string $output, string $breakString): string
1174
    {
1175
        while (true) {
233 ✔
1176
            // Check if we should remove the current response
1177
            if ($this->shouldRemoveCurrentResponse($output, $breakString)) {
233 ✔
1178
                $breakStringPos = strpos($output, $breakString);
18 ✔
1179
                if ($breakStringPos !== false) {
18 ✔
1180
                    $output = substr($output, $breakStringPos + 4);
18 ✔
1181

1182
                    continue;
18 ✔
1183
                }
1184
            }
1185

1186
            // No more intermediate responses to remove
1187
            break;
233 ✔
1188
        }
1189

1190
        return $output;
233 ✔
1191
    }
1192

1193
    /**
1194
     * Check if the current response (at the beginning of output) should be removed.
1195
     */
1196
    private function shouldRemoveCurrentResponse(string $output, string $breakString): bool
1197
    {
1198
        // HTTP/x.x 1xx responses (Continue, Processing, etc.)
1199
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) {
233 ✔
1200
            return true;
8 ✔
1201
        }
1202

1203
        // HTTP/x.x 200 Connection established (proxy responses)
1204
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) {
233 ✔
1205
            return true;
6 ✔
1206
        }
1207

1208
        // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed
1209
        $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false;
233 ✔
1210
        if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) {
233 ✔
1211
            // Check if there's a Location header
1212
            $breakStringPos = strpos($output, $breakString);
4 ✔
1213
            if ($breakStringPos !== false) {
4 ✔
1214
                $headerSection = substr($output, 0, $breakStringPos);
4 ✔
1215
                $headers       = explode("\n", $headerSection);
4 ✔
1216

1217
                foreach ($headers as $header) {
4 ✔
1218
                    if (str_starts_with(strtolower($header), 'location:')) {
4 ✔
1219
                        return true; // Found location header, this is a redirect to remove
2 ✔
1220
                    }
1221
                }
1222
            }
1223
        }
1224

1225
        // Digest auth challenges - only remove if there's another response after
1226
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') {
233 ✔
1227
            $breakStringPos = strpos($output, $breakString);
4 ✔
1228
            if ($breakStringPos !== false) {
4 ✔
1229
                $headerSection = substr($output, 0, $breakStringPos);
4 ✔
1230
                if (str_contains($headerSection, 'WWW-Authenticate: Digest')) {
4 ✔
1231
                    $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4);
4 ✔
1232

1233
                    return $nextBreakPos !== false; // Only remove if there's another response
4 ✔
1234
                }
1235
            }
1236
        }
1237

1238
        return false;
233 ✔
1239
    }
1240
}
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