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: * @api
17: */
18: class Uri implements UriInterface, HasHttpRegex
19: {
20: use ImmutableTrait;
21:
22: /**
23: * Replace empty HTTP and HTTPS paths with "/"
24: */
25: public const NORMALISE_EMPTY_PATH = 1;
26:
27: /**
28: * Replace two or more subsequent slashes in a path (e.g. "//") with a
29: * single slash ("/")
30: */
31: public const NORMALISE_MULTIPLE_SLASHES = 2;
32:
33: /**
34: * @var array<string,int>
35: */
36: protected const SCHEME_PORT = [
37: 'http' => 80,
38: 'https' => 443,
39: ];
40:
41: private const COMPONENT_NAME = [
42: \PHP_URL_SCHEME => 'scheme',
43: \PHP_URL_HOST => 'host',
44: \PHP_URL_PORT => 'port',
45: \PHP_URL_USER => 'user',
46: \PHP_URL_PASS => 'pass',
47: \PHP_URL_PATH => 'path',
48: \PHP_URL_QUERY => 'query',
49: \PHP_URL_FRAGMENT => 'fragment',
50: ];
51:
52: private ?string $Scheme = null;
53: private ?string $User = null;
54: private ?string $Password = null;
55: private ?string $Host = null;
56: private ?int $Port = null;
57: private string $Path = '';
58: private ?string $Query = null;
59: private ?string $Fragment = null;
60:
61: /**
62: * @param bool $strict If `false`, unencoded characters are percent-encoded
63: * before parsing.
64: */
65: final public function __construct(string $uri = '', bool $strict = false)
66: {
67: if ($uri === '') {
68: return;
69: }
70:
71: if (!$strict) {
72: $uri = $this->encode($uri);
73: }
74:
75: if (!Regex::match(self::URI_REGEX, $uri, $parts, \PREG_UNMATCHED_AS_NULL)) {
76: throw new InvalidArgumentException(sprintf('Invalid URI: %s', $uri));
77: }
78:
79: $this->Scheme = $this->filterScheme($parts['scheme'], false);
80: $this->User = $this->filterUserInfoPart($parts['user']);
81: $this->Password = $this->filterUserInfoPart($parts['pass']);
82: $this->Host = $this->filterHost($parts['host'], false);
83: $this->Port = $this->filterPort($parts['port']);
84: $this->Path = $this->filterPath($parts['path']);
85: $this->Query = $this->filterQueryOrFragment($parts['query']);
86: $this->Fragment = $this->filterQueryOrFragment($parts['fragment']);
87:
88: if ($this->Password !== null) {
89: $this->User ??= '';
90: }
91: }
92:
93: /**
94: * @param PsrUriInterface|Stringable|string $uri
95: * @return static
96: */
97: public static function from($uri): self
98: {
99: return $uri instanceof static
100: ? $uri
101: : new static((string) $uri);
102: }
103:
104: /**
105: * @inheritDoc
106: *
107: * @param bool $strict If `false`, unencoded characters are percent-encoded
108: * before parsing.
109: */
110: public static function parse(string $uri, int $component = -1, bool $strict = false)
111: {
112: try {
113: $parts = (new static($uri, $strict))->getComponents();
114: } catch (InvalidArgumentException $ex) {
115: return false;
116: }
117:
118: if ($component === -1) {
119: return $parts;
120: }
121:
122: $name = self::COMPONENT_NAME[$component] ?? null;
123: if ($name === null) {
124: throw new InvalidArgumentException(
125: sprintf('Invalid component: %d', $component),
126: );
127: }
128: return $parts[$name] ?? null;
129: }
130:
131: /**
132: * @inheritDoc
133: */
134: public function getComponents(): array
135: {
136: return Arr::whereNotNull([
137: 'scheme' => $this->Scheme,
138: 'host' => $this->Host,
139: 'port' => $this->getPort(),
140: 'user' => $this->User,
141: 'pass' => $this->Password,
142: 'path' => Str::coalesce($this->Path, null),
143: 'query' => $this->Query,
144: 'fragment' => $this->Fragment,
145: ]);
146: }
147:
148: /**
149: * @inheritDoc
150: */
151: public function isRelativeReference(): bool
152: {
153: return $this->Scheme === null;
154: }
155:
156: /**
157: * @inheritDoc
158: */
159: public function getScheme(): string
160: {
161: return (string) $this->Scheme;
162: }
163:
164: /**
165: * @inheritDoc
166: */
167: public function getAuthority(): string
168: {
169: $authority = '';
170:
171: if ($this->User !== null) {
172: $authority .= $this->getUserInfo() . '@';
173: }
174:
175: $authority .= $this->Host;
176:
177: $port = $this->getPort();
178: if ($port !== null) {
179: $authority .= ':' . $port;
180: }
181:
182: return $authority;
183: }
184:
185: /**
186: * @inheritDoc
187: */
188: public function getUserInfo(): string
189: {
190: if ($this->Password !== null) {
191: return $this->User . ':' . $this->Password;
192: }
193:
194: return (string) $this->User;
195: }
196:
197: /**
198: * @inheritDoc
199: */
200: public function getHost(): string
201: {
202: return (string) $this->Host;
203: }
204:
205: /**
206: * @inheritDoc
207: */
208: public function getPort(): ?int
209: {
210: if (
211: $this->Scheme !== null
212: && isset(static::SCHEME_PORT[$this->Scheme])
213: && static::SCHEME_PORT[$this->Scheme] === $this->Port
214: ) {
215: return null;
216: }
217:
218: return $this->Port;
219: }
220:
221: /**
222: * @inheritDoc
223: */
224: public function getPath(): string
225: {
226: return $this->Path;
227: }
228:
229: /**
230: * @inheritDoc
231: */
232: public function getQuery(): string
233: {
234: return (string) $this->Query;
235: }
236:
237: /**
238: * @inheritDoc
239: */
240: public function getFragment(): string
241: {
242: return (string) $this->Fragment;
243: }
244:
245: /**
246: * @inheritDoc
247: */
248: public function withScheme(string $scheme): PsrUriInterface
249: {
250: return $this
251: ->with('Scheme', $this->filterScheme($scheme))
252: ->validate();
253: }
254:
255: /**
256: * @inheritDoc
257: */
258: public function withUserInfo(string $user, ?string $password = null): PsrUriInterface
259: {
260: if ($user === '') {
261: $user = null;
262: $password = null;
263: } else {
264: $user = $this->filterUserInfoPart($user);
265: $password = $this->filterUserInfoPart($password);
266: }
267:
268: return $this
269: ->with('User', $user)
270: ->with('Password', $password)
271: ->validate();
272: }
273:
274: /**
275: * @inheritDoc
276: */
277: public function withHost(string $host): PsrUriInterface
278: {
279: return $this
280: ->with('Host', $this->filterHost(Str::coalesce($host, null)))
281: ->validate();
282: }
283:
284: /**
285: * @inheritDoc
286: */
287: public function withPort(?int $port): PsrUriInterface
288: {
289: return $this
290: ->with('Port', $this->filterPort($port))
291: ->validate();
292: }
293:
294: /**
295: * @inheritDoc
296: */
297: public function withPath(string $path): PsrUriInterface
298: {
299: return $this
300: ->with('Path', $this->filterPath($path))
301: ->validate();
302: }
303:
304: /**
305: * @inheritDoc
306: */
307: public function withQuery(string $query): PsrUriInterface
308: {
309: $query = Str::coalesce($query, null);
310: return $this
311: ->with('Query', $this->filterQueryOrFragment($query));
312: }
313:
314: /**
315: * @inheritDoc
316: */
317: public function withFragment(string $fragment): PsrUriInterface
318: {
319: $fragment = Str::coalesce($fragment, null);
320: return $this
321: ->with('Fragment', $this->filterQueryOrFragment($fragment));
322: }
323:
324: /**
325: * @inheritDoc
326: *
327: * @param int-mask-of<Uri::NORMALISE_*> $flags
328: */
329: public function normalise(int $flags = Uri::NORMALISE_EMPTY_PATH): UriInterface
330: {
331: $uri = $this->removeDotSegments();
332:
333: if (
334: $flags & self::NORMALISE_EMPTY_PATH
335: && $uri->Path === ''
336: && ($uri->Scheme === 'http' || $uri->Scheme === 'https')
337: ) {
338: $uri = $uri->withPath('/');
339: }
340:
341: if (
342: $flags & self::NORMALISE_MULTIPLE_SLASHES
343: && strpos($uri->Path, '//') !== false
344: ) {
345: $uri = $uri->withPath(Regex::replace('/\/\/++/', '/', $uri->Path));
346: }
347:
348: return $uri;
349: }
350:
351: /**
352: * @inheritDoc
353: */
354: public function follow($reference): UriInterface
355: {
356: if ($this->isRelativeReference()) {
357: throw new InvalidArgumentException(
358: 'Reference cannot be resolved relative to another reference'
359: );
360: }
361:
362: // Compliant with [RFC3986] Section 5.2.2 ("Transform References")
363: $reference = static::from($reference);
364: if (!$reference->isRelativeReference()) {
365: return $reference->removeDotSegments();
366: }
367:
368: $target = $this->withFragment((string) $reference->Fragment);
369:
370: if ($reference->getAuthority() !== '') {
371: return $target
372: ->withHost((string) $reference->Host)
373: ->withPort($reference->Port)
374: ->withUserInfo((string) $reference->User, $reference->Password)
375: ->withPath($reference->removeDotSegments()->Path)
376: ->withQuery((string) $reference->Query);
377: }
378:
379: if ($reference->Path === '') {
380: if ($reference->Query !== null) {
381: return $target->withQuery($reference->Query);
382: }
383: return $target;
384: }
385:
386: $target = $target->withQuery((string) $reference->Query);
387:
388: if ($reference->Path[0] === '/') {
389: return $target
390: ->withPath($reference->Path)
391: ->removeDotSegments();
392: }
393:
394: return $target
395: ->mergeRelativePath($reference->Path)
396: ->removeDotSegments();
397: }
398:
399: /**
400: * @inheritDoc
401: */
402: public function __toString(): string
403: {
404: $uri = '';
405:
406: if ($this->Scheme !== null) {
407: $uri .= "{$this->Scheme}:";
408: }
409:
410: $authority = $this->getAuthority();
411: if (
412: $authority !== ''
413: || $this->Host !== null
414: || $this->Scheme === 'file'
415: ) {
416: $uri .= "//$authority";
417: }
418:
419: $uri .= $this->Path;
420:
421: if ($this->Query !== null) {
422: $uri .= "?{$this->Query}";
423: }
424:
425: if ($this->Fragment !== null) {
426: $uri .= "#{$this->Fragment}";
427: }
428:
429: return $uri;
430: }
431:
432: /**
433: * @inheritDoc
434: */
435: public function jsonSerialize(): string
436: {
437: return $this->__toString();
438: }
439:
440: /**
441: * @return static
442: */
443: private function mergeRelativePath(string $path): self
444: {
445: // As per [RFC3986] Section 5.2.3 ("Merge Paths")
446: if ($this->getAuthority() !== '' && $this->Path === '') {
447: return $this->withPath("/$path");
448: }
449:
450: if (strpos($this->Path, '/') === false) {
451: return $this->withPath($path);
452: }
453:
454: $merge = implode('/', Arr::pop(explode('/', $this->Path)));
455: return $this->withPath("$merge/$path");
456: }
457:
458: /**
459: * @return static
460: */
461: private function removeDotSegments(): self
462: {
463: // As per [RFC3986] Section 5.2.4 ("Remove Dot Segments")
464: return $this->isRelativeReference()
465: ? $this
466: : $this->withPath(File::resolvePath($this->Path, true));
467: }
468:
469: private function filterScheme(?string $scheme, bool $validate = true): ?string
470: {
471: if ($scheme === null || $scheme === '') {
472: return null;
473: }
474:
475: if ($validate && !Regex::match(self::SCHEME_REGEX, $scheme)) {
476: throw new InvalidArgumentException(
477: sprintf('Invalid scheme: %s', $scheme)
478: );
479: }
480:
481: return Str::lower($scheme);
482: }
483:
484: private function filterUserInfoPart(?string $part): ?string
485: {
486: if ($part === null || $part === '') {
487: return $part;
488: }
489:
490: return $this->normaliseComponent($part, '[]#/:?@[]');
491: }
492:
493: private function filterHost(?string $host, bool $validate = true): ?string
494: {
495: if ($host === null || $host === '') {
496: return $host;
497: }
498:
499: if ($validate) {
500: $host = $this->encode($host);
501: if (!Regex::match(self::HOST_REGEX, $host)) {
502: throw new InvalidArgumentException(
503: sprintf('Invalid host: %s', $host)
504: );
505: }
506: }
507:
508: $host = Str::lower($this->decodeUnreserved($host));
509: return $this->normaliseComponent($host, '[#/?@]', false);
510: }
511:
512: /**
513: * @param string|int|null $port
514: */
515: private function filterPort($port): ?int
516: {
517: if ($port === null || $port === '') {
518: return null;
519: }
520:
521: $port = (int) $port;
522: if ($port < 0 || $port > 65535) {
523: throw new InvalidArgumentException(
524: sprintf('Invalid port: %d', $port)
525: );
526: }
527:
528: return $port;
529: }
530:
531: private function filterPath(?string $path): string
532: {
533: if ($path === null || $path === '') {
534: return '';
535: }
536:
537: return $this->normaliseComponent($path, '[]#?[]');
538: }
539:
540: private function filterQueryOrFragment(?string $part): ?string
541: {
542: if ($part === null || $part === '') {
543: return $part;
544: }
545:
546: return $this->normaliseComponent($part, '[]#[]');
547: }
548:
549: /**
550: * @param bool $decodeUnreserved `false` if unreserved characters are
551: * already decoded.
552: */
553: private function normaliseComponent(
554: string $part,
555: string $encodeRegex = '',
556: bool $decodeUnreserved = true
557: ): string {
558: if ($decodeUnreserved) {
559: $part = $this->decodeUnreserved($part);
560: }
561:
562: if ($encodeRegex !== '') {
563: $encodeRegex = str_replace('/', '\/', $encodeRegex) . '|';
564: }
565:
566: return Regex::replaceCallbackArray([
567: // Use uppercase hexadecimal digits
568: '/%([0-9a-f]{2})/i' =>
569: fn($matches) => '%' . Str::upper($matches[1]),
570: // Encode everything except reserved and unreserved characters
571: "/(?:%(?![0-9a-f]{2})|{$encodeRegex}[^]!#\$%&'()*+,\/:;=?@[])+/i" =>
572: fn($matches) => rawurlencode($matches[0]),
573: ], $part);
574: }
575:
576: private function decodeUnreserved(string $part): string
577: {
578: return Regex::replaceCallback(
579: '/%(2[de]|5f|7e|3[0-9]|[46][1-9a-f]|[57][0-9a])/i',
580: fn($matches) => chr((int) hexdec($matches[1])),
581: $part,
582: );
583: }
584:
585: /**
586: * Percent-encode every character in a URI or URI component except reserved,
587: * unreserved and pre-encoded characters
588: */
589: private function encode(string $partOrUri): string
590: {
591: return Regex::replaceCallback(
592: '/(?:%(?![0-9a-f]{2})|[^]!#$%&\'()*+,\/:;=?@[])+/i',
593: fn($matches) => rawurlencode($matches[0]),
594: $partOrUri,
595: );
596: }
597:
598: /**
599: * @return $this
600: */
601: private function validate(): self
602: {
603: if ($this->getAuthority() === '') {
604: if (substr($this->Path, 0, 2) === '//') {
605: throw new InvalidArgumentException(
606: 'Path cannot begin with "//" in URI without authority'
607: );
608: }
609:
610: if (
611: $this->Scheme === null
612: && $this->Path !== ''
613: && Regex::match('/^[^\/:]*+:/', $this->Path)
614: ) {
615: throw new InvalidArgumentException(
616: 'Path cannot begin with colon segment in URI without scheme'
617: );
618: }
619: } elseif ($this->Path !== '' && $this->Path[0] !== '/') {
620: throw new InvalidArgumentException(
621: 'Path must be empty or begin with "/" in URI with authority'
622: );
623: }
624:
625: return $this;
626: }
627: }
628: