Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.93% covered (success)
96.93%
379 / 391
94.19% covered (success)
94.19%
81 / 86
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
96.93% covered (success)
96.93%
379 / 391
94.19% covered (success)
94.19%
81 / 86
262
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
20
 createMulti
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fromCurlCommand
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setMethod
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getMethod
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 hasMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 setOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addOption
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 syncRequestFromOptions
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
16
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasOption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeOption
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setHandler
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addMiddleware
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMiddleware
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMiddleware
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMultiHandler
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getMultiHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMultiHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAuth
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasAuth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setHeaders
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addHeaders
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 getHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 hasHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasHeader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 removeHeader
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeAllHeaders
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRequest
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getResponse
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 removeData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeAllData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 setQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 removeQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeAllQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 setType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 hasType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 removeType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setFiles
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 addFile
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 getFiles
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 getFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setBody
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setBodyFromFile
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 hasBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getBody
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 getBodyContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 getBodyContentLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
 removeBody
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 prepare
90.24% covered (success)
90.24%
37 / 41
0.00% covered (danger)
0.00%
0 / 1
30.84
 send
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 dispatch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 dispatchRequest
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 processMiddleware
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 sendRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 convertToClientRequest
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 sendAsync
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 reset
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
14
 toCurlCommand
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __call
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 __callStatic
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
12
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;
16
17use Pop\Http\Client\Handler\Stream;
18use Pop\Http\Client\Request;
19use Pop\Http\Client\Response;
20use Pop\Http\Client\Handler\Curl;
21use Pop\Http\Client\Handler\CurlMulti;
22use Pop\Http\Client\Handler\HandlerInterface;
23use Pop\Http\Client\Handler\Mock;
24use Pop\Http\Client\Middleware\CallableMiddleware;
25use Pop\Http\Client\Middleware\MiddlewareInterface;
26use Pop\Http\Client\Middleware\Pipeline;
27use Pop\Http\Body;
28use Pop\Mime\Part\Header;
29use Psr\Http\Client\ClientInterface;
30use Psr\Http\Message\RequestInterface;
31use Psr\Http\Message\ResponseInterface;
32
33/**
34 * HTTP client class
35 *
36 * @category   Pop
37 * @package    Pop\Http
38 * @author     Nick Sagona, III <nick@popphp.org>
39 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
40 * @license    https://www.popphp.org/license     New BSD License
41 * @version    6.0.0
42 */
43class Client extends AbstractHttp implements ClientInterface
44{
45
46    /**
47     * Client options
48     * @var array
49     */
50    protected array $options = [];
51
52    /**
53     * Request handler
54     * @var ?HandlerInterface
55     */
56    protected ?HandlerInterface $handler = null;
57
58    /**
59     * Request multi-handler
60     * @var ?CurlMulti
61     */
62    protected ?CurlMulti $multiHandler = null;
63
64    /**
65     * HTTP auth object
66     * @var ?Auth
67     */
68    protected ?Auth $auth = null;
69
70    /**
71     * Registered middleware, in registration order (first-registered = outermost)
72     * @var array
73     */
74    protected array $middleware = [];
75
76    /**
77     * Instantiate the client object
78     *
79     * Optional parameters are
80     *  - Request URI string
81     *  - Client request instance
82     *  - Client response instance
83     *  - Client handler instance
84     *  - Auth instance
85     *  - Options array
86     */
87    public function __construct()
88    {
89        $args     = func_get_args();
90        $request  = null;
91        $response = null;
92        $options  = null;
93        $handler  = null;
94
95        foreach ($args as $arg) {
96            if (is_string($arg)) {
97                $request = new Request($arg);
98            } else if ($arg instanceof Client\Request) {
99                $request = $arg;
100            } else if ($arg instanceof Client\Response) {
101                $response = $arg;
102            } else if ($arg instanceof Client\Handler\HandlerInterface) {
103                $handler = $arg;
104            } else if ($arg instanceof Auth) {
105                $this->setAuth($arg);
106            } else if (is_array($arg)) {
107                $options = $arg;
108            }
109        }
110
111        parent::__construct($request, $response);
112
113        if ($options !== null) {
114            $this->setOptions($options);
115        }
116
117        if ($handler !== null) {
118            if ($handler instanceof CurlMulti) {
119                $this->setMultiHandler($handler);
120            } else {
121                $this->setHandler($handler);
122            }
123        }
124
125        if ((!$this->hasRequest()) && (isset($this->options['base_uri']) || $this->hasOption('method') ||
126            $this->hasOption('data') || $this->hasOption('query') || $this->hasOption('headers') ||
127            $this->hasOption('type') || $this->hasOption('files'))) {
128            $this->request = new Request(
129                isset($this->options['base_uri']) ? new Uri($this->options['base_uri']) : null,
130                $this->options['method'] ?? 'GET'
131            );
132        }
133
134        $this->syncRequestFromOptions();
135    }
136
137    /**
138     * Factory to create a multi-handler object
139     *
140     * @param  array                    $requests
141     * @param  Client\Handler\CurlMulti $multiHandler
142     * @return CurlMulti
143     */
144    public static function createMulti(
145        array $requests, Client\Handler\CurlMulti $multiHandler = new Client\Handler\CurlMulti()
146    ): CurlMulti
147    {
148        foreach ($requests as $request) {
149            $client = new Client($request);
150            $client->setMultiHandler($multiHandler);
151        }
152
153        return $multiHandler;
154    }
155
156    /**
157     * Method to convert Curl CLI command to a client object
158     *
159     * @param  string $command
160     * @throws Curl\Exception
161     * @return Client
162     */
163    public static function fromCurlCommand(string $command): Client
164    {
165        return Curl\Command::commandToClient($command);
166    }
167
168    /**
169     * Set the request object
170     *
171     * Overrides AbstractHttp::setRequest() so that any options already set on this client
172     * (via the constructor or setOptions()/addOption()) get synced onto a request that arrives
173     * via a direct setRequest() call - not just one materialized internally by prepare(). Without
174     * this, options set before an explicit setRequest() call were silently dropped, since
175     * syncRequestFromOptions() is a no-op until a request exists.
176     *
177     * @param  AbstractRequest $request
178     * @return Client
179     */
180    public function setRequest(AbstractRequest $request): Client
181    {
182        parent::setRequest($request);
183        $this->syncRequestFromOptions();
184        return $this;
185    }
186
187    /**
188     * Set method
189     *
190     * @param  string $method
191     * @return Client
192     */
193    public function setMethod(string $method): Client
194    {
195        if ($this->hasRequest()) {
196            $this->request()->setMethod($method);
197        } else {
198            $this->options['method'] = $method;
199        }
200
201        return $this;
202    }
203
204    /**
205     * Get method
206     *
207     * @return ?string
208     */
209    public function getMethod(): ?string
210    {
211        if ($this->hasRequest()) {
212            return $this->request()->getMethod();
213        } else {
214            return $this->options['method'] ?? null;
215        }
216    }
217
218    /**
219     * Has method
220     *
221     * @return bool
222     */
223    public function hasMethod(): bool
224    {
225        return ((($this->hasRequest()) && !empty($this->request()->getMethod())) || isset($this->options['method']));
226    }
227
228    /**
229     * Set options
230     *
231     * Supported options
232     *  - 'base_uri'
233     *  - 'method'
234     *  - 'headers'
235     *  - 'user_agent'
236     *  - 'query' (can only be encoded query string on the URI)
237     *  - 'data' (can be any request data)
238     *  - 'files'
239     *  - 'type'
240     *  - 'auto'
241     *  - 'async'
242     *  - 'verify_peer'
243     *  - 'allow_self_signed'
244     *  - 'no_content_length'
245     *  - 'raw_data'
246     *  - 'force_custom_method' (Curl only - forces CURLOPT_CUSTOMREQUEST)
247     *
248     * @param  array $options
249     * @return Client
250     */
251    public function setOptions(array $options): Client
252    {
253        $this->options = $options;
254        $this->syncRequestFromOptions();
255
256        return $this;
257    }
258
259    /**
260     * Add options
261     *
262     * @param  array $options
263     * @return Client
264     */
265    public function addOptions(array $options): Client
266    {
267        foreach ($options as $name => $value) {
268            $this->addOption($name, $value);
269        }
270        return $this;
271    }
272
273    /**
274     * Add an option
275     *
276     * @param  string $name
277     * @param  mixed  $value
278     * @return Client
279     */
280    public function addOption(string $name, mixed $value): Client
281    {
282        $this->options[$name] = $value;
283        $this->syncRequestFromOptions();
284
285        return $this;
286    }
287
288    /**
289     * Push request-shaped options ('type', 'headers', 'query', 'data', 'files') onto the
290     * client's request, if one exists. Options are configuration that seeds the request the
291     * moment both are available - they are not a second, parallel data store - so this is the
292     * one place that seeding happens, called any time the request or the options change.
293     *
294     * 'query' and 'data' are merged onto the request key-by-key (addQuery()/addData()), not
295     * replaced wholesale (setQuery()/setData()) - this method can run again later (e.g. from a
296     * later addOption() call for an unrelated key), and a wholesale replace would silently wipe
297     * out any data/query values added directly in between via addData()/addQuery() or a prior
298     * sync. 'headers' and 'files' are naturally merge-safe already (addHeaders()/addData() key
299     * on name), and 'type' is guarded to apply only once (first request type wins).
300     *
301     * @return void
302     */
303    protected function syncRequestFromOptions(): void
304    {
305        if (!$this->hasRequest()) {
306            return;
307        }
308
309        if ($this->hasOption('method')) {
310            $this->request()->setMethod($this->options['method']);
311        }
312
313        if ($this->hasOption('type') && !$this->request()->hasRequestType()) {
314            $this->setType($this->options['type']);
315        }
316
317        if ($this->hasOption('headers') && is_array($this->options['headers'])) {
318            $this->addHeaders($this->options['headers']);
319        }
320
321        if ($this->hasOption('query') && is_array($this->options['query'])) {
322            foreach ($this->options['query'] as $name => $value) {
323                $this->addQuery($name, $value);
324            }
325        }
326
327        if ($this->hasOption('data') && is_array($this->options['data'])) {
328            foreach ($this->options['data'] as $name => $value) {
329                $this->addData($name, $value);
330            }
331        }
332
333        if ($this->hasOption('files')) {
334            foreach ($this->options['files'] as $i => $file) {
335                $name = is_numeric($i) ? 'file' . ($i + 1) : $i;
336                $this->addData($name, [
337                    'filename'    => $file,
338                    'contentType' => Client\Data::getMimeTypeFromFilename($file)
339                ]);
340            }
341        }
342    }
343
344    /**
345     * Get options
346     *
347     * @return array
348     */
349    public function getOptions(): array
350    {
351        return $this->options;
352    }
353
354    /**
355     * Get options
356     *
357     * @param  string $name
358     * @return mixed
359     */
360    public function getOption(string $name): mixed
361    {
362        return $this->options[$name] ?? null;
363    }
364
365    /**
366     * Has options
367     *
368     * @return bool
369     */
370    public function hasOptions(): bool
371    {
372        return !empty($this->options);
373    }
374
375    /**
376     * Has option
377     *
378     * @return bool
379     */
380    public function hasOption(string $name): bool
381    {
382        return array_key_exists($name, $this->options);
383    }
384
385    /**
386     * Remove option
387     *
388     * @param  string $name
389     * @return Client
390     */
391    public function removeOption(string $name): Client
392    {
393        if (isset($this->options[$name])) {
394            unset($this->options[$name]);
395        }
396        return $this;
397    }
398
399    /**
400     * Remove options
401     *
402     * @return Client
403     */
404    public function removeOptions(): Client
405    {
406        $this->options = [];
407        return $this;
408    }
409
410    /**
411     * Set handler
412     *
413     * @param  HandlerInterface $handler
414     * @return Client
415     */
416    public function setHandler(HandlerInterface $handler): Client
417    {
418        $this->handler = $handler;
419        return $this;
420    }
421
422    /**
423     * Get handler
424     *
425     * @return HandlerInterface|null
426     */
427    public function getHandler(): HandlerInterface|null
428    {
429        return $this->handler;
430    }
431
432    /**
433     * Has handler
434     *
435     * @return bool
436     */
437    public function hasHandler(): bool
438    {
439        return ($this->handler !== null);
440    }
441
442    /**
443     * Add a middleware to the pipeline. A plain callable is wrapped in
444     * CallableMiddleware automatically. Registration order is wrap order: the
445     * first-registered middleware is outermost.
446     *
447     * @param  MiddlewareInterface|callable $middleware
448     * @return Client
449     */
450    public function addMiddleware(MiddlewareInterface|callable $middleware): Client
451    {
452        $this->middleware[] = ($middleware instanceof MiddlewareInterface)
453            ? $middleware : new CallableMiddleware($middleware);
454
455        return $this;
456    }
457
458    /**
459     * Get the registered middleware, in registration order
460     *
461     * @return array
462     */
463    public function getMiddleware(): array
464    {
465        return $this->middleware;
466    }
467
468    /**
469     * Has any middleware registered
470     *
471     * @return bool
472     */
473    public function hasMiddleware(): bool
474    {
475        return !empty($this->middleware);
476    }
477
478    /**
479     * Set multi-handler
480     *
481     * @param  CurlMulti $multiHandler
482     * @return Client
483     */
484    public function setMultiHandler(CurlMulti $multiHandler): Client
485    {
486        $this->multiHandler = $multiHandler;
487
488        if (!($this->handler instanceof Curl)) {
489            $this->handler = new Curl();
490            $this->multiHandler->addClient($this);
491        }
492
493        return $this;
494    }
495
496    /**
497     * Get multi-handler
498     *
499     * @return CurlMulti
500     */
501    public function getMultiHandler(): CurlMulti
502    {
503        return $this->multiHandler;
504    }
505
506    /**
507     * Has multi-handler
508     *
509     * @return bool
510     */
511    public function hasMultiHandler(): bool
512    {
513        return ($this->multiHandler !== null);
514    }
515
516    /**
517     * Set auth
518     *
519     * @param  Auth $auth
520     * @return Client
521     */
522    public function setAuth(Auth $auth): Client
523    {
524        $this->auth = $auth;
525        return $this;
526    }
527
528    /**
529     * Get auth
530     *
531     * @return ?Auth
532     */
533    public function getAuth(): ?Auth
534    {
535        return $this->auth;
536    }
537
538    /**
539     * Has auth
540     *
541     * @return bool
542     */
543    public function hasAuth(): bool
544    {
545        return ($this->auth !== null);
546    }
547
548    /**
549     * Set headers (clear out any existing headers)
550     *
551     * @param  array $headers
552     * @return Client
553     */
554    public function setHeaders(array $headers): Client
555    {
556        $this->request()->setHeaders($headers);
557        return $this;
558    }
559
560    /**
561     * Add headers
562     *
563     * @param  array $headers
564     * @return Client
565     */
566    public function addHeaders(array $headers): Client
567    {
568        $this->request()->addHeaders($headers);
569        return $this;
570    }
571
572    /**
573     * Add header
574     *
575     * @param  Header|string|int $header
576     * @param  mixed             $value
577     * @return Client
578     */
579    public function addHeader(Header|string|int $header, mixed $value = null): Client
580    {
581        $this->request()->addHeader($header, $value);
582        return $this;
583    }
584
585    /**
586     * Get headers
587     *
588     * @return mixed
589     */
590    public function getHeaders(): mixed
591    {
592        return ($this->hasRequest() && $this->request()->hasHeaders()) ? $this->request()->getHeaderObjects() : null;
593    }
594
595    /**
596     * Get header
597     *
598     * @param  string $name
599     * @return mixed
600     */
601    public function getHeader(string $name): mixed
602    {
603        return ($this->hasRequest() && $this->request()->hasHeader($name)) ? $this->request()->getHeaderObject($name) : null;
604    }
605
606    /**
607     * Has headers
608     *
609     * @return bool
610     */
611    public function hasHeaders(): bool
612    {
613        return $this->hasRequest() && $this->request()->hasHeaders();
614    }
615
616    /**
617     * Has header
618     *
619     * @param  string $name
620     * @return bool
621     */
622    public function hasHeader(string $name): bool
623    {
624        return $this->hasRequest() && $this->request()->hasHeader($name);
625    }
626
627    /**
628     * Remove header
629     *
630     * @param  string $name
631     * @return Client
632     */
633    public function removeHeader(string $name): Client
634    {
635        if ($this->hasHeader($name)) {
636            $this->request()->removeHeader($name);
637        }
638        return $this;
639    }
640
641    /**
642     * Remove all headers
643     *
644     * @return Client
645     */
646    public function removeAllHeaders(): Client
647    {
648        if ($this->hasHeaders()) {
649            $this->request()->removeHeaders();
650        }
651        return $this;
652    }
653
654    /**
655     * Get the client's request, materializing an empty one if none exists yet -
656     * every data-touching setter goes through this, so there is exactly one
657     * place a Client\Request gets created on demand instead of the old
658     * options-array-vs-request dual bookkeeping.
659     *
660     * @return Client\Request
661     */
662    protected function request(): Client\Request
663    {
664        if (!($this->request instanceof Request)) {
665            $this->request = new Request();
666        }
667        return $this->request;
668    }
669
670    /**
671     * Get the request
672     *
673     * @throws Exception
674     * @return Request
675     */
676    public function getRequest(): Request
677    {
678        if (!($this->request instanceof Request)) {
679            throw new Exception('Error: The request object has not been created.');
680        }
681        return $this->request;
682    }
683
684    /**
685     * Get the response
686     *
687     * @throws Exception
688     * @return Response
689     */
690    public function getResponse(): Response
691    {
692        if (!($this->response instanceof Response)) {
693            throw new Exception('Error: The response object has not been created.');
694        }
695        return $this->response;
696    }
697
698    /**
699     * Set data
700     *
701     * @param  array $data
702     * @return Client
703     */
704    public function setData(array $data): Client
705    {
706        $this->request()->setData($data);
707        return $this;
708    }
709
710    /**
711     * Add data
712     *
713     * @param  string $name
714     * @param  mixed  $value
715     * @return Client
716     */
717    public function addData(string $name, mixed $value): Client
718    {
719        $this->request()->addData($name, $value);
720        return $this;
721    }
722
723    /**
724     * Get data
725     *
726     * @param  ?string $key
727     * @return mixed
728     */
729    public function getData(?string $key = null): mixed
730    {
731        return $this->hasRequest() ? $this->request()->getData()?->getData($key) : null;
732    }
733
734    /**
735     * Has data
736     *
737     * @param  ?string $key
738     * @return bool
739     */
740    public function hasData(?string $key = null): bool
741    {
742        return $this->hasRequest() && $this->request()->hasData() && $this->request()->getData()->hasData($key);
743    }
744
745    /**
746     * Remove data
747     *
748     * @param  string $key
749     * @return Client
750     */
751    public function removeData(string $key): Client
752    {
753        if ($this->hasData($key)) {
754            $this->request()->removeData($key);
755        }
756        return $this;
757    }
758
759    /**
760     * Remove all data
761     *
762     * @return Client
763     */
764    public function removeAllData(): Client
765    {
766        if ($this->hasRequest() && $this->request()->hasData()) {
767            $this->request()->removeAllData();
768        }
769        return $this;
770    }
771
772    /**
773     * Set query
774     *
775     * @param  array $query
776     * @return Client
777     */
778    public function setQuery(array $query): Client
779    {
780        $this->request()->setQuery($query);
781        return $this;
782    }
783
784    /**
785     * Add query
786     *
787     * @param  string $name
788     * @param  mixed  $value
789     * @return Client
790     */
791    public function addQuery(string $name, mixed $value): Client
792    {
793        $this->request()->addQuery($name, $value);
794        return $this;
795    }
796
797    /**
798     * Get query
799     *
800     * @param  ?string $key
801     * @return mixed
802     */
803    public function getQuery(?string $key = null): mixed
804    {
805        return $this->hasRequest() ? $this->request()->getQuery()?->getData($key) : null;
806    }
807
808    /**
809     * Has query
810     *
811     * @param  ?string $key
812     * @return bool
813     */
814    public function hasQuery(?string $key = null): bool
815    {
816        return $this->hasRequest() && $this->request()->hasQuery() && $this->request()->getQuery()->hasData($key);
817    }
818
819    /**
820     * Remove query
821     *
822     * @param  string $key
823     * @return Client
824     */
825    public function removeQuery(string $key): Client
826    {
827        if ($this->hasQuery($key)) {
828            $this->request()->removeQuery($key);
829        }
830        return $this;
831    }
832
833    /**
834     * Remove all query data
835     *
836     * @return Client
837     */
838    public function removeAllQuery(): Client
839    {
840        if ($this->hasRequest() && $this->request()->hasQuery()) {
841            $this->request()->removeAllQuery();
842        }
843        return $this;
844    }
845
846    /**
847     * Set type
848     *
849     * @param  string $type
850     * @param  bool   $addHeader
851     * @return Client
852     */
853    public function setType(string $type, bool $addHeader = true): Client
854    {
855        $this->request()->setRequestType($type, $addHeader);
856        return $this;
857    }
858
859    /**
860     * Get type
861     *
862     * @return mixed
863     */
864    public function getType(): mixed
865    {
866        return $this->hasRequest() ? $this->request()->getRequestType() : null;
867    }
868
869    /**
870     * Has type
871     *
872     * @return bool
873     */
874    public function hasType(): bool
875    {
876        return $this->hasRequest() && $this->request()->hasRequestType();
877    }
878
879    /**
880     * Remove type
881     *
882     * @return Client
883     */
884    public function removeType(): Client
885    {
886        if ($this->hasType()) {
887            $this->request()->removeRequestType();
888        }
889        return $this;
890    }
891
892    /**
893     * Set files
894     *
895     * @param  array|string $files
896     * @param  bool         $multipart
897     * @throws Exception
898     * @return Client
899     */
900    public function setFiles(array|string $files, bool $multipart = true): Client
901    {
902        if (is_string($files)) {
903            $files = [$files];
904        }
905
906        // Replace semantics, consistent with setData()/setHeaders()/setQuery() - a set*() call
907        // supersedes what was there before; add*() is the accumulating counterpart.
908        $this->removeFiles();
909
910        foreach ($files as $i => $file) {
911            $this->addFile($file, is_numeric($i) ? null : $i);
912        }
913
914        if ($multipart) {
915            $this->setType(Client\Request::MULTIPART);
916        }
917
918        return $this;
919    }
920
921    /**
922     * Add file
923     *
924     * @param  string  $file
925     * @param  ?string $name
926     * @throws Exception
927     * @return Client
928     */
929    public function addFile(string $file, ?string $name = null): Client
930    {
931        if (!file_exists($file)) {
932            throw new Exception("Error: The file '" . $file . "' does not exist.");
933        }
934
935        if ($name === null) {
936            // hasFile($name) rescans and rebuilds the entire request data array on every candidate
937            // name - an O(n) getFiles() call per attempt. Data::hasData() is a direct isset() check
938            // against the same underlying array, so this is O(1) per attempt instead. It also avoids
939            // a name colliding with any existing data key, not just file-shaped ones (hasFile() only
940            // matches file-shaped entries).
941            $data = $this->request()->getData();
942            $i    = 1;
943            do {
944                $name = 'file' . $i;
945                $i++;
946            } while ($data?->hasData($name));
947        }
948
949        $this->addData($name, ['filename' => $file, 'contentType' => Client\Data::getMimeTypeFromFilename($file)]);
950
951        return $this;
952    }
953
954    /**
955     * Get files
956     *
957     * @return array|null
958     */
959    public function getFiles(): array|null
960    {
961        if (!$this->hasRequest() || !$this->request()->hasData()) {
962            return null;
963        }
964
965        $files = [];
966        foreach ($this->request()->getData()->getData() as $name => $value) {
967            if (is_array($value) && isset($value['filename'])) {
968                $files[$name] = $value['filename'];
969            }
970        }
971
972        return !empty($files) ? $files : null;
973    }
974
975    /**
976     * Get file
977     *
978     * @param  string $key
979     * @return ?string
980     */
981    public function getFile(string $key): ?string
982    {
983        return $this->getFiles()[$key] ?? null;
984    }
985
986    /**
987     * Has files
988     *
989     * @return bool
990     */
991    public function hasFiles(): bool
992    {
993        return !empty($this->getFiles());
994    }
995
996    /**
997     * Has file
998     *
999     * @param  string $key
1000     * @return bool
1001     */
1002    public function hasFile(string $key): bool
1003    {
1004        return array_key_exists($key, $this->getFiles() ?? []);
1005    }
1006
1007    /**
1008     * Remove file
1009     *
1010     * @param  string $key
1011     * @return Client
1012     */
1013    public function removeFile(string $key): Client
1014    {
1015        if ($this->hasFile($key)) {
1016            $this->removeData($key);
1017        }
1018        return $this;
1019    }
1020
1021    /**
1022     * Remove all files
1023     *
1024     * @return Client
1025     */
1026    public function removeFiles(): Client
1027    {
1028        foreach (array_keys($this->getFiles() ?? []) as $key) {
1029            $this->removeData($key);
1030        }
1031        return $this;
1032    }
1033
1034    /**
1035     * Set request body
1036     *
1037     * @param  string $body
1038     * @throws Exception
1039     * @return Client
1040     */
1041    public function setBody(string $body): Client
1042    {
1043        if ($this->request === null) {
1044            throw new Exception('Error: The request object has not been created.');
1045        }
1046
1047        $this->request()->setBody($body);
1048
1049        return $this;
1050    }
1051
1052    /**
1053     * Set request body from file
1054     *
1055     * @param  string $file
1056     * @throws Exception
1057     * @return Client
1058     */
1059    public function setBodyFromFile(string $file): Client
1060    {
1061        if ($this->request === null) {
1062            throw new Exception('Error: The request object has not been created.');
1063        }
1064        if (!file_exists($file)) {
1065            throw new Exception("Error: The file '" . $file . "' does not exist.");
1066        }
1067
1068        $this->request()->setBody(file_get_contents($file));
1069
1070        return $this;
1071    }
1072
1073    /**
1074     * Has request body
1075     *
1076     * @return bool
1077     */
1078    public function hasBody(): bool
1079    {
1080        return (($this->request !== null) && ($this->request()->hasBody()));
1081    }
1082
1083    /**
1084     * Get request body
1085     *
1086     * @return ?Body
1087     */
1088    public function getBody(): ?Body
1089    {
1090        return (($this->request !== null) && ($this->request()->hasBody())) ? $this->request()->getBody() : null;
1091    }
1092
1093    /**
1094     * Get request body content
1095     *
1096     * @return ?string
1097     */
1098    public function getBodyContent(): ?string
1099    {
1100        return (($this->request !== null) && ($this->request()->hasBody())) ? $this->request()->getBodyContent() : null;
1101    }
1102
1103    /**
1104     * Get request body content length
1105     *
1106     * @param  bool $mb
1107     * @return int
1108     */
1109    public function getBodyContentLength(bool $mb = false): int
1110    {
1111        return (($this->request !== null) && ($this->request()->hasBody())) ? $this->request()->getBodyContentLength($mb) : 0;
1112    }
1113
1114    /**
1115     * Remove the body
1116     *
1117     * @return Client
1118     */
1119    public function removeBody(): Client
1120    {
1121        if (($this->request !== null) && ($this->request()->hasBody())) {
1122            $this->request()->removeBody();
1123        }
1124        return $this;
1125    }
1126
1127    /**
1128     * Prepare the client request
1129     *
1130     * @param  ?string $uri
1131     * @param  ?string $method
1132     * @throws Exception|Client\Exception
1133     * @return Client
1134     */
1135    public function prepare(?string $uri = null, ?string $method = null): Client
1136    {
1137        // Check that there is a request object or a URI
1138        if ((!$this->hasRequest()) && ($uri === null) && !isset($this->options['base_uri'])) {
1139            throw new Exception('Error: There is no request URI to send.');
1140        }
1141
1142        if (($method === null) && isset($this->options['method'])) {
1143            $method = $this->options['method'];
1144        }
1145
1146        if ($this->hasRequest()) {
1147            // Set request URI
1148            if ($uri !== null) {
1149                if (isset($this->options['base_uri']) && !str_starts_with($uri, $this->options['base_uri'])) {
1150                    $uri = $this->options['base_uri'] . $uri;
1151                }
1152                $this->request()->setUri(new Uri($uri));
1153            // Else, check and adjust for base_uri
1154            } else if (isset($this->options['base_uri']) && !str_starts_with($this->request()->getUriAsString(), $this->options['base_uri'])) {
1155                $this->request()->setUri($this->options['base_uri'] . $this->request()->getUriAsString());
1156            }
1157
1158            // Set method
1159            if ($method !== null) {
1160                $this->request()->setMethod($method);
1161            }
1162        // Create new request object
1163        } else {
1164            if (($uri === null) && isset($this->options['base_uri'])) {
1165                $uri = $this->options['base_uri'];
1166            } else if (isset($this->options['base_uri']) && !str_starts_with($uri, $this->options['base_uri'])) {
1167                $uri = $this->options['base_uri'] . $uri;
1168            }
1169            $this->setRequest(new Request(new Uri($uri), ($method ?? 'GET')));
1170
1171            // A request has just been materialized for the first time, so seed it from any
1172            // request-shaped options. syncRequestFromOptions() is a no-op without a request, so
1173            // options set on a client that had no request yet (e.g. `new Client(new Curl())` then
1174            // `addOption('data', [...])`) would otherwise be silently dropped. This deliberately
1175            // does NOT run when a request already existed: addOption()/setOptions() sync
1176            // themselves at call time in that case, and re-running the sync here would merge
1177            // stale option data back over any explicit setData()/removeData() made since.
1178            $this->syncRequestFromOptions();
1179        }
1180
1181        // An explicit method argument to prepare() outranks a 'method' option the sync just applied
1182        if ($method !== null) {
1183            $this->request()->setMethod($method);
1184        }
1185
1186        // Set request type
1187        if ($this->hasOption('type')) {
1188            $this->request()->setRequestType($this->options['type']);
1189        }
1190
1191        // Set no Content-Length header flag
1192        if ($this->hasOption('no_content_length')) {
1193            $this->request()->setNoContentLength($this->options['no_content_length']);
1194        }
1195
1196        // Set raw data flag
1197        if ($this->hasOption('raw_data')) {
1198            $this->request()->setRawData($this->options['raw_data']);
1199        }
1200
1201        // Set (or reset) handler
1202        if (!$this->hasHandler()) {
1203            $this->setHandler(new Curl());
1204        } else {
1205            $this->getHandler()->reset();
1206        }
1207
1208        // Set user-agent
1209        if ($this->hasOption('user_agent')) {
1210            if ($this->handler instanceof Curl) {
1211                $this->handler->setOption(CURLOPT_USERAGENT, $this->options['user_agent']);
1212            } else if ($this->handler instanceof Stream) {
1213                $this->handler->addContextOption('http', ['user_agent' => $this->options['user_agent']]);
1214            }
1215        }
1216
1217        // Handle SSL options
1218        if (($this->handler instanceof Curl) || ($this->handler instanceof Stream) || ($this->handler instanceof Mock)) {
1219            if ($this->hasOption('verify_peer')) {
1220                $this->handler->setVerifyPeer((bool)$this->options['verify_peer']);
1221            }
1222            if ($this->hasOption('allow_self_signed')) {
1223                $this->handler->allowSelfSigned((bool)$this->options['allow_self_signed']);
1224            }
1225        }
1226
1227        return $this;
1228    }
1229
1230    /**
1231     * Send the client request
1232     *
1233     * @param  ?string $uri
1234     * @param  ?string $method
1235     * @throws Exception|Client\Exception|Client\Handler\Exception
1236     * @return Response|Promise|array|string
1237     */
1238    public function send(?string $uri = null, ?string $method = null): Response|Promise|array|string
1239    {
1240        if (isset($this->options['async']) && ($this->options['async'] === true)) {
1241            return $this->sendAsync();
1242        } else {
1243            return $this->dispatch($uri, $method);
1244        }
1245    }
1246
1247    /**
1248     * Perform the actual synchronous dispatch, always - unlike send(), this does
1249     * NOT redirect to sendAsync() when the 'async' option is set. Promise::wait()/
1250     * resolve() call this (not send()) to perform the real request: since a Promise
1251     * wraps a Client whose 'async' option is still true for the lifetime of that
1252     * Client, calling send() from inside wait()/resolve() would re-enter the async
1253     * branch above and return a brand-new Promise instead of ever dispatching.
1254     *
1255     * @param  ?string $uri
1256     * @param  ?string $method
1257     * @throws Exception|Client\Exception|Client\Handler\Exception
1258     * @return Response|array|string
1259     */
1260    public function dispatch(?string $uri = null, ?string $method = null): Response|array|string
1261    {
1262        $this->prepare($uri, $method);
1263        $this->response = $this->processMiddleware($this->request);
1264
1265        return (($this->hasOption('auto')) && ($this->options['auto'])) ?
1266            $this->response->getParsedResponse() : $this->response;
1267    }
1268
1269    /**
1270     * Perform the actual handler dispatch for the given request - the terminal
1271     * handler at the center of the middleware pipeline. If $request is a
1272     * Client\Request, it's re-pointed as $this->request first, so any
1273     * middleware-applied modifications (e.g. an injected header) are reflected
1274     * in what's actually sent. A foreign (non-Client\Request) PSR-7 request is
1275     * converted first, reusing the same conversion sendRequest() uses.
1276     *
1277     * Note: this is public (so a terminal callable passed into Pipeline::process()
1278     * can reach it), but it assumes prepare() has already run - it does not create
1279     * a handler itself, and depends on $this->handler already being set.
1280     *
1281     * @param  RequestInterface $request
1282     * @throws Exception|Client\Exception|Client\Handler\Exception
1283     * @return Response
1284     */
1285    public function dispatchRequest(RequestInterface $request): Response
1286    {
1287        if (!($request instanceof Request)) {
1288            $request = $this->convertToClientRequest($request);
1289        }
1290
1291        if ($request !== $this->request) {
1292            parent::setRequest($request);
1293        }
1294
1295        $response = (isset($this->options['force_custom_method']) && ($this->handler instanceof Curl)) ?
1296            $this->handler->prepare($request, $this->auth, (bool)$this->options['force_custom_method'])->send() :
1297            $this->handler->prepare($request, $this->auth)->send();
1298
1299        if (!($response instanceof Response)) {
1300            throw new Client\Exception('Error: The handler did not return a valid response.');
1301        }
1302
1303        $this->response = $response;
1304
1305        return $this->response;
1306    }
1307
1308    /**
1309     * Run the request through the middleware pipeline (if any), terminating in
1310     * dispatchRequest(). Shared by dispatch() and sendRequest() so middleware
1311     * behaves identically regardless of entry point.
1312     *
1313     * MiddlewareInterface::process() is typed to the broader PSR-7 ResponseInterface
1314     * (see that interface's docblock), but this method's return type is narrowed to
1315     * Client\Response, since that's the concrete type Client's internals require. If
1316     * a middleware short-circuits with some other ResponseInterface implementation,
1317     * that mismatch is caught below and re-thrown as a clear Client\Exception rather
1318     * than surfacing as a raw TypeError.
1319     *
1320     * @param  RequestInterface $request
1321     * @throws Exception|Client\Exception|Client\Handler\Exception
1322     * @return Response
1323     */
1324    protected function processMiddleware(RequestInterface $request): Response
1325    {
1326        if (!$this->hasMiddleware()) {
1327            return $this->dispatchRequest($request);
1328        }
1329
1330        $pipeline = new Pipeline($this->middleware);
1331        $response = $pipeline->process($request, fn(RequestInterface $req) => $this->dispatchRequest($req));
1332
1333        if (!($response instanceof Response)) {
1334            throw new Client\Exception(
1335                'Error: A middleware returned a ' . get_class($response) .
1336                ', but Pop\Http\Client requires middleware to return a Pop\Http\Client\Response ' .
1337                '(see Pop\Http\Client\Middleware\MiddlewareInterface::process()).'
1338            );
1339        }
1340
1341        return $response;
1342    }
1343
1344    /**
1345     * Send a PSR-7 request and return a PSR-7 response, per PSR-18
1346     *
1347     * @param  RequestInterface $request
1348     * @throws Exception|Client\Exception|Client\RequestException|Client\Handler\Exception
1349     * @return ResponseInterface
1350     */
1351    public function sendRequest(RequestInterface $request): ResponseInterface
1352    {
1353        $clientRequest = $this->convertToClientRequest($request);
1354
1355        if (!$clientRequest->hasUri()) {
1356            throw new Client\RequestException('Error: There is no request URI to send.', $clientRequest);
1357        }
1358
1359        $this->setRequest($clientRequest);
1360        $this->prepare();
1361
1362        $this->response = $this->processMiddleware($this->request);
1363
1364        return $this->response;
1365    }
1366
1367    /**
1368     * Convert a foreign (non-Client\Request) PSR-7 RequestInterface into a
1369     * Client\Request. A Client\Request passed in is returned as-is. Shared by
1370     * sendRequest() (the PSR-18 entry point) and dispatchRequest() (so a
1371     * middleware that substitutes a foreign PSR-7 request is honored rather
1372     * than silently dropped).
1373     *
1374     * @param  RequestInterface $request
1375     * @throws Client\Exception
1376     * @return Request
1377     */
1378    protected function convertToClientRequest(RequestInterface $request): Request
1379    {
1380        if ($request instanceof Request) {
1381            return $request;
1382        }
1383
1384        try {
1385            $clientRequest = new Request((string)$request->getUri(), $request->getMethod());
1386            foreach ($request->getHeaders() as $name => $values) {
1387                $clientRequest->addHeader($name, array_shift($values));
1388                foreach ($values as $value) {
1389                    $clientRequest->getHeaderObject($name)->addValue($value);
1390                }
1391            }
1392            $body = (string)$request->getBody();
1393            if ($body !== '') {
1394                $clientRequest->setBody($body);
1395            }
1396        } catch (\Throwable $e) {
1397            throw new Client\Exception(
1398                'Error: Unable to convert the given ' . get_class($request) .
1399                ' PSR-7 request into a Pop\Http\Client\Request: ' . $e->getMessage(), 0, $e
1400            );
1401        }
1402
1403        return $clientRequest;
1404    }
1405
1406    /**
1407     * Method to send the request asynchronously
1408     *
1409     * @return Promise
1410     */
1411    public function sendAsync(): Promise
1412    {
1413        return new Promise($this);
1414    }
1415
1416    /**
1417     * Method to render the request as a string
1418     *
1419     * @return string
1420     */
1421    public function render(): string
1422    {
1423        $this->prepare();
1424
1425        if (isset($this->options['force_custom_method']) && ($this->handler instanceof Curl)) {
1426            $this->handler->prepare($this->request(), $this->auth, (bool)$this->options['force_custom_method']);
1427        } else {
1428            $this->handler->prepare($this->request(), $this->auth);
1429        }
1430
1431        $uri       = $this->handler->getUriObject();
1432        $uriString = $uri->getUri();
1433        if ($uri->hasQuery()) {
1434            $uriString .= '?' . $uri->getQuery();
1435        }
1436
1437        $request = $this->request()->getMethod() . ' ' . $uriString . ' HTTP/' . $this->handler->getHttpVersion() . "\r\n" .
1438            'Host: ' . $uri->getFullHost() . "\r\n" . $this->request()->getHeadersAsString() . "\r\n";
1439
1440        if ($this->request()->hasDataContent()) {
1441            $request .= $this->request()->getDataContent();
1442        // Multipart data is prepared lazily - prepareData() only mints the boundary and declares
1443        // the Content-Type header, leaving the body to the handler's own (streaming) path - so
1444        // there is no buffered data content to display here. render() is a one-shot debug/
1445        // inspection method rather than part of the send path, so building the rendered body on
1446        // demand here is fine, and reusing the already-declared boundary keeps the header and
1447        // the displayed body in agreement.
1448        } else if (($this->request()->hasData()) && ($this->request()->isMultipart())) {
1449            $boundary    = null;
1450            $contentType = $this->request()->getHeaderValueAsString('Content-Type');
1451            if ($contentType !== null) {
1452                $boundary = Parser::parseMediaType($contentType)['params']['boundary'] ?? null;
1453            }
1454
1455            $request .= Body\Multipart::build($this->request()->getData()->getData(), $boundary)->getContent();
1456        }
1457        return $request;
1458    }
1459
1460    /**
1461     * Method to reset the client
1462     *
1463     * @param  bool $data
1464     * @param  bool $headers
1465     * @param  bool $clear
1466     * @return static
1467     */
1468    public function reset(?bool $data = true, ?bool $headers = false, bool $clear = false): static
1469    {
1470        // Fully clear out and reset of client object
1471        if ($clear) {
1472            $this->request      = null;
1473            $this->response     = null;
1474            $this->handler      = null;
1475            $this->multiHandler = null;
1476            $this->auth         = null;
1477            $this->options      = [];
1478        // Clear out basic client info & data
1479        } else {
1480            if ($data) {
1481                if (isset($this->options['query'])) {
1482                    unset($this->options['query']);
1483                }
1484                if (isset($this->options['data'])) {
1485                    unset($this->options['data']);
1486                }
1487                if (isset($this->options['files'])) {
1488                    unset($this->options['files']);
1489                }
1490                if ($this->request !== null) {
1491                    if ($this->request()->hasQuery()) {
1492                        $this->request()->removeAllQuery();
1493                    }
1494                    if ($this->request()->hasData()) {
1495                        $this->request()->removeAllData();
1496                    }
1497                }
1498            }
1499            if ($headers) {
1500                if (isset($this->options['headers'])) {
1501                    unset($this->options['headers']);
1502                }
1503                if (isset($this->options['user_agent'])) {
1504                    unset($this->options['user_agent']);
1505                }
1506                if ($this->request !== null) {
1507                    if ($this->request()->hasHeaders()) {
1508                        $this->request()->removeHeaders();
1509                    }
1510                }
1511            }
1512        }
1513
1514        return $this;
1515    }
1516
1517    /**
1518     * Method to convert client object to a Curl command for the CLI
1519     *
1520     * @throws Exception|Curl\Exception
1521     * @return string
1522     */
1523    public function toCurlCommand(): string
1524    {
1525        if ($this->handler instanceof Stream) {
1526            throw new Exception('Error: The client handler must be an instance of Curl');
1527        }
1528
1529        if (!$this->hasHandler()) {
1530            $this->setHandler(new Curl());
1531        }
1532
1533        return Curl\Command::clientToCommand($this);
1534    }
1535
1536    /**
1537     * To string magic method to render the client request to a raw string
1538     *
1539     */
1540    public function __toString(): string
1541    {
1542        return $this->render();
1543    }
1544
1545    /**
1546     * Magic method to send requests by the method name, i.e. $client->get('http://localhost/');
1547     *
1548     * @param  string $methodName
1549     * @param  array  $arguments
1550     * @throws Exception|Client\Exception|Client\Handler\Exception
1551     * @return Response|Promise|array|string
1552     */
1553    public function __call(string $methodName, array $arguments): Response|Promise|array|string
1554    {
1555        if (str_contains($methodName, 'Async')) {
1556            if (isset($arguments[0])) {
1557                $methodName = strtoupper(substr($methodName, 0, strpos($methodName, 'Async')));
1558                $this->prepare($arguments[0], $methodName);
1559            }
1560            return $this->sendAsync();
1561        } else {
1562            return $this->send(($arguments[0] ?? null), strtoupper($methodName));
1563        }
1564    }
1565
1566    /**
1567     * Magic method to send requests by the static method name, i.e. Client::get('http://localhost/');
1568     *
1569     * @param  string $methodName
1570     * @param  array  $arguments
1571     * @throws Exception|Client\Exception|Client\Handler\Exception
1572     * @return Response|Promise|array|string
1573     */
1574    public static function __callStatic(string $methodName, array $arguments): Response|Promise|array|string
1575    {
1576        $client = new static();
1577        $uri    = null;
1578
1579        if (count($arguments) > 1) {
1580            foreach ($arguments as $arg) {
1581                if (is_string($arg)) {
1582                    $client->setRequest(new Client\Request($arg));
1583                } else if ($arg instanceof Client\Request) {
1584                    $client->setRequest($arg);
1585                } else if ($arg instanceof Client\Response) {
1586                    $client->setResponse($arg);
1587                } else if ($arg instanceof Client\Handler\HandlerInterface) {
1588                    $client->setHandler($arg);
1589                } else if ($arg instanceof Auth) {
1590                    $client->setAuth($arg);
1591                } else if (is_array($arg)) {
1592                    $client->setOptions($arg);
1593                }
1594            }
1595        }
1596
1597        if ((!$client->hasRequest()) && isset($arguments[0])) {
1598            $uri = ($arguments[0]);
1599        }
1600
1601        if (str_contains($methodName, 'Async')) {
1602            $methodName = strtoupper(substr($methodName, 0, strpos($methodName, 'Async')));
1603            $client->prepare($uri, $methodName);
1604            return $client->sendAsync();
1605        } else {
1606            return $client->send($uri, strtoupper($methodName));
1607        }
1608    }
1609
1610}