• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aplus-framework / http / 23966445012

03 Apr 2026 11:46PM UTC coverage: 98.989% (+0.02%) from 98.973%
23966445012

push

github

natanfelles
Update Request::getIp method

10 of 10 new or added lines in 1 file covered. (100.0%)

4 existing lines in 1 file now uncovered.

1664 of 1681 relevant lines covered (98.99%)

14.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.92
/src/Request.php
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
 */
10
namespace Framework\HTTP;
11

12
use BadMethodCallException;
13
use Framework\Helpers\ArraySimple;
14
use InvalidArgumentException;
15
use JetBrains\PhpStorm\ArrayShape;
16
use JetBrains\PhpStorm\Pure;
17
use LogicException;
18
use Override;
19
use RequestParseBodyException;
20
use RuntimeException;
21
use stdClass;
22
use UnexpectedValueException;
23

24
/**
25
 * Class Request.
26
 *
27
 * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#HTTP_Requests
28
 *
29
 * @package http
30
 */
31
class Request extends Message implements RequestInterface
32
{
33
    /**
34
     * @var array<string,UploadedFile|array<mixed>>|null
35
     */
36
    protected ?array $files = null;
37
    /**
38
     * @var array<int,array<mixed>>|null
39
     */
40
    protected ?array $parsedBody = null;
41
    /**
42
     * @var array<string,mixed>|null
43
     */
44
    protected ?array $parseBodyOptions = null;
45
    /**
46
     * HTTP Authorization Header parsed.
47
     *
48
     * @var array<string,string|null>|null
49
     */
50
    protected ?array $auth = null;
51
    /**
52
     * @var string|null Basic or Digest
53
     */
54
    protected ?string $authType = null;
55
    protected string $host;
56
    protected int $port;
57
    /**
58
     * @var array<string,array<mixed>|null>
59
     */
60
    protected array $negotiation = [
61
        'ACCEPT' => null,
62
        'CHARSET' => null,
63
        'ENCODING' => null,
64
        'LANGUAGE' => null,
65
    ];
66
    protected URL | false | null $referrer = null;
67
    protected UserAgent | false | null $userAgent = null;
68
    protected bool $isAjax;
69
    /**
70
     * Tell if is an HTTPS connection.
71
     *
72
     * @var bool
73
     */
74
    protected bool $isSecure;
75
    protected int $jsonFlags = 0;
76
    protected string $ipKey = 'REMOTE_ADDR';
77

78
    /**
79
     * Request constructor.
80
     *
81
     * @param array<string> $allowedHosts set allowed hosts if your
82
     * server don't serve by Host header, as Nginx do
83
     *
84
     * @throws UnexpectedValueException if invalid Host
85
     */
86
    public function __construct(array $allowedHosts = [])
87
    {
88
        if ($allowedHosts) {
167✔
89
            $this->validateHost($allowedHosts);
134✔
90
        }
91
        $this->prepareStatusLine();
167✔
92
    }
93

94
    /**
95
     * @param string $method
96
     * @param array<int,mixed> $arguments
97
     *
98
     * @throws BadMethodCallException for method not allowed or method not found
99
     *
100
     * @return static
101
     */
102
    public function __call(string $method, array $arguments)
103
    {
104
        if ($method === 'setBody') {
6✔
105
            return $this->setBody(...$arguments);
4✔
106
        }
107
        if (\method_exists($this, $method)) {
2✔
108
            throw new BadMethodCallException("Method not allowed: {$method}");
1✔
109
        }
110
        throw new BadMethodCallException("Method not found: {$method}");
1✔
111
    }
112

113
    #[Override]
114
    public function __toString() : string
115
    {
116
        if ($this->parseContentType() === 'multipart/form-data') {
2✔
117
            $this->setBody($this->getMultipartBody());
1✔
118
        }
119
        return parent::__toString();
2✔
120
    }
121

122
    /**
123
     * NOTE: Created by comparing a request made with Firefox.
124
     *
125
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types#multipartform-data
126
     *
127
     * @return string
128
     */
129
    protected function getMultipartBody() : string
130
    {
131
        $bodyParts = [];
1✔
132
        /**
133
         * @var array<string,string> $parsedBody
134
         */
135
        $parsedBody = ArraySimple::convert($this->getParsedBody());
1✔
136
        foreach ($parsedBody as $field => $value) {
1✔
137
            $field = \htmlspecialchars($field, \ENT_QUOTES | \ENT_HTML5);
1✔
138
            $bodyParts[] = \implode("\r\n", [
1✔
139
                "Content-Disposition: form-data; name=\"{$field}\"",
1✔
140
                '',
1✔
141
                $value,
1✔
142
            ]);
1✔
143
        }
144
        /**
145
         * @var array<string,UploadedFile> $files
146
         */
147
        $files = ArraySimple::convert($this->getFiles());
1✔
148
        foreach ($files as $field => $file) {
1✔
149
            $field = \htmlspecialchars($field, \ENT_QUOTES | \ENT_HTML5);
1✔
150
            $field = \preg_replace('/\[\d+]/', '[]', $field);
1✔
151
            $filename = \htmlspecialchars($file->getFullPath(), \ENT_QUOTES | \ENT_HTML5);
1✔
152
            $contentType = $file->getClientType() ?: 'application/octet-stream';
1✔
153
            $getContentsOf = $file->isMoved() ? $file->getDestination() : $file->getTmpName();
1✔
154
            $data = '';
1✔
155
            if ($getContentsOf !== '') {
1✔
156
                $data = \file_get_contents($getContentsOf);
1✔
157
            }
158
            $bodyParts[] = \implode("\r\n", [
1✔
159
                "Content-Disposition: form-data; name=\"{$field}\"; filename=\"{$filename}\"",
1✔
160
                'Content-Type: ' . $contentType,
1✔
161
                '',
1✔
162
                $data,
1✔
163
            ]);
1✔
164
        }
165
        $boundary = '';
1✔
166
        $boundaryParts = \explode(';', $this->getContentType());
1✔
167
        foreach ($boundaryParts as $boundaryPart) {
1✔
168
            $boundaryPart = \trim($boundaryPart);
1✔
169
            if (\str_starts_with(\strtolower($boundaryPart), 'boundary=')) {
1✔
170
                $boundary = \substr($boundaryPart, \strlen('boundary='));
1✔
171
                break;
1✔
172
            }
173
        }
174
        foreach ($bodyParts as &$part) {
1✔
175
            $part = "--{$boundary}\r\n{$part}";
1✔
176
        }
177
        unset($part);
1✔
178
        $bodyParts[] = "--{$boundary}--";
1✔
179
        $bodyParts[] = '';
1✔
180
        $bodyParts = \implode("\r\n", $bodyParts);
1✔
181
        /**
182
         * Uncomment the code below to make a raw test.
183
         *
184
         * @see \Tests\HTTP\RequestTest::testToStringMultipart()
185
         */
186
        /*
187
        $serverLength = (string) $_SERVER['CONTENT_LENGTH'];
188
        $algoLength = (string) \strlen($bodyParts);
189
        \var_dump($serverLength, $algoLength);
190
        if ($serverLength !== $algoLength) {
191
            throw new \Exception(
192
                '$_SERVER CONTENT_LENGTH is ' . $serverLength
193
                . ', but the algorithm calculated ' . $algoLength
194
            );
195
        }
196
        */
197
        return $bodyParts;
1✔
198
    }
199

200
    /**
201
     * Check if Host header is allowed.
202
     *
203
     * @see https://expressionengine.com/blog/http-host-and-server-name-security-issues
204
     * @see http://nginx.org/en/docs/http/request_processing.html
205
     *
206
     * @param array<string> $allowedHosts
207
     */
208
    protected function validateHost(array $allowedHosts) : void
209
    {
210
        $host = $_SERVER['HTTP_HOST'] ?? null;
134✔
211
        if (!\in_array($host, $allowedHosts, true)) {
134✔
212
            throw new UnexpectedValueException('Invalid Host: ' . $host);
1✔
213
        }
214
    }
215

216
    protected function prepareStatusLine() : void
217
    {
218
        $this->setProtocol($_SERVER['SERVER_PROTOCOL']);
167✔
219
        $this->setMethod($_SERVER['REQUEST_METHOD']);
167✔
220
        $url = $this->isSecure() ? 'https' : 'http';
167✔
221
        $url .= '://' . $_SERVER['HTTP_HOST'];
167✔
222
        $url .= $_SERVER['REQUEST_URI'];
167✔
223
        $this->setUrl($url);
167✔
224
        $this->setHost($this->getUrl()->getHost());
167✔
225
    }
226

227
    public function getHeader(string $name) : ?string
228
    {
229
        $this->prepareHeaders();
40✔
230
        return $this->headers[\strtolower($name)] ?? null;
40✔
231
    }
232

233
    /**
234
     * @return array<string,string>
235
     */
236
    public function getHeaders() : array
237
    {
238
        $this->prepareHeaders();
15✔
239
        return $this->headers;
15✔
240
    }
241

242
    protected function prepareHeaders() : void
243
    {
244
        if (!empty($this->headers)) {
42✔
245
            return;
35✔
246
        }
247
        foreach ($_SERVER as $name => $value) {
28✔
248
            if (\str_starts_with($name, 'HTTP_')) {
28✔
249
                $name = \strtr(\substr($name, 5), ['_' => '-']);
28✔
250
                $this->setHeader($name, $value);
28✔
251
            }
252
        }
253
    }
254

255
    public function getCookie(string $name) : ?Cookie
256
    {
257
        $this->prepareCookies();
1✔
258
        return $this->cookies[$name] ?? null;
1✔
259
    }
260

261
    /**
262
     * Get all Cookies.
263
     *
264
     * @return array<string,Cookie>
265
     */
266
    public function getCookies() : array
267
    {
268
        $this->prepareCookies();
1✔
269
        return $this->cookies;
1✔
270
    }
271

272
    protected function prepareCookies() : void
273
    {
274
        if (!empty($this->cookies)) {
2✔
275
            return;
1✔
276
        }
277
        foreach ($_COOKIE as $name => $value) {
2✔
278
            $this->setCookie(new Cookie($name, $value));
2✔
279
        }
280
    }
281

282
    /**
283
     * @see https://www.php.net/manual/en/wrappers.php.php#wrappers.php.input
284
     *
285
     * @return string
286
     */
287
    public function getBody() : string
288
    {
289
        if (!isset($this->body)) {
18✔
290
            $this->body = (string) \file_get_contents('php://input');
14✔
291
        }
292
        return $this->body;
18✔
293
    }
294

295
    protected function prepareFiles() : void
296
    {
297
        if (isset($this->files)) {
16✔
298
            return;
13✔
299
        }
300
        $this->files = $this->getInputFiles();
16✔
301
    }
302

303
    /**
304
     * @param int $type
305
     * @param string|null $name
306
     * @param int|null $filter
307
     * @param array<int,int>|int $options
308
     *
309
     * @see https://www.php.net/manual/en/function.filter-var
310
     * @see https://www.php.net/manual/en/filter.filters.php
311
     *
312
     * @return mixed
313
     */
314
    protected function filterInput(
315
        int $type,
316
        ?string $name = null,
317
        ?int $filter = null,
318
        array | int $options = 0
319
    ) : mixed {
320
        $input = match ($type) {
25✔
321
            \INPUT_POST => $_POST,
10✔
322
            \INPUT_GET => $_GET,
1✔
323
            \INPUT_COOKIE => $_COOKIE,
1✔
324
            \INPUT_ENV => $_ENV,
1✔
325
            \INPUT_SERVER => $_SERVER,
13✔
326
            default => throw new InvalidArgumentException('Invalid input type: ' . $type)
1✔
327
        };
25✔
328
        if ($name !== null) {
24✔
329
            $input = \in_array($type, [\INPUT_POST, \INPUT_GET], true)
21✔
330
                ? ArraySimple::value($name, $input)
7✔
331
                : $input[$name] ?? null;
14✔
332
        }
333
        if ($filter !== null) {
24✔
334
            $input = \filter_var($input, $filter, $options);
2✔
335
        }
336
        return $input;
24✔
337
    }
338

339
    /**
340
     * Force an HTTPS connection on same URL.
341
     */
342
    public function forceHttps() : void
343
    {
344
        if (!$this->isSecure()) {
2✔
345
            \header(
1✔
346
                'Location: ' . $this->getUrl()->setScheme('https'),
1✔
347
                true,
1✔
348
                Status::MOVED_PERMANENTLY
1✔
349
            );
1✔
350
            if (!\defined('TESTING')) {
1✔
351
                // @codeCoverageIgnoreStart
352
                exit;
353
                // @codeCoverageIgnoreEnd
354
            }
355
        }
356
    }
357

358
    /**
359
     * Get the Authorization type.
360
     *
361
     * @return string|null Basic, Bearer, Digest or null for none
362
     */
363
    public function getAuthType() : ?string
364
    {
365
        if ($this->authType === null) {
4✔
366
            $auth = $_SERVER['HTTP_AUTHORIZATION'] ?? null;
4✔
367
            if ($auth) {
4✔
368
                $this->parseAuth($auth);
3✔
369
            }
370
        }
371
        return $this->authType;
4✔
372
    }
373

374
    /**
375
     * Get Basic authorization.
376
     *
377
     * @return array<string>|null Two keys: username and password
378
     */
379
    #[ArrayShape(['username' => 'string|null', 'password' => 'string|null'])]
380
    public function getBasicAuth() : ?array
381
    {
382
        return $this->getAuthType() === 'Basic'
2✔
383
            ? $this->auth
1✔
384
            : null;
2✔
385
    }
386

387
    /**
388
     * Get Bearer authorization.
389
     *
390
     * @return array<string>|null One key: token
391
     */
392
    #[ArrayShape(['token' => 'string|null'])]
393
    public function getBearerAuth() : ?array
394
    {
395
        return $this->getAuthType() === 'Bearer'
2✔
396
            ? $this->auth
1✔
397
            : null;
2✔
398
    }
399

400
    /**
401
     * Get Digest authorization.
402
     *
403
     * @return array<string>|null Nine keys: username, realm, nonce, uri,
404
     * response, opaque, qop, nc, cnonce
405
     */
406
    #[ArrayShape([
407
        'username' => 'string|null',
408
        'realm' => 'string|null',
409
        'nonce' => 'string|null',
410
        'uri' => 'string|null',
411
        'response' => 'string|null',
412
        'opaque' => 'string|null',
413
        'qop' => 'string|null',
414
        'nc' => 'string|null',
415
        'cnonce' => 'string|null',
416
    ])]
417
    public function getDigestAuth() : ?array
418
    {
419
        return $this->getAuthType() === 'Digest'
2✔
420
            ? $this->auth
1✔
421
            : null;
2✔
422
    }
423

424
    /**
425
     * @param string $authorization
426
     *
427
     * @return array<string,string|null>
428
     */
429
    protected function parseAuth(string $authorization) : array
430
    {
431
        $this->auth = [];
3✔
432
        [$type, $attributes] = \array_pad(\explode(' ', $authorization, 2), 2, null);
3✔
433
        if ($type === 'Basic') {
3✔
434
            $this->authType = $type;
1✔
435
            $this->auth = $this->parseBasicAuth($attributes);
1✔
436
        } elseif ($type === 'Bearer') {
2✔
437
            $this->authType = $type;
1✔
438
            $this->auth = $this->parseBearerAuth($attributes);
1✔
439
        } elseif ($type === 'Digest') {
1✔
440
            $this->authType = $type;
1✔
441
            $this->auth = $this->parseDigestAuth($attributes);
1✔
442
        }
443
        return $this->auth;
3✔
444
    }
445

446
    /**
447
     * @param string $attributes
448
     *
449
     * @return array<string,string|null>
450
     */
451
    #[ArrayShape(['username' => 'string|null', 'password' => 'string|null'])]
