Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.89% covered (success)
91.89%
102 / 111
77.78% covered (success)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Curl
91.89% covered (success)
91.89%
102 / 111
77.78% covered (success)
77.78%
14 / 18
65.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
87.50% covered (success)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 create
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setOption
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setMethod
77.78% covered (success)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
11.10
 setReturnTransfer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setReturnHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setVerifyPeer
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 allowSelfSigned
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isReturnTransfer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isReturnHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isVerifyPeer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isAllowSelfSigned
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 prepare
89.29% covered (success)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
15.28
 send
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 parseResponse
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 reset
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 disconnect
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http\Client\Handler;
16
17use Pop\Http\Auth;
18use Pop\Http\Parser;
19use Pop\Http\Client\Request;
20use Pop\Http\Client\Response;
21
22/**
23 * HTTP client curl handler class
24 *
25 * @category   Pop
26 * @package    Pop\Http
27 * @author     Nick Sagona, III <nick@popphp.org>
28 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
29 * @license    https://www.popphp.org/license     New BSD License
30 * @version    6.0.0
31 */
32class Curl extends AbstractCurl
33{
34
35    /**
36     * Constructor
37     *
38     * Instantiate the Curl handler object
39     *
40     * @param  ?array $options
41     * @param  bool   $default
42     * @throws Exception
43     */
44    public function __construct(?array $options = null, bool $default = true)
45    {
46        if (!function_exists('curl_init')) {
47            throw new Exception('Error: Curl is not available.');
48        }
49
50        $this->resource = curl_init();
51
52        if ($default) {
53            $this->setOption(CURLOPT_HEADER, true);
54            $this->setOption(CURLOPT_RETURNTRANSFER, true);
55        }
56
57        if (!empty($options)) {
58            $this->setOptions($options);
59        }
60    }
61
62    /**
63     * Factory method to create a Curl handler
64     *
65     * @param  string $method
66     * @param  ?array $options
67     * @param  bool  $default
68     * @return Curl
69     *
70     *@throws Exception
71     */
72    public static function create(string $method = 'GET', ?array $options = null, bool $default = true): Curl
73    {
74        $handler = new self($options, $default);
75        $handler->setMethod($method);
76        return $handler;
77    }
78
79    /**
80     * Set Curl option
81     *
82     * @param  int   $opt
83     * @param  mixed $val
84     * @return AbstractCurl
85     */
86    public function setOption(int $opt, mixed $val): AbstractCurl
87    {
88        parent::setOption($opt, $val);
89        curl_setopt($this->resource, $opt, $val);
90
91        return $this;
92    }
93
94    /**
95     * Set the method
96     *
97     * @param  string $method
98     * @param  bool   $forceCustom
99     * @return Curl
100     */
101    public function setMethod(string $method, bool $forceCustom = false): Curl
102    {
103        if ($method == 'GET') {
104            if ($this->hasOption(CURLOPT_POST)) {
105                $this->removeOption(CURLOPT_POST);
106            }
107            if ($this->hasOption(CURLOPT_CUSTOMREQUEST)) {
108                $this->removeOption(CURLOPT_CUSTOMREQUEST);
109            }
110            // libcurl does NOT revert a handle to GET when CURLOPT_POST/CURLOPT_POSTFIELDS are
111            // unset - it stays in POST mode until GET is asserted explicitly. Only matters when a
112            // handler instance is reused, but there the request would otherwise silently go out
113            // as a POST (verified on the wire).
114            $this->setOption(CURLOPT_HTTPGET, true);
115        } else {
116            if ($this->hasOption(CURLOPT_HTTPGET)) {
117                $this->removeOption(CURLOPT_HTTPGET);
118            }
119            if (($method == 'POST') && (!$forceCustom)) {
120                $this->setOption(CURLOPT_POST, true);
121                if ($this->hasOption(CURLOPT_CUSTOMREQUEST)) {
122                    $this->removeOption(CURLOPT_CUSTOMREQUEST);
123                }
124            } else {
125                $this->setOption(CURLOPT_CUSTOMREQUEST, $method);
126            }
127        }
128
129        if ($method == 'HEAD') {
130            $this->setOption(CURLOPT_NOBODY, true);
131        } else if ($this->hasOption(CURLOPT_NOBODY)) {
132            $this->removeOption(CURLOPT_NOBODY);
133        }
134
135        return $this;
136    }
137
138    /**
139     * Set Curl option to return the transfer (set to true by default)
140     *
141     * @param  bool $transfer
142     * @return Curl
143     */
144    public function setReturnTransfer(bool $transfer = true): Curl
145    {
146        $this->setOption(CURLOPT_RETURNTRANSFER, (bool)$transfer);
147        return $this;
148    }
149
150    /**
151     * Set Curl option to return the headers (set to true by default)
152     *
153     * @param  bool $header
154     * @return Curl
155     */
156    public function setReturnHeader(bool $header = true): Curl
157    {
158        $this->setOption(CURLOPT_HEADER, (bool)$header);
159        return $this;
160    }
161
162    /**
163     * Set Curl option to set verify peer (verifies the domain's SSL cert)
164     *
165     * @param  bool $verify
166     * @return Curl
167     */
168    public function setVerifyPeer(bool $verify = true): Curl
169    {
170        $this->setOption(CURLOPT_SSL_VERIFYPEER, (bool)$verify);
171        return $this;
172    }
173
174    /**
175     * Set Curl option to set to allow self-signed certs
176     *
177     * @param  bool $allow
178     * @return Curl
179     */
180    public function allowSelfSigned(bool $allow = true): Curl
181    {
182        $this->setOption(CURLOPT_SSL_VERIFYHOST, (bool)$allow);
183        return $this;
184    }
185
186    /**
187     * Check if Curl is set to return transfer
188     *
189     * @return bool
190     */
191    public function isReturnTransfer(): bool
192    {
193        return (isset($this->options[CURLOPT_RETURNTRANSFER]) && ($this->options[CURLOPT_RETURNTRANSFER] == true));
194    }
195
196    /**
197     * Check if Curl is set to return header
198     *
199     * @return bool
200     */
201    public function isReturnHeader(): bool
202    {
203        return (isset($this->options[CURLOPT_HEADER]) && ($this->options[CURLOPT_HEADER] == true));
204    }
205
206    /**
207     * Check if Curl is set to verify peer
208     *
209     * @return bool
210     */
211    public function isVerifyPeer(): bool
212    {
213        return (isset($this->options[CURLOPT_SSL_VERIFYPEER]) && ($this->options[CURLOPT_SSL_VERIFYPEER] == true));
214    }
215
216    /**
217     * Check if Curl is set to allow self-signed certs
218     *
219     * @return bool
220     */
221    public function isAllowSelfSigned(): bool
222    {
223        return (isset($this->options[CURLOPT_SSL_VERIFYHOST]) && ($this->options[CURLOPT_SSL_VERIFYHOST] == true));
224    }
225
226    /**
227     * Return the Curl last info
228     *
229     * @param  ?int $opt
230     * @return array|string|int|float|bool
231     */
232    public function getInfo(?int $opt = null): array|string|int|float|bool
233    {
234        return ($opt !== null) ? curl_getinfo($this->resource, $opt) : curl_getinfo($this->resource);
235    }
236
237    /**
238     * Method to prepare the handler
239     *
240     * @param  Request $request
241     * @param  ?Auth    $auth
242     * @param  bool     $forceCustom
243     * @param  bool     $clear
244     * @throws \Pop\Http\Exception
245     * @return Curl
246     */
247    public function prepare(Request $request, ?Auth $auth = null, bool $forceCustom = false, bool $clear = true): Curl
248    {
249        $this->request = $request;
250
251        $this->setMethod($request->getMethod(), $forceCustom);
252
253        // Clear headers for a fresh request based on the headers in the request object,
254        // else fall back to pre-defined headers in the stream context
255        if (($clear) && $this->hasOption(CURLOPT_HTTPHEADER)) {
256            $this->setOption(CURLOPT_HTTPHEADER, []);
257        }
258
259        // Add auth header
260        if ($auth !== null) {
261            $request->addHeader($auth->createAuthHeader());
262        }
263
264        // Prepare data and data headers
265        if (($request->hasData()) && (!$request->getData()->isPrepared())) {
266            $request->prepareData();
267        }
268
269        $headers = $this->collectRequestHeaders($request);
270        if ($this->hasOption(CURLOPT_HTTPHEADER)) {
271            $customHeaders = $this->getOption(CURLOPT_HTTPHEADER);
272            foreach ($customHeaders as $customHeader) {
273                if (!in_array($customHeader, $headers)) {
274                    $headers[] = $customHeader;
275                }
276            }
277        }
278
279        ['queryString' => $queryString, 'body' => $body] = $this->resolveRequestBody($request);
280
281        // Multipart bodies are handed to curl as an array (scalars + CURLFile) so it can build
282        // its own matching multipart framing; pop-http's own Content-Type/boundary header (set by
283        // prepareData() above) would otherwise disagree with the boundary curl actually generates.
284        // Matched case-insensitively: HTTP header names are case-insensitive on the wire, so a
285        // user-supplied 'content-type: ...' would otherwise survive this strip and be sent
286        // alongside curl's own auto-generated multipart Content-Type.
287        if (is_array($body)) {
288            $headers = array_values(array_filter($headers, fn($header) => stripos($header, 'content-type:') !== 0));
289        }
290
291        $this->setOption(CURLOPT_HTTPHEADER, $headers);
292
293        if ($body !== null) {
294            $this->setOption(CURLOPT_POSTFIELDS, $body);
295        // A reused handler must not carry a previous request's body forward: curl switches the
296        // method to POST whenever CURLOPT_POSTFIELDS is set, so a stale value would silently
297        // turn a subsequent body-less GET into a POST. Gated on $clear for the same reason the
298        // header handling above is: with $clear = false the caller is explicitly asking to fall
299        // back to whatever was pre-defined on the handler itself.
300        } else if (($clear) && $this->hasOption(CURLOPT_POSTFIELDS)) {
301            $this->removeOption(CURLOPT_POSTFIELDS);
302            // Clearing CURLOPT_POSTFIELDS is itself a curl_setopt(..., null) call, which puts
303            // the handle right back into POST mode - so the method has to be re-asserted AFTER
304            // the removal, not just once at the top of prepare().
305            $this->setMethod($request->getMethod(), $forceCustom);
306        }
307
308        $this->uri = $request->getUriAsString();
309        if (!empty($queryString) && !str_contains($this->uri, '?')) {
310            $this->uri .= $queryString;
311        }
312
313        $this->setOption(CURLOPT_URL, $this->uri);
314
315        return $this;
316    }
317
318    /**
319     * Method to send the request
320     *
321     * @throws Exception
322     * @return Response
323     */
324    public function send(): Response
325    {
326        $this->response = curl_exec($this->resource);
327
328        if ($this->response === false) {
329            throw new Exception(
330                'Error: ' . curl_errno($this->resource) . ' => ' . curl_error($this->resource) . '.',
331                0, null, curl_errno($this->resource), $this->request
332            );
333        }
334
335        return $this->parseResponse();
336    }
337
338    /**
339     * Parse the response
340     *
341     * @return Response
342     */
343    public function parseResponse(): Response
344    {
345        $response = new Response();
346
347        // If the CURLOPT_RETURNTRANSFER option is set, get the response body and parse the headers.
348        if (isset($this->options[CURLOPT_RETURNTRANSFER]) && ($this->options[CURLOPT_RETURNTRANSFER])) {
349            $headerSize = (int)$this->getInfo(CURLINFO_HEADER_SIZE);
350            if (isset($this->options[CURLOPT_HEADER]) && ($this->options[CURLOPT_HEADER])) {
351                $parsedHeaders = Parser::parseHeaders(substr($this->response, 0, $headerSize));
352                if (!empty($parsedHeaders['version'])) {
353                    $response->setVersion($parsedHeaders['version']);
354                }
355                if (!empty($parsedHeaders['code'])) {
356                    $response->setCode((int)$parsedHeaders['code']);
357                }
358                if (!empty($parsedHeaders['message'])) {
359                    $response->setMessage($parsedHeaders['message']);
360                }
361                if (!empty($parsedHeaders['headers'])) {
362                    $response->addHeaders($parsedHeaders['headers']);
363                }
364                if (!empty($this->response)) {
365                    $response->setBody(substr($this->response, $headerSize));
366                }
367            } else if (!empty($this->response)) {
368                $response->setBody($this->response);
369            }
370        }
371
372        if ($response->hasHeader('Content-Encoding')) {
373            $response->decodeBodyContent();
374        }
375
376        return $response;
377    }
378
379    /**
380     * Method to reset the handler
381     *
382     * @param  bool $default
383     * @return Curl
384     */
385    public function reset(bool $default = true): Curl
386    {
387        curl_reset($this->resource);
388        $this->response = null;
389        $this->options  = [];
390
391        if ($default) {
392            $this->setOption(CURLOPT_HEADER, true);
393            $this->setOption(CURLOPT_RETURNTRANSFER, true);
394        }
395
396        return $this;
397    }
398
399    /**
400     * Close the handler connection
401     *
402     * @return void
403     */
404    public function disconnect(): void
405    {
406        if ($this->hasResource()) {
407            // curl_close() has had no effect since PHP 8.0 (curl handles are unref-counted like
408            // any other object) and is deprecated as of 8.5 - dropping the reference is sufficient.
409            $this->resource = null;
410            $this->response = null;
411            $this->options  = [];
412        }
413    }
414
415}