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: | |
17: | |
18: | |
19: | |
20: | class Uri implements UriInterface |
21: | { |
22: | use HasMutator; |
23: | |
24: | |
25: | |
26: | |
27: | public const EXPAND_EMPTY_PATH = 1; |
28: | |
29: | |
30: | |
31: | |
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> (?®_char) | [:@] ) |
62: | ) |
63: | (?: (?<scheme> [a-z] [-a-z0-9+.]* ) : )?+ |
64: | (?: |
65: | // |
66: | (?<authority> |
67: | (?: |
68: | (?<userinfo> |
69: | (?<user> (?®_char)* ) |
70: | (?: : (?<pass> (?: (?®_char) | : )* ) )? |
71: | ) |
72: | @ |
73: | )?+ |
74: | (?<host> (?®_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) ) | (?= (?®_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: | |
104: | |
105: | |
106: | |
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: | |
138: | |
139: | |
140: | |
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: | |
153: | |
154: | |
155: | |
156: | |
157: | |
158: | |
159: | |
160: | public static function fromParts(array $parts): self |
161: | { |
162: | return (new static())->applyParts($parts); |
163: | } |
164: | |
165: | |
166: | |
167: | |
168: | |
169: | |
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: | |
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: | |
201: | |
202: | |
203: | |
204: | |
205: | |
206: | |
207: | public static function unparse(array $parts): string |
208: | { |
209: | return (string) static::fromParts($parts); |
210: | } |
211: | |
212: | |
213: | |
214: | |
215: | |
216: | |
217: | |
218: | public static function resolveReference($reference, $baseUri): string |
219: | { |
220: | return (string) static::from($baseUri)->follow($reference); |
221: | } |
222: | |
223: | |
224: | |
225: | |
226: | |
227: | |
228: | |
229: | |
230: | |
231: | public static function isAuthorityForm(string $requestTarget): bool |
232: | { |
233: | return (bool) Regex::match(self::AUTHORITY_FORM, $requestTarget); |
234: | } |
235: | |
236: | |
237: | |
238: | |
239: | public function toParts(): array |
240: | { |
241: | |
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: | |
256: | |
257: | public function isReference(): bool |
258: | { |
259: | return $this->Scheme === null; |
260: | } |
261: | |
262: | |
263: | |
264: | |
265: | public function getScheme(): string |
266: | { |
267: | return (string) $this->Scheme; |
268: | } |
269: | |
270: | |
271: | |
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: | |
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: | |
305: | |
306: | public function getHost(): string |
307: | { |
308: | return (string) $this->Host; |
309: | } |
310: | |
311: | |
312: | |
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: | |
329: | |
330: | public function getPath(): string |
331: | { |
332: | return $this->Path; |
333: | } |
334: | |
335: | |
336: | |
337: | |
338: | public function getQuery(): string |
339: | { |
340: | return (string) $this->Query; |
341: | } |
342: | |
343: | |
344: | |
345: | |
346: | public function getFragment(): string |
347: | { |
348: | return (string) $this->Fragment; |
349: | } |
350: | |
351: | |
352: | |
353: | |
354: | public function withScheme(string $scheme): PsrUriInterface |
355: | { |
356: | return $this |
357: | ->with('Scheme', $this->filterScheme($scheme)) |
358: | ->validate(); |
359: | } |
360: | |
361: | |
362: | |
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: | |
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: | |
392: | |
393: | public function withPort(?int $port): PsrUriInterface |
394: | { |
395: | return $this |
396: | ->with('Port', $this->filterPort($port)) |
397: | ->validate(); |
398: | } |
399: | |
400: | |
401: | |
402: | |
403: | public function withPath(string $path): PsrUriInterface |
404: | { |
405: | return $this |
406: | ->with('Path', $this->filterPath($path)) |
407: | ->validate(); |
408: | } |
409: | |
410: | |
411: | |
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: | |
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: | |
430: | |
431: | |
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: | |
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: | |
502: | |
503: | |
504: | |
505: | |
506: | |
507: | public function removeDotSegments(): self |
508: | { |
509: | |
510: | if ($this->isReference()) { |
511: | return $this; |
512: | } |
513: | |
514: | return $this->withPath(File::resolvePath($this->Path, true)); |
515: | } |
516: | |
517: | |
518: | |
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: | |
552: | |
553: | public function jsonSerialize(): string |
554: | { |
555: | return $this->__toString(); |
556: | } |
557: | |
558: | |
559: | |
560: | |
561: | |
562: | |
563: | |
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: | |
581: | |
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: | |
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: | |
686: | |
687: | |
688: | |
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: | |
705: | '/%([0-9a-f]{2})/i' => |
706: | fn(array $matches) => '%' . Str::upper($matches[1]), |
707: | |
708: | "/(?:%(?![0-9a-f]{2})|{$encodeRegex}[^]!#\$%&'()*+,\/:;=?@[])+/i" => |
709: | fn(array $matches) => rawurlencode($matches[0]), |
710: | ], $part); |
711: | } |
712: | |
713: | |
714: | |
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: | |
727: | |
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: | |
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: | |