From cd167d6a3ed277b6fe16770b84c238958a0ebce4 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Mon, 23 Dec 2024 15:36:13 +0100 Subject: [PATCH] #148 Adding more convenient methods to UriInterface --- interfaces/Contracts/UriInterface.php | 15 + interfaces/IPv4/BCMathCalculator.php | 4 +- uri/Uri.php | 515 +++++++++++++++++++++++++- uri/UriTest.php | 329 ++++++++++++++++ 4 files changed, 860 insertions(+), 3 deletions(-) diff --git a/interfaces/Contracts/UriInterface.php b/interfaces/Contracts/UriInterface.php index ae335ddf..d0ec2ecc 100644 --- a/interfaces/Contracts/UriInterface.php +++ b/interfaces/Contracts/UriInterface.php @@ -24,8 +24,23 @@ * * @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 toNormalizedString() returns the normalized string representation of the URI * @method array toComponents() returns an associative array containing all the URI components. * @method self when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null) conditionally return a new instance + * @method self normalize() returns a new URI instance with normalized components + * @method self resolve(UriInterface $uri) resolves a URI against a base URI using RFC3986 rules + * @method self relativize(UriInterface $uri) relativize a URI against a base URI using RFC3986 rules + * @method self|null getOrigin() returns the URI origin as described in the WHATWG URL Living standard specification + * @method bool isOpaque() tells whether the given URI object represents an opaque URI. + * @method bool isAbsolute() tells whether the URI represents an absolute URI. + * @method bool isNetworkPath() tells whether the URI represents a network path URI. + * @method bool isAbsolutePath() tells whether the URI represents an absolute URI path. + * @method bool isRelativePath() tells whether the given URI object represents a relative path. + * @method bool isCrossOrigin(UriInterface $uri) tells whether the URI comes from a different origin than the current instance. + * @method bool isSameOrigin(UriInterface $uri) tells whether the URI comes from the same origin as the current instance. + * @method bool isSameDocument(UriInterface $uri) tells whether the given URI object represents the same document. + * @method bool isLocalFile() tells whether the `file` scheme base URI represents a local file. + * @method bool equals(UriInterface $uri, bool $excludeFragment) tells whether the given URI object represents the same document. It can take the fragment in account if it is explicitly specified */ interface UriInterface extends JsonSerializable, Stringable { diff --git a/interfaces/IPv4/BCMathCalculator.php b/interfaces/IPv4/BCMathCalculator.php index b12ac995..73c5c56e 100644 --- a/interfaces/IPv4/BCMathCalculator.php +++ b/interfaces/IPv4/BCMathCalculator.php @@ -53,12 +53,12 @@ public function pow(mixed $value, int $exponent): string return bcpow((string) $value, (string) $exponent, self::SCALE); } - public function compare(mixed $value1, $value2): int + public function compare(mixed $value1, mixed $value2): int { return bccomp((string) $value1, (string) $value2, self::SCALE); } - public function multiply(mixed $value1, $value2): string + public function multiply(mixed $value1, mixed $value2): string { return bcmul((string) $value1, (string) $value2, self::SCALE); } diff --git a/uri/Uri.php b/uri/Uri.php index 21511dd7..8a400e8a 100644 --- a/uri/Uri.php +++ b/uri/Uri.php @@ -21,7 +21,10 @@ use League\Uri\Exceptions\ConversionFailed; use League\Uri\Exceptions\MissingFeature; use League\Uri\Exceptions\SyntaxError; +use League\Uri\Idna\Converter as IdnaConverter; use League\Uri\Idna\Converter as IdnConverter; +use League\Uri\IPv4\Converter as IPv4Converter; +use League\Uri\IPv6\Converter as IPv6Converter; use League\Uri\UriTemplate\TemplateCanNotBeExpanded; use Psr\Http\Message\UriInterface as Psr7UriInterface; use SensitiveParameter; @@ -29,9 +32,12 @@ use function array_filter; use function array_map; +use function array_pop; +use function array_reduce; use function base64_decode; use function base64_encode; use function count; +use function end; use function explode; use function file_get_contents; use function filter_var; @@ -42,14 +48,20 @@ use function ltrim; use function preg_match; use function preg_replace_callback; +use function preg_split; +use function rawurldecode; use function rawurlencode; +use function sort; use function str_contains; +use function str_repeat; use function str_replace; +use function strcmp; use function strlen; use function strpos; use function strspn; use function strtolower; use function substr; +use function uksort; use const FILEINFO_MIME; use const FILTER_FLAG_IPV4; @@ -57,6 +69,7 @@ use const FILTER_NULL_ON_FAILURE; use const FILTER_VALIDATE_BOOLEAN; use const FILTER_VALIDATE_IP; +use const PREG_SPLIT_NO_EMPTY; /** * @phpstan-import-type ComponentMap from UriString @@ -199,6 +212,12 @@ final class Uri implements UriInterface */ private const ASCII = "\x20\x65\x69\x61\x73\x6E\x74\x72\x6F\x6C\x75\x64\x5D\x5B\x63\x6D\x70\x27\x0A\x67\x7C\x68\x76\x2E\x66\x62\x2C\x3A\x3D\x2D\x71\x31\x30\x43\x32\x2A\x79\x78\x29\x28\x4C\x39\x41\x53\x2F\x50\x22\x45\x6A\x4D\x49\x6B\x33\x3E\x35\x54\x3C\x44\x34\x7D\x42\x7B\x38\x46\x77\x52\x36\x37\x55\x47\x4E\x3B\x4A\x7A\x56\x23\x48\x4F\x57\x5F\x26\x21\x4B\x3F\x58\x51\x25\x59\x5C\x09\x5A\x2B\x7E\x5E\x24\x40\x60\x7F\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F"; + /** @var array */ + private const WHATWG_SPECIAL_SCHEMES = ['ftp' => 1, 'http' => 1, 'https' => 1, 'ws' => 1, 'wss' => 1]; + + /** @var array */ + private const DOT_SEGMENTS = ['.' => 1, '..' => 1]; + private readonly ?string $scheme; private readonly ?string $user; private readonly ?string $pass; @@ -210,6 +229,7 @@ final class Uri implements UriInterface private readonly ?string $query; private readonly ?string $fragment; private readonly string $uri; + private readonly ?string $origin; private function __construct( ?string $scheme, @@ -232,8 +252,9 @@ private function __construct( $this->userInfo = $this->formatUserInfo($this->user, $this->pass); $this->authority = UriString::buildAuthority($this->toComponents()); $this->uri = UriString::buildUri($this->scheme, $this->authority, $this->path, $this->query, $this->fragment); - $this->assertValidState(); + + $this->origin = $this->setOrigin(); } /** @@ -867,6 +888,57 @@ private function assertValidState(): void } } + /** + * Sets the URI origin. + * + * The origin read-only property of the URL interface returns a string containing the Unicode serialization + * of the origin of the represented URL. + */ + private function setOrigin(): ?string + { + $scheme = $this->scheme; + if ('blob' !== $scheme) { + // host - make sure IP host are in decimal form, IPv6 are compressed normal host are already fixed + $host = $this->host; + if (null !== $host) { + $hostIp = IPv4Converter::fromEnvironment()->toDecimal($host); + $host = IdnConverter::toUnicode((string) IPv6Converter::compress(match (true) { + '' === $host, + null === $hostIp, + $host === $hostIp => $host, + default => $hostIp, + }))->domain(); + } + + try { + return match (true) { + isset(static::WHATWG_SPECIAL_SCHEMES[$scheme]) => $this + ->withFragment(null) + ->withQuery(null) + ->withPath('') + ->withUserInfo(null) + ->withHost($host) + ->toString(), + default => null, + }; + } catch (UriException) { + return null; + } + } + + try { + $components = UriString::parse($this->path); + $scheme = strtolower($components['scheme'] ?? ''); + if (!isset(static::WHATWG_SPECIAL_SCHEMES[$scheme])) { + return null; + } + + return self::fromComponents($components)->origin; + } catch (SyntaxError $e) { + return null; + } + } + /** * URI validation for URI schemes which allows only scheme and path components. */ @@ -1037,6 +1109,11 @@ public function getFragment(): ?string return $this->fragment; } + public function getOrigin(): ?self + { + return null === $this->origin ? null : Uri::new($this->origin); + } + /** * Apply the callback if the given "condition" is (or resolves to) true. * @@ -1219,6 +1296,442 @@ public function withFragment(Stringable|string|null $fragment): UriInterface }; } + /** + * Tells whether the `file` scheme base URI represents a local file. + */ + public function isLocalFile(): bool + { + return match (true) { + 'file' !== $this->scheme => false, + in_array($this->authority, ['', null, 'localhost'], true) => true, + default => false, + }; + } + + /** + * Tells whether the URI is opaque or not. + * + * A URI is opaque if and only if it is absolute + * and does not have an authority path. + */ + public function isOpaque(): bool + { + return null === $this->authority + && null !== $this->scheme; + } + + /** + * Tells whether two URI do not share the same origin. + */ + public function isCrossOrigin(Stringable|string $uri): bool + { + if (null === $this->origin) { + return true; + } + + $uriOrigin = self::tryNew($uri)?->origin; + + return match(true) { + null === $uriOrigin, + $uriOrigin !== $this->origin => true, + default => false, + }; + } + + public function isSameOrigin(Stringable|string $uri): bool + { + return ! $this->isCrossOrigin($uri); + } + + /** + * Tells whether the URI is absolute. + */ + public function isAbsolute(): bool + { + return null !== $this->scheme; + } + + /** + * Tells whether the URI is a network path. + */ + public function isNetworkPath(): bool + { + return null === $this->scheme + && null !== $this->authority; + } + + /** + * Tells whether the URI is an absolute path. + */ + public function isAbsolutePath(): bool + { + return null === $this->scheme + && null === $this->authority + && '/' === ($this->path[0] ?? ''); + } + + /** + * Tells whether the URI is a relative path. + */ + public function isRelativePath(): bool + { + return null === $this->scheme + && null === $this->authority + && '/' !== ($this->path[0] ?? ''); + } + + /** + * Tells whether both URI refers to the same document. + */ + public function isSameDocument(UriInterface|Stringable|string $uri): bool + { + return $this->equals($uri); + } + + public function equals(UriInterface|Stringable|string $uri, bool $excludeFragment = true): bool + { + if (!$uri instanceof UriInterface) { + $uri = self::tryNew($uri); + if (null === $uri) { + return false; + } + } + + $current = $this->normalize(); + + return $uri + ->when($excludeFragment, fn (self $uri) => $uri->withFragment(null)) + ->withPath($this->normalizePath($uri)) + ->withQuery($this->normalizeQuery($uri->getQuery())) + ->toString() === $current + ->when($excludeFragment, fn (self $uri) => $uri->withFragment(null)) + ->withPath($this->normalizePath($current)) + ->withQuery($this->normalizeQuery($current->getQuery())) + ->toString(); + } + + private function normalizePath(UriInterface $uri): string + { + static $regexpEncodedChars = ',%(2[D|E]|3\d|4[1-9|A-F]|5[\d|AF]|6[1-9|A-F]|7[\d|E]),i'; + $path = preg_replace_callback($regexpEncodedChars, static fn (array $matches): string => rawurldecode($matches[0]), $uri->getPath()) ?? ''; + if (null !== $uri->getAuthority() && '' === $path) { + return '/'; + } + + return $path; + } + + private function normalizeQuery(?string $query): ?string + { + if (null === $query) { + return null; + } + + static $regexpEncodedChars = ',%(2[D|E]|3\d|4[1-9|A-F]|5[\d|AF]|6[1-9|A-F]|7[\d|E]),i'; + + return preg_replace_callback($regexpEncodedChars, static fn (array $matches): string => rawurldecode($matches[0]), $query) ?? ''; + } + + public function toNormalizedString(): string + { + return $this->normalize()->toString(); + } + + /** + * Tells whether the URI contains an Internationalized Domain Name (IDN). + */ + public function hasIdn(): bool + { + return IdnaConverter::isIdn($this->host); + } + + /** + * Tells whether the URI contains an IPv4 regardless if it is mapped or native. + */ + public function hasIPv4(): bool + { + return IPv4Converter::fromEnvironment()->isIpv4($this->host); + } + + public function normalize(): UriInterface + { + // host - make sure IP host are in decimal form, IPv6 are compressed normal host are already fixed + $host = $this->host; + if (null !== $host) { + $hostIp = IPv4Converter::fromEnvironment()->toDecimal($host); + $host = IdnConverter::toUnicode((string) IPv6Converter::compress(match (true) { + '' === $host, + null === $hostIp, + $host === $hostIp => $host, + default => $hostIp, + }))->domain(); + } + + // path - remove dot segments + $path = $this->path; + if ('/' === ($path[0] ?? '') || '' !== $this->scheme.$this->authority) { + $path = self::removeDotSegments($path); + } + + if (null !== $this->authority && '' === $path) { + $path = '/'; + } + + // query - sort pairs + $query = $this->query; + if (null !== $query && '' !== $query) { + $query = $this->sortQuery($query); + } + + return $this->withHost($host)->withPath($path)->withQuery($query); + } + + private function sortQuery(?string $query): ?string + { + $codepoints = fn (?string $str): string => in_array($str, ['', null], true) ? '' : implode('.', array_map( + mb_ord(...), /* @phpstan-ignore-line */ + (array) preg_split(pattern:'//u', subject: $str, flags: PREG_SPLIT_NO_EMPTY) + )); + + $compare = fn (string $name1, string $name2): int => match (1) { + preg_match('/[^\x20-\x7f]/', $name1.$name2) => strcmp($codepoints($name1), $codepoints($name2)), + default => strcmp($name1, $name2), + }; + + $pairs = QueryString::parseFromValue($query); + $parameters = array_reduce($pairs, function (array $carry, array $pair) { + $carry[$pair[0]] ??= []; + $carry[$pair[0]][] = $pair[1]; + + return $carry; + }, []); + + uksort($parameters, $compare); + + $newPairs = []; + foreach ($parameters as $key => $values) { + $newPairs = [...$newPairs, ...array_map(fn ($value) => [$key, $value], $values)]; + } + + return match ($newPairs) { + $pairs => $query, + default => QueryString::buildFromPairs($newPairs), + }; + } + + /** + * Remove dot segments from the URI path as per RFC specification. + */ + private static function removeDotSegments(string $path): string + { + if (!str_contains($path, '.')) { + return $path; + } + + $reducer = function (array $carry, string $segment): array { + if ('..' === $segment) { + array_pop($carry); + + return $carry; + } + + if (!isset(static::DOT_SEGMENTS[$segment])) { + $carry[] = $segment; + } + + return $carry; + }; + + $oldSegments = explode('/', $path); + $newPath = implode('/', array_reduce($oldSegments, $reducer(...), [])); + if (isset(static::DOT_SEGMENTS[end($oldSegments)])) { + $newPath .= '/'; + } + + return $newPath; + } + + /** + * Resolves a URI against a base URI using RFC3986 rules. + * + * This method MUST retain the state of the submitted URI instance, and return + * a URI instance of the same type that contains the applied modifications. + * + * This method MUST be transparent when dealing with error and exceptions. + * It MUST not alter or silence them apart from validating its own parameters. + */ + public function resolve(Stringable|string $uri): UriInterface + { + if (!$uri instanceof UriInterface) { + $uri = self::new($uri); + } + + if (null !== $uri->getScheme()) { + return $uri + ->withPath(self::removeDotSegments($uri->getPath())); + } + + if (null !== $uri->getAuthority()) { + return $uri + ->withPath(self::removeDotSegments($uri->getPath())) + ->withScheme($this->scheme); + } + + [$path, $query] = $this->resolvePathAndQuery($uri); + $path = self::removeDotSegments($path); + if ('' !== $path && '/' !== $path[0]) { + $path = '/'.$path; + } + + return $uri + ->withPath($path) + ->withQuery($query) + ->withHost($this->host) + ->withPort($this->port) + ->withUserInfo($this->user, $this->pass) + ->withScheme($this->scheme); + } + + /** + * Resolves an URI path and query component. + * + * @return array{0:string, 1:string|null} + */ + private function resolvePathAndQuery(UriInterface $uri): array + { + if (str_starts_with($uri->getPath(), '/')) { + return [$uri->getPath(), $uri->getQuery()]; + } + + if ('' === $uri->getPath()) { + return [$this->path, $uri->getQuery() ?? $this->query]; + } + + $targetPath = $uri->getPath(); + if (null !== $this->authority && '' === $this->path) { + $targetPath = '/'.$targetPath; + } + + if ('' !== $this->path) { + $segments = explode('/', $this->path); + array_pop($segments); + if ([] !== $segments) { + $targetPath = implode('/', $segments).'/'.$targetPath; + } + } + + return [$targetPath, $uri->getQuery()]; + } + + /** + * Relativize a URI according to a base URI. + * + * This method MUST retain the state of the submitted URI instance, and return + * a URI instance of the same type that contains the applied modifications. + * + * This method MUST be transparent when dealing with error and exceptions. + * It MUST not alter of silence them apart from validating its own parameters. + */ + public function relativize(Stringable|string $uri): UriInterface + { + if (!$uri instanceof UriInterface) { + $uri = self::new($uri); + } + + if ( + $this->scheme !== $uri->getScheme() || + $this->authority !== $uri->getAuthority() || + $uri->isRelativePath()) { + return $uri; + } + + $targetPath = $uri->getPath(); + $basePath = $this->path; + + $uri = $uri + ->withScheme(null) + ->withPort(null) + ->withUserInfo(null) + ->withHost(null); + + return match (true) { + $targetPath !== $basePath => $uri->withPath(self::relativizePath($targetPath, $basePath)), + $this->query === $uri->getQuery() => $uri->withPath('')->withQuery(null), + null === $uri->getQuery() => $uri->withPath(self::formatPathWithEmptyBaseQuery($targetPath)), + default => $uri->withPath(''), + }; + } + + /** + * Formatting the path to keep a resolvable URI. + */ + private static function formatPathWithEmptyBaseQuery(string $path): string + { + $targetSegments = self::getSegments($path); + /** @var string $basename */ + $basename = end($targetSegments); + + return '' === $basename ? './' : $basename; + } + + /** + * Relatives the URI for an authority-less target URI. + */ + private static function relativizePath(string $path, string $basePath): string + { + $baseSegments = self::getSegments($basePath); + $targetSegments = self::getSegments($path); + $targetBasename = array_pop($targetSegments); + array_pop($baseSegments); + foreach ($baseSegments as $offset => $segment) { + if (!isset($targetSegments[$offset]) || $segment !== $targetSegments[$offset]) { + break; + } + unset($baseSegments[$offset], $targetSegments[$offset]); + } + $targetSegments[] = $targetBasename; + + return static::formatRelativePath( + str_repeat('../', count($baseSegments)).implode('/', $targetSegments), + $basePath + ); + } + + /** + * Formatting the path to keep a valid URI. + */ + private static function formatRelativePath(string $path, string $basePath): string + { + $colonPosition = strpos($path, ':'); + $slashPosition = strpos($path, '/'); + + return match (true) { + '' === $path => match (true) { + '' === $basePath, + '/' === $basePath => $basePath, + default => './', + }, + false === $colonPosition => $path, + false === $slashPosition, + $colonPosition < $slashPosition => "./$path", + default => $path, + }; + } + + /** + * returns the path segments. + * + * @return string[] + */ + private static function getSegments(string $path): array + { + return explode('/', match (true) { + '' === $path, + '/' !== $path[0] => $path, + default => substr($path, 1), + }); + } + /** * DEPRECATION WARNING! This method will be removed in the next major point release. * diff --git a/uri/UriTest.php b/uri/UriTest.php index 460d027a..df2bd816 100644 --- a/uri/UriTest.php +++ b/uri/UriTest.php @@ -18,12 +18,15 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Psr\Http\Message\UriInterface as Psr7UriInterface; use TypeError; #[CoversClass(Uri::class)] #[Group('uri')] class UriTest extends TestCase { + private const BASE_URI = 'http://a/b/c/d;p?q'; + private Uri $uri; protected function setUp(): void @@ -463,4 +466,330 @@ public function testItThrowsWhenTheUriComponentValueIsNull(): void Uri::new('https://www.example.com/')->withPath(Port::new()); } + + #[DataProvider('resolveProvider')] + public function testCreateResolve(string $baseUri, string $uri, string $expected): void + { + self::assertSame($expected, Uri::new($baseUri)->resolve($uri)->toString()); + } + + public static function resolveProvider(): array + { + return [ + 'base uri' => [self::BASE_URI, '', self::BASE_URI], + 'scheme' => [self::BASE_URI, 'http://d/e/f', 'http://d/e/f'], + 'path 1' => [self::BASE_URI, 'g', 'http://a/b/c/g'], + 'path 2' => [self::BASE_URI, './g', 'http://a/b/c/g'], + 'path 3' => [self::BASE_URI, 'g/', 'http://a/b/c/g/'], + 'path 4' => [self::BASE_URI, '/g', 'http://a/g'], + 'authority' => [self::BASE_URI, '//g', 'http://g'], + 'query' => [self::BASE_URI, '?y', 'http://a/b/c/d;p?y'], + 'path + query' => [self::BASE_URI, 'g?y', 'http://a/b/c/g?y'], + 'fragment' => [self::BASE_URI, '#s', 'http://a/b/c/d;p?q#s'], + 'path + fragment' => [self::BASE_URI, 'g#s', 'http://a/b/c/g#s'], + 'path + query + fragment' => [self::BASE_URI, 'g?y#s', 'http://a/b/c/g?y#s'], + 'single dot 1' => [self::BASE_URI, '.', 'http://a/b/c/'], + 'single dot 2' => [self::BASE_URI, './', 'http://a/b/c/'], + 'single dot 3' => [self::BASE_URI, './g/.', 'http://a/b/c/g/'], + 'single dot 4' => [self::BASE_URI, 'g/./h', 'http://a/b/c/g/h'], + 'double dot 1' => [self::BASE_URI, '..', 'http://a/b/'], + 'double dot 2' => [self::BASE_URI, '../', 'http://a/b/'], + 'double dot 3' => [self::BASE_URI, '../g', 'http://a/b/g'], + 'double dot 4' => [self::BASE_URI, '../..', 'http://a/'], + 'double dot 5' => [self::BASE_URI, '../../', 'http://a/'], + 'double dot 6' => [self::BASE_URI, '../../g', 'http://a/g'], + 'double dot 7' => [self::BASE_URI, '../../../g', 'http://a/g'], + 'double dot 8' => [self::BASE_URI, '../../../../g', 'http://a/g'], + 'double dot 9' => [self::BASE_URI, 'g/../h' , 'http://a/b/c/h'], + 'mulitple slashes' => [self::BASE_URI, 'foo////g', 'http://a/b/c/foo////g'], + 'complex path 1' => [self::BASE_URI, ';x', 'http://a/b/c/;x'], + 'complex path 2' => [self::BASE_URI, 'g;x', 'http://a/b/c/g;x'], + 'complex path 3' => [self::BASE_URI, 'g;x?y#s', 'http://a/b/c/g;x?y#s'], + 'complex path 4' => [self::BASE_URI, 'g;x=1/./y', 'http://a/b/c/g;x=1/y'], + 'complex path 5' => [self::BASE_URI, 'g;x=1/../y', 'http://a/b/c/y'], + 'dot segments presence 1' => [self::BASE_URI, '/./g', 'http://a/g'], + 'dot segments presence 2' => [self::BASE_URI, '/../g', 'http://a/g'], + 'dot segments presence 3' => [self::BASE_URI, 'g.', 'http://a/b/c/g.'], + 'dot segments presence 4' => [self::BASE_URI, '.g', 'http://a/b/c/.g'], + 'dot segments presence 5' => [self::BASE_URI, 'g..', 'http://a/b/c/g..'], + 'dot segments presence 6' => [self::BASE_URI, '..g', 'http://a/b/c/..g'], + 'origin uri without path' => ['http://h:b@a', 'b/../y', 'http://h:b@a/y'], + 'not same origin' => [self::BASE_URI, 'ftp://a/b/c/d', 'ftp://a/b/c/d'], + ]; + } + + + + public function testRelativizeIsNotMade(): void + { + $uri = '//path#fragment'; + + self::assertEquals($uri, Uri::new('https://example.com/path')->relativize($uri)->toString()); + } + + #[DataProvider('relativizeProvider')] + public function testRelativize(string $uri, string $resolved, string $expected): void + { + self::assertSame( + $expected, + Uri::new(Http::new($uri))->relativize($resolved)->toString() + ); + } + + public static function relativizeProvider(): array + { + return [ + 'different scheme' => [self::BASE_URI, 'https://a/b/c/d;p?q', 'https://a/b/c/d;p?q'], + 'different authority' => [self::BASE_URI, 'https://g/b/c/d;p?q', 'https://g/b/c/d;p?q'], + 'empty uri' => [self::BASE_URI, '', ''], + 'same uri' => [self::BASE_URI, self::BASE_URI, ''], + 'same path' => [self::BASE_URI, 'http://a/b/c/d;p', 'd;p'], + 'parent path 1' => [self::BASE_URI, 'http://a/b/c/', './'], + 'parent path 2' => [self::BASE_URI, 'http://a/b/', '../'], + 'parent path 3' => [self::BASE_URI, 'http://a/', '../../'], + 'parent path 4' => [self::BASE_URI, 'http://a', '../../'], + 'sibling path 1' => [self::BASE_URI, 'http://a/b/c/g', 'g'], + 'sibling path 2' => [self::BASE_URI, 'http://a/b/c/g/h', 'g/h'], + 'sibling path 3' => [self::BASE_URI, 'http://a/b/g', '../g'], + 'sibling path 4' => [self::BASE_URI, 'http://a/g', '../../g'], + 'query' => [self::BASE_URI, 'http://a/b/c/d;p?y', '?y'], + 'fragment' => [self::BASE_URI, 'http://a/b/c/d;p?q#s', '#s'], + 'path + query' => [self::BASE_URI, 'http://a/b/c/g?y', 'g?y'], + 'path + fragment' => [self::BASE_URI, 'http://a/b/c/g#s', 'g#s'], + 'path + query + fragment' => [self::BASE_URI, 'http://a/b/c/g?y#s', 'g?y#s'], + 'empty segments' => [self::BASE_URI, 'http://a/b/c/foo////g', 'foo////g'], + 'empty segments 1' => [self::BASE_URI, 'http://a/b////c/foo/g', '..////c/foo/g'], + 'relative single dot 1' => [self::BASE_URI, '.', '.'], + 'relative single dot 2' => [self::BASE_URI, './', './'], + 'relative double dot 1' => [self::BASE_URI, '..', '..'], + 'relative double dot 2' => [self::BASE_URI, '../', '../'], + 'path with colon 1' => ['http://a/', 'http://a/d:p', './d:p'], + 'path with colon 2' => [self::BASE_URI, 'http://a/b/c/g/d:p', 'g/d:p'], + 'scheme + auth 1' => ['http://a', 'http://a?q#s', '?q#s'], + 'scheme + auth 2' => ['http://a/', 'http://a?q#s', '/?q#s'], + '2 relative paths 1' => ['a/b', '../..', '../..'], + '2 relative paths 2' => ['a/b', './.', './.'], + '2 relative paths 3' => ['a/b', '../c', '../c'], + '2 relative paths 4' => ['a/b', 'c/..', 'c/..'], + '2 relative paths 5' => ['a/b', 'c/.', 'c/.'], + 'baseUri with query' => ['/a/b/?q', '/a/b/#h', './#h'], + 'targetUri with fragment' => ['/', '/#h', '#h'], + 'same document' => ['/', '/', ''], + 'same URI normalized' => ['http://a', 'http://a/', ''], + ]; + } + + /** + * @param array $infos + */ + #[DataProvider('uriProvider')] + public function testInfo( + Psr7UriInterface|Uri $uri, + Psr7UriInterface|Uri|null $base_uri, + array $infos + ): void { + if (null !== $base_uri) { + self::assertSame($infos['same_document'], Uri::new($base_uri)->isSameDocument($uri)); + } + self::assertSame($infos['relative_path'], Uri::new($uri)->isRelativePath()); + self::assertSame($infos['absolute_path'], Uri::new($uri)->isAbsolutePath()); + self::assertSame($infos['absolute_uri'], Uri::new($uri)->isAbsolute()); + self::assertSame($infos['network_path'], Uri::new($uri)->isNetworkPath()); + } + + public static function uriProvider(): array + { + return [ + 'absolute uri' => [ + 'uri' => Http::new('http://a/p?q#f'), + 'base_uri' => null, + 'infos' => [ + 'absolute_uri' => true, + 'network_path' => false, + 'absolute_path' => false, + 'relative_path' => false, + 'same_document' => false, + ], + ], + 'network relative uri' => [ + 'uri' => Http::new('//스타벅스코리아.com/p?q#f'), + 'base_uri' => Http::new('//xn--oy2b35ckwhba574atvuzkc.com/p?q#z'), + 'infos' => [ + 'absolute_uri' => false, + 'network_path' => true, + 'absolute_path' => false, + 'relative_path' => false, + 'same_document' => true, + ], + ], + 'path relative uri with non empty path' => [ + 'uri' => Http::new('p?q#f'), + 'base_uri' => null, + 'infos' => [ + 'absolute_uri' => false, + 'network_path' => false, + 'absolute_path' => false, + 'relative_path' => true, + 'same_document' => false, + ], + ], + 'path relative uri with empty' => [ + 'uri' => Http::new('?q#f'), + 'base_uri' => null, + 'infos' => [ + 'absolute_uri' => false, + 'network_path' => false, + 'absolute_path' => false, + 'relative_path' => true, + 'same_document' => false, + ], + ], + ]; + } + + public function testIsFunctionsThrowsTypeError(): void + { + self::assertTrue(Uri::new('http://example.com')->isAbsolute()); + self::assertFalse(Uri::new('http://example.com')->isNetworkPath()); + self::assertTrue(Uri::new('/example.com')->isAbsolutePath()); + self::assertTrue(Uri::new('example.com#foobar')->isRelativePath()); + } + + #[DataProvider('sameValueAsProvider')] + public function testSameValueAs(Psr7UriInterface|Uri $uri1, Psr7UriInterface|Uri $uri2, bool $expected): void + { + self::assertSame($expected, Uri::new($uri2)->isSameDocument($uri1)); + } + + public static function sameValueAsProvider(): array + { + return [ + '2 disctincts URIs' => [ + Http::new('http://example.com'), + Uri::new('ftp://example.com'), + false, + ], + '2 identical URIs' => [ + Http::new('http://example.com'), + Http::new('http://example.com'), + true, + ], + '2 identical URIs after removing dot segment' => [ + Http::new('http://example.org/~foo/'), + Http::new('http://example.ORG/bar/./../~foo/'), + true, + ], + '2 distincts relative URIs' => [ + Http::new('~foo/'), + Http::new('../~foo/'), + false, + ], + '2 identical relative URIs' => [ + Http::new('../%7efoo/'), + Http::new('../~foo/'), + true, + ], + '2 identical URIs after normalization (1)' => [ + Http::new('HtTp://مثال.إختبار:80/%7efoo/%7efoo/'), + Http::new('http://xn--mgbh0fb.xn--kgbechtv/%7Efoo/~foo/'), + true, + ], + '2 identical URIs after normalization (2)' => [ + Http::new('http://www.example.com'), + Http::new('http://www.example.com/'), + true, + ], + '2 identical URIs after normalization (3)' => [ + Http::new('http://www.example.com'), + Http::new('http://www.example.com:/'), + true, + ], + '2 identical URIs after normalization (4)' => [ + Http::new('http://www.example.com'), + Http::new('http://www.example.com:80/'), + true, + ], + ]; + } + + #[DataProvider('getOriginProvider')] + public function testGetOrigin(Psr7UriInterface|Uri|string $uri, ?string $expectedOrigin): void + { + self::assertSame($expectedOrigin, Uri::new($uri)->getOrigin()?->toString()); + } + + public static function getOriginProvider(): array + { + return [ + 'http uri' => [ + 'uri' => Uri::new('https://example.com/path?query#fragment'), + 'expectedOrigin' => 'https://example.com', + ], + 'http uri with non standard port' => [ + 'uri' => Uri::new('https://example.com:81/path?query#fragment'), + 'expectedOrigin' => 'https://example.com:81', + ], + 'relative uri' => [ + 'uri' => Uri::new('//example.com:81/path?query#fragment'), + 'expectedOrigin' => null, + ], + 'absolute uri with user info' => [ + 'uri' => Uri::new('https://user:pass@example.com:81/path?query#fragment'), + 'expectedOrigin' => 'https://example.com:81', + ], + 'opaque URI' => [ + 'uri' => Uri::new('mailto:info@thephpleague.com'), + 'expectedOrigin' => null, + ], + 'file URI' => [ + 'uri' => Uri::new('file:///usr/bin/test'), + 'expectedOrigin' => null, + ], + 'blob' => [ + 'uri' => Uri::new('blob:https://mozilla.org:443/'), + 'expectedOrigin' => 'https://mozilla.org', + ], + 'normalized ipv4' => [ + 'uri' => 'https://0:443/', + 'expectedOrigin' => 'https://0.0.0.0', + ], + 'normalized ipv4 with object' => [ + 'uri' => Uri::new('https://0:443/'), + 'expectedOrigin' => 'https://0.0.0.0', + ], + 'compressed ipv6' => [ + 'uri' => 'https://[1050:0000:0000:0000:0005:0000:300c:326b]:443/', + 'expectedOrigin' => 'https://[1050::5:0:300c:326b]', + ], + ]; + } + + #[DataProvider('getCrossOriginExamples')] + public function testIsCrossOrigin(string $original, string $modified, bool $expected): void + { + self::assertSame($expected, !Uri::new($original)->isSameOrigin($modified)); + } + + /** + * @return array + */ + public static function getCrossOriginExamples(): array + { + return [ + 'different path' => ['http://example.com/123', 'http://example.com/', false], + 'same port with default value (1)' => ['https://example.com/123', 'https://example.com:443/', false], + 'same port with default value (2)' => ['ws://example.com:80/123', 'ws://example.com/', false], + 'same explicit port' => ['wss://example.com:443/123', 'wss://example.com:443/', false], + 'same origin with i18n host' => ['https://xn--bb-bjab.be./path', 'https://Bébé.BE./path', false], + 'same origin using a blob' => ['blob:https://mozilla.org:443/', 'https://mozilla.org/123', false], + 'different scheme' => ['https://example.com/123', 'ftp://example.com/', true], + 'different host' => ['ftp://example.com/123', 'ftp://www.example.com/123', true], + 'different port implicit' => ['https://example.com/123', 'https://example.com:81/', true], + 'different port explicit' => ['https://example.com:80/123', 'https://example.com:81/', true], + 'same scheme different port' => ['https://example.com:443/123', 'https://example.com:444/', true], + 'comparing two opaque URI' => ['ldap://ldap.example.net', 'ldap://ldap.example.net', true], + 'comparing a URI with an origin and one with an opaque origin' => ['https://example.com:443/123', 'ldap://ldap.example.net', true], + 'cross origin using a blob' => ['blob:http://mozilla.org:443/', 'https://mozilla.org/123', true], + ]; + } }