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