452
    #[Pure]
453
    protected function parseBasicAuth(string $attributes) : array
454
    {
455
        $data = [
1✔
456
            'username' => null,
1✔
457
            'password' => null,
1✔
458
        ];
1✔
459
        $attributes = \base64_decode($attributes);
1✔
460
        if ($attributes) {
1✔
461
            [
1✔
462
                $data['username'],
1✔
463
                $data['password'],
1✔
464
            ] = \array_pad(\explode(':', $attributes, 2), 2, null);
1✔
465
        }
466
        return $data;
1✔
467
    }
468

469
    /**
470
     * @param string $attributes
471
     *
472
     * @return array<string,string|null>
473
     */
474
    #[ArrayShape(['token' => 'string|null'])]
475
    #[Pure]
476
    protected function parseBearerAuth(string $attributes) : array
477
    {
478
        $data = [
1✔
479
            'token' => null,
1✔
480
        ];
1✔
481
        if ($attributes) {
1✔
482
            $data['token'] = $attributes;
1✔
483
        }
484
        return $data;
1✔
485
    }
486

487
    /**
488
     * @param string $attributes
489
     *
490
     * @return array<string,string|null>
491
     */
492
    #[ArrayShape([
493
        'username' => 'string|null',
494
        'realm' => 'string|null',
495
        'nonce' => 'string|null',
496
        'uri' => 'string|null',
497
        'response' => 'string|null',
498
        'opaque' => 'string|null',
499
        'qop' => 'string|null',
500
        'nc' => 'string|null',
