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: | |
17: | |
18: | |
19: | |
20: | class Uri implements UriInterface |
21: | { |
22: | use ImmutableTrait; |
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: | 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: | |
136: | |
137: | |
138: | |
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: | |
151: | |
152: | |
153: | |
154: | |
155: | |
156: | |
157: | |
158: | public static function fromParts(array $parts): self |
159: | { |
160: | return (new static())->applyParts($parts); |
161: | } |
162: | |
163: | |
164: | |
165: | |
166: | |
167: | |
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: | |
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: | |
199: | |
200: | |
201: | |
202: | |
203: | |
204: | |
205: | public static function unparse(array $parts): string |
206: | { |
207: | return (string) static::fromParts($parts); |
208: | } |
209: | |
210: | |
211: | |
212: | |
213: | |
214: | |
215: | |
216: | public static function resolveReference($reference, $baseUri): string |
217: | { |
218: | return (string) static::from($baseUri)->follow($reference); |
219: | } |
220: | |
221: | |
222: | |
223: | |
224: | |
225: | |
226: | |
227: | |
228: | |
229: | public static function isAuthorityForm(string $requestTarget): bool |
230: | { |
231: | return (bool) Regex::match(self::AUTHORITY_FORM, $requestTarget); |
232: | } |
233: | |
234: | |
235: | |
236: | |
237: | public function toParts(): array |
238: | { |
239: | |
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: | |
254: | |
255: | public function isReference(): bool |
256: | { |
257: | return $this->Scheme === null; |
258: | } |
259: | |
260: | |
261: | |
262: | |
263: | public function getScheme(): string |
264: | { |
265: | return (string) $this->Scheme; |
266: | } |
267: | |
268: | |
269: | |
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: | |
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: | |
303: | |
304: | public function getHost(): string |
305: | { |
306: | return (string) $this->Host; |
307: | } |
308: | |
309: | |
310: | |
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: | |
327: | |
328: | public function getPath(): string |
329: | { |
330: | return $this->Path; |
331: | } |
332: | |
333: | |
334: | |
335: | |
336: | public function getQuery(): string |
337: | { |
338: | return (string) $this->Query; |
339: | } |
340: | |
341: | |
342: | |
343: | |
344: | public function getFragment(): string |
345: | { |
346: | return (string) $this->Fragment; |
347: | } |
348: | |
349: | |
350: | |
351: | |
352: | public function withScheme(string $scheme): PsrUriInterface |
353: | { |
354: | return $this |
355: | ->with('Scheme', $this->filterScheme($scheme)) |
356: | ->validate(); |
357: | } |
358: | |
359: | |
360: | |
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: | |
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: | |
390: | |
391: | public function withPort(?int $port): PsrUriInterface |
392: | { |
393: | return $this |
394: | ->with('Port', $this->filterPort($port)) |
395: | ->validate(); |
396: | } |
397: | |
398: | |
399: | |
400: | |
401: | public function withPath(string $path): PsrUriInterface |
402: | { |
403: | return $this |
404: | ->with('Path', $this->filterPath($path)) |
405: | ->validate(); |
406: | } |
407: | |
408: | |
409: | |
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: | |
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: | |
428: | |
429: | |
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: | |
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: | |
500: | |
501: | |
502: | |
503: | |
504: | |
505: | public function removeDotSegments(): self |
506: | { |
507: | |
508: | if ($this->isReference()) { |
509: | return $this; |
510: | } |
511: | |
512: | return $this->withPath(File::resolvePath($this->Path, true)); |
513: | } |
514: | |
515: | |
516: | |
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: | |
550: | |
551: | public function jsonSerialize(): string |
552: | { |
553: | return $this->__toString(); |
554: | } |
555: | |
556: | |
557: | |
558: | |
559: | |
560: | |
561: | |
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: | |
579: | |
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: | |
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: | |
684: | |
685: | |
686: | |
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: | |
703: | '/%([0-9a-f]{2})/i' => |
704: | fn(array $matches) => '%' . Str::upper($matches[1]), |
705: | |
706: | "/(?:%(?![0-9a-f]{2})|{$encodeRegex}[^]!#\$%&'()*+,\/:;=?@[])+/i" => |
707: | fn(array $matches) => rawurlencode($matches[0]), |
708: | ], $part); |
709: | } |
710: | |
711: | |
712: | |
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: | |
725: | |
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: | |
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: | |