3
declare(strict_types=1);
6
* This file is part of CodeIgniter 4 framework.
8
* (c) CodeIgniter Foundation <admin@codeigniter.com>
10
* For the full copyright and license information, please view
11
* the LICENSE file that was distributed with this source code.
14
namespace CodeIgniter\HTTP;
16
use CodeIgniter\HTTP\Exceptions\HTTPException;
18
use Config\CURLRequest as ConfigCURLRequest;
19
use InvalidArgumentException;
22
* A lightweight HTTP client for sending synchronous HTTP requests via cURL.
24
* @see \CodeIgniter\HTTP\CURLRequestTest
26
class CURLRequest extends OutgoingRequest
29
* The response object associated with this request
31
* @var ResponseInterface|null
36
* The original response object associated with this request
38
* @var ResponseInterface|null
40
protected $responseOrig;
43
* The URI associated with this request
57
* The default setting values
61
protected $defaultConfig = [
63
'connect_timeout' => 150,
69
* Default values for when 'allow_redirects'
74
protected $redirectDefaults = [
84
* The number of milliseconds to delay before
85
* sending the request.
89
protected $delay = 0.0;
92
* The default options from the constructor. Applied to all requests.
94
private readonly array $defaultOptions;
97
* Whether share options between requests or not.
99
* If true, all the options won't be reset between requests.
100
* It may cause an error request with unnecessary headers.
102
private readonly bool $shareOptions;
105
* Takes an array of options to set the following possible class properties:
109
* - any other request options to use as defaults.
111
* @param array<string, mixed> $options
113
public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = [])
115
if (! function_exists('curl_version')) {
116
throw HTTPException::forMissingCurl(); // @codeCoverageIgnore
119
parent::__construct(Method::GET, $uri);
121
$this->responseOrig = $response ?? new Response($config);
122
// Remove the default Content-Type header.
123
$this->responseOrig->removeHeader('Content-Type');
125
$this->baseURI = $uri->useRawQueryString();
126
$this->defaultOptions = $options;
128
/** @var ConfigCURLRequest|null $configCURLRequest */
129
$configCURLRequest = config(ConfigCURLRequest::class);
130
$this->shareOptions = $configCURLRequest->shareOptions ?? true;
132
$this->config = $this->defaultConfig;
133
$this->parseOptions($options);
137
* Sends an HTTP request to the specified $url. If this is a relative
138
* URL, it will be merged with $this->baseURI to form a complete URL.
140
* @param string $method HTTP method
142
public function request($method, string $url, array $options = []): ResponseInterface
144
$this->response = clone $this->responseOrig;
146
$this->parseOptions($options);
148
$url = $this->prepareURL($url);
150
$method = esc(strip_tags($method));
152
$this->send($method, $url);
154
if ($this->shareOptions === false) {
155
$this->resetOptions();
158
return $this->response;
162
* Reset all options to default.
166
protected function resetOptions()
170
$this->headerMap = [];
176
$this->config = $this->defaultConfig;
178
// Set the default options for next request
179
$this->parseOptions($this->defaultOptions);
183
* Convenience method for sending a GET request.
185
public function get(string $url, array $options = []): ResponseInterface
187
return $this->request(Method::GET, $url, $options);
191
* Convenience method for sending a DELETE request.
193
public function delete(string $url, array $options = []): ResponseInterface
195
return $this->request('DELETE', $url, $options);
199
* Convenience method for sending a HEAD request.
201
public function head(string $url, array $options = []): ResponseInterface
203
return $this->request('HEAD', $url, $options);
207
* Convenience method for sending an OPTIONS request.
209
public function options(string $url, array $options = []): ResponseInterface
211
return $this->request('OPTIONS', $url, $options);
215
* Convenience method for sending a PATCH request.
217
public function patch(string $url, array $options = []): ResponseInterface
219
return $this->request('PATCH', $url, $options);
223
* Convenience method for sending a POST request.
225
public function post(string $url, array $options = []): ResponseInterface
227
return $this->request(Method::POST, $url, $options);
231
* Convenience method for sending a PUT request.
233
public function put(string $url, array $options = []): ResponseInterface
235
return $this->request(Method::PUT, $url, $options);
239
* Set the HTTP Authentication.
241
* @param string $type basic or digest
245
public function setAuth(string $username, string $password, string $type = 'basic')
247
$this->config['auth'] = [
257
* Set form data to be sent.
259
* @param bool $multipart Set TRUE if you are sending CURLFiles
263
public function setForm(array $params, bool $multipart = false)
266
$this->config['multipart'] = $params;
268
$this->config['form_params'] = $params;
275
* Set JSON data to be sent.
277
* @param array|bool|float|int|object|string|null $data
281
public function setJSON($data)
283
$this->config['json'] = $data;
289
* Sets the correct settings based on the options array
294
protected function parseOptions(array $options)
296
if (array_key_exists('baseURI', $options)) {
297
$this->baseURI = $this->baseURI->setURI($options['baseURI']);
298
unset($options['baseURI']);
301
if (array_key_exists('headers', $options) && is_array($options['headers'])) {
302
foreach ($options['headers'] as $name => $value) {
303
$this->setHeader($name, $value);
306
unset($options['headers']);
309
if (array_key_exists('delay', $options)) {
310
// Convert from the milliseconds passed in
311
// to the seconds that sleep requires.
312
$this->delay = (float) $options['delay'] / 1000;
313
unset($options['delay']);
316
if (array_key_exists('body', $options)) {
317
$this->setBody($options['body']);
318
unset($options['body']);
321
foreach ($options as $key => $value) {
322
$this->config[$key] = $value;
327
* If the $url is a relative URL, will attempt to create
328
* a full URL by prepending $this->baseURI to it.
330
protected function prepareURL(string $url): string
332
// If it's a full URI, then we have nothing to do here...
333
if (str_contains($url, '://')) {
337
$uri = $this->baseURI->resolveRelativeURI($url);
339
// Create the string instead of casting to prevent baseURL muddling
340
return URI::createURIString(
342
$uri->getAuthority(),
350
* Fires the actual cURL request.
352
* @return ResponseInterface
354
public function send(string $method, string $url)
356
// Reset our curl options so we're on a fresh slate.
359
if (! empty($this->config['query']) && is_array($this->config['query'])) {
360
// This is likely too naive a solution.
361
// Should look into handling when $url already
362
// has query vars on it.
363
$url .= '?' . http_build_query($this->config['query']);
364
unset($this->config['query']);
367
$curlOptions[CURLOPT_URL] = $url;
368
$curlOptions[CURLOPT_RETURNTRANSFER] = true;
369
$curlOptions[CURLOPT_HEADER] = true;
370
$curlOptions[CURLOPT_FRESH_CONNECT] = true;
371
// Disable @file uploads in post data.
372
$curlOptions[CURLOPT_SAFE_UPLOAD] = true;
374
$curlOptions = $this->setCURLOptions($curlOptions, $this->config);
375
$curlOptions = $this->applyMethod($method, $curlOptions);
376
$curlOptions = $this->applyRequestHeaders($curlOptions);
378
// Do we need to delay this request?
379
if ($this->delay > 0) {
380
usleep((int) $this->delay * 1_000_000);
383
$output = $this->sendRequest($curlOptions);
385
// Set the string we want to break our response from
386
$breakString = "\r\n\r\n";
388
while (str_starts_with($output, 'HTTP/1.1 100 Continue')) {
389
$output = substr($output, strpos($output, $breakString) + 4);
392
if (str_starts_with($output, 'HTTP/1.1 200 Connection established')) {
393
$output = substr($output, strpos($output, $breakString) + 4);
396
// If request and response have Digest
397
if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest' && str_contains($output, 'WWW-Authenticate: Digest')) {
398
$output = substr($output, strpos($output, $breakString) + 4);
401
// Split out our headers and body
402
$break = strpos($output, $breakString);
404
if ($break !== false) {
406
$headers = explode("\n", substr($output, 0, $break));
408
$this->setResponseHeaders($headers);
411
$body = substr($output, $break + 4);
412
$this->response->setBody($body);
414
$this->response->setBody($output);
417
return $this->response;
421
* Adds $this->headers to the cURL request.
423
protected function applyRequestHeaders(array $curlOptions = []): array
425
if (empty($this->headers)) {
431
foreach (array_keys($this->headers) as $name) {
432
$set[] = $name . ': ' . $this->getHeaderLine($name);
435
$curlOptions[CURLOPT_HTTPHEADER] = $set;
443
protected function applyMethod(string $method, array $curlOptions): array
445
$this->method = $method;
446
$curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
448
$size = strlen($this->body ?? '');
452
return $this->applyBody($curlOptions);
455
if ($method === Method::PUT || $method === Method::POST) {
456
// See http://tools.ietf.org/html/rfc7230#section-3.3.2
457
if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
458
$this->setHeader('Content-Length', '0');
460
} elseif ($method === 'HEAD') {
461
$curlOptions[CURLOPT_NOBODY] = 1;
470
protected function applyBody(array $curlOptions = []): array
472
if (! empty($this->body)) {
473
$curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
480
* Parses the header retrieved from the cURL response into
481
* our Response object.
485
protected function setResponseHeaders(array $headers = [])
487
foreach ($headers as $header) {
488
if (($pos = strpos($header, ':')) !== false) {
489
$title = trim(substr($header, 0, $pos));
490
$value = trim(substr($header, $pos + 1));
492
if ($this->response instanceof Response) {
493
$this->response->addHeader($title, $value);
495
$this->response->setHeader($title, $value);
497
} elseif (str_starts_with($header, 'HTTP')) {
498
preg_match('#^HTTP\/([12](?:\.[01])?) (\d+) (.+)#', $header, $matches);
500
if (isset($matches[1])) {
501
$this->response->setProtocolVersion($matches[1]);
504
if (isset($matches[2])) {
505
$this->response->setStatusCode((int) $matches[2], $matches[3] ?? null);
516
* @throws InvalidArgumentException
518
protected function setCURLOptions(array $curlOptions = [], array $config = [])
521
if (! empty($config['auth'])) {
522
$curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1];
524
if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') {
525
$curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST;
527
$curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
532
if (! empty($config['cert'])) {
533
$cert = $config['cert'];
535
if (is_array($cert)) {
536
$curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
540
if (! is_file($cert)) {
541
throw HTTPException::forSSLCertNotFound($cert);
544
$curlOptions[CURLOPT_SSLCERT] = $cert;
548
if (isset($config['verify'])) {
549
if (is_string($config['verify'])) {
550
$file = realpath($config['verify']) ?: $config['verify'];
552
if (! is_file($file)) {
553
throw HTTPException::forInvalidSSLKey($config['verify']);
556
$curlOptions[CURLOPT_CAINFO] = $file;
557
$curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
558
$curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
559
} elseif (is_bool($config['verify'])) {
560
$curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
561
$curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
566
if (isset($config['proxy'])) {
567
$curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
568
$curlOptions[CURLOPT_PROXY] = $config['proxy'];
572
if ($config['debug']) {
573
$curlOptions[CURLOPT_VERBOSE] = 1;
574
$curlOptions[CURLOPT_STDERR] = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
578
if (! empty($config['decode_content'])) {
579
$accept = $this->getHeaderLine('Accept-Encoding');
581
if ($accept !== '') {
582
$curlOptions[CURLOPT_ENCODING] = $accept;
584
$curlOptions[CURLOPT_ENCODING] = '';
585
$curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
590
if (array_key_exists('allow_redirects', $config)) {
591
$settings = $this->redirectDefaults;
593
if (is_array($config['allow_redirects'])) {
594
$settings = array_merge($settings, $config['allow_redirects']);
597
if ($config['allow_redirects'] === false) {
598
$curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
600
$curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
601
$curlOptions[CURLOPT_MAXREDIRS] = $settings['max'];
603
if ($settings['strict'] === true) {
604
$curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
609
foreach ($settings['protocols'] as $proto) {
610
$protocols += constant('CURLPROTO_' . strtoupper($proto));
613
$curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
618
$curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000;
620
// Connection Timeout
621
$curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000;
623
// Post Data - application/x-www-form-urlencoded
624
if (! empty($config['form_params']) && is_array($config['form_params'])) {
625
$postFields = http_build_query($config['form_params']);
626
$curlOptions[CURLOPT_POSTFIELDS] = $postFields;
628
// Ensure content-length is set, since CURL doesn't seem to
629
// calculate it when HTTPHEADER is set.
630
$this->setHeader('Content-Length', (string) strlen($postFields));
631
$this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
634
// Post Data - multipart/form-data
635
if (! empty($config['multipart']) && is_array($config['multipart'])) {
636
// setting the POSTFIELDS option automatically sets multipart
637
$curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
641
$curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
644
if (isset($config['json'])) {
645
// Will be set as the body in `applyBody()`
646
$json = json_encode($config['json']);
647
$this->setBody($json);
648
$this->setHeader('Content-Type', 'application/json');
649
$this->setHeader('Content-Length', (string) strlen($json));
653
if (! empty($config['version'])) {
654
$version = sprintf('%.1F', $config['version']);
655
if ($version === '1.0') {
656
$curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
657
} elseif ($version === '1.1') {
658
$curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
659
} elseif ($version === '2.0') {
660
$curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
665
if (isset($config['cookie'])) {
666
$curlOptions[CURLOPT_COOKIEJAR] = $config['cookie'];
667
$curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
671
if (isset($config['user_agent'])) {
672
$curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
679
* Does the actual work of initializing cURL, setting the options,
680
* and grabbing the output.
682
* @codeCoverageIgnore
684
protected function sendRequest(array $curlOptions = []): string
688
curl_setopt_array($ch, $curlOptions);
690
// Send the request and wait for a response.
691
$output = curl_exec($ch);
693
if ($output === false) {
694
throw HTTPException::forCurlError((string) curl_errno($ch), curl_error($ch));