501
        'cnonce' => 'string|null',
502
    ])]
503
    protected function parseDigestAuth(string $attributes) : array
504
    {
505
        $data = [
1✔
506
            'username' => null,
1✔
507
            'realm' => null,
1✔
508
            'nonce' => null,
1✔
509
            'uri' => null,
1✔
510
            'response' => null,
1✔
511
            'opaque' => null,
1✔
512
            'qop' => null,
1✔
513
            'nc' => null,
1✔
514
            'cnonce' => null,
1✔
515
        ];
1✔
516
        \preg_match_all(
1✔
517
            '#(username|realm|nonce|uri|response|opaque|qop|nc|cnonce)=(?:([\'"])([^\2]+?)\2|([^\s,]+))#',
1✔
518
            $attributes,
1✔
519
            $matches,
1✔
520
            \PREG_SET_ORDER
1✔
521
        );
1✔
522
        foreach ($matches as $match) {
1✔
523
            if (isset($match[3])) {
1✔
524
                $data[$match[1]] = $match[3] ?: $match[4] ?? '';
1✔
525
            }
526
        }
527
        return $data;
1✔
528
    }
529

530
    /**
531
     * Get the Parsed Body or part of it.
532
     *
533
     * @param string|null $name
534
     * @param int|null $filter
535
     * @param array<int,int>|int $filterOptions
536
     *
537
     * @throws RequestParseBodyException
538
     *
539
     * @return array<mixed>|mixed|string|null
540
     */
541
    public function getParsedBody(
542
        ?string $name = null,
543
        ?int $filter = null,
544
        array | int $filterOptions = 0
545
    ) : mixed {
546
        if ($this->isMethod(Method::POST)) {
9✔
547
            return $this->getPost($name, $filter, $filterOptions);
9✔
548
        }
549
        return $this->getFilteredParsedBody($name, $filter, $filterOptions);
1✔
550
    }
551

552
    /**
553
     * @param string|null $name
554
     * @param int|null $filter
555
     * @param array<int,int>|int $filterOptions
556
     *
557
     * @throws RequestParseBodyException
558
     *
559
     * @return array<mixed>|mixed|string|null
560
     */
561
    protected function getFilteredParsedBody(
562
        ?string $name = null,
563
        ?int $filter = null,
564
        array | int $filterOptions = 0
565
    ) : mixed {
566
        if (!$this->isParsedBody()) {
3✔
567
            $this->parseBody($this->getParseBodyOptions());
3✔
568
        }
569
        $variable = $name === null
3✔
570
            ? $this->parsedBody[0]
3✔
571
            : ArraySimple::value($name, $this->parsedBody[0]);
3✔
572
        return $filter === null
3✔
573
            ? $variable
3✔
574
            : \filter_var($variable, $filter, $filterOptions);
3✔
575
    }
576

577
    /**
578
     * @param array<string,mixed>|null $options
579
     *
580
     * @throws RequestParseBodyException
581
     * @throws LogicException
582
     *
583
     * @return static
584
     */
585
    public function parseBody(?array $options = null) : static
586
    {
587
        if ($this->isParsedBody()) {
5✔
588
            throw new LogicException('Parse error: the request body has already been parsed');
1✔
589
        }
590
        if (!$this->isForm()) {
5✔
591
            $this->parsedBody = [[], []];
3✔
592
            return $this;
3✔
593
        }
594
        $options ??= $this->getParseBodyOptions();
3✔
595
        $this->parsedBody = $this->requestParseBody($options);
3✔
596
        return $this;
3✔
597
    }
598

599
    /**
600
     * @param array<string,mixed>|null $options
601
     *
602
     * @throws RequestParseBodyException
603
     *
604
     * @return array<int,array<mixed>>
605
     */
606
    protected function requestParseBody(?array $options = null) : array
607
    {
608
        return \request_parse_body($options);
1✔
609
    }
610

611
    public function isParsedBody() : bool
612
    {
613
        return isset($this->parsedBody);
21✔
614
    }
615

616
    /**
617
     * @return array<string,mixed>|null
618
     */
619
    public function getParseBodyOptions() : ?array
620
    {
621
        return $this->parseBodyOptions;
4✔
622
    }
623

624
    /**
625
     * @param array<string,mixed>|null $options
626
     *
627
     * @see https://www.php.net/manual/en/function.request-parse-body.php#refsect1-function.request-parse-body-parameters
628
     */
629
    public function setParseBodyOptions(?array $options) : static
630
    {
631
        $this->parseBodyOptions = $options;
1✔
632
        return $this;
1✔
633
    }
634

635
    /**
636
     * Get the request body as JSON.
637
     *
638
     * @param bool|null $associative When true, JSON objects will be returned as
639
     * associative arrays; when false, JSON objects will be returned as objects.
640
     * When null, JSON objects will be returned as associative arrays or objects
641
     * depending on whether JSON_OBJECT_AS_ARRAY is set in the flags.
642
     * @param int|null $flags [optional] <p>
643
     *  Bitmask consisting of <b>JSON_BIGINT_AS_STRING</b>,
644
     *  <b>JSON_INVALID_UTF8_IGNORE</b>,
645
     *  <b>JSON_INVALID_UTF8_SUBSTITUTE</b>,
646
     *  <b>JSON_OBJECT_AS_ARRAY</b>,
647
     *  <b>JSON_THROW_ON_ERROR</b>.
648
     *  </p>
649
     *  <p>Default is none when null.</p>
650
     * @param int<1,max> $depth user specified recursion depth
651
     *
652
     * @see https://www.php.net/manual/en/function.json-decode.php
653
     * @see https://www.php.net/manual/en/json.constants.php
654
     *
655
     * @return array<string,mixed>|false|stdClass If option JSON_THROW_ON_ERROR
656
     * is not set, return false if json_decode fail. Otherwise, return a
657
     * stdClass instance, or an array if the $associative argument is passed as
658
     * true.
659
     */
