Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
286 / 286
100.00% covered (success)
100.00%
62 / 62
CRAP
100.00% covered (success)
100.00%
1 / 1
Request
100.00% covered (success)
100.00%
286 / 286
100.00% covered (success)
100.00%
62 / 62
138
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __call
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 __toString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMultipartBody
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
6
 validateHost
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 prepareStatusLine
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getHeader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepareHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getCookie
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCookies
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepareCookies
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getBody
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 prepareFiles
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 filterInput
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 forceHttps
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getAuthType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getBasicAuth
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getBearerAuth
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDigestAuth
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseAuth
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 parseBasicAuth
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 parseBearerAuth
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 parseDigestAuth
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 getParsedBody
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 getJson
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getNegotiableValues
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 negotiate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getAccepts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 negotiateAccept
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCharsets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 negotiateCharset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEncodings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 negotiateEncoding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLanguages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 negotiateLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEnv
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFiles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasFiles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getGet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getIp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRedirectData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 getPort
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getProxiedIp
n/a
0 / 0
n/a
0 / 0
3
 getReferer
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getServer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserAgent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 setUserAgent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 isAjax
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isSecure
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isForm
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isPost
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getInputFiles
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 setHost
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework HTTP Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\HTTP;
11
12use BadMethodCallException;
13use Framework\Helpers\ArraySimple;
14use InvalidArgumentException;
15use JetBrains\PhpStorm\ArrayShape;
16use JetBrains\PhpStorm\Deprecated;
17use JetBrains\PhpStorm\Pure;
18use LogicException;
19use stdClass;
20use UnexpectedValueException;
21
22/**
23 * Class Request.
24 *
25 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#HTTP_Requests
26 *
27 * @package http
28 */
29class Request extends Message implements RequestInterface
30{
31    /**
32     * @var array<string,array<mixed>|UploadedFile>
33     */
34    protected array $files = [];
35    /**
36     * @var array<string,mixed>|null
37     */
38    protected ?array $parsedBody = null;
39    /**
40     * HTTP Authorization Header parsed.
41     *
42     * @var array<string,string|null>|null
43     */
44    protected ?array $auth = null;
45    /**
46     * @var string|null Basic or Digest
47     */
48    protected ?string $authType = null;
49    protected string $host;
50    protected int $port;
51    /**
52     * Request X-Request-ID header.
53     */
54    protected string | false $id;
55    /**
56     * @var array<string,array<mixed>|null>
57     */
58    protected array $negotiation = [
59        'ACCEPT' => null,
60        'CHARSET' => null,
61        'ENCODING' => null,
62        'LANGUAGE' => null,
63    ];
64    protected false | URL $referrer;
65    protected false | UserAgent $userAgent;
66    protected bool $isAjax;
67    /**
68     * Tell if is a HTTPS connection.
69     *
70     * @var bool
71     */
72    protected bool $isSecure;
73
74    /**
75     * Request constructor.
76     *
77     * @param array<string> $allowedHosts set allowed hosts if your
78     * server don't serve by Host header, as Nginx do
79     *
80     * @throws UnexpectedValueException if invalid Host
81     */
82    public function __construct(array $allowedHosts = [])
83    {
84        if ($allowedHosts) {
85            $this->validateHost($allowedHosts);
86        }
87        $this->prepareStatusLine();
88    }
89
90    /**
91     * @param string $method
92     * @param array<int,mixed> $arguments
93     *
94     * @throws BadMethodCallException for method not allowed or method not found
95     *
96     * @return static
97     */
98    public function __call(string $method, array $arguments)
99    {
100        if ($method === 'setBody') {
101            return $this->setBody(...$arguments);
102        }
103        if (\method_exists($this, $method)) {
104            throw new BadMethodCallException("Method not allowed: {$method}");
105        }
106        throw new BadMethodCallException("Method not found: {$method}");
107    }
108
109    public function __toString() : string
110    {
111        if ($this->parseContentType() === 'multipart/form-data') {
112            $this->setBody($this->getMultipartBody());
113        }
114        return parent::__toString();
115    }
116
117    /**
118     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#multipartform-data
119     *
120     * @return string
121     */
122    protected function getMultipartBody() : string
123    {
124        $bodyParts = [];
125        /**
126         * @var array<string,string> $post
127         */
128        $post = ArraySimple::convert($this->getPost());
129        foreach ($post as $field => $value) {
130            $field = \htmlspecialchars($field, \ENT_QUOTES | \ENT_HTML5);
131            $bodyParts[] = \implode("\r\n", [
132                "Content-Disposition: form-data; name=\"{$field}\"",
133                '',
134                $value,
135            ]);
136        }
137        /**
138         * @var array<string,UploadedFile> $files
139         */
140        $files = ArraySimple::convert($this->getFiles());
141        foreach ($files as $field => $file) {
142            $field = \htmlspecialchars($field, \ENT_QUOTES | \ENT_HTML5);
143            $filename = \htmlspecialchars($file->getName(), \ENT_QUOTES | \ENT_HTML5);
144            $getContentsOf = $file->isMoved() ? $file->getDestination() : $file->getTmpName();
145            $data = '';
146            if ($getContentsOf !== '') {
147                $data = \file_get_contents($getContentsOf);
148            }
149            $bodyParts[] = \implode("\r\n", [
150                "Content-Disposition: form-data; name=\"{$field}\"; filename=\"{$filename}\"",
151                'Content-Type: ' . $file->getClientType(),
152                '',
153                $data,
154            ]);
155        }
156        $boundary = \explode(';', $this->getContentType(), 2);
157        $boundary = \trim($boundary[1]);
158        $boundary = \substr($boundary, \strlen('boundary='));
159        foreach ($bodyParts as &$part) {
160            $part = "--{$boundary}\r\n{$part}";
161        }
162        unset($part);
163        $bodyParts[] = "--{$boundary}--";
164        $bodyParts[] = '';
165        $bodyParts = \implode("\r\n", $bodyParts);
166        /**
167         * Uncomment the code below to make a raw test.
168         *
169         * @see \Tests\HTTP\RequestTest::testToStringMultipart()
170         */
171        /*
172        $serverLength = (string) $_SERVER['CONTENT_LENGTH'];
173        $algoLength = (string) \strlen($bodyParts);
174        if ($serverLength !== $algoLength) {
175            throw new \Exception(
176                '$_SERVER CONTENT_LENGTH is ' . $serverLength
177                . ', but the algorithm calculated ' . $algoLength
178            );
179        }
180        */
181        return $bodyParts;
182    }
183
184    /**
185     * Check if Host header is allowed.
186     *
187     * @see https://expressionengine.com/blog/http-host-and-server-name-security-issues
188     * @see http://nginx.org/en/docs/http/request_processing.html
189     *
190     * @param array<string> $allowedHosts
191     */
192    protected function validateHost(array $allowedHosts) : void
193    {
194        $host = $_SERVER['HTTP_HOST'] ?? null;
195        if ( ! \in_array($host, $allowedHosts, true)) {
196            throw new UnexpectedValueException('Invalid Host: ' . $host);
197        }
198    }
199
200    protected function prepareStatusLine() : void
201    {
202        $this->setProtocol($_SERVER['SERVER_PROTOCOL']);
203        $this->setMethod($_SERVER['REQUEST_METHOD']);
204        $url = $this->isSecure() ? 'https' : 'http';
205        $url .= '://' . $_SERVER['HTTP_HOST'];
206        $url .= $_SERVER['REQUEST_URI'];
207        $this->setUrl($url);
208        $this->setHost($this->getUrl()->getHost());
209    }
210
211    public function getHeader(string $name) : ?string
212    {
213        $this->prepareHeaders();
214        return $this->headers[\strtolower($name)] ?? null;
215    }
216
217    /**
218     * @return array<string,string>
219     */
220    public function getHeaders() : array
221    {
222        $this->prepareHeaders();
223        return $this->headers;
224    }
225
226    protected function prepareHeaders() : void
227    {
228        if ( ! empty($this->headers)) {
229            return;
230        }
231        foreach ($_SERVER as $name => $value) {
232            if (\str_starts_with($name, 'HTTP_')) {
233                $name = \strtr(\substr($name, 5), ['_' => '-']);
234                $this->setHeader($name, $value);
235            }
236        }
237    }
238
239    public function getCookie(string $name) : ?Cookie
240    {
241        $this->prepareCookies();
242        return $this->cookies[$name] ?? null;
243    }
244
245    /**
246     * Get all Cookies.
247     *
248     * @return array<string,Cookie>
249     */
250    public function getCookies() : array
251    {
252        $this->prepareCookies();
253        return $this->cookies;
254    }
255
256    protected function prepareCookies() : void
257    {
258        if ( ! empty($this->cookies)) {
259            return;
260        }
261        foreach ($_COOKIE as $name => $value) {
262            $this->setCookie(new Cookie($name, $value));
263        }
264    }
265
266    /**
267     * @see https://www.php.net/manual/en/wrappers.php.php#wrappers.php.input
268     *
269     * @return string
270     */
271    public function getBody() : string
272    {
273        if ( ! isset($this->body)) {
274            $this->body = (string) \file_get_contents('php://input');
275        }
276        return $this->body;
277    }
278
279    protected function prepareFiles() : void
280    {
281        if ( ! empty($this->files)) {
282            return;
283        }
284        $this->files = $this->getInputFiles();
285    }
286
287    /**
288     * @param int $type
289     * @param string|null $name
290     * @param int|null $filter
291     * @param array<int,int>|int $options
292     *
293     * @see https://www.php.net/manual/en/function.filter-var
294     * @see https://www.php.net/manual/en/filter.filters.php
295     *
296     * @return mixed
297     */
298    protected function filterInput(
299        int $type,
300        string $name = null,
301        int $filter = null,
302        array | int $options = 0
303    ) : mixed {
304        $input = match ($type) {
305            \INPUT_POST => $_POST,
306            \INPUT_GET => $_GET,
307            \INPUT_COOKIE => $_COOKIE,
308            \INPUT_ENV => $_ENV,
309            \INPUT_SERVER => $_SERVER,
310            default => throw new InvalidArgumentException('Invalid input type: ' . $type)
311        };
312        if ($name !== null) {
313            $input = \in_array($type, [\INPUT_POST, \INPUT_GET], true)
314                ? ArraySimple::value($name, $input)
315                : $input[$name] ?? null;
316        }
317        if ($filter !== null) {
318            $input = \filter_var($input, $filter, $options);
319        }
320        return $input;
321    }
322
323    /**
324     * Force an HTTPS connection on same URL.
325     */
326    public function forceHttps() : void
327    {
328        if ( ! $this->isSecure()) {
329            \header(
330                'Location: ' . $this->getUrl()->setScheme('https'),
331                true,
332                Status::MOVED_PERMANENTLY
333            );
334            if ( ! \defined('TESTING')) {
335                // @codeCoverageIgnoreStart
336                exit;
337                // @codeCoverageIgnoreEnd
338            }
339        }
340    }
341
342    /**
343     * Get the Authorization type.
344     *
345     * @return string|null Basic, Bearer, Digest or null for none
346     */
347    public function getAuthType() : ?string
348    {
349        if ($this->authType === null) {
350            $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
351            if ($auth) {
352                $this->parseAuth($auth);
353            }
354        }
355        return $this->authType;
356    }
357
358    /**
359     * Get Basic authorization.
360     *
361     * @return array<string>|null Two keys: username and password
362     */
363    #[ArrayShape(['username' => 'string|null', 'password' => 'string|null'])]
364    public function getBasicAuth() : ?array
365    {
366        return $this->getAuthType() === 'Basic'
367            ? $this->auth
368            : null;
369    }
370
371    /**
372     * Get Bearer authorization.
373     *
374     * @return array<string>|null One key: token
375     */
376    #[ArrayShape(['token' => 'string|null'])]
377    public function getBearerAuth() : ?array
378    {
379        return $this->getAuthType() === 'Bearer'
380            ? $this->auth
381            : null;
382    }
383
384    /**
385     * Get Digest authorization.
386     *
387     * @return array<string>|null Nine keys: username, realm, nonce, uri,
388     * response, opaque, qop, nc, cnonce
389     */
390    #[ArrayShape([
391        'username' => 'string|null',
392        'realm' => 'string|null',
393        'nonce' => 'string|null',
394        'uri' => 'string|null',
395        'response' => 'string|null',
396        'opaque' => 'string|null',
397        'qop' => 'string|null',
398        'nc' => 'string|null',
399        'cnonce' => 'string|null',
400    ])]
401    public function getDigestAuth() : ?array
402    {
403        return $this->getAuthType() === 'Digest'
404            ? $this->auth
405            : null;
406    }
407
408    /**
409     * @param string $authorization
410     *
411     * @return array<string,string|null>
412     */
413    protected function parseAuth(string $authorization) : array
414    {
415        $this->auth = [];
416        [$type, $attributes] = \array_pad(\explode(' ', $authorization, 2), 2, null);
417        if ($type === 'Basic') {
418            $this->authType = $type;
419            $this->auth = $this->parseBasicAuth($attributes);
420        } elseif ($type === 'Bearer') {
421            $this->authType = $type;
422            $this->auth = $this->parseBearerAuth($attributes);
423        } elseif ($type === 'Digest') {
424            $this->authType = $type;
425            $this->auth = $this->parseDigestAuth($attributes);
426        }
427        return $this->auth;
428    }
429
430    /**
431     * @param string $attributes
432     *
433     * @return array<string,string|null>
434     */
435    #[ArrayShape(['username' => 'string|null', 'password' => 'string|null'])]
436    #[Pure]
437    protected function parseBasicAuth(string $attributes) : array
438    {
439        $data = [
440            'username' => null,
441            'password' => null,
442        ];
443        $attributes = \base64_decode($attributes);
444        if ($attributes) {
445            [
446                $data['username'],
447                $data['password'],
448            ] = \array_pad(\explode(':', $attributes, 2), 2, null);
449        }
450        return $data;
451    }
452
453    /**
454     * @param string $attributes
455     *
456     * @return array<string,string|null>
457     */
458    #[ArrayShape(['token' => 'string|null'])]
459    #[Pure]
460    protected function parseBearerAuth(string $attributes) : array
461    {
462        $data = [
463            'token' => null,
464        ];
465        if ($attributes) {
466            $data['token'] = $attributes;
467        }
468        return $data;
469    }
470
471    /**
472     * @param string $attributes
473     *
474     * @return array<string,string|null>
475     */
476    #[ArrayShape([
477        'username' => 'string|null',
478        'realm' => 'string|null',
479        'nonce' => 'string|null',
480        'uri' => 'string|null',
481        'response' => 'string|null',
482        'opaque' => 'string|null',
483        'qop' => 'string|null',
484        'nc' => 'string|null',
485        'cnonce' => 'string|null',
486    ])]
487    protected function parseDigestAuth(string $attributes) : array
488    {
489        $data = [
490            'username' => null,
491            'realm' => null,
492            'nonce' => null,
493            'uri' => null,
494            'response' => null,
495            'opaque' => null,
496            'qop' => null,
497            'nc' => null,
498            'cnonce' => null,
499        ];
500        \preg_match_all(
501            '#(username|realm|nonce|uri|response|opaque|qop|nc|cnonce)=(?:([\'"])([^\2]+?)\2|([^\s,]+))#',
502            $attributes,
503            $matches,
504            \PREG_SET_ORDER
505        );
506        foreach ($matches as $match) {
507            if (isset($match[1], $match[3])) {
508                $data[$match[1]] = $match[3] ?: $match[4] ?? '';
509            }
510        }
511        return $data;
512    }
513
514    /**
515     * Get the Parsed Body or part of it.
516     *
517     * @param string|null $name
518     * @param int|null $filter
519     * @param array<int,int>|int $filterOptions
520     *
521     * @see Request::filterInput()
522     *
523     * @return array<int|string,mixed>|mixed|string|null
524     */
525    public function getParsedBody(
526        string $name = null,
527        int $filter = null,
528        array | int $filterOptions = 0
529    ) {
530        if ($this->getMethod() === Method::POST) {
531            return $this->getPost($name, $filter, $filterOptions);
532        }
533        if ($this->parsedBody === null) {
534            $this->isForm()
535                ? \parse_str($this->getBody(), $this->parsedBody)
536                : $this->parsedBody = [];
537        }
538        $variable = $name === null
539            ? $this->parsedBody
540            : ArraySimple::value($name, $this->parsedBody);
541        return $filter !== null
542            ? \filter_var($variable, $filter, $filterOptions)
543            : $variable;
544    }
545
546    /**
547     * Get the request body as JSON.
548     *
549     * @param bool|null $associative When true, JSON objects will be returned as
550     * associative arrays; when false, JSON objects will be returned as objects.
551     * When null, JSON objects will be returned as associative arrays or objects
552     * depending on whether JSON_OBJECT_AS_ARRAY is set in the flags.
553     * @param int $flags <p>
554     * Bitmask of JSON decode options:<br/>
555     * {@see \JSON_BIGINT_AS_STRING} decodes large integers as their original
556     * string value.<br/>
557     * {@see \JSON_INVALID_UTF8_IGNORE} ignores invalid UTF-8 characters,<br/>
558     * {@see \JSON_INVALID_UTF8_SUBSTITUTE} converts invalid UTF-8 characters to
559     * \0xfffd,<br/>
560     * {@see \JSON_OBJECT_AS_ARRAY} decodes JSON objects as PHP array, since
561     * 7.2.0 used by default if $assoc parameter is null,<br/>
562     * {@see \JSON_THROW_ON_ERROR} when passed this flag, the error behaviour of
563     * these functions is changed. The global error state is left untouched, and
564     * if an error occurs that would otherwise set it, these functions instead
565     * throw a JsonException<br/>
566     * </p>
567     * @param int<1,max> $depth user specified recursion depth
568     *
569     * @see https://www.php.net/manual/en/function.json-decode.php
570     * @see https://www.php.net/manual/en/json.constants.php
571     *
572     * @return array<string,mixed>|false|stdClass If option JSON_THROW_ON_ERROR
573     * is not set, return false if json_decode fail. Otherwise return a
574     * stdClass instance, or an array if the $associative argument is passed as
575     * true.
576     */
577    public function getJson(
578        ?bool $associative = null,
579        int $flags = 0,
580        int $depth = 512
581    ) : array | stdClass | false {
582        $body = \json_decode($this->getBody(), $associative, $depth, $flags);
583        if (\json_last_error() !== \JSON_ERROR_NONE) {
584            return false;
585        }
586        return $body;
587    }
588
589    /**
590     * @param string $type
591     *
592     * @return array<int,string>
593     */
594    protected function getNegotiableValues(string $type) : array
595    {
596        if ($this->negotiation[$type]) {
597            return $this->negotiation[$type];
598        }
599        $header = $_SERVER['HTTP_ACCEPT' . ($type !== 'ACCEPT' ? '_' . $type : '')] ?? null;
600        $this->negotiation[$type] = \array_keys(static::parseQualityValues(
601            $header
602        ));
603        $this->negotiation[$type] = \array_map('\strtolower', $this->negotiation[$type]);
604        return $this->negotiation[$type];
605    }
606
607    /**
608     * @param string $type
609     * @param array<int,string> $negotiable
610     *
611     * @return string
612     */
613    protected function negotiate(string $type, array $negotiable) : string
614    {
615        $negotiable = \array_map('\strtolower', $negotiable);
616        foreach ($this->getNegotiableValues($type) as $item) {
617            if (\in_array($item, $negotiable, true)) {
618                return $item;
619            }
620        }
621        return $negotiable[0];
622    }
623
624    /**
625     * Get the mime types of the Accept header.
626     *
627     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept
628     *
629     * @return array<int,string>
630     */
631    public function getAccepts() : array
632    {
633        return $this->getNegotiableValues('ACCEPT');
634    }
635
636    /**
637     * Negotiate the Accept header.
638     *
639     * @param array<int,string> $negotiable Allowed mime types
640     *
641     * @return string The negotiated mime type
642     */
643    public function negotiateAccept(array $negotiable) : string
644    {
645        return $this->negotiate('ACCEPT', $negotiable);
646    }
647
648    /**
649     * Get the Accept-Charset's.
650     *
651     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Charset
652     *
653     * @return array<int,string>
654     */
655    public function getCharsets() : array
656    {
657        return $this->getNegotiableValues('CHARSET');
658    }
659
660    /**
661     * Negotiate the Accept-Charset.
662     *
663     * @param array<int,string> $negotiable Allowed charsets
664     *
665     * @return string The negotiated charset
666     */
667    public function negotiateCharset(array $negotiable) : string
668    {
669        return $this->negotiate('CHARSET', $negotiable);
670    }
671
672    /**
673     * Get the Accept-Encoding.
674     *
675     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding
676     *
677     * @return array<int,string>
678     */
679    public function getEncodings() : array
680    {
681        return $this->getNegotiableValues('ENCODING');
682    }
683
684    /**
685     * Negotiate the Accept-Encoding.
686     *
687     * @param array<int,string> $negotiable The allowed encodings
688     *
689     * @return string The negotiated encoding
690     */
691    public function negotiateEncoding(array $negotiable) : string
692    {
693        return $this->negotiate('ENCODING', $negotiable);
694    }
695
696    /**
697     * Get the Accept-Language's.
698     *
699     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
700     *
701     * @return array<int,string>
702     */
703    public function getLanguages() : array
704    {
705        return $this->getNegotiableValues('LANGUAGE');
706    }
707
708    /**
709     * Negotiated the Accept-Language.
710     *
711     * @param array<int,string> $negotiable Allowed languages
712     *
713     * @return string The negotiated language
714     */
715    public function negotiateLanguage(array $negotiable) : string
716    {
717        return $this->negotiate('LANGUAGE', $negotiable);
718    }
719
720    /**
721     * Get the Content-Type header value.
722     *
723     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
724     *
725     * @return string|null
726     */
727    #[Pure]
728    public function getContentType() : ?string
729    {
730        return $_SERVER['HTTP_CONTENT_TYPE'] ?? null;
731    }
732
733    /**
734     * @param string|null $name
735     * @param int|null $filter
736     * @param array<int,int>|int $filterOptions
737     *
738     * @see Request::filterInput()
739     *
740     * @return mixed
741     */
742    public function getEnv(
743        string $name = null,
744        int $filter = null,
745        array | int $filterOptions = 0
746    ) : mixed {
747        return $this->filterInput(\INPUT_ENV, $name, $filter, $filterOptions);
748    }
749
750    /**
751     * @return array<string,array<mixed>|UploadedFile>
752     */
753    public function getFiles() : array
754    {
755        $this->prepareFiles();
756        return $this->files;
757    }
758
759    public function hasFiles() : bool
760    {
761        $this->prepareFiles();
762        return ! empty($this->files);
763    }
764
765    public function getFile(string $name) : ?UploadedFile
766    {
767        $this->prepareFiles();
768        $file = ArraySimple::value($name, $this->files);
769        return \is_array($file) ? null : $file;
770    }
771
772    /**
773     * Get the URL GET queries.
774     *
775     * @param string|null $name
776     * @param int|null $filter
777     * @param array<int,int>|int $filterOptions
778     *
779     * @see Request::filterInput()
780     *
781     * @return mixed
782     */
783    public function getGet(
784        string $name = null,
785        int $filter = null,
786        array | int $filterOptions = 0
787    ) : mixed {
788        return $this->filterInput(\INPUT_GET, $name, $filter, $filterOptions);
789    }
790
791    /**
792     * @return string
793     */
794    #[Pure]
795    public function getHost() : string
796    {
797        return $this->host;
798    }
799
800    /**
801     * Get the X-Request-ID header.
802     *
803     * @return string|null
804     */
805    public function getId() : string | null
806    {
807        if (isset($this->id)) {
808            return $this->id === false ? null : $this->id;
809        }
810        $this->id = $_SERVER['HTTP_X_REQUEST_ID'] ?? false;
811        return $this->getId();
812    }
813
814    /**
815     * Get the connection IP.
816     *
817     * @return string
818     */
819    public function getIp() : string
820    {
821        return $_SERVER['REMOTE_ADDR'];
822    }
823
824    #[Pure]
825    public function getMethod() : string
826    {
827        return parent::getMethod();
828    }
829
830    /**
831     * @param string $method
832     *
833     * @throws InvalidArgumentException for invalid method
834     *
835     * @return bool
836     */
837    public function isMethod(string $method) : bool
838    {
839        return parent::isMethod($method);
840    }
841
842    /**
843     * Gets data from the last request, if it was redirected.
844     *
845     * @param string|null $key a key name or null to get all data
846     *
847     * @see Response::redirect()
848     *
849     * @throws LogicException if PHP Session is not active to get redirect data
850     *
851     * @return mixed an array containing all data, the key value or null
852     * if the key was not found
853     */
854    public function getRedirectData(string $key = null) : mixed
855    {
856        static $data;
857        if ($data === null && \session_status() !== \PHP_SESSION_ACTIVE) {
858            throw new LogicException('Session must be active to get redirect data');
859        }
860        if ($data === null) {
861            $data = $_SESSION['$']['redirect_data'] ?? false;
862            unset($_SESSION['$']['redirect_data']);
863        }
864        if ($key !== null && $data) {
865            return ArraySimple::value($key, $data);
866        }
867        return $data === false ? null : $data;
868    }
869
870    /**
871     * Get the URL port.
872     *
873     * @return int
874     */
875    public function getPort() : int
876    {
877        return $this->port ?? $_SERVER['SERVER_PORT'];
878    }
879
880    /**
881     * Get POST data.
882     *
883     * @param string|null $name
884     * @param int|null $filter
885     * @param array<int,int>|int $filterOptions
886     *
887     * @see Request::filterInput()
888     *
889     * @return mixed
890     */
891    public function getPost(
892        string $name = null,
893        int $filter = null,
894        array | int $filterOptions = 0
895    ) : mixed {
896        return $this->filterInput(\INPUT_POST, $name, $filter, $filterOptions);
897    }
898
899    /**
900     * Get the connection IP via a proxy header.
901     *
902     * @return string|null
903     *
904     * @deprecated Use {@see Request::getHeader()}
905     *
906     * @codeCoverageIgnore
907     */
908    #[Deprecated(
909        reason: 'since HTTP Library version 5.5, use getHeader() instead',
910        replacement: '%class%->getHeader()'
911    )]
912    public function getProxiedIp() : ?string
913    {
914        \trigger_error(
915            'Method ' . __METHOD__ . ' is deprecated',
916            \E_USER_DEPRECATED
917        );
918        foreach ([
919            'X-Real-IP',
920            'X-Forwarded-For',
921            'Client-IP',
922            'X-Client-IP',
923            'X-Cluster-Client-IP',
924        ] as $header) {
925            $header = $this->getHeader($header);
926            if ($header) {
927                $ip = \explode(',', $header, 2)[0];
928                return \trim($ip);
929            }
930        }
931        return null;
932    }
933
934    /**
935     * Get the Referer header.
936     *
937     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer
938     *
939     * @return URL|null
940     */
941    public function getReferer() : ?URL
942    {
943        if ( ! isset($this->referrer)) {
944            $this->referrer = false;
945            $referer = $_SERVER['HTTP_REFERER'] ?? null;
946            if ($referer !== null) {
947                try {
948                    $this->referrer = new URL($referer);
949                } catch (InvalidArgumentException) {
950                    $this->referrer = false;
951                }
952            }
953        }
954        return $this->referrer ?: null;
955    }
956
957    /**
958     * Get $_SERVER variables.
959     *
960     * @param string|null $name
961     * @param int|null $filter
962     * @param array<int,int>|int $filterOptions
963     *
964     * @see Request::filterInput()
965     *
966     * @return mixed
967     */
968    public function getServer(
969        string $name = null,
970        int $filter = null,
971        array | int $filterOptions = 0
972    ) : mixed {
973        return $this->filterInput(\INPUT_SERVER, $name, $filter, $filterOptions);
974    }
975
976    /**
977     * Gets the requested URL.
978     *
979     * @return URL
980     */
981    #[Pure]
982    public function getUrl() : URL
983    {
984        return parent::getUrl();
985    }
986
987    /**
988     * Gets the User Agent client.
989     *
990     * @return UserAgent|null the UserAgent object or null if no user-agent
991     * header was received
992     */
993    public function getUserAgent() : ?UserAgent
994    {
995        if (isset($this->userAgent) && $this->userAgent instanceof UserAgent) {
996            return $this->userAgent;
997        }
998        $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
999        $userAgent ? $this->setUserAgent($userAgent) : $this->userAgent = false;
1000        return $this->userAgent ?: null;
1001    }
1002
1003    /**
1004     * @param string|UserAgent $userAgent
1005     *
1006     * @return static
1007     */
1008    protected function setUserAgent(string | UserAgent $userAgent) : static
1009    {
1010        if ( ! $userAgent instanceof UserAgent) {
1011            $userAgent = new UserAgent($userAgent);
1012        }
1013        $this->userAgent = $userAgent;
1014        return $this;
1015    }
1016
1017    /**
1018     * Check if is an AJAX Request based in the X-Requested-With Header.
1019     *
1020     * The X-Requested-With Header containing the "XMLHttpRequest" value is
1021     * used by various javascript libraries.
1022     *
1023     * @return bool
1024     */
1025    public function isAjax() : bool
1026    {
1027        if (isset($this->isAjax)) {
1028            return $this->isAjax;
1029        }
1030        $received = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? null;
1031        return $this->isAjax = ($received
1032            && \strtolower($received) === 'xmlhttprequest');
1033    }
1034
1035    /**
1036     * Say if a connection has HTTPS.
1037     *
1038     * @return bool
1039     */
1040    public function isSecure() : bool
1041    {
1042        if (isset($this->isSecure)) {
1043            return $this->isSecure;
1044        }
1045        $scheme = $_SERVER['REQUEST_SCHEME'] ?? null;
1046        $https = $_SERVER['HTTPS'] ?? null;
1047        return $this->isSecure = ($scheme === 'https' || $https === 'on');
1048    }
1049
1050    /**
1051     * Say if the request is done with application/x-www-form-urlencoded
1052     * Content-Type.
1053     *
1054     * @return bool
1055     */
1056    #[Pure]
1057    public function isForm() : bool
1058    {
1059        return $this->parseContentType() === 'application/x-www-form-urlencoded';
1060    }
1061
1062    /**
1063     * Say if the request is a JSON call.
1064     *
1065     * @return bool
1066     */
1067    #[Pure]
1068    public function isJson() : bool
1069    {
1070        return $this->parseContentType() === 'application/json';
1071    }
1072
1073    /**
1074     * Say if the request method is POST.
1075     *
1076     * @return bool
1077     */
1078    #[Pure]
1079    public function isPost() : bool
1080    {
1081        return $this->getMethod() === Method::POST;
1082    }
1083
1084    /**
1085     * @see https://www.sitepoint.com/community/t/-files-array-structure/2728/5
1086     *
1087     * @return array<string,array<mixed>|UploadedFile>
1088     */
1089    protected function getInputFiles() : array
1090    {
1091        if (empty($_FILES)) {
1092            return [];
1093        }
1094        $makeObjects = static function (
1095            array $array,
1096            callable $makeObjects
1097        ) : array | UploadedFile {
1098            $return = [];
1099            foreach ($array as $k => $v) {
1100                if (\is_array($v)) {
1101                    $return[$k] = $makeObjects($v, $makeObjects);
1102                    continue;
1103                }
1104                return new UploadedFile($array);
1105            }
1106            return $return;
1107        };
1108        return $makeObjects(ArraySimple::files(), $makeObjects); // @phpstan-ignore-line
1109    }
1110
1111    /**
1112     * @param string $host
1113     *
1114     * @throws InvalidArgumentException for invalid host
1115     *
1116     * @return static
1117     */
1118    protected function setHost(string $host) : static
1119    {
1120        $filteredHost = 'http://' . $host;
1121        $filteredHost = \filter_var($filteredHost, \FILTER_VALIDATE_URL);
1122        if ( ! $filteredHost) {
1123            throw new InvalidArgumentException("Invalid host: {$host}");
1124        }
1125        $host = \parse_url($filteredHost);
1126        $this->host = $host['host']; // @phpstan-ignore-line
1127        if (isset($host['port'])) {
1128            $this->port = $host['port'];
1129        }
1130        return $this;
1131    }
1132}