Uri::getPath()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
eloc 1
c 1
b 1
f 0
dl 0
loc 3
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace GuzzleHttp\Psr7;
6
7
use GuzzleHttp\Psr7\Exception\MalformedUriException;
8
use Psr\Http\Message\UriInterface;
9
10
/**
11
 * PSR-7 URI implementation.
12
 *
13
 * @author Michael Dowling
14
 * @author Tobias Schultze
15
 * @author Matthew Weier O'Phinney
16
 */
17
class Uri implements UriInterface, \JsonSerializable
18
{
19
    /**
20
     * Absolute http and https URIs require a host per RFC 7230 Section 2.7
21
     * but in generic URIs the host can be empty. So for http(s) URIs
22
     * we apply this default host when no host is given yet to form a
23
     * valid URI.
24
     */
25
    private const HTTP_DEFAULT_HOST = 'localhost';
26
27
    private const DEFAULT_PORTS = [
28
        'http'  => 80,
29
        'https' => 443,
30
        'ftp' => 21,
31
        'gopher' => 70,
32
        'nntp' => 119,
33
        'news' => 119,
34
        'telnet' => 23,
35
        'tn3270' => 23,
36
        'imap' => 143,
37
        'pop' => 110,
38
        'ldap' => 389,
39
    ];
40
41
    /**
42
     * Unreserved characters for use in a regex.
43
     *
44
     * @link https://tools.ietf.org/html/rfc3986#section-2.3
45
     */
46
    private const CHAR_UNRESERVED = 'a-zA-Z0-9_\-\.~';
47
48
    /**
49
     * Sub-delims for use in a regex.
50
     *
51
     * @link https://tools.ietf.org/html/rfc3986#section-2.2
52
     */
53
    private const CHAR_SUB_DELIMS = '!\$&\'\(\)\*\+,;=';
54
    private const QUERY_SEPARATORS_REPLACEMENT = ['=' => '%3D', '&' => '%26'];
55
56
    /** @var string Uri scheme. */
57
    private $scheme = '';
58
59
    /** @var string Uri user info. */
60
    private $userInfo = '';
61
62
    /** @var string Uri host. */
63
    private $host = '';
64
65
    /** @var int|null Uri port. */
66
    private $port;
67
68
    /** @var string Uri path. */
69
    private $path = '';
70
71
    /** @var string Uri query string. */
72
    private $query = '';
73
74
    /** @var string Uri fragment. */
75
    private $fragment = '';
76
77
    /** @var string|null String representation */
78
    private $composedComponents;
79
80
    public function __construct(string $uri = '')
81
    {
82
        if ($uri !== '') {
83
            $parts = self::parse($uri);
84
            if ($parts === false) {
85
                throw new MalformedUriException("Unable to parse URI: $uri");
86
            }
87
            $this->applyParts($parts);
88
        }
89
    }
90
    /**
91
     * UTF-8 aware \parse_url() replacement.
92
     *
93
     * The internal function produces broken output for non ASCII domain names
94
     * (IDN) when used with locales other than "C".
95
     *
96
     * On the other hand, cURL understands IDN correctly only when UTF-8 locale
97
     * is configured ("C.UTF-8", "en_US.UTF-8", etc.).
98
     *
99
     * @see https://bugs.php.net/bug.php?id=52923
100
     * @see https://www.php.net/manual/en/function.parse-url.php#114817
101
     * @see https://curl.haxx.se/libcurl/c/CURLOPT_URL.html#ENCODING
102
     *
103
     * @return array|false
104
     */
105
    private static function parse(string $url)
106
    {
107
        // If IPv6
108
        $prefix = '';
109
        if (preg_match('%^(.*://\[[0-9:a-f]+\])(.*?)$%', $url, $matches)) {
110
            /** @var array{0:string, 1:string, 2:string} $matches */
111
            $prefix = $matches[1];
112
            $url = $matches[2];
113
        }
114
115
        /** @var string */
116
        $encodedUrl = preg_replace_callback(
117
            '%[^:/@?&=#]+%usD',
118
            static function ($matches) {
119
                return urlencode($matches[0]);
120
            },
121
            $url
122
        );
123
124
        $result = parse_url($prefix . $encodedUrl);
125
126
        if ($result === false) {
127
            return false;
128
        }
129
130
        return array_map('urldecode', $result);
131
    }
132
133
    public function __toString(): string
134
    {
135
        if ($this->composedComponents === null) {
136
            $this->composedComponents = self::composeComponents(
137
                $this->scheme,
138
                $this->getAuthority(),
139
                $this->path,
140
                $this->query,
141
                $this->fragment
142
            );
143
        }
144
145
        return $this->composedComponents;
146
    }
147
148
    /**
149
     * Composes a URI reference string from its various components.
150
     *
151
     * Usually this method does not need to be called manually but instead is used indirectly via
152
     * `Psr\Http\Message\UriInterface::__toString`.
153
     *
154
     * PSR-7 UriInterface treats an empty component the same as a missing component as
155
     * getQuery(), getFragment() etc. always return a string. This explains the slight
156
     * difference to RFC 3986 Section 5.3.
157
     *
158
     * Another adjustment is that the authority separator is added even when the authority is missing/empty
159
     * for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with
160
     * `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But
161
     * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to
162
     * that format).
163
     *
164
     * @link https://tools.ietf.org/html/rfc3986#section-5.3
165
     */
166
    public static function composeComponents(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string
167
    {
168
        $uri = '';
169
170
        // weak type checks to also accept null until we can add scalar type hints
171
        if ($scheme != '') {
172
            $uri .= $scheme . ':';
173
        }
174
175
        if ($authority != ''|| $scheme === 'file') {
176
            $uri .= '//' . $authority;
177
        }
178
179
        $uri .= $path;
180
181
        if ($query != '') {
182
            $uri .= '?' . $query;
183
        }
184
185
        if ($fragment != '') {
186
            $uri .= '#' . $fragment;
187
        }
188
189
        return $uri;
190
    }
191
192
    /**
193
     * Whether the URI has the default port of the current scheme.
194
     *
195
     * `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used
196
     * independently of the implementation.
197
     */
198
    public static function isDefaultPort(UriInterface $uri): bool
199
    {
200
        return $uri->getPort() === null
201
            || (isset(self::DEFAULT_PORTS[$uri->getScheme()]) && $uri->getPort() === self::DEFAULT_PORTS[$uri->getScheme()]);
202
    }
203
204
    /**
205
     * Whether the URI is absolute, i.e. it has a scheme.
206
     *
207
     * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true
208
     * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative
209
     * to another URI, the base URI. Relative references can be divided into several forms:
210
     * - network-path references, e.g. '//example.com/path'
211
     * - absolute-path references, e.g. '/path'
212
     * - relative-path references, e.g. 'subpath'
213
     *
214
     * @see Uri::isNetworkPathReference
215
     * @see Uri::isAbsolutePathReference
216
     * @see Uri::isRelativePathReference
217
     * @link https://tools.ietf.org/html/rfc3986#section-4
218
     */
219
    public static function isAbsolute(UriInterface $uri): bool
220
    {
221
        return $uri->getScheme() !== '';
222
    }
223
224
    /**
225
     * Whether the URI is a network-path reference.
226
     *
227
     * A relative reference that begins with two slash characters is termed an network-path reference.
228
     *
229
     * @link https://tools.ietf.org/html/rfc3986#section-4.2
230
     */
231
    public static function isNetworkPathReference(UriInterface $uri): bool
232
    {
233
        return $uri->getScheme() === '' && $uri->getAuthority() !== '';
234
    }
235
236
    /**
237
     * Whether the URI is a absolute-path reference.
238
     *
239
     * A relative reference that begins with a single slash character is termed an absolute-path reference.
240
     *
241
     * @link https://tools.ietf.org/html/rfc3986#section-4.2
242
     */
243
    public static function isAbsolutePathReference(UriInterface $uri): bool
244
    {
245
        return $uri->getScheme() === ''
246
            && $uri->getAuthority() === ''
247
            && isset($uri->getPath()[0])
248
            && $uri->getPath()[0] === '/';
249
    }
250
251
    /**
252
     * Whether the URI is a relative-path reference.
253
     *
254
     * A relative reference that does not begin with a slash character is termed a relative-path reference.
255
     *
256
     * @link https://tools.ietf.org/html/rfc3986#section-4.2
257
     */
258
    public static function isRelativePathReference(UriInterface $uri): bool
259
    {
260
        return $uri->getScheme() === ''
261
            && $uri->getAuthority() === ''
262
            && (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/');
263
    }
264
265
    /**
266
     * Whether the URI is a same-document reference.
267
     *
268
     * A same-document reference refers to a URI that is, aside from its fragment
269
     * component, identical to the base URI. When no base URI is given, only an empty
270
     * URI reference (apart from its fragment) is considered a same-document reference.
271
     *
272
     * @param UriInterface      $uri  The URI to check
273
     * @param UriInterface|null $base An optional base URI to compare against
274
     *
275
     * @link https://tools.ietf.org/html/rfc3986#section-4.4
276
     */
277
    public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool
278
    {
279
        if ($base !== null) {
280
            $uri = UriResolver::resolve($base, $uri);
281
282
            return ($uri->getScheme() === $base->getScheme())
283
                && ($uri->getAuthority() === $base->getAuthority())
284
                && ($uri->getPath() === $base->getPath())
285
                && ($uri->getQuery() === $base->getQuery());
286
        }
287
288
        return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === '';
289
    }
290
291
    /**
292
     * Creates a new URI with a specific query string value removed.
293
     *
294
     * Any existing query string values that exactly match the provided key are
295
     * removed.
296
     *
297
     * @param UriInterface $uri URI to use as a base.
298
     * @param string       $key Query string key to remove.
299
     */
300
    public static function withoutQueryValue(UriInterface $uri, string $key): UriInterface
301
    {
302
        $result = self::getFilteredQueryString($uri, [$key]);
303
304
        return $uri->withQuery(implode('&', $result));
305
    }
306
307
    /**
308
     * Creates a new URI with a specific query string value.
309
     *
310
     * Any existing query string values that exactly match the provided key are
311
     * removed and replaced with the given key value pair.
312
     *
313
     * A value of null will set the query string key without a value, e.g. "key"
314
     * instead of "key=value".
315
     *
316
     * @param UriInterface $uri   URI to use as a base.
317
     * @param string       $key   Key to set.
318
     * @param string|null  $value Value to set
319
     */
320
    public static function withQueryValue(UriInterface $uri, string $key, ?string $value): UriInterface
321
    {
322
        $result = self::getFilteredQueryString($uri, [$key]);
323
324
        $result[] = self::generateQueryString($key, $value);
325
326
        return $uri->withQuery(implode('&', $result));
327
    }
328
329
    /**
330
     * Creates a new URI with multiple specific query string values.
331
     *
332
     * It has the same behavior as withQueryValue() but for an associative array of key => value.
333
     *
334
     * @param UriInterface               $uri           URI to use as a base.
335
     * @param array<string, string|null> $keyValueArray Associative array of key and values
336
     */
337
    public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface
338
    {
339
        $result = self::getFilteredQueryString($uri, array_keys($keyValueArray));
340
341
        foreach ($keyValueArray as $key => $value) {
342
            $result[] = self::generateQueryString((string) $key, $value !== null ? (string) $value : null);
343
        }
344
345
        return $uri->withQuery(implode('&', $result));
346
    }
347
348
    /**
349
     * Creates a URI from a hash of `parse_url` components.
350
     *
351
     * @link http://php.net/manual/en/function.parse-url.php
352
     *
353
     * @throws MalformedUriException If the components do not form a valid URI.
354
     */
355
    public static function fromParts(array $parts): UriInterface
356
    {
357
        $uri = new self();
358
        $uri->applyParts($parts);
359
        $uri->validateState();
360
361
        return $uri;
362
    }
363
364
    public function getScheme(): string
365
    {
366
        return $this->scheme;
367
    }
368
369
    public function getAuthority(): string
370
    {
371
        $authority = $this->host;
372
        if ($this->userInfo !== '') {
373
            $authority = $this->userInfo . '@' . $authority;
374
        }
375
376
        if ($this->port !== null) {
377
            $authority .= ':' . $this->port;
378
        }
379
380
        return $authority;
381
    }
382
383
    public function getUserInfo(): string
384
    {
385
        return $this->userInfo;
386
    }
387
388
    public function getHost(): string
389
    {
390
        return $this->host;
391
    }
392
393
    public function getPort(): ?int
394
    {
395
        return $this->port;
396
    }
397
398
    public function getPath(): string
399
    {
400
        return $this->path;
401
    }
402
403
    public function getQuery(): string
404
    {
405
        return $this->query;
406
    }
407
408
    public function getFragment(): string
409
    {
410
        return $this->fragment;
411
    }
412
413
    public function withScheme($scheme): UriInterface
414
    {
415
        $scheme = $this->filterScheme($scheme);
416
417
        if ($this->scheme === $scheme) {
418
            return $this;
419
        }
420
421
        $new = clone $this;
422
        $new->scheme = $scheme;
423
        $new->composedComponents = null;
424
        $new->removeDefaultPort();
425
        $new->validateState();
426
427
        return $new;
428
    }
429
430
    public function withUserInfo($user, $password = null): UriInterface
431
    {
432
        $info = $this->filterUserInfoComponent($user);
433
        if ($password !== null) {
434
            $info .= ':' . $this->filterUserInfoComponent($password);
435
        }
436
437
        if ($this->userInfo === $info) {
438
            return $this;
439
        }
440
441
        $new = clone $this;
442
        $new->userInfo = $info;
443
        $new->composedComponents = null;
444
        $new->validateState();
445
446
        return $new;
447
    }
448
449
    public function withHost($host): UriInterface
450
    {
451
        $host = $this->filterHost($host);
452
453
        if ($this->host === $host) {
454
            return $this;
455
        }
456
457
        $new = clone $this;
458
        $new->host = $host;
459
        $new->composedComponents = null;
460
        $new->validateState();
461
462
        return $new;
463
    }
464
465
    public function withPort($port): UriInterface
466
    {
467
        $port = $this->filterPort($port);
468
469
        if ($this->port === $port) {
470
            return $this;
471
        }
472
473
        $new = clone $this;
474
        $new->port = $port;
475
        $new->composedComponents = null;
476
        $new->removeDefaultPort();
477
        $new->validateState();
478
479
        return $new;
480
    }
481
482
    public function withPath($path): UriInterface
483
    {
484
        $path = $this->filterPath($path);
485
486
        if ($this->path === $path) {
487
            return $this;
488
        }
489
490
        $new = clone $this;
491
        $new->path = $path;
492
        $new->composedComponents = null;
493
        $new->validateState();
494
495
        return $new;
496
    }
497
498
    public function withQuery($query): UriInterface
499
    {
500
        $query = $this->filterQueryAndFragment($query);
501
502
        if ($this->query === $query) {
503
            return $this;
504
        }
505
506
        $new = clone $this;
507
        $new->query = $query;
508
        $new->composedComponents = null;
509
510
        return $new;
511
    }
512
513
    public function withFragment($fragment): UriInterface
514
    {
515
        $fragment = $this->filterQueryAndFragment($fragment);
516
517
        if ($this->fragment === $fragment) {
518
            return $this;
519
        }
520
521
        $new = clone $this;
522
        $new->fragment = $fragment;
523
        $new->composedComponents = null;
524
525
        return $new;
526
    }
527
528
    public function jsonSerialize(): string
529
    {
530
        return $this->__toString();
531
    }
532
533
    /**
534
     * Apply parse_url parts to a URI.
535
     *
536
     * @param array $parts Array of parse_url parts to apply.
537
     */
538
    private function applyParts(array $parts): void
539
    {
540
        $this->scheme = isset($parts['scheme'])
541
            ? $this->filterScheme($parts['scheme'])
542
            : '';
543
        $this->userInfo = isset($parts['user'])
544
            ? $this->filterUserInfoComponent($parts['user'])
545
            : '';
546
        $this->host = isset($parts['host'])
547
            ? $this->filterHost($parts['host'])
548
            : '';
549
        $this->port = isset($parts['port'])
550
            ? $this->filterPort($parts['port'])
551
            : null;
552
        $this->path = isset($parts['path'])
553
            ? $this->filterPath($parts['path'])
554
            : '';
555
        $this->query = isset($parts['query'])
556
            ? $this->filterQueryAndFragment($parts['query'])
557
            : '';
558
        $this->fragment = isset($parts['fragment'])
559
            ? $this->filterQueryAndFragment($parts['fragment'])
560
            : '';
561
        if (isset($parts['pass'])) {
562
            $this->userInfo .= ':' . $this->filterUserInfoComponent($parts['pass']);
563
        }
564
565
        $this->removeDefaultPort();
566
    }
567
568
    /**
569
     * @param mixed $scheme
570
     *
571
     * @throws \InvalidArgumentException If the scheme is invalid.
572
     */
573
    private function filterScheme($scheme): string
574
    {
575
        if (!is_string($scheme)) {
576
            throw new \InvalidArgumentException('Scheme must be a string');
577
        }
578
579
        return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
580
    }
581
582
    /**
583
     * @param mixed $component
584
     *
585
     * @throws \InvalidArgumentException If the user info is invalid.
586
     */
587
    private function filterUserInfoComponent($component): string
588
    {
589
        if (!is_string($component)) {
590
            throw new \InvalidArgumentException('User info must be a string');
591
        }
592
593
        return preg_replace_callback(
594
            '/(?:[^%' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . ']+|%(?![A-Fa-f0-9]{2}))/',
595
            [$this, 'rawurlencodeMatchZero'],
596
            $component
597
        );
598
    }
599
600
    /**
601
     * @param mixed $host
602
     *
603
     * @throws \InvalidArgumentException If the host is invalid.
604
     */
605
    private function filterHost($host): string
606
    {
607
        if (!is_string($host)) {
608
            throw new \InvalidArgumentException('Host must be a string');
609
        }
610
611
        return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
612
    }
613
614
    /**
615
     * @param mixed $port
616
     *
617
     * @throws \InvalidArgumentException If the port is invalid.
618
     */
619
    private function filterPort($port): ?int
620
    {
621
        if ($port === null) {
622
            return null;
623
        }
624
625
        $port = (int) $port;
626
        if (0 > $port || 0xffff < $port) {
627
            throw new \InvalidArgumentException(
628
                sprintf('Invalid port: %d. Must be between 0 and 65535', $port)
629
            );
630
        }
631
632
        return $port;
633
    }
634
635
    /**
636
     * @param string[] $keys
637
     *
638
     * @return string[]
639
     */
640
    private static function getFilteredQueryString(UriInterface $uri, array $keys): array
641
    {
642
        $current = $uri->getQuery();
643
644
        if ($current === '') {
645
            return [];
646
        }
647
648
        $decodedKeys = array_map('rawurldecode', $keys);
649
650
        return array_filter(explode('&', $current), function ($part) use ($decodedKeys) {
651
            return !in_array(rawurldecode(explode('=', $part)[0]), $decodedKeys, true);
652
        });
653
    }
654
655
    private static function generateQueryString(string $key, ?string $value): string
656
    {
657
        // Query string separators ("=", "&") within the key or value need to be encoded
658
        // (while preventing double-encoding) before setting the query string. All other
659
        // chars that need percent-encoding will be encoded by withQuery().
660
        $queryString = strtr($key, self::QUERY_SEPARATORS_REPLACEMENT);
661
662
        if ($value !== null) {
663
            $queryString .= '=' . strtr($value, self::QUERY_SEPARATORS_REPLACEMENT);
664
        }
665
666
        return $queryString;
667
    }
668
669
    private function removeDefaultPort(): void
670
    {
671
        if ($this->port !== null && self::isDefaultPort($this)) {
672
            $this->port = null;
673
        }
674
    }
675
676
    /**
677
     * Filters the path of a URI
678
     *
679
     * @param mixed $path
680
     *
681
     * @throws \InvalidArgumentException If the path is invalid.
682
     */
683
    private function filterPath($path): string
684
    {
685
        if (!is_string($path)) {
686
            throw new \InvalidArgumentException('Path must be a string');
687
        }
688
689
        return preg_replace_callback(
690
            '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/',
691
            [$this, 'rawurlencodeMatchZero'],
692
            $path
693
        );
694
    }
695
696
    /**
697
     * Filters the query string or fragment of a URI.
698
     *
699
     * @param mixed $str
700
     *
701
     * @throws \InvalidArgumentException If the query or fragment is invalid.
702
     */
703
    private function filterQueryAndFragment($str): string
704
    {
705
        if (!is_string($str)) {
706
            throw new \InvalidArgumentException('Query and fragment must be a string');
707
        }
708
709
        return preg_replace_callback(
710
            '/(?:[^' . self::CHAR_UNRESERVED . self::CHAR_SUB_DELIMS . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/',
711
            [$this, 'rawurlencodeMatchZero'],
712
            $str
713
        );
714
    }
715
716
    private function rawurlencodeMatchZero(array $match): string
717
    {
718
        return rawurlencode($match[0]);
719
    }
720
721
    private function validateState(): void
722
    {
723
        if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) {
724
            $this->host = self::HTTP_DEFAULT_HOST;
725
        }
726
727
        if ($this->getAuthority() === '') {
728
            if (0 === strpos($this->path, '//')) {
729
                throw new MalformedUriException('The path of a URI without an authority must not start with two slashes "//"');
730
            }
731
            if ($this->scheme === '' && false !== strpos(explode('/', $this->path, 2)[0], ':')) {
732
                throw new MalformedUriException('A relative URI must not have a path beginning with a segment containing a colon');
733
            }
734
        } elseif (isset($this->path[0]) && $this->path[0] !== '/') {
735
            throw new MalformedUriException('The path of a URI with an authority must start with a slash "/" or be empty');
736
        }
737
    }
738
}
739