660
    public function getJson(
661
        ?bool $associative = null,
662
        ?int $flags = null,
663
        int $depth = 512
664
    ) : array | false | stdClass {
665
        if ($flags === null) {
2✔
666
            $flags = $this->getJsonFlags();
2✔
667
        }
668
        $body = \json_decode($this->getBody(), $associative, $depth, $flags);
2✔
669
        if (\json_last_error() !== \JSON_ERROR_NONE) {
2✔
670
            return false;
1✔
671
        }
672
        return $body;
1✔
673
    }
674

675
    /**
676
     * @param string $type
677
     *
678
     * @return array<int,string>
679
     */
680
    protected function getNegotiableValues(string $type) : array
681
    {
682
        if ($this->negotiation[$type]) {
5✔
683
            return $this->negotiation[$type];
4✔
684
        }
685
        $header = $_SERVER['HTTP_ACCEPT' . ($type !== 'ACCEPT' ? '_' . $type : '')] ?? null;
5✔
686
        $this->negotiation[$type] = \array_keys(static::parseQualityValues(
5✔
687
            $header
5✔
688
        ));
5✔
689
        $this->negotiation[$type] = \array_map('\strtolower', $this->negotiation[$type]);
5✔
690
        return $this->negotiation[$type];
5✔
691
    }
692

693
    /**
694
     * @param string $type
695
     * @param array<int,string> $negotiable
696
     *
697
     * @return string
698
     */
699
    protected function negotiate(string $type, array $negotiable) : string
700
    {
701
        $negotiable = \array_map('\strtolower', $negotiable);
4✔
702
        foreach ($this->getNegotiableValues($type) as $item) {
4✔
703
            if (\in_array($item, $negotiable, true)) {
4✔
704
                return $item;
4✔
705
            }
706
        }
707
        return $negotiable[0];
4✔
708
    }
709

710
    /**
711
     * Get the mime types of the Accept header.
712
     *
713
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept
714
     *
715
     * @return array<int,string>
716
     */
717
    public function getAccepts() : array
718
    {
719
        return $this->getNegotiableValues('ACCEPT');
2✔
720
    }
721

722
    /**
723
     * Negotiate the Accept header.
724
     *
725
     * @param array<int,string> $negotiable Allowed mime types
726
     *
727
     * @return string The negotiated mime type
728
     */
729
    public function negotiateAccept(array $negotiable) : string
730
    {
731
        return $this->negotiate('ACCEPT', $negotiable);
1✔
732
    }
733

734
    /**
735
     * Get the Accept-Charset's.
736
     *
737
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Charset
738
     *
739
     * @return array<int,string>
740
     */
741
    public function getCharsets() : array
742
    {
743
        return $this->getNegotiableValues('CHARSET');
1✔
744
    }
745

746
    /**
747
     * Negotiate the Accept-Charset.
748
     *
749
     * @param array<int,string> $negotiable Allowed charsets
750
     *
751
     * @return string The negotiated charset
752
     */
753
    public function negotiateCharset(array $negotiable) : string
754
    {
755
        return $this->negotiate('CHARSET', $negotiable);
1✔
756
    }
757

758
    /**
759
     * Get the Accept-Encoding.
760
     *
761
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Encoding
762
     *
763
     * @return array<int,string>
764
     */
765
    public function getEncodings() : array
766
    {
767
        return $this->getNegotiableValues('ENCODING');
1✔
768
    }
769

770
    /**
771
     * Negotiate the Accept-Encoding.
772
     *
773
     * @param array<int,string> $negotiable The allowed encodings
774
     *
775
     * @return string The negotiated encoding
776
     */
777
    public function negotiateEncoding(array $negotiable) : string
778
    {
779
        return $this->negotiate('ENCODING', $negotiable);
1✔
780
    }
781

782
    /**
783
     * Get the Accept-Language's.
784
     *
785
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
786
     *
787
     * @return array<int,string>
788
     */
789
    public function getLanguages() : array
790
    {
791
        return $this->getNegotiableValues('LANGUAGE');
1✔
792
    }
793

794
    /**
795
     * Negotiated the Accept-Language.
796
     *
797
     * @param array<int,string> $negotiable Allowed languages
798
     *
799
     * @return string The negotiated language
800
     */
801
    public function negotiateLanguage(array $negotiable) : string
802
    {
803
        return $this->negotiate('LANGUAGE', $negotiable);
1✔
804
    }
805

806
    /**
807
     * Get the Content-Type header value.
808
     *
809
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
810
     *
811
     * @return string|null
812
     */
813
    #[Pure]
814
    public function getContentType() : ?string
815
    {
816
        return $_SERVER['HTTP_CONTENT_TYPE'] ?? null;
2✔
817
    }
818

819
    /**
820
     * @param string|null $name
821
     * @param int|null $filter
822
     * @param array<int,int>|int $filterOptions
823
     *
824
     * @return mixed
825
     */
826
    public function getEnv(
827
        ?string $name = null,
828
        ?int $filter = null,
829
        array | int $filterOptions = 0
830
    ) : mixed {
831
        return $this->filterInput(\INPUT_ENV, $name, $filter, $filterOptions);
1✔
832
    }
833

834
    /**
835
     * @return array<string,UploadedFile|array<mixed>>
836
     */
837
    public function getFiles() : array
838
    {
839
        $this->prepareFiles();
4✔
840
        return $this->files;
4✔
841
    }
842

843
    public function hasFiles() : bool
844
    {
845
        $this->prepareFiles();
13✔
846
        return !empty($this->files);
13✔
847
    }
848

849
    public function getFile(string $name) : ?UploadedFile
850
    {
851
        $this->prepareFiles();
1✔
852
        $file = ArraySimple::value($name, $this->files);
1✔
853
        return \is_array($file) ? null : $file;
1✔
854
    }
855

