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

codeigniter4 / CodeIgniter4 / 24007717705

05 Apr 2026 06:27PM UTC coverage: 86.545% (-0.008%) from 86.553%
24007717705

push

github

web-flow
refactor: remove deprecations in HTTP (#10000)

* refactor: remove deprecations in HTTP

* Add test for useRawQueryString constructor parameter

* Fix changelog

* add test to CURLRequestTest

17 of 18 new or added lines in 5 files covered. (94.44%)

6 existing lines in 1 file now uncovered.

22686 of 26213 relevant lines covered (86.54%)

220.11 hits per line

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

96.46
/system/HTTP/URI.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use CodeIgniter\Exceptions\InvalidArgumentException;
17
use CodeIgniter\HTTP\Exceptions\HTTPException;
18
use Config\App;
19
use SensitiveParameter;
20
use Stringable;
21

22
/**
23
 * Abstraction for a uniform resource identifier (URI).
24
 *
25
 * @see \CodeIgniter\HTTP\URITest
26
 */
27
class URI implements Stringable
28
{
29
    /**
30
     * Sub-delimiters used in query strings and fragments.
31
     */
32
    public const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
33

34
    /**
35
     * Unreserved characters used in paths, query strings, and fragments.
36
     */
37
    public const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
38

39
    /**
40
     * List of URI segments.
41
     *
42
     * Starts at 1 instead of 0
43
     *
44
     * @var array<int, string>
45
     */
46
    protected $segments = [];
47

48
    /**
49
     * The URI Scheme.
50
     *
51
     * @var string
52
     */
53
    protected $scheme = 'http';
54

55
    /**
56
     * URI User Info
57
     *
58
     * @var string|null
59
     */
60
    protected $user;
61

62
    /**
63
     * URI User Password
64
     *
65
     * @var string|null
66
     */
67
    protected $password;
68

69
    /**
70
     * URI Host
71
     *
72
     * @var string|null
73
     */
74
    protected $host;
75

76
    /**
77
     * URI Port
78
     *
79
     * @var int|null
80
     */
81
    protected $port;
82

83
    /**
84
     * URI path.
85
     *
86
     * @var string|null
87
     */
88
    protected $path;
89

90
    /**
91
     * The name of any fragment.
92
     *
93
     * @var string
94
     */
95
    protected $fragment = '';
96

97
    /**
98
     * The query string.
99
     *
100
     * @var array<string, string>
101
     */
102
    protected $query = [];
103

104
    /**
105
     * Default schemes/ports.
106
     *
107
     * @var array{
108
     *  http: int,
109
     *  https: int,
110
     *  ftp: int,
111
     *  sftp: int,
112
     * }
113
     */
114
    protected $defaultPorts = [
115
        'http'  => 80,
116
        'https' => 443,
117
        'ftp'   => 21,
118
        'sftp'  => 22,
119
    ];
120

121
    /**
122
     * Whether passwords should be shown in userInfo/authority calls.
123
     * Default to false because URIs often show up in logs
124
     *
125
     * @var bool
126
     */
127
    protected $showPassword = false;
128

129
    /**
130
     * If true, will continue instead of throwing exceptions.
131
     *
132
     * @var bool
133
     */
134
    protected $silent = false;
135

136
    /**
137
     * If true, will use raw query string.
138
     *
139
     * @var bool
140
     */
141
    protected $rawQueryString = false;
142

143
    /**
144
     * Builds a representation of the string from the component parts.
145
     *
146
     * @param string|null $scheme URI scheme. E.g., http, ftp
147
     *
148
     * @return string URI string with only passed parts. Maybe incomplete as a URI.
149
     */
150
    public static function createURIString(
151
        ?string $scheme = null,
152
        ?string $authority = null,
153
        ?string $path = null,
154
        ?string $query = null,
155
        ?string $fragment = null,
156
    ): string {
157
        $uri = '';
1,869✔
158

159
        if ((string) $scheme !== '') {
1,869✔
160
            $uri .= $scheme . '://';
1,867✔
161
        }
162

163
        if ((string) $authority !== '') {
1,869✔
164
            $uri .= $authority;
1,818✔
165
        }
166

167
        if ((string) $path !== '') {
1,869✔
168
            $uri .= str_ends_with($uri, '/')
1,862✔
169
                ? ltrim($path, '/')
51✔
170
                : '/' . ltrim($path, '/');
1,811✔
171
        }
172

173
        if ((string) $query !== '') {
1,869✔
174
            $uri .= '?' . $query;
82✔
175
        }
176

177
        if ((string) $fragment !== '') {
1,869✔
178
            $uri .= '#' . $fragment;
8✔
179
        }
180

181
        return $uri;
1,869✔
182
    }
183

184
    /**
185
     * Used when resolving and merging paths to correctly interpret and
186
     * remove single and double dot segments from the path per
187
     * RFC 3986 Section 5.2.4
188
     *
189
     * @see http://tools.ietf.org/html/rfc3986#section-5.2.4
190
     *
191
     * @internal
192
     */
193
    public static function removeDotSegments(string $path): string
194
    {
195
        if ($path === '' || $path === '/') {
1,974✔
196
            return $path;
1,762✔
197
        }
198

199
        $output = [];
1,909✔
200

201
        $input = explode('/', $path);
1,909✔
202

203
        if ($input[0] === '') {
1,909✔
204
            unset($input[0]);
1,874✔
205
            $input = array_values($input);
1,874✔
206
        }
207

208
        // This is not a perfect representation of the
209
        // RFC, but matches most cases and is pretty
210
        // much what Guzzle uses. Should be good enough
211
        // for almost every real use case.
212
        foreach ($input as $segment) {
1,909✔
213
            if ($segment === '..') {
1,909✔
214
                array_pop($output);
16✔
215
            } elseif ($segment !== '.' && $segment !== '') {
1,907✔
216
                $output[] = $segment;
1,904✔
217
            }
218
        }
219

220
        $output = implode('/', $output);
1,909✔
221
        $output = trim($output, '/ ');
1,909✔
222

223
        // Add leading slash if necessary
224
        if (str_starts_with($path, '/')) {
1,909✔
225
            $output = '/' . $output;
1,874✔
226
        }
227

228
        // Add trailing slash if necessary
229
        if ($output !== '/' && str_ends_with($path, '/')) {
1,909✔
230
            $output .= '/';
1,169✔
231
        }
232

233
        return $output;
1,909✔
234
    }
235

236
    /**
237
     * Constructor.
238
     *
239
     * @param string|null $uri The URI to parse.
240
     *
241
     * @throws HTTPException
242
     *
243
     * @TODO null for param $uri should be removed.
244
     *      See https://www.php-fig.org/psr/psr-17/#26-urifactoryinterface
245
     */
246
    public function __construct(?string $uri = null, bool $useRawQueryString = false)
247
    {
248
        $this->useRawQueryString($useRawQueryString);
2,044✔
249

250
        if ($uri === null) {
2,044✔
251
            return;
320✔
252
        }
253

254
        $this->setUri($uri);
1,915✔
255
    }
256

257
    /**
258
     * If $silent == true, then will not throw exceptions and will
259
     * attempt to continue gracefully.
260
     *
261
     * @deprecated 4.4.0 Method not in PSR-7
262
     *
263
     * @return URI
264
     */
265
    public function setSilent(bool $silent = true)
266
    {
NEW
267
        @trigger_error(sprintf('The %s method is deprecated and will be removed in CodeIgniter 5.0.', __METHOD__), E_USER_DEPRECATED);
×
268

UNCOV
269
        $this->silent = $silent;
×
270

UNCOV
271
        return $this;
×
272
    }
273

274
    /**
275
     * If $raw == true, then will use parseStr() method
276
     * instead of native parse_str() function.
277
     *
278
     * Note: Method not in PSR-7
279
     *
280
     * @return URI
281
     */
282
    public function useRawQueryString(bool $raw = true)
283
    {
284
        $this->rawQueryString = $raw;
2,044✔
285

286
        return $this;
2,044✔
287
    }
288

289
    /**
290
     * Sets and overwrites any current URI information.
291
     *
292
     * @throws HTTPException
293
     */
294
    private function setUri(string $uri): self
295
    {
296
        $parts = parse_url($uri);
1,915✔
297

298
        if (is_array($parts)) {
1,915✔
299
            $this->applyParts($parts);
1,912✔
300

301
            return $this;
1,912✔
302
        }
303

304
        if ($this->silent) {
3✔
UNCOV
305
            return $this;
×
306
        }
307

308
        throw HTTPException::forUnableToParseURI($uri);
3✔
309
    }
310

311
    /**
312
     * Retrieve the scheme component of the URI.
313
     *
314
     * If no scheme is present, this method MUST return an empty string.
315
     *
316
     * The value returned MUST be normalized to lowercase, per RFC 3986
317
     * Section 3.1.
318
     *
319
     * The trailing ":" character is not part of the scheme and MUST NOT be
320
     * added.
321
     *
322
     * @see https://tools.ietf.org/html/rfc3986#section-3.1
323
     */
324
    public function getScheme(): string
325
    {
326
        return $this->scheme;
1,919✔
327
    }
328

329
    /**
330
     * Retrieve the authority component of the URI.
331
     *
332
     * If no authority information is present, this method MUST return an empty
333
     * string.
334
     *
335
     * The authority syntax of the URI is:
336
     *
337
     * <pre>
338
     * [user-info@]host[:port]
339
     * </pre>
340
     *
341
     * If the port component is not set or is the standard port for the current
342
     * scheme, it SHOULD NOT be included.
343
     *
344
     * @see https://tools.ietf.org/html/rfc3986#section-3.2
345
     *
346
     * @return string The URI authority, in "[user-info@]host[:port]" format.
347
     */
348
    public function getAuthority(bool $ignorePort = false): string
349
    {
350
        if ((string) $this->host === '') {
1,875✔
351
            return '';
103✔
352
        }
353

354
        $authority = $this->host;
1,825✔
355

356
        if ((string) $this->getUserInfo() !== '') {
1,825✔
357
            $authority = $this->getUserInfo() . '@' . $authority;
7✔
358
        }
359

360
        // Don't add port if it's a standard port for this scheme
361
        if ((int) $this->port !== 0 && ! $ignorePort && $this->port !== ($this->defaultPorts[$this->scheme] ?? null)) {
1,825✔
362
            $authority .= ':' . $this->port;
200✔
363
        }
364

365
        $this->showPassword = false;
1,825✔
366

367
        return $authority;
1,825✔
368
    }
369

370
    /**
371
     * Retrieve the user information component of the URI.
372
     *
373
     * If no user information is present, this method MUST return an empty
374
     * string.
375
     *
376
     * If a user is present in the URI, this will return that value;
377
     * additionally, if the password is also present, it will be appended to the
378
     * user value, with a colon (":") separating the values.
379
     *
380
     * NOTE that be default, the password, if available, will NOT be shown
381
     * as a security measure as discussed in RFC 3986, Section 7.5. If you know
382
     * the password is not a security issue, you can force it to be shown
383
     * with $this->showPassword();
384
     *
385
     * The trailing "@" character is not part of the user information and MUST
386
     * NOT be added.
387
     *
388
     * @return string|null The URI user information, in "username[:password]" format.
389
     */
390
    public function getUserInfo()
391
    {
392
        $userInfo = $this->user;
1,825✔
393

394
        if ($this->showPassword === true && (string) $this->password !== '') {
1,825✔
395
            $userInfo .= ':' . $this->password;
1✔
396
        }
397

398
        return $userInfo;
1,825✔
399
    }
400

401
    /**
402
     * Temporarily sets the URI to show a password in userInfo. Will
403
     * reset itself after the first call to authority().
404
     *
405
     * Note: Method not in PSR-7
406
     *
407
     * @return URI
408
     */
409
    public function showPassword(bool $val = true)
410
    {
411
        $this->showPassword = $val;
1✔
412

413
        return $this;
1✔
414
    }
415

416
    /**
417
     * Retrieve the host component of the URI.
418
     *
419
     * If no host is present, this method MUST return an empty string.
420
     *
421
     * The value returned MUST be normalized to lowercase, per RFC 3986
422
     * Section 3.2.2.
423
     *
424
     * @see    http://tools.ietf.org/html/rfc3986#section-3.2.2
425
     *
426
     * @return string The URI host.
427
     */
428
    public function getHost(): string
429
    {
430
        return $this->host ?? '';
1,936✔
431
    }
432

433
    /**
434
     * Retrieve the port component of the URI.
435
     *
436
     * If a port is present, and it is non-standard for the current scheme,
437
     * this method MUST return it as an integer. If the port is the standard port
438
     * used with the current scheme, this method SHOULD return null.
439
     *
440
     * If no port is present, and no scheme is present, this method MUST return
441
     * a null value.
442
     *
443
     * If no port is present, but a scheme is present, this method MAY return
444
     * the standard port for that scheme, but SHOULD return null.
445
     *
446
     * @return int|null The URI port.
447
     */
448
    public function getPort()
449
    {
450
        return $this->port;
58✔
451
    }
452

453
    /**
454
     * Retrieve the path component of the URI.
455
     *
456
     * The path can either be empty or absolute (starting with a slash) or
457
     * rootless (not starting with a slash). Implementations MUST support all
458
     * three syntaxes.
459
     *
460
     * Normally, the empty path "" and absolute path "/" are considered equal as
461
     * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically
462
     * do this normalization because in contexts with a trimmed base path, e.g.
463
     * the front controller, this difference becomes significant. It's the task
464
     * of the user to handle both "" and "/".
465
     *
466
     * The value returned MUST be percent-encoded, but MUST NOT double-encode
467
     * any characters. To determine what characters to encode, please refer to
468
     * RFC 3986, Sections 2 and 3.3.
469
     *
470
     * As an example, if the value should include a slash ("/") not intended as
471
     * delimiter between path segments, that value MUST be passed in encoded
472
     * form (e.g., "%2F") to the instance.
473
     *
474
     * @see    https://tools.ietf.org/html/rfc3986#section-2
475
     * @see    https://tools.ietf.org/html/rfc3986#section-3.3
476
     *
477
     * @return string The URI path.
478
     */
479
    public function getPath(): string
480
    {
481
        return $this->path ?? '';
1,883✔
482
    }
483

484
    /**
485
     * Retrieve the query string
486
     *
487
     * @param array{except?: list<string>|string, only?: list<string>|string} $options
488
     */
489
    public function getQuery(array $options = []): string
490
    {
491
        $vars = $this->query;
1,864✔
492

493
        if (array_key_exists('except', $options)) {
1,864✔
494
            if (! is_array($options['except'])) {
2✔
495
                $options['except'] = [$options['except']];
1✔
496
            }
497

498
            foreach ($options['except'] as $var) {
2✔
499
                unset($vars[$var]);
2✔
500
            }
501
        } elseif (array_key_exists('only', $options)) {
1,863✔
502
            $temp = [];
3✔
503

504
            if (! is_array($options['only'])) {
3✔
505
                $options['only'] = [$options['only']];
1✔
506
            }
507

508
            foreach ($options['only'] as $var) {
3✔
509
                if (array_key_exists($var, $vars)) {
3✔
510
                    $temp[$var] = $vars[$var];
3✔
511
                }
512
            }
513

514
            $vars = $temp;
3✔
515
        }
516

517
        return $vars === [] ? '' : http_build_query($vars);
1,864✔
518
    }
519

520
    /**
521
     * Retrieve a URI fragment
522
     */
523
    public function getFragment(): string
524
    {
525
        return $this->fragment ?? '';
1,860✔
526
    }
527

528
    /**
529
     * Returns the segments of the path as an array.
530
     *
531
     * @return array<int, string>
532
     */
533
    public function getSegments(): array
534
    {
535
        return $this->segments;
35✔
536
    }
537

538
    /**
539
     * Returns the value of a specific segment of the URI path.
540
     * Allows to get only existing segments or the next one.
541
     *
542
     * @param int    $number  Segment number starting at 1
543
     * @param string $default Default value
544
     *
545
     * @return string The value of the segment. If you specify the last +1
546
     *                segment, the $default value. If you specify the last +2
547
     *                or more throws HTTPException.
548
     */
549
    public function getSegment(int $number, string $default = ''): string
550
    {
551
        if ($number < 1) {
14✔
552
            throw HTTPException::forURISegmentOutOfRange($number);
1✔
553
        }
554

555
        if ($number > count($this->segments) + 1 && ! $this->silent) {
13✔
556
            throw HTTPException::forURISegmentOutOfRange($number);
4✔
557
        }
558

559
        // The segment should treat the array as 1-based for the user
560
        // but we still have to deal with a zero-based array.
561
        $number--;
9✔
562

563
        return $this->segments[$number] ?? $default;
9✔
564
    }
565

566
    /**
567
     * Set the value of a specific segment of the URI path.
568
     * Allows to set only existing segments or add new one.
569
     *
570
     * Note: Method not in PSR-7
571
     *
572
     * @param int        $number Segment number starting at 1
573
     * @param int|string $value
574
     *
575
     * @return $this
576
     */
577
    public function setSegment(int $number, $value)
578
    {
579
        if ($number < 1) {
19✔
580
            throw HTTPException::forURISegmentOutOfRange($number);
1✔
581
        }
582

583
        if ($number > count($this->segments) + 1) {
18✔
584
            if ($this->silent) {
3✔
UNCOV
585
                return $this;
×
586
            }
587

588
            throw HTTPException::forURISegmentOutOfRange($number);
3✔
589
        }
590

591
        // The segment should treat the array as 1-based for the user
592
        // but we still have to deal with a zero-based array.
593
        $number--;
16✔
594

595
        $this->segments[$number] = $value;
16✔
596

597
        return $this->refreshPath();
16✔
598
    }
599

600
    /**
601
     * Returns the total number of segments.
602
     *
603
     * Note: Method not in PSR-7
604
     */
605
    public function getTotalSegments(): int
606
    {
607
        return count($this->segments);
33✔
608
    }
609

610
    /**
611
     * Formats the URI as a string.
612
     *
613
     * Warning: For backwards-compatibility this method
614
     * assumes URIs with the same host as baseURL should
615
     * be relative to the project's configuration.
616
     * This aspect of __toString() is deprecated and should be avoided.
617
     */
618
    public function __toString(): string
619
    {
620
        $path   = $this->getPath();
1,750✔
621
        $scheme = $this->getScheme();
1,750✔
622

623
        // If the hosts matches then assume this should be relative to baseURL
624
        [$scheme, $path] = $this->changeSchemeAndPath($scheme, $path);
1,750✔
625

626
        return static::createURIString(
1,750✔
627
            $scheme,
1,750✔
628
            $this->getAuthority(),
1,750✔
629
            $path, // Absolute URIs should use a "/" for an empty path
1,750✔
630
            $this->getQuery(),
1,750✔
631
            $this->getFragment(),
1,750✔
632
        );
1,750✔
633
    }
634

635
    /**
636
     * Change the path (and scheme) assuming URIs with the same host as baseURL
637
     * should be relative to the project's configuration.
638
     *
639
     * @return array{string, string}
640
     *
641
     * @deprecated 4.2.0 This method will be deleted.
642
     */
643
    private function changeSchemeAndPath(string $scheme, string $path): array
644
    {
645
        // Check if this is an internal URI
646
        $config  = config(App::class);
1,750✔
647
        $baseUri = new self($config->baseURL);
1,750✔
648

649
        if (
650
            str_starts_with($this->getScheme(), 'http')
1,750✔
651
            && $this->getHost() === $baseUri->getHost()
1,750✔
652
        ) {
653
            // Check for additional segments
654
            $basePath = trim($baseUri->getPath(), '/') . '/';
1,703✔
655
            $trimPath = ltrim($path, '/');
1,703✔
656

657
            if ($basePath !== '/' && ! str_starts_with($trimPath, $basePath)) {
1,703✔
658
                $path = $basePath . $trimPath;
×
659
            }
660

661
            // Check for forced HTTPS
662
            if ($config->forceGlobalSecureRequests) {
1,703✔
663
                $scheme = 'https';
5✔
664
            }
665
        }
666

667
        return [$scheme, $path];
1,750✔
668
    }
669

670
    /**
671
     * Parses the given string and saves the appropriate authority pieces.
672
     *
673
     * Note: Method not in PSR-7
674
     *
675
     * @return $this
676
     */
677
    public function setAuthority(string $str)
678
    {
679
        $parts = parse_url($str);
104✔
680

681
        if (! isset($parts['path'])) {
104✔
682
            $parts['path'] = $this->getPath();
1✔
683
        }
684

685
        if (! isset($parts['host']) && $parts['path'] !== '') {
104✔
686
            $parts['host'] = $parts['path'];
55✔
687
            unset($parts['path']);
55✔
688
        }
689

690
        $this->applyParts($parts);
104✔
691

692
        return $this;
104✔
693
    }
694

695
    /**
696
     * Return an instance with the specified scheme.
697
     *
698
     * This method MUST retain the state of the current instance, and return
699
     * an instance that contains the specified scheme.
700
     *
701
     * Implementations MUST support the schemes "http" and "https" case
702
     * insensitively, and MAY accommodate other schemes if required.
703
     *
704
     * An empty scheme is equivalent to removing the scheme.
705
     *
706
     * @param string $scheme The scheme to use with the new instance.
707
     *
708
     * @return static A new instance with the specified scheme.
709
     *
710
     * @throws InvalidArgumentException for invalid or unsupported schemes.
711
     */
712
    public function withScheme(string $scheme)
713
    {
714
        $uri = clone $this;
1,913✔
715

716
        $scheme = strtolower($scheme);
1,913✔
717

718
        $uri->scheme = preg_replace('#:(//)?$#', '', $scheme);
1,913✔
719

720
        return $uri;
1,913✔
721
    }
722

723
    /**
724
     * Sets the userInfo/Authority portion of the URI.
725
     *
726
     * @param string $user The user's username
727
     * @param string $pass The user's password
728
     *
729
     * @return $this
730
     *
731
     * @TODO PSR-7: Should be `withUserInfo($user, $password = null)`.
732
     */
733
    public function setUserInfo(string $user, #[SensitiveParameter] string $pass)
734
    {
735
        $this->user     = trim($user);
2✔
736
        $this->password = trim($pass);
2✔
737

738
        return $this;
2✔
739
    }
740

741
    /**
742
     * Sets the host name to use.
743
     *
744
     * @return $this
745
     *
746
     * @TODO PSR-7: Should be `withHost($host)`.
747
     */
748
    public function setHost(string $str)
749
    {
750
        $this->host = trim($str);
142✔
751

752
        return $this;
142✔
753
    }
754

755
    /**
756
     * Sets the port portion of the URI.
757
     *
758
     * @return $this
759
     *
760
     * @TODO PSR-7: Should be `withPort($port)`.
761
     */
762
    public function setPort(?int $port = null)
763
    {
764
        if ($port === null) {
4✔
765
            return $this;
×
766
        }
767

768
        if ($port > 0 && $port <= 65535) {
4✔
769
            $this->port = $port;
1✔
770

771
            return $this;
1✔
772
        }
773

774
        if ($this->silent) {
3✔
UNCOV
775
            return $this;
×
776
        }
777

778
        throw HTTPException::forInvalidPort($port);
3✔
779
    }
780

781
    /**
782
     * Sets the path portion of the URI.
783
     *
784
     * @return $this
785
     *
786
     * @TODO PSR-7: Should be `withPath($port)`.
787
     */
788
    public function setPath(string $path)
789
    {
790
        $this->path = $this->filterPath($path);
123✔
791

792
        $tempPath = trim($this->path, '/');
123✔
793

794
        $this->segments = ($tempPath === '') ? [] : explode('/', $tempPath);
123✔
795

796
        return $this;
123✔
797
    }
798

799
    /**
800
     * Sets the path portion of the URI based on segments.
801
     *
802
     * @return $this
803
     */
804
    protected function refreshPath(): self
805
    {
806
        $this->path = $this->filterPath(implode('/', $this->segments));
11✔
807

808
        $tempPath = trim($this->path, '/');
11✔
809

810
        $this->segments = $tempPath === '' ? [] : explode('/', $tempPath);
11✔
811

812
        return $this;
11✔
813
    }
814

815
    /**
816
     * Sets the query portion of the URI, while attempting
817
     * to clean the various parts of the query keys and values.
818
     *
819
     * @return $this
820
     *
821
     * @TODO PSR-7: Should be `withQuery($query)`.
822
     */
823
    public function setQuery(string $query)
824
    {
825
        if (str_contains($query, '#')) {
286✔
826
            if ($this->silent) {
1✔
UNCOV
827
                return $this;
×
828
            }
829

830
            throw HTTPException::forMalformedQueryString();
1✔
831
        }
832

833
        // Can't have leading ?
834
        if ($query !== '' && str_starts_with($query, '?')) {
285✔
835
            $query = substr($query, 1);
2✔
836
        }
837

838
        if ($this->rawQueryString) {
285✔
839
            $this->query = $this->parseStr($query);
93✔
840
        } else {
841
            parse_str($query, $this->query);
194✔
842
        }
843

844
        return $this;
285✔
845
    }
846

847
    /**
848
     * A convenience method to pass an array of items in as the Query
849
     * portion of the URI.
850
     *
851
     * @return URI
852
     *
853
     * @TODO: PSR-7: Should be `withQueryParams(array $query)`
854
     */
855
    public function setQueryArray(array $query)
856
    {
857
        $query = http_build_query($query);
16✔
858

859
        return $this->setQuery($query);
16✔
860
    }
861

862
    /**
863
     * Adds a single new element to the query vars.
864
     *
865
     * Note: Method not in PSR-7
866
     *
867
     * @param int|string|null $value
868
     *
869
     * @return $this
870
     */
871
    public function addQuery(string $key, $value = null)
872
    {
873
        $this->query[$key] = $value;
40✔
874

875
        return $this;
40✔
876
    }
877

878
    /**
879
     * Removes one or more query vars from the URI.
880
     *
881
     * Note: Method not in PSR-7
882
     *
883
     * @param string ...$params
884
     *
885
     * @return $this
886
     */
887
    public function stripQuery(...$params)
888
    {
889
        foreach ($params as $param) {
1✔
890
            unset($this->query[$param]);
1✔
891
        }
892

893
        return $this;
1✔
894
    }
895

896
    /**
897
     * Filters the query variables so that only the keys passed in
898
     * are kept. The rest are removed from the object.
899
     *
900
     * Note: Method not in PSR-7
901
     *
902
     * @param string ...$params
903
     *
904
     * @return $this
905
     */
906
    public function keepQuery(...$params)
907
    {
908
        $temp = [];
1✔
909

910
        foreach ($this->query as $key => $value) {
1✔
911
            if (! in_array($key, $params, true)) {
1✔
912
                continue;
1✔
913
            }
914

915
            $temp[$key] = $value;
1✔
916
        }
917

918
        $this->query = $temp;
1✔
919

920
        return $this;
1✔
921
    }
922

923
    /**
924
     * Sets the fragment portion of the URI.
925
     *
926
     * @see https://tools.ietf.org/html/rfc3986#section-3.5
927
     *
928
     * @return $this
929
     *
930
     * @TODO PSR-7: Should be `withFragment($fragment)`.
931
     */
932
    public function setFragment(string $string)
933
    {
934
        $this->fragment = trim($string, '# ');
208✔
935

936
        return $this;
208✔
937
    }
938

939
    /**
940
     * Encodes any dangerous characters, and removes dot segments.
941
     * While dot segments have valid uses according to the spec,
942
     * this URI class does not allow them.
943
     */
944
    protected function filterPath(?string $path = null): string
945
    {
946
        $orig = $path;
1,921✔
947

948
        // Decode/normalize percent-encoded chars so
949
        // we can always have matching for Routes, etc.
950
        $path = urldecode($path);
1,921✔
951

952
        // Remove dot segments
953
        $path = self::removeDotSegments($path);
1,921✔
954

955
        // Fix up some leading slash edge cases...
956
        if (str_starts_with($orig, './')) {
1,921✔
957
            $path = '/' . $path;
1✔
958
        }
959
        if (str_starts_with($orig, '../')) {
1,921✔
960
            $path = '/' . $path;
1✔
961
        }
962

963
        // Encode characters
964
        $path = preg_replace_callback(
1,921✔
965
            '/(?:[^' . static::CHAR_UNRESERVED . ':@&=\+\$,\/;%]+|%(?![A-Fa-f0-9]{2}))/',
1,921✔
966
            static fn (array $matches): string => rawurlencode($matches[0]),
1,921✔
967
            $path,
1,921✔
968
        );
1,921✔
969

970
        return $path;
1,921✔
971
    }
972

973
    /**
974
     * Saves our parts from a parse_url call.
975
     *
976
     * @param array{
977
     *  host?: string,
978
     *  user?: string,
979
     *  path?: string,
980
     *  query?: string,
981
     *  fragment?: string,
982
     *  scheme?: string,
983
     *  port?: int,
984
     *  pass?: string,
985
     * } $parts
986
     *
987
     * @return void
988
     */
989
    protected function applyParts(array $parts)
990
    {
991
        if (isset($parts['host']) && $parts['host'] !== '') {
1,913✔
992
            $this->host = $parts['host'];
1,865✔
993
        }
994

995
        if (isset($parts['user']) && $parts['user'] !== '') {
1,913✔
996
            $this->user = $parts['user'];
5✔
997
        }
998

999
        if (isset($parts['path']) && $parts['path'] !== '') {
1,913✔
1000
            $this->path = $this->filterPath($parts['path']);
1,911✔
1001
        }
1002

1003
        if (isset($parts['query']) && $parts['query'] !== '') {
1,913✔
1004
            $this->setQuery($parts['query']);
13✔
1005
        }
1006

1007
        if (isset($parts['fragment']) && $parts['fragment'] !== '') {
1,913✔
1008
            $this->fragment = $parts['fragment'];
4✔
1009
        }
1010

1011
        $this->scheme = $this->withScheme($parts['scheme'] ?? 'http')->getScheme();
1,913✔
1012

1013
        if (isset($parts['port'])) {
1,913✔
1014
            // Valid port numbers are enforced by earlier parse_url or setPort()
1015
            $this->port = $parts['port'];
203✔
1016
        }
1017

1018
        if (isset($parts['pass'])) {
1,913✔
1019
            $this->password = $parts['pass'];
2✔
1020
        }
1021

1022
        if (isset($parts['path']) && $parts['path'] !== '') {
1,913✔
1023
            $tempPath = trim($parts['path'], '/');
1,911✔
1024

1025
            $this->segments = $tempPath === '' ? [] : explode('/', $tempPath);
1,911✔
1026
        }
1027
    }
1028

1029
    /**
1030
     * Combines one URI string with this one based on the rules set out in
1031
     * RFC 3986 Section 2
1032
     *
1033
     * @see http://tools.ietf.org/html/rfc3986#section-5.2
1034
     *
1035
     * @return URI
1036
     */
1037
    public function resolveRelativeURI(string $uri)
1038
    {
1039
        /*
1040
         * NOTE: We don't use removeDotSegments in this
1041
         * algorithm since it's already done by this line!
1042
         */
1043
        $relative = new self($uri, $this->rawQueryString);
103✔
1044

1045
        if ($relative->getScheme() === $this->getScheme()) {
103✔
1046
            $relative = $relative->withScheme('');
93✔
1047
        }
1048

1049
        $transformed = clone $relative;
103✔
1050

1051
        // 5.2.2 Transform References in a non-strict method (no scheme)
1052
        if ($relative->getAuthority() !== '') {
103✔
1053
            $transformed
2✔
1054
                ->setAuthority($relative->getAuthority())
2✔
1055
                ->setPath($relative->getPath())
2✔
1056
                ->setQuery($relative->getQuery());
2✔
1057
        } else {
1058
            if ($relative->getPath() === '') {
101✔
1059
                $transformed->setPath($this->getPath());
6✔
1060

1061
                if ($relative->getQuery() !== '') {
6✔
1062
                    $transformed->setQuery($relative->getQuery());
2✔
1063
                } else {
1064
                    $transformed->setQuery($this->getQuery());
4✔
1065
                }
1066
            } else {
1067
                if (str_starts_with($relative->getPath(), '/')) {
95✔
1068
                    $transformed->setPath($relative->getPath());
52✔
1069
                } else {
1070
                    $transformed->setPath($this->mergePaths($this, $relative));
43✔
1071
                }
1072

1073
                $transformed->setQuery($relative->getQuery());
95✔
1074
            }
1075

1076
            $transformed->setAuthority($this->getAuthority());
101✔
1077
        }
1078

1079
        $transformed = $transformed->withScheme($this->getScheme());
103✔
1080
        $transformed->setFragment($relative->getFragment());
103✔
1081

1082
        return $transformed;
103✔
1083
    }
1084

1085
    /**
1086
     * Given 2 paths, will merge them according to rules set out in RFC 2986,
1087
     * Section 5.2
1088
     *
1089
     * @see http://tools.ietf.org/html/rfc3986#section-5.2.3
1090
     */
1091
    protected function mergePaths(self $base, self $reference): string
1092
    {
1093
        if ($base->getAuthority() !== '' && $base->getPath() === '') {
43✔
1094
            return '/' . ltrim($reference->getPath(), '/ ');
1✔
1095
        }
1096

1097
        $path = explode('/', $base->getPath());
42✔
1098

1099
        if ($path[0] === '') {
42✔
1100
            unset($path[0]);
42✔
1101
        }
1102

1103
        array_pop($path);
42✔
1104
        $path[] = $reference->getPath();
42✔
1105

1106
        return implode('/', $path);
42✔
1107
    }
1108

1109
    /**
1110
     * This is equivalent to the native PHP parse_str() function.
1111
     * This version allows the dot to be used as a key of the query string.
1112
     *
1113
     * @return array<string, string>
1114
     */
1115
    protected function parseStr(string $query): array
1116
    {
1117
        $return = [];
93✔
1118
        $query  = explode('&', $query);
93✔
1119

1120
        $params = array_map(static fn (string $chunk): ?string => preg_replace_callback(
93✔
1121
            '/^(?<key>[^&=]+?)(?:\[[^&=]*\])?=(?<value>[^&=]+)/',
93✔
1122
            static fn (array $match): string => str_replace($match['key'], bin2hex($match['key']), $match[0]),
93✔
1123
            urldecode($chunk),
93✔
1124
        ), $query);
93✔
1125

1126
        $params = implode('&', $params);
93✔
1127
        parse_str($params, $result);
93✔
1128

1129
        foreach ($result as $key => $value) {
93✔
1130
            // Array key might be int
1131
            $return[hex2bin((string) $key)] = $value;
5✔
1132
        }
1133

1134
        return $return;
93✔
1135
    }
1136
}
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