From d74c6b3b79d539fd2958bf88c45c8a04d50280a0 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Tue, 24 Dec 2024 18:58:20 +0100 Subject: [PATCH] Adding more URI string representation (#150) --- CHANGELOG.md | 4 ++ Uri.php | 104 +++++++++++++++++++++++++--- UriTest.php | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86e611d56..522155c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ All Notable changes to `League\Uri` will be documented in this file - `Uri::getOrigin` - `Uri::isSameOrigin` - `Uri::isCrossOrigin` +- `Uri::todisplayString` shows the URI in a human-readable format which may be an invalid URI. +- `Uri::toUnixPath` returns the URI path as a Unix Path or `null` +- `Uri::toWindowsPath` returns the URI path as a Windows Path or `null` +- `Uri::toRfc8089` return the URI in a RFC8089 formator `null` ### Fixed diff --git a/Uri.php b/Uri.php index a021b4bd7..ee437ceb4 100644 --- a/Uri.php +++ b/Uri.php @@ -1000,25 +1000,112 @@ private function isNonEmptyHostUriWithoutFragmentAndQuery(): bool return $this->isNonEmptyHostUri() && null === $this->fragment && null === $this->query; } + public function __toString(): string + { + return $this->toString(); + } + + public function jsonSerialize(): string + { + return $this->toString(); + } + public function toString(): string { return $this->uri; } + public function toNormalizedString(): string + { + return $this->normalize()->toString(); + } + + public function toDisplayString(): string + { + /** @var ComponentMap $components */ + $components = array_map( + fn (?string $value): ?string => (null === $value || '' === $value) ? $value : rawurldecode($value), + $this->normalize()->toComponents() + ); + + if (null !== $components['host']) { + $components['host'] = IdnaConverter::toUnicode($components['host'])->domain(); + } + + if ('/' === $components['path'] && null !== $this->authority) { + $components['path'] = ''; + } + + return UriString::build($components); + } + /** - * {@inheritDoc} + * Returns the Unix filesystem path. + * + * The method will return null if a scheme is present and is not the `file` scheme */ - public function __toString(): string + public function toUnixPath(): ?string { - return $this->toString(); + return match ($this->scheme) { + 'file', null => rawurldecode($this->path), + default => null, + }; } /** - * {@inheritDoc} + * Returns the Windows filesystem path. + * + * The method will return null if a scheme is present and is not the `file` scheme */ - public function jsonSerialize(): string + public function toWindowsPath(): ?string { - return $this->toString(); + static $regexpWindowsPath = ',^(?[a-zA-Z]:),'; + + if (!in_array($this->scheme, ['file', null], true)) { + return null; + } + + $originalPath = $this->path; + $path = $originalPath; + if ('/' === ($path[0] ?? '')) { + $path = substr($path, 1); + } + + if (1 === preg_match($regexpWindowsPath, $path, $matches)) { + $root = $matches['root']; + $path = substr($path, strlen($root)); + + return $root.str_replace('/', '\\', rawurldecode($path)); + } + + $host = $this->host; + + return match (null) { + $host => str_replace('/', '\\', rawurldecode($originalPath)), + default => '\\\\'.$host.'\\'.str_replace('/', '\\', rawurldecode($path)), + }; + } + + /** + * Returns a string representation of a File URI according to RFC8089. + * + * The method will return null if the URI scheme is not the `file` scheme + * + * @see https://datatracker.ietf.org/doc/html/rfc8089 + */ + public function toRfc8089(): ?string + { + $path = $this->path; + + return match (true) { + 'file' !== $this->scheme => null, + in_array($this->authority, ['', null, 'localhost'], true) => 'file:'.match (true) { + '' === $path, + '/' === $path[0] => $path, + default => '/'.$path, + }, + default => $this->toString(), + }; } /** @@ -1408,11 +1495,6 @@ public function equals(UriInterface|Stringable|string $uri, bool $excludeFragmen }; } - public function toNormalizedString(): string - { - return $this->normalize()->toString(); - } - /** * Tells whether the URI contains an Internationalized Domain Name (IDN). */ diff --git a/UriTest.php b/UriTest.php index df2bd8161..44564b627 100644 --- a/UriTest.php +++ b/UriTest.php @@ -11,12 +11,14 @@ namespace League\Uri; +use GuzzleHttp\Psr7\Utils; use League\Uri\Components\HierarchicalPath; use League\Uri\Components\Port; use League\Uri\Exceptions\SyntaxError; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Psr\Http\Message\UriInterface as Psr7UriInterface; use TypeError; @@ -792,4 +794,191 @@ public static function getCrossOriginExamples(): array 'cross origin using a blob' => ['blob:http://mozilla.org:443/', 'https://mozilla.org/123', true], ]; } + + #[DataProvider('idnUriProvider')] + public function testItReturnsTheCorrectUriString(string $expected, string $input): void + { + self::assertSame($expected, Uri::new($input)->toDisplayString()); + } + + public static function idnUriProvider(): iterable + { + yield 'basic uri stays the same' => [ + 'expected' => 'http://example.com/foo/bar', + 'input' => 'http://example.com/foo/bar', + ]; + + yield 'idn host are changed' => [ + 'expected' => 'http://bébé.be', + 'input' => 'http://xn--bb-bjab.be', + ]; + + yield 'idn host are the same' => [ + 'expected' => 'http://bébé.be', + 'input' => 'http://bébé.be', + ]; + + yield 'the rest of the URI is not affected and uses RFC3986 rules' => [ + 'expected' => 'http://bébé.be?q=toto le héros', + 'input' => 'http://bébé.be:80?q=toto%20le%20h%C3%A9ros', + ]; + } + + #[DataProvider('unixpathProvider')] + public function testReturnsUnixPath(?string $expected, string $input): void + { + self::assertSame($expected, Uri::new($input)->toUnixPath()); + self::assertSame($expected, Uri::new(Utils::uriFor($input))->toUnixPath()); + } + + public static function unixpathProvider(): array + { + return [ + 'relative path' => [ + 'expected' => 'path', + 'input' => 'path', + ], + 'absolute path' => [ + 'expected' => '/path', + 'input' => 'file:///path', + ], + 'path with empty char' => [ + 'expected' => '/path empty/bar', + 'input' => 'file:///path%20empty/bar', + ], + 'relative path with dot segments' => [ + 'expected' => 'path/./relative', + 'input' => 'path/./relative', + ], + 'absolute path with dot segments' => [ + 'expected' => '/path/./../relative', + 'input' => 'file:///path/./../relative', + ], + 'unsupported scheme' => [ + 'expected' => null, + 'input' => 'http://example.com/foo/bar', + ], + ]; + } + + #[DataProvider('windowLocalPathProvider')] + public function testReturnsWindowsPath(?string $expected, string $input): void + { + self::assertSame($expected, Uri::new($input)->toWindowsPath()); + } + + public static function windowLocalPathProvider(): array + { + return [ + 'relative path' => [ + 'expected' => 'path', + 'input' => 'path', + ], + 'relative path with dot segments' => [ + 'expected' => 'path\.\relative', + 'input' => 'path/./relative', + ], + 'absolute path' => [ + 'expected' => 'c:\windows\My Documents 100%20\foo.txt', + 'input' => 'file:///c:/windows/My%20Documents%20100%2520/foo.txt', + ], + 'windows relative path' => [ + 'expected' => 'c:My Documents 100%20\foo.txt', + 'input' => 'file:///c:My%20Documents%20100%2520/foo.txt', + ], + 'absolute path with `|`' => [ + 'expected' => 'c:\windows\My Documents 100%20\foo.txt', + 'input' => 'file:///c:/windows/My%20Documents%20100%2520/foo.txt', + ], + 'windows relative path with `|`' => [ + 'expected' => 'c:My Documents 100%20\foo.txt', + 'input' => 'file:///c:My%20Documents%20100%2520/foo.txt', + ], + 'absolute path with dot segments' => [ + 'expected' => '\path\.\..\relative', + 'input' => '/path/./../relative', + ], + 'absolute UNC path' => [ + 'expected' => '\\\\server\share\My Documents 100%20\foo.txt', + 'input' => 'file://server/share/My%20Documents%20100%2520/foo.txt', + ], + 'unsupported scheme' => [ + 'expected' => null, + 'input' => 'http://example.com/foo/bar', + ], + ]; + } + + #[DataProvider('rfc8089UriProvider')] + public function testReturnsRFC8089UriString(?string $expected, string $input): void + { + self::assertSame($expected, Uri::new($input)->toRfc8089()); + } + + public static function rfc8089UriProvider(): iterable + { + return [ + 'localhost' => [ + 'expected' => 'file:/etc/fstab', + 'input' => 'file://localhost/etc/fstab', + ], + 'empty authority' => [ + 'expected' => 'file:/etc/fstab', + 'input' => 'file:///etc/fstab', + ], + 'file with authority' => [ + 'expected' => 'file://yesman/etc/fstab', + 'input' => 'file://yesman/etc/fstab', + ], + 'invalid scheme' => [ + 'expected' => null, + 'input' => 'foobar://yesman/etc/fstab', + ], + ]; + } + + #[Test] + #[DataProvider('providesUriToDisplay')] + public function it_will_generate_the_display_uri_string(string $input, string $output): void + { + self::assertSame($output, Uri::new($input)->toDisplayString()); + } + + public static function providesUriToDisplay(): iterable + { + yield 'empty string' => [ + 'input' => '', + 'output' => '', + ]; + + yield 'host IPv6' => [ + 'input' => 'https://[fe80:0000:0000:0000:0000:0000:0000:000a%25en1]/foo/bar', + 'output' => 'https://[fe80::a%en1]/foo/bar', + ]; + + yield 'IPv6 gets expanded if needed' => [ + 'input' => 'http://bébé.be?q=toto%20le%20h%C3%A9ros', + 'output' => 'http://bébé.be?q=toto le héros', + ]; + + yield 'complex URI' => [ + 'input' => 'https://xn--google.com/secret/../search?q=%F0%9F%8D%94', + 'output' => 'https://䕮䕵䕶䕱.com/search?q=🍔', + ]; + + yield 'basic uri stays the same' => [ + 'input' => 'http://example.com/foo/bar', + 'output' => 'http://example.com/foo/bar', + ]; + + yield 'idn host are changed' => [ + 'input' => 'http://xn--bb-bjab.be', + 'output' => 'http://bébé.be', + ]; + + yield 'idn host are the same' => [ + 'input' => 'http://bébé.be', + 'output' => 'http://bébé.be', + ]; + } }