856
    /**
857
     * Get the URL GET queries.
858
     *
859
     * @param string|null $name
860
     * @param int|null $filter
861
     * @param array<int,int>|int $filterOptions
862
     *
863
     * @return mixed
864
     */
865
    public function getGet(
866
        ?string $name = null,
867
        ?int $filter = null,
868
        array | int $filterOptions = 0
869
    ) : mixed {
870
        return $this->filterInput(\INPUT_GET, $name, $filter, $filterOptions);
1✔
871
    }
872

873
    /**
874
     * @return string
875
     */
876
    #[Pure]
877
    public function getHost() : string
878
    {
879
        return $this->host;
2✔
880
    }
881

882
    /**
883
     * Get the X-Request-ID header.
884
     *
885
     * @return string|null
886
     */
887
    public function getId() : ?string
888
    {
889
        return $_SERVER['HTTP_X_REQUEST_ID'] ?? null;
2✔
890
    }
891

892
    /**
893
     * Get the connection IP.
894
     *
895
     * @param bool $validate True to validate the IP address, otherwise false
896
     *
897
     * @throws RuntimeException for invalid IP address
898
     *
899
     * @return string
900
     */
901
    public function getIp(bool $validate = true) : string
902
    {
903
        $key = $this->getIpKey();
16✔
904
        $ip = match($key) {
16✔
905
            'HTTP_FORWARDED' => $this->getIpFromHttpForwarded(),
2✔
906
            'HTTP_X_FORWARDED_FOR' => $this->getIpFromHttpXForwardedFor(),
1✔
907
            default => $_SERVER[$key]
13✔
908
        };
16✔
909
        if ($validate && !\filter_var($ip, \FILTER_VALIDATE_IP)) {
16✔
910
            throw new RuntimeException(
1✔
911
                "The value of {$key} is not a valid IP address"
1✔
912
            );
1✔
913
        }
914
        return $ip;
16✔
915
    }
916

917
    /**
918
     * Get IP from the X-Forwarded-For header.
919
     *
920
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For
921
     *
922
     * @return string The IP address
923
     */
924
    protected function getIpFromHttpXForwardedFor() : string
925
    {
926
        $ip = \explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'], 2)[0];
1✔
927
        $ip = \trim($ip);
1✔
928
        return $ip;
1✔
929
    }
930

931
    /**
932
     * Get IP from the Forwarded header.
933
     *
934
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Forwarded
935
     *
936
     * @return string The IP address
937
     */
938
    protected function getIpFromHttpForwarded() : string
939
    {
940
        $directives = \strtolower($_SERVER['HTTP_FORWARDED']);
2✔
941
        $directives = \explode(',', $directives, 2)[0];
2✔
942
        $directives = \explode(';', $directives);
2✔
943
        foreach ($directives as $directive) {
2✔
944
            $directive = \trim($directive);
2✔
945
            if (!\str_starts_with($directive, 'for')) {
2✔
946
                continue;
1✔
947
            }
948
            $directive = \explode('=', $directive, 2)[1];
2✔
949
            $directive = \trim($directive);
2✔
950
            if (\str_starts_with($directive, '"')) {
2✔
951
                $directive = \substr($directive, 1, -1);
2✔
952
            }
953
            if (\str_starts_with($directive, '[')) {
2✔
954
                $pos = \strrpos($directive, ']');
1✔
955
                $directive = \substr($directive, 1, $pos - 1);
1✔
956
            }
957
            return $directive;
2✔
958
        }
959
        throw new RuntimeException(
1✔
960
            'The IP address could not be get from the Forwarded header'
1✔
961
        );
1✔
962
    }
963

964
    /**
965
     * Set the key used to get the IP address from the superglobal $_SERVER.
966
     *
967
     * @param string $ipKey
968
     *
969
     * @throws InvalidArgumentException for IP key not set in the $_SERVER var
970
     *
971
     * @return static
972
     */
973
    public function setIpKey(string $ipKey) : static
974
    {
975
        if (!isset($_SERVER[$ipKey])) {
4✔
976
            throw new InvalidArgumentException(
1✔
977
                'The IP Key "' . $ipKey . '" is not set in the $_SERVER'
1✔
978
            );
1✔
979
        }
980
        $this->ipKey = $ipKey;
3✔
981
        return $this;
3✔
982
    }
983

984
    /**
985
     * Get the key used to get the IP address from the superglobal $_SERVER.
986
     *
987
     * @return string
988
     */
989
    public function getIpKey() : string
990
    {
991
        return $this->ipKey;
16✔
992
    }
993

994
    #[Override]
995
    #[Pure]
996
    public function getMethod() : string
997
    {
998
        return parent::getMethod();
31✔
999
    }
1000

1001
    /**
1002
     * @param string $method
1003
     *
1004
     * @throws InvalidArgumentException for invalid method
1005
     *
1006
     * @return bool
1007
     */
1008
    #[Override]
1009
    public function isMethod(string $method) : bool
1010
    {
1011
        return parent::isMethod($method);
23✔
1012
    }
1013

1014
    /**
1015
     * Gets data from the last request, if it was redirected.
1016
     *
1017
     * @param string|null $key a key name or null to get all data
1018
     *
1019
     * @see Response::redirect()
1020
     *
1021
     * @throws LogicException if PHP Session is not active to get redirect data
1022
     *
1023
     * @return mixed an array containing all data, the key value or null
1024
     * if the key was not found
1025
     */
1026
    public function getRedirectData(?string $key = null) : mixed
1027
    {
1028
        static $data;
4✔
1029
        if ($data === null && \session_status() !== \PHP_SESSION_ACTIVE) {
4✔
1030
            throw new LogicException('Session must be active to get redirect data');
1✔
1031
        }
1032
        if ($data === null) {
3✔
1033
            $data = $_SESSION['$']['redirect_data'] ?? false;
3✔
1034
            unset($_SESSION['$']['redirect_data']);
3✔
1035
        }
1036
        if ($key !== null && $data) {
3✔
1037
            return ArraySimple::value($key, $data);
1✔
1038
        }
1039
        return $data === false ? null : $data;
3✔
1040
    }
