1: <?php declare(strict_types=1);
2:
3: namespace Salient\Http;
4:
5: use Psr\Http\Message\UriInterface as PsrUriInterface;
6: use Salient\Contract\Http\UriInterface;
7: use Salient\Core\Concern\ImmutableTrait;
8: use Salient\Utility\Arr;
9: use Salient\Utility\File;
10: use Salient\Utility\Regex;
11: use Salient\Utility\Str;
12: use InvalidArgumentException;
13: use Stringable;
14:
15: /**
16: * A PSR-7 URI with strict [RFC3986] compliance
17: *
18: * @api
19: */
20: class Uri implements UriInterface
21: {
22: use ImmutableTrait;
23:
24: /**
25: * Replace empty HTTP and HTTPS paths with "/"
26: */
27: public const EXPAND_EMPTY_PATH = 1;
28:
29: /**
30: * Collapse two or more subsequent slashes in the path (e.g. "//") to a
31: * single slash ("/")
32: */
33: public const COLLAPSE_MULTIPLE_SLASHES = 2;
34:
35: protected const SCHEME_PORT = [
36: 'http' => 80,
37: 'https' => 443,
38: ];
39:
40: private const COMPONENT_NAME = [
41: \PHP_URL_SCHEME => 'scheme',
42: \PHP_URL_HOST => 'host',
43: \PHP_URL_PORT => 'port',
44: \PHP_URL_USER => 'user',
45: \PHP_URL_PASS => 'pass',
46: \PHP_URL_PATH => 'path',
47: \PHP_URL_QUERY => 'query',
48: \PHP_URL_FRAGMENT => 'fragment',
49: ];
50:
51: private const URI_SCHEME = '/^[a-z][-a-z0-9+.]*$/iD';
52: private const URI_HOST = '/^(([-a-z0-9!$&\'()*+,.;=_~]|%[0-9a-f]{2})++|\[[0-9a-f:]++\])$/iD';
53:
54: private const URI = <<<'REGEX'
55: ` ^
56: (?(DEFINE)
57: (?<unreserved> [-a-z0-9._~] )
58: (?<sub_delims> [!$&'()*+,;=] )
59: (?<pct_encoded> % [0-9a-f]{2} )
60: (?<reg_char> (?&unreserved) | (?&pct_encoded) | (?&sub_delims) )
61: (?<pchar> (?&reg_char) | [:@] )
62: )
63: (?: (?<scheme> [a-z] [-a-z0-9+.]* ) : )?+
64: (?:
65: //
66: (?<authority>
67: (?:
68: (?<userinfo>
69: (?<user> (?&reg_char)* )
70: (?: : (?<pass> (?: (?&reg_char) | : )* ) )?
71: )
72: @
73: )?+
74: (?<host> (?&reg_char)*+ | \[ (?<ipv6address> [0-9a-f:]++ ) \] )
75: (?: : (?<port> [0-9]+ ) )?+
76: )
77: # Path after authority must be empty or begin with "/"
78: (?= / | \? | \# | $ ) |
79: # Path cannot begin with "//" except after authority
80: (?= / ) (?! // ) |
81: # Rootless paths can only begin with a ":" segment after scheme
82: (?(<scheme>) (?= (?&pchar) ) | (?= (?&reg_char) | @ ) (?! [^/:]++ : ) ) |
83: (?= \? | \# | $ )
84: )
85: (?<path> (?: (?&pchar) | / )*+ )
86: (?: \? (?<query> (?: (?&pchar) | [?/] )* ) )?+
87: (?: \# (?<fragment> (?: (?&pchar) | [?/] )* ) )?+
88: $ `ixD
89: REGEX;
90:
91: private const AUTHORITY_FORM = '/^(([-a-z0-9!$&\'()*+,.;=_~]|%[0-9a-f]{2})++|\[[0-9a-f:]++\]):[0-9]++$/iD';
92:
93: protected ?string $Scheme = null;
94: protected ?string $User = null;
95: protected ?string $Password = null;
96: protected ?string $Host = null;
97: protected ?int $Port = null;
98: protected string $Path = '';
99: protected ?string $Query = null;
100: protected ?string $Fragment = null;
101:
102: /**
103: * @param bool $strict If `false`, unencoded characters are percent-encoded
104: * before parsing.
105: */
106: final public function __construct(string $uri = '', bool $strict = false)
107: {
108: if ($uri === '') {
109: return;
110: }
111:
112: if (!$strict) {
113: $uri = $this->encode($uri);
114: }
115:
116: if (!Regex::match(self::URI, $uri, $parts, \PREG_UNMATCHED_AS_NULL)) {
117: throw new InvalidArgumentException(sprintf('Invalid URI: %s', $uri));
118: }
119:
120: $this->Scheme = $this->filterScheme($parts['scheme'], false);
121: $this->User = $this->filterUserInfoPart($parts['user']);
122: $this->Password = $this->filterUserInfoPart($parts['pass']);
123: $this->Host = $this->filterHost($parts['host'], false);
124: $this->Port = $this->filterPort($parts['port']);
125: $this->Path = $this->filterPath($parts['path']);
126: $this->Query = $this->filterQueryOrFragment($parts['query']);
127: $this->Fragment = $this->filterQueryOrFragment($parts['fragment']);
128:
129: if ($this->Password !== null) {
130: $this->User ??= '';
131: }
132: }
133:
134: /**
135: * Resolve a value to a Uri object
136: *
137: * @param PsrUriInterface|Stringable|string $uri
138: * @return static
139: */
140: public static function from($uri): self
141: {
142: if ($uri instanceof static) {
143: return $uri;
144: }
145:
146: return new static((string) $uri);
147: }
148:
149: /**
150: * Get a new instance from an array of URI components
151: *
152: * Accepts arrays returned by {@see parse_url()},
153: * {@see UriInterface::parse()} and {@see UriInterface::toParts()}.
154: *
155: * @param array{scheme?:string,host?:string,port?:int,user?:string,pass?:string,path?:string,query?:string,fragment?:string} $parts
156: * @return static
157: */
158: public static function fromParts(array $parts): self
159: {
160: return (new static())->applyParts($parts);
161: }
162:
163: /**
164: * @inheritDoc
165: *
166: * @param bool $strict If `false`, unencoded characters are percent-encoded
167: * before parsing.
168: */
169: public static function parse(string $uri, ?int $component = null, bool $strict = false)
170: {
171: $noComponent = $component === null || $component === -1;
172:
173: $name = $noComponent
174: ? null
175: // @phpstan-ignore nullCoalesce.offset
176: : self::COMPONENT_NAME[$component] ?? null;
177:
178: if (!$noComponent && $name === null) {
179: throw new InvalidArgumentException(sprintf(
180: 'Invalid component: %d',
181: $component,
182: ));
183: }
184:
185: try {
186: $parts = (new static($uri, $strict))->toParts();
187: } catch (InvalidArgumentException $ex) {
188: @trigger_error($ex->getMessage(), \E_USER_NOTICE);
189: return false;
190: }
191:
192: return $noComponent
193: ? $parts
194: : $parts[$name] ?? null;
195: }
196:
197: /**
198: * Convert an array of URI components to a URI
199: *
200: * Accepts arrays returned by {@see parse_url()},
201: * {@see UriInterface::parse()} and {@see UriInterface::toParts()}.
202: *
203: * @param array{scheme?:string,host?:string,port?:int,user?:string,pass?:string,path?:string,query?:string,fragment?:string} $parts
204: */
205: public static function unparse(array $parts): string
206: {
207: return (string) static::fromParts($parts);
208: }
209:
210: /**
211: * Resolve a URI reference relative to a given base URI
212: *
213: * @param PsrUriInterface|Stringable|string $reference
214: * @param PsrUriInterface|Stringable|string $baseUri
215: */
216: public static function resolveReference($reference, $baseUri): string
217: {
218: return (string) static::from($baseUri)->follow($reference);
219: }
220:
221: /**
222: * Check if a value is a valid authority-form request target
223: *
224: * [RFC7230], Section 5.3.3: "When making a CONNECT request to establish a
225: * tunnel through one or more proxies, a client MUST send only the target
226: * URI's authority component (excluding any userinfo and its "@" delimiter)
227: * as the request-target."
228: */
229: public static function isAuthorityForm(string $requestTarget): bool
230: {
231: return (bool) Regex::match(self::AUTHORITY_FORM, $requestTarget);
232: }
233:
234: /**
235: * @inheritDoc
236: */
237: public function toParts(): array
238: {
239: /** @var array{scheme?:string,host?:string,port?:int,user?:string,pass?:string,path?:string,query?:string,fragment?:string} */
240: return Arr::whereNotNull([
241: 'scheme' => $this->Scheme,
242: 'host' => $this->Host,
243: 'port' => $this->getPort(),
244: 'user' => $this->User,
245: 'pass' => $this->Password,
246: 'path' => Str::coalesce($this->Path, null),
247: 'query' => $this->Query,
248: 'fragment' => $this->Fragment,
249: ]);
250: }
251:
252: /**
253: * @inheritDoc
254: */
255: public function isReference(): bool
256: {
257: return $this->Scheme === null;
258: }
259:
260: /**
261: * @inheritDoc
262: */
263: public function getScheme(): string
264: {
265: return (string) $this->Scheme;
266: }
267:
268: /**
269: * @inheritDoc
270: */
271: public function getAuthority(): string
272: {
273: $authority = '';
274:
275: if ($this->User !== null) {
276: $authority .= $this->getUserInfo() . '@';
277: }
278:
279: $authority .= $this->Host;
280:
281: $port = $this->getPort();
282: if ($port !== null) {
283: $authority .= ':' . $port;
284: }
285:
286: return $authority;
287: }
288:
289: /**
290: * @inheritDoc
291: */
292: public function getUserInfo(): string
293: {
294: if ($this->Password !== null) {
295: return $this->User . ':' . $this->Password;
296: }
297:
298: return (string) $this->User;
299: }
300:
301: /**
302: * @inheritDoc
303: */
304: public function getHost(): string
305: {
306: return (string) $this->Host;
307: }
308:
309: /**
310: * @inheritDoc
311: */
312: public function getPort(): ?int
313: {
314: if (
315: $this->Scheme !== null
316: && isset(static::SCHEME_PORT[$this->Scheme])
317: && static::SCHEME_PORT[$this->Scheme] === $this->Port
318: ) {
319: return null;
320: }
321:
322: return $this->Port;
323: }
324:
325: /**
326: * @inheritDoc
327: */
328: public function getPath(): string
329: {
330: return $this->Path;
331: }
332:
333: /**
334: * @inheritDoc
335: */
336: public function getQuery(): string
337: {
338: return (string) $this->Query;
339: }
340:
341: /**
342: * @inheritDoc
343: */
344: public function getFragment(): string
345: {
346: return (string) $this->Fragment;
347: }
348:
349: /**
350: * @inheritDoc
351: */
352: public function withScheme(string $scheme): PsrUriInterface
353: {
354: return $this
355: ->with('Scheme', $this->filterScheme($scheme))
356: ->validate();
357: }
358:
359: /**
360: * @inheritDoc
361: */
362: public function withUserInfo(string $user, ?string $password = null): PsrUriInterface
363: {
364: if ($user === '') {
365: $user = null;
366: $password = null;
367: } else {
368: $user = $this->filterUserInfoPart($user);
369: $password = $this->filterUserInfoPart($password);
370: }
371:
372: return $this
373: ->with('User', $user)
374: ->with('Password', $password)
375: ->validate();
376: }
377:
378: /**
379: * @inheritDoc
380: */
381: public function withHost(string $host): PsrUriInterface
382: {
383: return $this
384: ->with('Host', $this->filterHost(Str::coalesce($host, null)))
385: ->validate();
386: }
387:
388: /**
389: * @inheritDoc
390: */
391: public function withPort(?int $port): PsrUriInterface
392: {
393: return $this
394: ->with('Port', $this->filterPort($port))
395: ->validate();
396: }
397:
398: /**
399: * @inheritDoc
400: */
401: public function withPath(string $path): PsrUriInterface
402: {
403: return $this
404: ->with('Path', $this->filterPath($path))
405: ->validate();
406: }
407:
408: /**
409: * @inheritDoc
410: */
411: public function withQuery(string $query): PsrUriInterface
412: {
413: return $this
414: ->with('Query', $this->filterQueryOrFragment(Str::coalesce($query, null)));
415: }
416:
417: /**
418: * @inheritDoc
419: */
420: public function withFragment(string $fragment): PsrUriInterface
421: {
422: return $this
423: ->with('Fragment', $this->filterQueryOrFragment(Str::coalesce($fragment, null)));
424: }
425:
426: /**
427: * @inheritDoc
428: *
429: * @param int-mask-of<Uri::EXPAND_EMPTY_PATH|Uri::COLLAPSE_MULTIPLE_SLASHES> $flags
430: */
431: public function normalise(int $flags = Uri::EXPAND_EMPTY_PATH): UriInterface
432: {
433: $uri = $this->removeDotSegments();
434:
435: if (
436: ($flags & self::EXPAND_EMPTY_PATH)
437: && $uri->Path === ''
438: && ($uri->Scheme === 'http' || $uri->Scheme === 'https')
439: ) {
440: $uri = $uri->withPath('/');
441: }
442:
443: if ($flags & self::COLLAPSE_MULTIPLE_SLASHES) {
444: $uri = $uri->withPath(Regex::replace('/\/\/++/', '/', $uri->Path));
445: }
446:
447: return $uri;
448: }
449:
450: /**
451: * @inheritDoc
452: */
453: public function follow($reference): UriInterface
454: {
455: if ($this->isReference()) {
456: throw new InvalidArgumentException(
457: 'Reference cannot be resolved relative to another reference'
458: );
459: }
460:
461: $reference = static::from($reference);
462: if (!$reference->isReference()) {
463: return $reference->removeDotSegments();
464: }
465:
466: $target = $this->withFragment((string) $reference->Fragment);
467:
468: if ($reference->getAuthority() !== '') {
469: return $target
470: ->withHost((string) $reference->Host)
471: ->withPort($reference->Port)
472: ->withUserInfo((string) $reference->User, $reference->Password)
473: ->withPath($reference->removeDotSegments()->Path)
474: ->withQuery((string) $reference->Query);
475: }
476:
477: if ($reference->Path === '') {
478: if ($reference->Query !== null) {
479: return $target
480: ->withQuery($reference->Query);
481: }
482: return $target;
483: }
484:
485: $target = $target->withQuery((string) $reference->Query);
486:
487: if ($reference->Path[0] === '/') {
488: return $target
489: ->withPath($reference->Path)
490: ->removeDotSegments();
491: }
492:
493: return $target
494: ->mergeRelativePath($reference->Path)
495: ->removeDotSegments();
496: }
497:
498: /**
499: * Get an instance with "/./" and "/../" segments removed from the path
500: *
501: * Compliant with \[RFC3986] Section 5.2.4 ("Remove Dot Segments").
502: *
503: * @return static
504: */
505: public function removeDotSegments(): self
506: {
507: // Relative references can only be resolved relative to an absolute URI
508: if ($this->isReference()) {
509: return $this;
510: }
511:
512: return $this->withPath(File::resolvePath($this->Path, true));
513: }
514:
515: /**
516: * @inheritDoc
517: */
518: public function __toString(): string
519: {
520: $uri = '';
521:
522: if ($this->Scheme !== null) {
523: $uri .= "{$this->Scheme}:";
524: }
525:
526: $authority = $this->getAuthority();
527: if (
528: $authority !== ''
529: || $this->Host !== null
530: || $this->Scheme === 'file'
531: ) {
532: $uri .= "//{$authority}";
533: }
534:
535: $uri .= $this->Path;
536:
537: if ($this->Query !== null) {
538: $uri .= "?{$this->Query}";
539: }
540:
541: if ($this->Fragment !== null) {
542: $uri .= "#{$this->Fragment}";
543: }
544:
545: return $uri;
546: }
547:
548: /**
549: * @inheritDoc
550: */
551: public function jsonSerialize(): string
552: {
553: return $this->__toString();
554: }
555:
556: /**
557: * Get an instance with a relative path merged into the path of the URI
558: *
559: * Implements \[RFC3986] Section 5.2.3 ("Merge Paths").
560: *
561: * @return static
562: */
563: private function mergeRelativePath(string $path): self
564: {
565: if ($this->getAuthority() !== '' && $this->Path === '') {
566: return $this->withPath("/$path");
567: }
568:
569: if (strpos($this->Path, '/') === false) {
570: return $this->withPath($path);
571: }
572:
573: $merge = implode('/', Arr::pop(explode('/', $this->Path)));
574: return $this->withPath("{$merge}/{$path}");
575: }
576:
577: /**
578: * @param array{scheme?:string,host?:string,port?:int,user?:string,pass?:string,path?:string,query?:string,fragment?:string} $parts
579: * @return $this
580: */
581: private function applyParts(array $parts): self
582: {
583: $this->Scheme = $this->filterScheme($parts['scheme'] ?? null);
584: $this->User = $this->filterUserInfoPart($parts['user'] ?? null);
585: $this->Password = $this->filterUserInfoPart($parts['pass'] ?? null);
586: $this->Host = $this->filterHost($parts['host'] ?? null);
587: $this->Port = $this->filterPort($parts['port'] ?? null);
588: $this->Path = $this->filterPath($parts['path'] ?? null);
589: $this->Query = $this->filterQueryOrFragment($parts['query'] ?? null);
590: $this->Fragment = $this->filterQueryOrFragment($parts['fragment'] ?? null);
591:
592: if ($this->Password !== null) {
593: $this->User ??= '';
594: }
595:
596: return $this;
597: }
598:
599: private function filterScheme(?string $scheme, bool $validate = true): ?string
600: {
601: if ($scheme === null || $scheme === '') {
602: return null;
603: }
604:
605: if ($validate && !Regex::match(self::URI_SCHEME, $scheme)) {
606: throw new InvalidArgumentException(
607: sprintf('Invalid scheme: %s', $scheme)
608: );
609: }
610:
611: return Str::lower($scheme);
612: }
613:
614: private function filterUserInfoPart(?string $part): ?string
615: {
616: if ($part === null || $part === '') {
617: return $part;
618: }
619:
620: return $this->normaliseComponent($part, '[]#/:?@[]');
621: }
622:
623: private function filterHost(?string $host, bool $validate = true): ?string
624: {
625: if ($host === null || $host === '') {
626: return $host;
627: }
628:
629: if ($validate) {
630: $host = $this->encode($host);
631: if (!Regex::match(self::URI_HOST, $host)) {
632: throw new InvalidArgumentException(
633: sprintf('Invalid host: %s', $host)
634: );
635: }
636: }
637:
638: return $this->normaliseComponent(
639: Str::lower($this->decodeUnreserved($host)),
640: '[#/?@]',
641: false
642: );
643: }
644:
645: /**
646: * @param string|int|null $port
647: */
648: private function filterPort($port): ?int
649: {
650: if ($port === null || $port === '') {
651: return null;
652: }
653:
654: $port = (int) $port;
655: if ($port < 0 || $port > 65535) {
656: throw new InvalidArgumentException(
657: sprintf('Invalid port: %d', $port)
658: );
659: }
660:
661: return $port;
662: }
663:
664: private function filterPath(?string $path): string
665: {
666: if ($path === null || $path === '') {
667: return '';
668: }
669:
670: return $this->normaliseComponent($path, '[]#?[]');
671: }
672:
673: private function filterQueryOrFragment(?string $part): ?string
674: {
675: if ($part === null || $part === '') {
676: return $part;
677: }
678:
679: return $this->normaliseComponent($part, '[]#[]');
680: }
681:
682: /**
683: * Normalise a URI component
684: *
685: * @param bool $decodeUnreserved `false` if unreserved characters have
686: * already been decoded.
687: */
688: private function normaliseComponent(
689: string $part,
690: string $encodeRegex = '',
691: bool $decodeUnreserved = true
692: ): string {
693: if ($decodeUnreserved) {
694: $part = $this->decodeUnreserved($part);
695: }
696:
697: if ($encodeRegex !== '') {
698: $encodeRegex = str_replace('/', '\/', $encodeRegex) . '|';
699: }
700:
701: return Regex::replaceCallbackArray([
702: // Use uppercase hexadecimal digits
703: '/%([0-9a-f]{2})/i' =>
704: fn(array $matches) => '%' . Str::upper($matches[1]),
705: // Encode everything except reserved and unreserved characters
706: "/(?:%(?![0-9a-f]{2})|{$encodeRegex}[^]!#\$%&'()*+,\/:;=?@[])+/i" =>
707: fn(array $matches) => rawurlencode($matches[0]),
708: ], $part);
709: }
710:
711: /**
712: * Decode unreserved characters in a URI component
713: */
714: private function decodeUnreserved(string $part): string
715: {
716: return Regex::replaceCallback(
717: '/%(2[de]|5f|7e|3[0-9]|[46][1-9a-f]|[57][0-9a])/i',
718: fn(array $matches) => chr((int) hexdec($matches[1])),
719: $part
720: );
721: }
722:
723: /**
724: * Percent-encode every character in a URI or URI component except reserved,
725: * unreserved and pre-encoded characters
726: */
727: private function encode(string $partOrUri): string
728: {
729: return Regex::replaceCallback(
730: '/(?:%(?![0-9a-f]{2})|[^]!#$%&\'()*+,\/:;=?@[])+/i',
731: fn(array $matches) => rawurlencode($matches[0]),
732: $partOrUri
733: );
734: }
735:
736: /**
737: * @return $this
738: */
739: private function validate(): self
740: {
741: if ($this->getAuthority() === '') {
742: if (substr($this->Path, 0, 2) === '//') {
743: throw new InvalidArgumentException(
744: 'Path cannot begin with "//" in URI without authority'
745: );
746: }
747:
748: if (
749: $this->Scheme === null
750: && $this->Path !== ''
751: && Regex::match('/^[^\/:]*+:/', $this->Path)
752: ) {
753: throw new InvalidArgumentException(
754: 'Path cannot begin with colon segment in URI without scheme'
755: );
756: }
757:
758: return $this;
759: }
760:
761: if ($this->Path !== '' && $this->Path[0] !== '/') {
762: throw new InvalidArgumentException(
763: 'Path must be empty or begin with "/" in URI with authority'
764: );
765: }
766:
767: return $this;
768: }
769: }
770: