diff --git a/packages/http/src/IsRequest.php b/packages/http/src/IsRequest.php index 53408abb37..930b07dce7 100644 --- a/packages/http/src/IsRequest.php +++ b/packages/http/src/IsRequest.php @@ -6,6 +6,7 @@ use Tempest\Http\Cookie\Cookie; use Tempest\Http\Session\Session; +use Tempest\Support\Uri\Uri; use Tempest\Validation\SkipValidation; use function Tempest\Container\get; @@ -64,8 +65,16 @@ public function __construct( $this->files = $files; $this->raw = $raw; - $this->path ??= $this->resolvePath(); - $this->query ??= $this->resolveQuery(); + if ($this->method === Method::CONNECT) { + $this->path ??= ''; + $this->query ??= []; + + return; + } + + $uri = Uri::from($this->uri); + $this->path ??= rawurldecode($uri->path ?? ''); + $this->query ??= $uri->query; } public function get(string $key, mixed $default = null): mixed @@ -94,25 +103,6 @@ public function getCookie(string $name): ?Cookie return $this->cookies[$name] ?? null; } - private function resolvePath(): string - { - $decodedUri = rawurldecode($this->uri); - $parsedUrl = parse_url($decodedUri); - - return $parsedUrl['path'] ?? ''; - } - - private function resolveQuery(): array - { - $decodedUri = rawurldecode($this->uri); - $parsedUrl = parse_url($decodedUri); - $queryString = $parsedUrl['query'] ?? ''; - - parse_str($queryString, $query); - - return $query; - } - public function has(string $key): bool { if ($this->hasBody($key)) { diff --git a/packages/http/tests/GenericRequestTest.php b/packages/http/tests/GenericRequestTest.php index d1abf37a83..3a3d8b7cf2 100644 --- a/packages/http/tests/GenericRequestTest.php +++ b/packages/http/tests/GenericRequestTest.php @@ -6,6 +6,7 @@ use LogicException; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Tempest\Http\ContentType; use Tempest\Http\GenericRequest; @@ -176,6 +177,21 @@ public function accepts_can_handle_priorities(): void $this->assertTrue($request->accepts(ContentType::XML)); } + #[Test] + #[TestWith(['example.com:443'])] + #[TestWith(['[::1]:443'])] + public function connect_requests_skip_uri_parsing(string $uri): void + { + $request = new GenericRequest( + method: Method::CONNECT, + uri: $uri, + ); + + $this->assertSame($uri, $request->uri); + $this->assertSame('', $request->path); + $this->assertSame([], $request->query); + } + #[Test] public function accepts_returns_true_on_first_match(): void { diff --git a/packages/router/src/Static/StaticGenerateCommand.php b/packages/router/src/Static/StaticGenerateCommand.php index 14627d3105..939a5826f8 100644 --- a/packages/router/src/Static/StaticGenerateCommand.php +++ b/packages/router/src/Static/StaticGenerateCommand.php @@ -32,6 +32,7 @@ use Tempest\Support\Filesystem; use Tempest\Support\Regex; use Tempest\Support\Str; +use Tempest\Support\Uri; use Tempest\View\Exceptions\ViewCompilationFailed; use Tempest\View\View; use Tempest\View\ViewRenderer; @@ -124,7 +125,7 @@ public function __invoke( $params = [$params]; } - $uri = parse_url(uri($staticPage->handler, ...$params), PHP_URL_PATH); + $uri = Uri\get_path(uri($staticPage->handler, ...$params)); $fileName = $uri === '/' ? 'index.html' diff --git a/packages/support/src/Uri/Uri.php b/packages/support/src/Uri/Uri.php index 4070039bfb..d527a718c9 100644 --- a/packages/support/src/Uri/Uri.php +++ b/packages/support/src/Uri/Uri.php @@ -8,8 +8,7 @@ use Tempest\Support\Str; use Tempest\Support\Str\ImmutableString; use Tempest\Support\Str\MutableString; - -use function parse_url; +use Uri\WhatWg\Url; final class Uri implements Stringable { @@ -67,21 +66,53 @@ public function __construct( */ public static function from(string $uri): self { - $parts = parse_url($uri); + $canBeAbsolute = $uri !== '' && $uri !== '*' && ! str_contains('/?#', $uri[0]); + + if ($canBeAbsolute && ($url = Url::parse($uri)) instanceof Url) { + return new self( + scheme: $url->getScheme(), + user: $url->getUsername(), + password: $url->getPassword() === '' ? null : $url->getPassword(), + host: $url->getAsciiHost(), + port: $url->getPort(), + path: $url->getPath() ?: null, + queryString: $url->getQuery(), + fragment: $url->getFragment(), + ); + } + + static $baseUrl = null; - if ($parts === false) { + $url = Url::parse($uri, $baseUrl ??= new Url('http://localhost')); + + if (! $url instanceof Url) { return new self(path: $uri); } + if (str_starts_with($uri, '//')) { + static $secureBaseUrl = null; + + $httpsUrl = Url::parse($uri, $secureBaseUrl ??= new Url('https://localhost')); + + return new self( + user: $url->getUsername(), + password: $url->getPassword() === '' ? null : $url->getPassword(), + host: $url->getAsciiHost(), + port: $url->getPort() ?? $httpsUrl?->getPort(), + path: $url->getPath() ?: null, + queryString: $url->getQuery(), + fragment: $url->getFragment(), + ); + } + + $path = str_starts_with($uri, '/') + ? $url->getPath() + : Str\strip_start($url->getPath(), '/'); + return new self( - scheme: $parts['scheme'] ?? null, - user: $parts['user'] ?? null, - password: $parts['pass'] ?? null, - host: $parts['host'] ?? null, - port: $parts['port'] ?? null, - path: $parts['path'] ?? null, - queryString: $parts['query'] ?? null, - fragment: $parts['fragment'] ?? null, + path: $path === '' ? null : $path, + queryString: $url->getQuery(), + fragment: $url->getFragment(), ); } diff --git a/packages/support/tests/Uri/FunctionsTest.php b/packages/support/tests/Uri/FunctionsTest.php index 4bda9bd86e..22cf1d28d4 100644 --- a/packages/support/tests/Uri/FunctionsTest.php +++ b/packages/support/tests/Uri/FunctionsTest.php @@ -31,15 +31,15 @@ final class FunctionsTest extends TestCase { #[Test] - #[TestWith(['https://example.com', ['foo' => 'bar'], 'https://example.com?foo=bar'])] - #[TestWith(['https://example.com?existing=value', ['foo' => 'bar'], 'https://example.com?foo=bar'])] - #[TestWith(['https://example.com', ['foo' => 'bar', 'baz' => 'qux'], 'https://example.com?foo=bar&baz=qux'])] + #[TestWith(['https://example.com', ['foo' => 'bar'], 'https://example.com/?foo=bar'])] + #[TestWith(['https://example.com?existing=value', ['foo' => 'bar'], 'https://example.com/?foo=bar'])] + #[TestWith(['https://example.com', ['foo' => 'bar', 'baz' => 'qux'], 'https://example.com/?foo=bar&baz=qux'])] #[TestWith(['https://example.com/path', [], 'https://example.com/path'])] #[TestWith(['malformed-uri', ['foo' => 'bar'], 'malformed-uri?foo=bar'])] - #[TestWith(['https://example.com', ['foo'], 'https://example.com?foo'])] - #[TestWith(['https://example.com', ['foo' => true], 'https://example.com?foo=true'])] - #[TestWith(['https://example.com', ['foo' => false], 'https://example.com?foo=false'])] - #[TestWith(['https://example.com', ['foo' => ['bar' => 'baz']], 'https://example.com?foo%5Bbar%5D=baz'])] + #[TestWith(['https://example.com', ['foo'], 'https://example.com/?foo'])] + #[TestWith(['https://example.com', ['foo' => true], 'https://example.com/?foo=true'])] + #[TestWith(['https://example.com', ['foo' => false], 'https://example.com/?foo=false'])] + #[TestWith(['https://example.com', ['foo' => ['bar' => 'baz']], 'https://example.com/?foo%5Bbar%5D=baz'])] public function set_query(string $uri, array $query, string $expected): void { $this->assertSame($expected, set_query($uri, ...$query)); @@ -57,8 +57,8 @@ public function get_query(string $uri, array $expected): void } #[Test] - #[TestWith(['https://example.com', 'frieren', 'https://example.com#frieren'])] - #[TestWith(['https://example.com#old', 'stark', 'https://example.com#stark'])] + #[TestWith(['https://example.com', 'frieren', 'https://example.com/#frieren'])] + #[TestWith(['https://example.com#old', 'stark', 'https://example.com/#stark'])] #[TestWith(['https://example.com/path?query=value', 'fern', 'https://example.com/path?query=value#fern'])] #[TestWith(['malformed-uri', 'fragment', 'malformed-uri#fragment'])] public function set_fragment(string $uri, string $fragment, string $expected): void @@ -77,9 +77,9 @@ public function get_fragment(string $uri, ?string $expected): void } #[Test] - #[TestWith(['https://example.com', 'flamme.org', 'https://flamme.org'])] + #[TestWith(['https://example.com', 'flamme.org', 'https://flamme.org/'])] #[TestWith(['https://old.com/path', 'neue.com', 'https://neue.com/path'])] - #[TestWith(['http://user:pass@old.com:8080', 'serie.com', 'http://user:pass@serie.com:8080'])] + #[TestWith(['http://user:pass@old.com:8080', 'serie.com', 'http://user:pass@serie.com:8080/'])] #[TestWith(['malformed-uri', 'domain.com', '//domain.com/malformed-uri'])] public function set_host(string $uri, string $host, string $expected): void { @@ -98,9 +98,9 @@ public function get_host(string $uri, ?string $expected): void } #[Test] - #[TestWith(['https://example.com', 'http', 'http://example.com'])] - #[TestWith(['http://example.com', 'https', 'https://example.com'])] - #[TestWith(['ftp://files.example.com', 'sftp', 'sftp://files.example.com'])] + #[TestWith(['https://example.com', 'http', 'http://example.com/'])] + #[TestWith(['http://example.com', 'https', 'https://example.com/'])] + #[TestWith(['ftp://files.example.com', 'sftp', 'sftp://files.example.com/'])] #[TestWith(['malformed-uri', 'https', 'https:malformed-uri'])] public function set_scheme(string $uri, string $scheme, string $expected): void { @@ -119,9 +119,9 @@ public function get_scheme(string $uri, ?string $expected): void } #[Test] - #[TestWith(['https://example.com', 8080, 'https://example.com:8080'])] - #[TestWith(['https://example.com:80', 9000, 'https://example.com:9000'])] - #[TestWith(['http://user:pass@example.com', 3000, 'http://user:pass@example.com:3000'])] + #[TestWith(['https://example.com', 8080, 'https://example.com:8080/'])] + #[TestWith(['https://example.com:80', 9000, 'https://example.com:9000/'])] + #[TestWith(['http://user:pass@example.com', 3000, 'http://user:pass@example.com:3000/'])] #[TestWith(['malformed-uri', 8080, 'malformed-uri'])] public function set_port(string $uri, int $port, string $expected): void { @@ -139,9 +139,9 @@ public function get_port(string $uri, ?int $expected): void } #[Test] - #[TestWith(['https://example.com', 'frieren', 'https://frieren@example.com'])] - #[TestWith(['https://old@example.com', 'fern', 'https://fern@example.com'])] - #[TestWith(['https://old:pass@example.com', 'stark', 'https://stark:pass@example.com'])] + #[TestWith(['https://example.com', 'frieren', 'https://frieren@example.com/'])] + #[TestWith(['https://old@example.com', 'fern', 'https://fern@example.com/'])] + #[TestWith(['https://old:pass@example.com', 'stark', 'https://stark:pass@example.com/'])] #[TestWith(['malformed-uri', 'user', '//user@malformed-uri'])] public function set_user(string $uri, string $user, string $expected): void { @@ -159,9 +159,9 @@ public function get_user(string $uri, ?string $expected): void } #[Test] - #[TestWith(['https://user@example.com', 'magic', 'https://user:magic@example.com'])] - #[TestWith(['https://user:old@example.com', 'spell', 'https://user:spell@example.com'])] - #[TestWith(['https://example.com', 'secret', 'https://:secret@example.com'])] + #[TestWith(['https://user@example.com', 'magic', 'https://user:magic@example.com/'])] + #[TestWith(['https://user:old@example.com', 'spell', 'https://user:spell@example.com/'])] + #[TestWith(['https://example.com', 'secret', 'https://:secret@example.com/'])] #[TestWith(['malformed-uri', 'pass', '//:pass@malformed-uri'])] public function set_password(string $uri, string $pass, string $expected): void { @@ -172,6 +172,7 @@ public function set_password(string $uri, string $pass, string $expected): void #[TestWith(['https://user:magic@example.com', 'magic'])] #[TestWith(['https://example.com', null])] #[TestWith(['https://user@example.com', null])] + #[TestWith(['https://user:0@example.com', '0'])] #[TestWith(['malformed-uri', null])] public function get_password(string $uri, ?string $expected): void { @@ -190,8 +191,9 @@ public function set_path(string $uri, string $path, string $expected): void #[Test] #[TestWith(['https://example.com/frieren/journey', '/frieren/journey'])] - #[TestWith(['https://example.com', null])] + #[TestWith(['https://example.com', '/'])] #[TestWith(['https://example.com/', '/'])] + #[TestWith(['frieren/journey', 'frieren/journey'])] #[TestWith(['malformed-uri', 'malformed-uri'])] public function get_path(string $uri, ?string $expected): void { @@ -211,10 +213,10 @@ public function get_segments(string $uri, array $expected): void } #[Test] - #[TestWith(['https://example.com?foo=bar', ['baz' => 'qux'], 'https://example.com?foo=bar&baz=qux'])] - #[TestWith(['https://example.com', ['foo' => 'bar'], 'https://example.com?foo=bar'])] - #[TestWith(['https://example.com?existing=value', ['new' => 'param'], 'https://example.com?existing=value&new=param'])] - #[TestWith(['https://example.com?foo=old', ['foo' => 'new'], 'https://example.com?foo=new'])] + #[TestWith(['https://example.com?foo=bar', ['baz' => 'qux'], 'https://example.com/?foo=bar&baz=qux'])] + #[TestWith(['https://example.com', ['foo' => 'bar'], 'https://example.com/?foo=bar'])] + #[TestWith(['https://example.com?existing=value', ['new' => 'param'], 'https://example.com/?existing=value&new=param'])] + #[TestWith(['https://example.com?foo=old', ['foo' => 'new'], 'https://example.com/?foo=new'])] #[TestWith(['malformed-uri?foo=bar', ['baz' => 'qux'], 'malformed-uri?foo=bar&baz=qux'])] public function merge_query(string $uri, array $query, string $expected): void { @@ -222,11 +224,11 @@ public function merge_query(string $uri, array $query, string $expected): void } #[Test] - #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo'], 'https://example.com?baz=qux'])] - #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo', 'baz'], 'https://example.com'])] - #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo' => 'bar'], 'https://example.com?baz=qux'])] - #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo' => 'wrong'], 'https://example.com?foo=bar&baz=qux'])] - #[TestWith(['https://example.com', ['nonexistent'], 'https://example.com'])] + #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo'], 'https://example.com/?baz=qux'])] + #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo', 'baz'], 'https://example.com/'])] + #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo' => 'bar'], 'https://example.com/?baz=qux'])] + #[TestWith(['https://example.com?foo=bar&baz=qux', ['foo' => 'wrong'], 'https://example.com/?foo=bar&baz=qux'])] + #[TestWith(['https://example.com', ['nonexistent'], 'https://example.com/'])] #[TestWith(['malformed-uri?foo=bar', ['foo'], 'malformed-uri'])] public function without_query(string $uri, array $query, string $expected): void { diff --git a/packages/support/tests/Uri/UriTest.php b/packages/support/tests/Uri/UriTest.php index 0c79188e4f..a01399b9a1 100644 --- a/packages/support/tests/Uri/UriTest.php +++ b/packages/support/tests/Uri/UriTest.php @@ -15,14 +15,14 @@ public function is_stringable(): void { $uri = Uri::from('https://example.com'); - $this->assertSame('https://example.com', $uri->toString()); - $this->assertSame('https://example.com', (string) $uri); + $this->assertSame('https://example.com/', $uri->toString()); + $this->assertSame('https://example.com/', (string) $uri); } #[Test] public function can_be_made_statically(): void { - $this->assertSame('https://example.com', Uri::from('https://example.com')->toString()); + $this->assertSame('https://example.com/', Uri::from('https://example.com')->toString()); } #[Test] @@ -33,10 +33,10 @@ public function scheme_manipulation(): void $this->assertSame('https', $uri->scheme); $clone = $uri->withScheme('http'); - $this->assertSame('http://example.com', $clone->toString()); + $this->assertSame('http://example.com/', $clone->toString()); $this->assertSame('http', $clone->scheme); - $this->assertSame('https://example.com', $uri->toString()); + $this->assertSame('https://example.com/', $uri->toString()); } #[Test] @@ -47,7 +47,7 @@ public function user_manipulation(): void $this->assertNull($uri->user); $clone = $uri->withUser('frieren'); - $this->assertSame('https://frieren@example.com', $clone->toString()); + $this->assertSame('https://frieren@example.com/', $clone->toString()); $this->assertSame('frieren', $clone->user); $this->assertNull($uri->user); @@ -61,7 +61,7 @@ public function password_manipulation(): void $this->assertNull($uri->password); $clone = $uri->withPassword('magic'); - $this->assertSame('https://frieren:magic@example.com', $clone->toString()); + $this->assertSame('https://frieren:magic@example.com/', $clone->toString()); $this->assertSame('magic', $clone->password); } @@ -73,7 +73,7 @@ public function host_manipulation(): void $this->assertSame('example.com', $uri->host); $clone = $uri->withHost('aureole.magic'); - $this->assertSame('https://aureole.magic', $clone->toString()); + $this->assertSame('https://aureole.magic/', $clone->toString()); $this->assertSame('aureole.magic', $clone->host); $this->assertSame('example.com', $uri->host); @@ -87,7 +87,7 @@ public function port_manipulation(): void $this->assertNull($uri->port); $clone = $uri->withPort(8080); - $this->assertSame('https://example.com:8080', $clone->toString()); + $this->assertSame('https://example.com:8080/', $clone->toString()); $this->assertSame(8080, $clone->port); $this->assertNull($uri->port); @@ -98,13 +98,13 @@ public function path_manipulation(): void { $uri = Uri::from('https://example.com'); - $this->assertNull($uri->path); + $this->assertSame('/', $uri->path); $clone = $uri->withPath('foo/bar'); $this->assertSame('https://example.com/foo/bar', $clone->toString()); $this->assertSame('foo/bar', $clone->path); - $this->assertNull($uri->path); + $this->assertSame('/', $uri->path); } #[Test] @@ -115,7 +115,7 @@ public function query_manipulation(): void $this->assertSame([], $uri->query); $clone = $uri->withQuery(destination: 'aureole', companion: 'fern'); - $this->assertSame('https://example.com?destination=aureole&companion=fern', $clone->toString()); + $this->assertSame('https://example.com/?destination=aureole&companion=fern', $clone->toString()); $this->assertSame(['destination' => 'aureole', 'companion' => 'fern'], $clone->query); $this->assertSame([], $uri->query); @@ -127,10 +127,10 @@ public function query_with_special_values(): void $uri = Uri::from('https://example.com'); $clone = $uri->withQuery('standalone'); - $this->assertSame('https://example.com?standalone', $clone->toString()); + $this->assertSame('https://example.com/?standalone', $clone->toString()); $clone = $uri->withQuery(active: true, disabled: false); - $this->assertSame('https://example.com?active=true&disabled=false', $clone->toString()); + $this->assertSame('https://example.com/?active=true&disabled=false', $clone->toString()); } #[Test] @@ -141,7 +141,7 @@ public function fragment_manipulation(): void $this->assertNull($uri->fragment); $clone = $uri->withFragment('adventure'); - $this->assertSame('https://example.com#adventure', $clone->toString()); + $this->assertSame('https://example.com/#adventure', $clone->toString()); $this->assertSame('adventure', $clone->fragment); $this->assertNull($uri->fragment); @@ -164,7 +164,7 @@ public function add_query(): void $uri = Uri::from('https://example.com?existing=value'); $clone = $uri->addQuery(destination: 'aureole', companion: 'fern'); - $this->assertSame('https://example.com?existing=value&destination=aureole&companion=fern', $clone->toString()); + $this->assertSame('https://example.com/?existing=value&destination=aureole&companion=fern', $clone->toString()); $this->assertSame(['existing' => 'value', 'destination' => 'aureole', 'companion' => 'fern'], $clone->query); $this->assertSame(['existing' => 'value'], $uri->query); @@ -178,7 +178,7 @@ public function remove_query(): void $this->assertSame(['foo' => 'bar', 'baz' => 'qux'], $uri->query); $clone = $uri->removeQuery(); - $this->assertSame('https://example.com', $clone->toString()); + $this->assertSame('https://example.com/', $clone->toString()); $this->assertSame([], $clone->query); $this->assertSame(['foo' => 'bar', 'baz' => 'qux'], $uri->query); @@ -190,7 +190,7 @@ public function remove_query_when_there_was_no_query(): void $uri = Uri::from('https://example.com'); $withoutQuery = $uri->removeQuery(); - $this->assertSame('https://example.com', $withoutQuery->toString()); + $this->assertSame('https://example.com/', $withoutQuery->toString()); $this->assertSame([], $withoutQuery->query); } @@ -200,23 +200,23 @@ public function without_query_removes_specific_parameters(): void $uri = Uri::from('https://example.com?foo=bar&baz=qux&active=true'); $clone = $uri->withoutQuery('foo'); - $this->assertSame('https://example.com?baz=qux&active=true', $clone->toString()); + $this->assertSame('https://example.com/?baz=qux&active=true', $clone->toString()); $this->assertSame(['baz' => 'qux', 'active' => 'true'], $clone->query); $clone = $uri->withoutQuery('foo', 'baz'); - $this->assertSame('https://example.com?active=true', $clone->toString()); + $this->assertSame('https://example.com/?active=true', $clone->toString()); $this->assertSame(['active' => 'true'], $clone->query); $clone = $uri->withoutQuery(foo: 'bar'); - $this->assertSame('https://example.com?baz=qux&active=true', $clone->toString()); + $this->assertSame('https://example.com/?baz=qux&active=true', $clone->toString()); $this->assertSame(['baz' => 'qux', 'active' => 'true'], $clone->query); $clone = $uri->withoutQuery(foo: 'different'); - $this->assertSame('https://example.com?foo=bar&baz=qux&active=true', $clone->toString()); + $this->assertSame('https://example.com/?foo=bar&baz=qux&active=true', $clone->toString()); $this->assertSame(['foo' => 'bar', 'baz' => 'qux', 'active' => 'true'], $clone->query); $clone = $uri->withoutQuery('nonexistent'); - $this->assertSame('https://example.com?foo=bar&baz=qux&active=true', $clone->toString()); + $this->assertSame('https://example.com/?foo=bar&baz=qux&active=true', $clone->toString()); $this->assertSame(['foo' => 'bar', 'baz' => 'qux', 'active' => 'true'], $uri->query); } @@ -227,7 +227,7 @@ public function without_query_on_empty_uri(): void $uri = Uri::from('https://example.com'); $clone = $uri->withoutQuery('foo', 'bar'); - $this->assertSame('https://example.com', $clone->toString()); + $this->assertSame('https://example.com/', $clone->toString()); $this->assertSame([], $clone->query); } @@ -292,4 +292,109 @@ public function edge_cases(): void $this->assertSame(['query' => 'value'], $uri->query); $this->assertSame('fragment', $uri->fragment); } + + #[Test] + public function zero_values_are_preserved(): void + { + $uri = Uri::from('https://user:0@example.com'); + $this->assertSame('0', $uri->password); + $this->assertSame('https://user:0@example.com/', $uri->toString()); + + $uri = Uri::from('0'); + $this->assertSame('0', $uri->toString()); + } + + #[Test] + public function host_normalization(): void + { + $uri = Uri::from('HTTPS://EXAMPLE.COM/path'); + $this->assertSame('https://example.com/path', $uri->toString()); + + $uri = Uri::from('https://bücher.example/'); + $this->assertSame('https://xn--bcher-kva.example/', $uri->toString()); + $this->assertSame('xn--bcher-kva.example', $uri->host); + } + + #[Test] + public function default_ports_are_dropped(): void + { + $uri = Uri::from('https://example.com:443/path'); + $this->assertNull($uri->port); + $this->assertSame('https://example.com/path', $uri->toString()); + + $uri = Uri::from('http://example.com:80/path'); + $this->assertNull($uri->port); + $this->assertSame('http://example.com/path', $uri->toString()); + } + + #[Test] + public function dot_segments_are_resolved(): void + { + $uri = Uri::from('https://example.com/a/../b'); + $this->assertSame('/b', $uri->path); + + $uri = Uri::from('https://example.com/a/./b'); + $this->assertSame('/a/b', $uri->path); + } + + #[Test] + public function protocol_relative_uris(): void + { + $uri = Uri::from('//example.com/path'); + $this->assertNull($uri->scheme); + $this->assertSame('example.com', $uri->host); + $this->assertSame('/path', $uri->path); + $this->assertSame('//example.com/path', $uri->toString()); + } + + #[Test] + public function relative_uris(): void + { + $uri = Uri::from('frieren/journey'); + $this->assertNull($uri->scheme); + $this->assertNull($uri->host); + $this->assertSame('frieren/journey', $uri->path); + $this->assertSame('frieren/journey', $uri->toString()); + + $uri = Uri::from('/frieren/journey'); + $this->assertSame('/frieren/journey', $uri->path); + $this->assertSame('/frieren/journey', $uri->toString()); + + $uri = Uri::from('frieren?chapter=1#end'); + $this->assertSame('frieren', $uri->path); + $this->assertSame('chapter=1', $uri->queryString); + $this->assertSame('end', $uri->fragment); + $this->assertSame('frieren?chapter=1#end', $uri->toString()); + + $uri = Uri::from('?foo=bar'); + $this->assertNull($uri->path); + $this->assertSame('foo=bar', $uri->queryString); + $this->assertSame('?foo=bar', $uri->toString()); + + $uri = Uri::from('#end'); + $this->assertNull($uri->path); + $this->assertSame('end', $uri->fragment); + $this->assertSame('#end', $uri->toString()); + } + + #[Test] + public function protocol_relative_uri_preserves_explicit_default_ports(): void + { + $uri = Uri::from('//example.com:80/path'); + $this->assertSame(80, $uri->port); + $this->assertSame('//example.com:80/path', (string) $uri); + + $uri = Uri::from('//example.com:443/path'); + $this->assertSame(443, $uri->port); + + $this->assertSame(80, Uri::from('//example.com:80/path')->withScheme('https')->port); + } + + #[Test] + public function protocol_relative_uri_normalizes_unicode_host_and_keeps_port(): void + { + $uri = Uri::from('//bücher.example:80/path'); + $this->assertSame('xn--bcher-kva.example', $uri->host); + $this->assertSame(80, $uri->port); + } }