Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.31% covered (success)
88.31%
136 / 154
83.72% covered (success)
83.72%
36 / 43
CRAP
0.00% covered (danger)
0.00%
0 / 1
Request
88.31% covered (success)
88.31%
136 / 154
83.72% covered (success)
83.72%
36 / 43
118.61
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createXml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createUrlEncoded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createMultipart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMethod
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addHeader
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
 setQuery
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 addQuery
75.00% covered (success)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 getQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setData
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 addData
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 getData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 removeAllData
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getUriAsString
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
10
 setRequestType
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
11.47
 getRequestType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasRequestType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeRequestType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 createAsJson
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAsXml
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isXml
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAsUrlEncoded
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isUrlEncoded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAsMultipart
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isMultipart
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createAsCustomType
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isCustomType
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
30
 isValidMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasDataContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getDataContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDataContentLength
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareData
75.00% covered (success)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 __call
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Pop PHP Framework (https://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@noladev.com>
7 * @copyright  Copyright (c) 2009-2025 NOLA Interactive, LLC.
8 * @license    https://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Http\Client;
15
16use Pop\Http\Uri;
17use Pop\Http\AbstractRequest;
18use Pop\Mime\Message;
19use Pop\Mime\Part\Header;
20
21/**
22 * HTTP client request class
23 *
24 * @category   Pop
25 * @package    Pop\Http
26 * @author     Nick Sagona, III <dev@noladev.com>
27 * @copyright  Copyright (c) 2009-2025 NOLA Interactive, LLC.
28 * @license    https://www.popphp.org/license     New BSD License
29 * @version    5.3.2
30 */
31class Request extends AbstractRequest
32{
33
34    /**
35     * Request type constants
36     * @var string
37     */
38    const URLENCODED = 'application/x-www-form-urlencoded';
39    const JSON       = 'application/json';
40    const XML        = 'application/xml';
41    const MULTIPART  = 'multipart/form-data';
42
43    /**
44     * Request method
45     * @var ?string
46     */
47    protected ?string $method = null;
48
49    /**
50     * Request type
51     * @var ?string
52     */
53    protected ?string $requestType = null;
54
55    /**
56     * Client request query data object
57     *
58     * Can only be a URL-encoded query string on the URI
59     *
60     * @var ?Data
61     */
62    protected ?Data $query = null;
63
64    /**
65     * Client request data object
66     *
67     * Can be any type of supported request data:
68     *     - URL-encoded query string on the URI (GET method)
69     *     - URL-encoded body (any method other than GET)
70     *     - JSON-encoded body
71     *     - XML-encoded body
72     *     - Multipart/form body
73     *
74     * @var ?Data
75     */
76    protected ?Data $data = null;
77
78    /**
79     * Constructor
80     *
81     * Instantiate the request data object
82     *
83     * @param  Uri|string|null $uri
84     * @param  string          $method
85     * @param  mixed           $data
86     * @param  ?string         $type
87     * @param  bool            $strict
88     * @throws Exception|\Pop\Http\Exception
89     */
90    public function __construct(Uri|string|null $uri = null, string $method = 'GET', mixed $data = null, ?string $type = null, bool $strict = false)
91    {
92        parent::__construct($uri);
93
94        if ($method !== null) {
95            $this->setMethod($method, $strict);
96        }
97        if ($data !== null) {
98            $this->setData($data);
99        }
100        if ($type !== null) {
101            $this->setRequestType($type);
102        }
103    }
104
105    /**
106     * Factory method to create a Request object
107     *
108     * @param  Uri|string|null $uri
109     * @param  string          $method
110     * @param  mixed           $data
111     * @param  ?string         $type
112     * @param  bool            $strict
113     * @throws Exception|\Pop\Http\Exception
114     * @return Request
115     */
116    public static function create(Uri|string|null $uri = null, string $method = 'GET', mixed $data = null, ?string $type = null, bool $strict = false): Request
117    {
118        return new self($uri, $method, $data, $type, $strict);
119    }
120
121    /**
122     * Factory method to create a JSON Request object
123     *
124     * @param  Uri|string|null $uri
125     * @param  string          $method
126     * @param  mixed           $data
127     * @param  bool            $strict
128     * @throws Exception|\Pop\Http\Exception
129     * @return Request
130     */
131    public static function createJson(Uri|string|null $uri = null, string $method = 'POST', mixed $data = null, bool $strict = false): Request
132    {
133        return new self($uri, $method, $data, Request::JSON, $strict);
134    }
135
136    /**
137     * Factory method to create an XML Request object
138     *
139     * @param  Uri|string|null $uri
140     * @param  string          $method
141     * @param  mixed           $data
142     * @param  bool            $strict
143     * @throws Exception|\Pop\Http\Exception
144     * @return Request
145     */
146    public static function createXml(Uri|string|null $uri = null, string $method = 'POST', mixed $data = null, bool $strict = false): Request
147    {
148        return new self($uri, $method, $data, Request::XML, $strict);
149    }
150
151    /**
152     * Factory method to create a URL-encoded Request object
153     *
154     * @param  Uri|string|null $uri
155     * @param  string          $method
156     * @param  mixed           $data
157     * @param  bool            $strict
158     * @throws Exception|\Pop\Http\Exception
159     * @return Request
160     */
161    public static function createUrlEncoded(Uri|string|null $uri = null, string $method = 'GET', mixed $data = null, bool $strict = false): Request
162    {
163        return new self($uri, $method, $data, Request::URLENCODED, $strict);
164    }
165
166    /**
167     * Factory method to create a multipart Request object
168     *
169     * @param  Uri|string|null $uri
170     * @param  string          $method
171     * @param  mixed           $data
172     * @param  bool            $strict
173     * @throws Exception|\Pop\Http\Exception
174     * @return Request
175     */
176    public static function createMultipart(Uri|string|null $uri = null, string $method = 'POST', mixed $data = null, bool $strict = false): Request
177    {
178        return new self($uri, $method, $data, Request::MULTIPART, $strict);
179    }
180
181    /**
182     * Set method
183     *
184     * @param  string $method
185     * @param  bool   $strict
186     * @throws Exception
187     * @return Request
188     */
189    public function setMethod(string $method, bool $strict = false): Request
190    {
191        $method = strtoupper($method);
192
193        if ($strict) {
194            if (!$this->isValidMethod($method)) {
195                throw new Exception('Error: That request method is not valid.');
196            }
197        }
198
199        $this->method = $method;
200        return $this;
201    }
202
203    /**
204     * Get method
205     *
206     * @return ?string
207     */
208    public function getMethod(): ?string
209    {
210        return $this->method;
211    }
212
213    /**
214     * Has method
215     *
216     * @return bool
217     */
218    public function hasMethod(): bool
219    {
220        return ($this->method !== null);
221    }
222
223    /**
224     * Add a header
225     *
226     * @param  Header|string|int $header
227     * @param  ?string           $value
228     * @return Request
229     */
230    public function addHeader(Header|string|int $header, ?string $value = null): Request
231    {
232        $contentType = null;
233        if (is_numeric($header) && ($value !== null)) {
234            $header = Header::parse($value);
235            $value  = null;
236        }
237        if (is_string($header) && ($header == 'Content-Type')) {
238            $contentType = $value;
239        } else if (($header instanceof Header) && ($header->getName() == 'Content-Type')) {
240            $contentType = $header->getValue()->getValue();
241        }
242
243        if (($contentType !== null) && ($this->requestType === null)) {
244            $this->requestType = $contentType;
245        }
246
247        parent::addHeader($header, $value);
248        return $this;
249    }
250
251    /**
252     * Set query data
253     *
254     * @param  mixed $query
255     * @param  mixed $filters
256     * @return Request
257     */
258    public function setQuery(mixed $query, mixed $filters = null): Request
259    {
260        $this->setRequestType(Request::URLENCODED);
261        $this->query = ($query instanceof Data) ? $query : new Data($query, $filters, $this);
262        return $this;
263    }
264
265    /**
266     * Add query data
267     *
268     * @param  mixed $name
269     * @param  mixed $value
270     * @return Request
271     */
272    public function addQuery(mixed $name, mixed $value): Request
273    {
274        $this->setRequestType(Request::URLENCODED);
275        if ($this->query === null) {
276            $this->setRequestType(Request::URLENCODED);
277            $this->query = new Data([], null, $this);
278        } else if (!$this->query->hasRequest()) {
279            $this->query->setRequest($this);
280        }
281        $this->query->addData($name, $value);
282
283        return $this;
284    }
285
286    /**
287     * Get query data
288     *
289     * @return ?Data
290     */
291    public function getQuery(): ?Data
292    {
293        return $this->query;
294    }
295
296    /**
297     * Has query data
298     *
299     * @return bool
300     */
301    public function hasQuery(): bool
302    {
303        return !empty($this->query);
304    }
305
306    /**
307     * Remove query data
308     *
309     * @param  string $key
310     * @return Request
311     */
312    public function removeQuery(string $key): Request
313    {
314        if ($this->query->hasData($key)) {
315            $this->query->removeData($key);
316        }
317        return $this;
318    }
319
320    /**
321     * Remove all query data
322     *
323     * @return Request
324     */
325    public function removeAllQuery(): Request
326    {
327        $this->query = null;
328
329        if ($this->hasHeader('Content-Type')) {
330            $this->removeHeader('Content-Type');
331        }
332        return $this;
333    }
334
335    /**
336     * Set data
337     *
338     * @param  mixed $data
339     * @param  mixed $filters
340     * @return Request
341     */
342    public function setData(mixed $data, mixed $filters = null): Request
343    {
344        if ($data instanceof Data) {
345            if (!$data->hasRequest()) {
346                $data->setRequest($this);
347            }
348        } else {
349            $data = new Data($data, $filters, $this);
350        }
351
352        $this->data = $data;
353
354        return $this;
355    }
356
357    /**
358     * Add data
359     *
360     * @param  mixed $name
361     * @param  mixed $value
362     * @return Request
363     */
364    public function addData(mixed $name, mixed $value): Request
365    {
366        if ($this->data === null) {
367            $this->data = new Data([], null, $this);
368        } else if (!$this->data->hasRequest()) {
369            $this->data->setRequest($this);
370        }
371        $this->data->addData($name, $value);
372
373        return $this;
374    }
375
376    /**
377     * Get data
378     *
379     * @return ?Data
380     */
381    public function getData(): ?Data
382    {
383        return $this->data;
384    }
385
386    /**
387     * Has data
388     *
389     * @return bool
390     */
391    public function hasData(): bool
392    {
393        return !empty($this->data);
394    }
395
396    /**
397     * Remove data
398     *
399     * @param  string $key
400     * @return Request
401     */
402    public function removeData(string $key): Request
403    {
404        if ($this->data->hasData($key)) {
405            $this->data->removeData($key);
406        }
407        return $this;
408    }
409
410    /**
411     * Remove all data
412     *
413     * @return Request
414     */
415    public function removeAllData(): Request
416    {
417        $this->data = null;
418
419        if ($this->hasHeader('Content-Type')) {
420            $this->removeHeader('Content-Type');
421        }
422        if ($this->hasHeader('Content-Length')) {
423            $this->removeHeader('Content-Length');
424        }
425        return $this;
426    }
427
428    /**
429     * Get full URI as string
430     *
431     * @param  bool $query
432     * @return string
433     */
434    public function getUriAsString(bool $query = true): string
435    {
436        $uri = parent::getUriAsString();
437
438        if ($query) {
439            $queryString = null;
440
441            // If request has generic query data
442            if ($this->hasQuery()) {
443                $queryString = $this->query->prepare()->getDataContent();
444            // Else, if request has explicit data configured to be a query string over GET
445            } else if (($this->method == 'GET') && ($this->hasData()) &&
446                (($this->requestType === null) || ($this->requestType == self::URLENCODED))) {
447                $queryString = $this->data->prepare()->getDataContent();
448            }
449
450            if (!empty($queryString) && !str_contains($uri, $queryString)) {
451                $uri .= ((str_contains($uri, '?')) ? '&' : '?') . $queryString;
452            }
453        }
454
455        return $uri;
456    }
457
458    /**
459     * Set request type
460     *
461     * @param  ?string $type
462     * @param  bool    $handleHeader
463     * @return Request
464     */
465    public function setRequestType(string $type = null, bool $handleHeader = true): Request
466    {
467        switch ($type) {
468            case self::JSON:
469                $this->createAsJson($type, $handleHeader);
470                break;
471            case self::XML:
472                $this->createAsXml($type, $handleHeader);
473                break;
474            case self::URLENCODED:
475                $this->createAsUrlEncoded($type, $handleHeader);
476                break;
477            case self::MULTIPART:
478                $this->createAsMultipart($type);
479                break;
480            default:
481                // Custom content-types
482                if ($type !== null) {
483                    if (strrpos($type, 'json') !== false) {
484                        $this->createAsJson($type);
485                    } else if (strrpos($type, 'xml') !== false) {
486                        $this->createAsXml($type);
487                    } else {
488                        $this->createAsCustomType($type);
489                    }
490                } else {
491                    $this->removeRequestType($handleHeader);
492                }
493
494        }
495
496        return $this;
497    }
498
499    /**
500     * Get request type
501     *
502     * @return ?string
503     */
504    public function getRequestType(): ?string
505    {
506        return $this->requestType;
507    }
508
509    /**
510     * Has request type
511     *
512     * @return bool
513     */
514    public function hasRequestType(): bool
515    {
516        return ($this->requestType !== null);
517    }
518
519    /**
520     * Remove request type
521     *
522     * @param  bool $removeHeader
523     * @return Request
524     */
525    public function removeRequestType(bool $removeHeader = false): Request
526    {
527        $this->requestType = null;
528        if (($removeHeader) && ($this->hasHeader('Content-Type'))) {
529            $this->removeHeader('Content-Type');
530        }
531        return $this;
532    }
533
534    /**
535     * Create request as JSON
536     *
537     * @param  string $type
538     * @param  bool   $addHeader
539     * @return Request
540     */
541    public function createAsJson(string $type = self::JSON, bool $addHeader = true): Request
542    {
543        $this->requestType = $type;
544
545        if ($this->hasHeader('Content-Type')) {
546            $this->removeHeader('Content-Type');
547        }
548        if ($addHeader) {
549            $this->addHeader('Content-Type', $this->requestType);
550        }
551        $this->addHeader('Content-Type', $this->requestType);
552
553        return $this;
554    }
555
556    /**
557     * Check if request is JSON
558     *
559     * @return bool
560     */
561    public function isJson(): bool
562    {
563        return str_contains(strtolower($this->requestType), 'json');
564    }
565
566    /**
567     * Create request as XML
568     *
569     * @param  string $type
570     * @param  bool   $addHeader
571     * @return Request
572     */
573    public function createAsXml(string $type = self::XML, bool $addHeader = true): Request
574    {
575        $this->requestType = $type;
576
577        if ($this->hasHeader('Content-Type')) {
578            $this->removeHeader('Content-Type');
579        }
580
581        if ($addHeader) {
582            $this->addHeader('Content-Type', $this->requestType);
583        }
584
585        return $this;
586    }
587
588    /**
589     * Check if request is XML
590     *
591     * @return bool
592     */
593    public function isXml(): bool
594    {
595        return str_contains(strtolower($this->requestType), 'xml');
596    }
597
598    /**
599     * Create request as a URL-encoded form
600     *
601     * @param  string $type
602     * @param  bool   $addHeader
603     * @return Request
604     */
605    public function createAsUrlEncoded(string $type = self::URLENCODED, bool $addHeader = true): Request
606    {
607        $this->requestType = $type;
608
609        if ($this->hasHeader('Content-Type')) {
610            $this->removeHeader('Content-Type');
611        }
612
613        if ($addHeader) {
614            $this->addHeader('Content-Type', $this->requestType);
615        }
616
617        return $this;
618    }
619
620    /**
621     * Check if request is a URL-encoded form
622     *
623     * @return bool
624     */
625    public function isUrlEncoded(): bool
626    {
627        return ($this->requestType == self::URLENCODED);
628    }
629
630    /**
631     * Create request as a multipart form
632     *
633     * @param  string $type
634     * @return Request
635     */
636    public function createAsMultipart(string $type = self::MULTIPART): Request
637    {
638        $this->requestType = $type;
639        return $this;
640    }
641
642    /**
643     * Check if request is a multipart form
644     *
645     * @return bool
646     */
647    public function isMultipart(): bool
648    {
649        return str_contains(strtolower($this->requestType), self::MULTIPART);
650    }
651
652    /**
653     * Create request as custom content type
654     *
655     * @param  string $type
656     * @param  bool   $addHeader
657     * @return Request
658     */
659    public function createAsCustomType(string $type, bool $addHeader = true): Request
660    {
661        $this->requestType = $type;
662
663        if ($addHeader) {
664            $this->addHeader('Content-Type', $this->requestType);
665        }
666        return $this;
667    }
668
669    /**
670     * Check if request is a custom content type
671     *
672     * @return bool
673     */
674    public function isCustomType(): bool
675    {
676        return (!empty($this->requestType) && (strrpos($this->requestType, 'json') !== false) &&
677            (strrpos($this->requestType, 'xml') !== false) && ($this->requestType !== self::URLENCODED) &&
678            ($this->requestType !== self::MULTIPART));
679    }
680
681    /**
682     * Is valid method
683     *
684     * @param  string $method
685     * @return bool
686     */
687    public function isValidMethod(string $method): bool
688    {
689        return in_array(strtoupper($method), ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'CONNECT', 'OPTIONS', 'TRACE']);
690    }
691
692    /**
693     * Has data content
694     *
695     * @return bool
696     */
697    public function hasDataContent(): bool
698    {
699        return (($this->data !== null) && ($this->data->hasDataContent()));
700    }
701
702    /**
703     * Get the data content
704     *
705     * @return ?string
706     */
707    public function getDataContent(): ?string
708    {
709        return $this->data?->getDataContent();
710    }
711
712    /**
713     * Get the data content length
714     *
715     * @param  bool $mb
716     * @return int|null
717     */
718    public function getDataContentLength(bool $mb = false): int|null
719    {
720        return $this->data?->getDataContentLength($mb);
721    }
722
723    /**
724     * Prepare request data
725     *
726     * @return Request
727     */
728    public function prepareData(): Request
729    {
730        if (!$this->data->hasRequest()) {
731            $this->data->setRequest($this);
732        }
733
734        $this->data->prepare();
735
736        return $this;
737    }
738
739    /**
740     * Magic method to check the is[Method](), i.e. $request->isPost();
741     *
742     * @param  string $methodName
743     * @param  ?array $arguments
744     * @throws Exception
745     * @return bool
746     */
747    public function __call(string $methodName, ?array $arguments = null): bool
748    {
749        if (str_starts_with($methodName, 'is')) {
750            $method = strtoupper(substr($methodName, 2));
751            if ($this->isValidMethod($method)) {
752                return ($this->method == $method);
753            } else {
754                throw new Exception('Error: That request method is not valid.');
755            }
756        } else {
757            throw new Exception('Error: That method/function is not valid.');
758        }
759    }
760
761}