1041

1042
    /**
1043
     * Get the URL port.
1044
     *
1045
     * @return int
1046
     */
1047
    public function getPort() : int
1048
    {
1049
        return $this->port ?? $_SERVER['SERVER_PORT'];
2✔
1050
    }
1051

1052
    /**
1053
     * Make an empty value according to the name type.
1054
     *
1055
     * @param string|null $name
1056
     *
1057
     * @return array<mixed>|null
1058
     */
1059
    protected function makeEmptyValue(?string $name) : ?array
1060
    {
1061
        return $name === null ? [] : null;
2✔
1062
    }
1063

1064
    /**
1065
     * Get POST data.
1066
     *
1067
     * @param string|null $name
1068
     * @param int|null $filter
1069
     * @param array<int,int>|int $filterOptions
1070
     *
1071
     * @throws RequestParseBodyException
1072
     *
1073
     * @return mixed
1074
     */
1075
    public function getPost(
1076
        ?string $name = null,
1077
        ?int $filter = null,
1078
        array | int $filterOptions = 0
1079
    ) : mixed {
1080
        if ($this->isEnabledPostDataReading()) {
10✔
1081
            return $this->filterInput(\INPUT_POST, $name, $filter, $filterOptions);
10✔
1082
        }
UNCOV
1083
        if ($this->isMethod(Method::POST)) {
×
UNCOV
1084
            return $this->getFilteredParsedBody($name, $filter, $filterOptions);
×
1085
        }
UNCOV
1086
        return $this->makeEmptyValue($name);
×
1087
    }
1088

1089
    /**
1090
     * Get PATCH data.
1091
     *
1092
     * @param string|null $name
1093
     * @param int|null $filter
1094
     * @param array<int,int>|int $filterOptions
1095
     *
1096
     * @throws RequestParseBodyException
1097
     *
1098
     * @return mixed
1099
     */
1100
    public function getPatch(
1101
        ?string $name = null,
1102
        ?int $filter = null,
1103
        array | int $filterOptions = 0
1104
    ) : mixed {
1105
        if ($this->isMethod(Method::PATCH)) {
1✔
1106
            return $this->getFilteredParsedBody($name, $filter, $filterOptions);
1✔
1107
        }
1108
        return $this->makeEmptyValue($name);
1✔
1109
    }
1110

1111
    /**
1112
     * Get PUT data.
1113
     *
1114
     * @param string|null $name
1115
     * @param int|null $filter
1116
     * @param array<int,int>|int $filterOptions
1117
     *
1118
     * @throws RequestParseBodyException
1119
     *
1120
     * @return mixed
1121
     */
1122
    public function getPut(
1123
        ?string $name = null,
1124
        ?int $filter = null,
1125
        array | int $filterOptions = 0
1126
    ) : mixed {
1127
        if ($this->isMethod(Method::PUT)) {
1✔
1128
            return $this->getFilteredParsedBody($name, $filter, $filterOptions);
1✔
1129
        }
1130
        return $this->makeEmptyValue($name);
1✔
1131
    }
1132

1133
    /**
1134
     * Get the Referer header.
1135
     *
1136
     * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referer
1137
     *
1138
     * @return URL|null
1139
     */
1140
    public function getReferer() : ?URL
1141
    {
1142
        if ($this->referrer instanceof URL) {
2✔
1143
            return $this->referrer;
1✔
1144
        }
1145
        if ($this->referrer === false) {
2✔
1146
            return null;
2✔
1147
        }
1148
        $referer = $_SERVER['HTTP_REFERER'] ?? null;
2✔
1149
        if ($referer === null) {
2✔
1150
            $this->referrer = false;
1✔
1151
            return null;
1✔
1152
        }
1153
        try {
1154
            $this->referrer = new URL($referer);
2✔
1155
        } catch (InvalidArgumentException) {
2✔
1156
            $this->referrer = false;
2✔
1157
        }
1158
        return $this->getReferer();
2✔
1159
    }
1160

1161
    /**
1162
     * Get $_SERVER variables.
1163
     *
1164
     * @param string|null $name
1165
     * @param int|null $filter
1166
     * @param array<int,int>|int $filterOptions
1167
     *
1168
     * @return mixed
1169
     */
1170
    public function getServer(
1171
        ?string $name = null,
1172
        ?int $filter = null,
1173
        array | int $filterOptions = 0
1174
    ) : mixed {
1175
        return $this->filterInput(\INPUT_SERVER, $name, $filter, $filterOptions);
12✔
1176
    }
1177

1178
    /**
1179
     * Gets the requested URL.
1180
     *
1181
     * @return URL
1182
     */
1183
    #[Override]
1184
    #[Pure]
1185
    public function getUrl() : URL
1186
    {
1187
        return parent::getUrl();
167✔
1188
    }
1189

1190
    /**
1191
     * Gets the User Agent client.
1192
     *
1193
     * @return UserAgent|null the UserAgent object or null if no user-agent
1194
     * header was received
1195
     */
1196
    public function getUserAgent() : ?UserAgent
1197
    {
1198
        if ($this->userAgent instanceof UserAgent) {
13✔
1199
            return $this->userAgent;
4✔
1200
        }
1201
        if ($this->userAgent === false) {
13✔
1202
            return null;
10✔
1203
        }
1204
        $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
13✔
1205
        isset($userAgent)
13✔
1206
            ? $this->setUserAgent($userAgent)
4✔
1207
            : $this->userAgent = false;
10✔
1208
        return $this->getUserAgent();
13✔
1209
    }
1210

1211
    /**
1212
     * @param UserAgent|string $userAgent
1213
     *
1214
     * @return static
1215
     */
