diff --git a/components/CHANGELOG.md b/components/CHANGELOG.md index 86561e42..9f9d0923 100644 --- a/components/CHANGELOG.md +++ b/components/CHANGELOG.md @@ -11,7 +11,6 @@ All Notable changes to `League\Uri\Components` will be documented in this file - `Modifier::prependQueryParameters` returns a modifier with prepend query paramters - `Modifier::when` conditional method to ease component building logic. - `Modifier::with*` method from the underlying `Uri` object are proxy to improve DX. -- `Modifier::displayUriString` shows the URI in a human-readable format which may be an invalid URI. - `Query::decoded` the string representation of the component decoded. - `URLSearchParams::decoded` the string representation of the component decoded. - `tryNew` named constructor added to all components class to returns a new instance on success or `null` on failure. diff --git a/components/Modifier.php b/components/Modifier.php index fdd7fd19..1cc75091 100644 --- a/components/Modifier.php +++ b/components/Modifier.php @@ -23,7 +23,6 @@ use League\Uri\Components\Host; use League\Uri\Components\Path; use League\Uri\Components\Query; -use League\Uri\Components\UserInfo; use League\Uri\Contracts\Conditionable; use League\Uri\Contracts\PathInterface; use League\Uri\Contracts\UriAccess; @@ -94,32 +93,6 @@ public function __toString(): string return $this->uri->__toString(); } - public function displayUriString(): string - { - $userInfo = UserInfo::fromUri($this->uri); - $host = $this->uri->getHost(); - if (null !== $host) { - $hostIp = self::ipv4Converter()->toDecimal($host); - $host = IdnConverter::toUnicode((string) IPv6Converter::compress(match (true) { - '' === $host, - null === $hostIp, - $host === $hostIp => $host, - default => $hostIp, - }))->domain(); - } - - return UriString::build([ - 'scheme' => $this->uri->getScheme(), - 'user' => $userInfo->getUser(), - 'pass' => $userInfo->getPass(), - 'host' => $host, - 'port' => $this->uri->getPort(), - 'path' => Path::fromUri($this->uri)->withoutDotSegments()->decoded(), - 'query' => Query::fromUri($this->uri)->decoded(), - 'fragment' => Fragment::fromUri($this->uri)->decoded(), - ]); - } - final public function __call(string $name, array $arguments): static { static $allowedMethods = [ diff --git a/components/ModifierTest.php b/components/ModifierTest.php index 4aeb856f..b2eb581e 100644 --- a/components/ModifierTest.php +++ b/components/ModifierTest.php @@ -832,35 +832,6 @@ public function testItCanSlicePathSegments(): void self::assertSame('http://www.localhost.com/the/sky/', Modifier::from($uri)->sliceSegments(2, 2)->getUriString()); } - #[DataProvider('idnUriProvider')] - public function testItReturnsTheCorrectUriString(string $expected, string $input): void - { - self::assertSame($expected, Modifier::from($input)->displayUriString()); - } - - 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('ipv6NormalizationUriProvider')] public function testItCanExpandOrCompressTheHost( string $inputUri, @@ -912,49 +883,4 @@ public function it_will_remove_empty_pairs_fix_issue_133(): void self::assertNull($removeEmptyPairs('https://a.b/c?=d')); self::assertNull($removeEmptyPairs('https://a.b/c?=')); } - - #[Test] - #[DataProvider('providesUriToDisplay')] - public function it_will_generate_the_display_uri_string(string $input, string $output): void - { - self::assertSame($output, Modifier::from($input)->displayUriString()); - } - - 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', - ]; - } } diff --git a/docs/uri/7.0/rfc3986.md b/docs/uri/7.0/rfc3986.md index 71a5d295..900a0eb9 100644 --- a/docs/uri/7.0/rfc3986.md +++ b/docs/uri/7.0/rfc3986.md @@ -122,10 +122,6 @@ echo $uri->getPath(); //displays "/how/are/you" echo $uri->getQuery(); //displays "foo=baz" echo $uri->getFragment(); //displays "title" echo $uri->getOrigin(); //returns '' -echo $uri->toString(); -//displays "http://foo:bar@www.example.com:81/how/are/you?foo=baz#title" -echo json_encode($uri); -//displays "http:\/\/foo:bar@www.example.com:81\/how\/are\/you?foo=baz#title" $uri->getComponents(); // returns array { // "scheme" => "http", @@ -253,6 +249,32 @@ Uri::new('https://example.com/123') The method takes into account i18n while comparing both URI if the PHP's `idn_*` functions can be used. +## URI string representation + +The `Uri` class handles URI according to RFC3986 as such you can retrieve its string representation using the +`toString` method. But `URI` can have multiple string representation depending on its scheme or context. As +such the package provides several other string representations: + +```php +use League\Uri\Uri; + +$uri = Uri::new("http://foo:bar@www.example.com:81/how/are/you?foo=baz#title"); + +echo $uri->toString(); //displays RFC3986 string representation +echo $uri; //displays RFC3986 string representation +echo json_encode($uri); //display JSON encoded string representation + +/** + * NEW in version 7.6+ + */ + +echo $uri->toNormalizedString(); //displays the normalized URI string representation +echo $uri->toDisplayString(); //displays the URI display representation +echo $uri->toRfc8089String(); //display the string file representation according to RFC8089 or null if the scheme is not file +echo $uri->toUnixPath(); //display the string path as a Unix Path or null if the scheme is not file +echo $uri->toWindowsPath(); //display the string path as a Windows Path or null if the scheme is not file +``` + ## Modifying URI properties Use the modifying methods exposed by all URI instances to replace one of the URI component. @@ -379,3 +401,4 @@ $uri->equals('eXAMPLE://a/./b/../b/%63/%7bfoo%7d', excludeFragment: false); // r In the last example the `equals` method took into account the URI `fragment` component. The `isSameDocument` follow closely RFC3986 and never takes into account the URI `fragment` component. + diff --git a/interfaces/Contracts/UriInterface.php b/interfaces/Contracts/UriInterface.php index 3b36ba13..0cfafe72 100644 --- a/interfaces/Contracts/UriInterface.php +++ b/interfaces/Contracts/UriInterface.php @@ -24,6 +24,9 @@ * * @method string|null getUsername() returns the user component of the URI. * @method string|null getPassword() returns the scheme-specific information about how to gain authorization to access the resource. + * @method string|null toUnixPath() returns the Unix filesystem path. The method returns null for any other scheme + * @method string|null toWindowsPath() returns the Windows filesystem path. The method returns null for any other scheme + * @method string|null toRfc8089() returns a string representation of a File URI according to RFC8089. The method returns null for any other scheme * @method string toNormalizedString() returns the normalized string representation of the URI * @method array toComponents() returns an associative array containing all the URI components. * @method self normalize() returns a new URI instance with normalized components diff --git a/uri/CHANGELOG.md b/uri/CHANGELOG.md index 86e611d5..522155c1 100644 --- a/uri/CHANGELOG.md +++ b/uri/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/Uri.php b/uri/Uri.php index a021b4bd..ee437ceb 100644 --- a/uri/Uri.php +++ b/uri/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 = ',^(?<root>[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/uri/UriTest.php b/uri/UriTest.php index df2bd816..44564b62 100644 --- a/uri/UriTest.php +++ b/uri/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', + ]; + } }