1216
    protected function setUserAgent(UserAgent | string $userAgent) : static
1217
    {
1218
        if (!$userAgent instanceof UserAgent) {
4✔
1219
            $userAgent = new UserAgent($userAgent);
4✔
1220
        }
1221
        $this->userAgent = $userAgent;
4✔
1222
        return $this;
4✔
1223
    }
1224

1225
    /**
1226
     * Check if is an AJAX Request based in the X-Requested-With Header.
1227
     *
1228
     * The X-Requested-With Header containing the "XMLHttpRequest" value is
1229
     * used by various JavaScript libraries.
1230
     *
1231
     * @return bool
1232
     */
1233
    public function isAjax() : bool
1234
    {
1235
        if (isset($this->isAjax)) {
2✔
1236
            return $this->isAjax;
2✔
1237
        }
1238
        $received = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? null;
2✔
1239
        return $this->isAjax = ($received
2✔
1240
            && \strtolower($received) === 'xmlhttprequest');
2✔
1241
    }
1242

1243
    /**
1244
     * Say if a connection has HTTPS.
1245
     *
1246
     * @return bool
1247
     */
1248
    public function isSecure() : bool
1249
    {
1250
        if (isset($this->isSecure)) {
167✔
1251
            return $this->isSecure;
16✔
1252
        }
1253
        $scheme = $_SERVER['REQUEST_SCHEME'] ?? '';
167✔
1254
        $https = $_SERVER['HTTPS'] ?? '';
167✔
1255
        return $this->isSecure = (
167✔
1256
            \strtolower($scheme) === 'https' || \strtolower($https) === 'on'
167✔
1257
        );
167✔
1258
    }
1259

1260
    /**
1261
     * Tells if the Content-Type header is application/x-www-form-urlencoded.
1262
     *
1263
     * @return bool
1264
     */
1265
    public function isFormUrlEncoded() : bool
1266
    {
1267
        return $this->parseContentType() === 'application/x-www-form-urlencoded';
17✔
1268
    }
1269

1270
    /**
1271
     * Tells if the Content-Type header is multipart/form-data.
1272
     *
1273
     * @return bool
1274
     */
1275
    public function isFormData() : bool
1276
    {
1277
        return $this->parseContentType() === 'multipart/form-data';
5✔
1278
    }
1279

1280
    /**
1281
     * Tells if the Content-Type header is application/x-www-form-urlencoded or
1282
     * multipart/form-data.
1283
     *
1284
     * @return bool
1285
     */
1286
    public function isForm() : bool
1287
    {
1288
        return $this->isFormUrlEncoded() || $this->isFormData();
6✔
1289
    }
1290

1291
    /**
1292
     * Say if the request is a JSON call.
1293
     *
1294
     * @return bool
1295
     */
1296
    public function isJson() : bool
1297
    {
1298
        return $this->parseContentType() === 'application/json';
1✔
1299
    }
1300

1301
    /**
1302
     * Say if the request method is POST.
1303
     *
1304
     * @return bool
1305
     */
1306
    public function isPost() : bool
1307
    {
1308
        return $this->isMethod(Method::POST);
13✔
1309
    }
1310

1311
    /**
1312
     * @see https://php.watch/codex/enable_post_data_reading
1313
     *
1314
     * @return bool
1315
     */
1316
    protected function isEnabledPostDataReading() : bool
1317
    {
1318
        return \ini_get('enable_post_data_reading') === '1';
23✔
1319
    }
1320

1321
    /**
1322
     * @see https://www.sitepoint.com/community/t/-files-array-structure/2728/5
1323
     *
1324
     * @return array<string,UploadedFile|array<mixed>>
1325
     */
1326
    protected function getInputFiles() : array
1327
    {
1328
        if (!$this->isParsedBody() && !$this->isEnabledPostDataReading()) {
16✔
UNCOV
1329
            $this->parseBody($this->getParseBodyOptions());
×
1330
        }
1331
        $files = $this->parsedBody[1] ?? $_FILES;
16✔
1332
        if (empty($files)) {
16✔
1333
            return [];
13✔
1334
        }
1335
        $makeObjects = static function (
3✔
1336
            array $array,
3✔
1337
            callable $makeObjects
3✔
1338
        ) : UploadedFile | array {
3✔
1339
            $return = [];
3✔
1340
            foreach ($array as $k => $v) {
3✔
1341
                if (\is_array($v)) {
3✔
1342
                    $return[$k] = $makeObjects($v, $makeObjects);
3✔
1343
                    continue;
3✔
1344
                }
1345
                return new UploadedFile($array);
3✔
1346
            }
1347
            return $return;
3✔
1348
        };
3✔
1349
        return $makeObjects(ArraySimple::files($files), $makeObjects); // @phpstan-ignore-line
3✔
1350
    }
1351

1352
    /**
1353
     * @param string $host
1354
     *
1355
     * @throws InvalidArgumentException for invalid host
1356
     *
1357
     * @return static
1358
     */
1359
    protected function setHost(string $host) : static
1360
    {
1361
        $filteredHost = 'http://' . $host;
167✔
1362
        $filteredHost = \filter_var($filteredHost, \FILTER_VALIDATE_URL);
167✔
1363
        if (!$filteredHost) {
167✔
1364
            throw new InvalidArgumentException("Invalid host: {$host}");
1✔
1365
        }
1366
        $host = \parse_url($filteredHost);
167✔
1367
        $this->host = $host['host']; // @phpstan-ignore-line
167✔
1368
        if (isset($host['port'])) {
167✔
1369
            $this->port = $host['port'];
9✔
1370
        }
1371
        return $this;
167✔
1372
    }
1373

1374
    /**
1375
     * Make a Response with the current Request.
1376
     *
1377
     * @return Response
1378
     */
1379
    public function makeResponse() : Response
1380
    {
1381
        return new Response($this);
1✔
1382
    }
1383
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc