From 391324515e5b4798706ebb295b54f936a64dcc66 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 --- components/Components/Component.php | 11 +- components/Components/URLSearchParams.php | 17 +- components/Modifier.php | 12 +- docs/uri/7.0/rfc3986.md | 174 ++++++- interfaces/Contracts/Conditionable.php | 26 ++ interfaces/Contracts/UriInterface.php | 16 +- interfaces/IPv4/BCMathCalculator.php | 4 +- uri/BaseUri.php | 12 +- uri/Http.php | 12 +- uri/HttpTest.php | 2 +- uri/Uri.php | 525 +++++++++++++++++++++- uri/UriTest.php | 329 ++++++++++++++ 12 files changed, 1065 insertions(+), 75 deletions(-) create mode 100644 interfaces/Contracts/Conditionable.php diff --git a/components/Components/Component.php b/components/Components/Component.php index 002ec838..123dcaea 100644 --- a/components/Components/Component.php +++ b/components/Components/Component.php @@ -13,6 +13,7 @@ namespace League\Uri\Components; +use League\Uri\Contracts\Conditionable; use League\Uri\Contracts\UriAccess; use League\Uri\Contracts\UriComponentInterface; use League\Uri\Contracts\UriInterface; @@ -26,7 +27,7 @@ use function preg_match; use function sprintf; -abstract class Component implements UriComponentInterface +abstract class Component implements UriComponentInterface, Conditionable { protected const REGEXP_INVALID_URI_CHARS = '/[\x00-\x1f\x7f]/'; @@ -84,14 +85,6 @@ final protected static function filterComponent(Stringable|int|string|null $comp }; } - /** - * Apply the callback if the given "condition" is (or resolves to) true. - * - * @param (callable($this): bool)|bool $condition - * @param callable($this): (static|null) $onSuccess - * @param ?callable($this): (static|null) $onFail - * - */ final public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static { if (!is_bool($condition)) { diff --git a/components/Components/URLSearchParams.php b/components/Components/URLSearchParams.php index b59ffa78..eba714d5 100644 --- a/components/Components/URLSearchParams.php +++ b/components/Components/URLSearchParams.php @@ -503,7 +503,7 @@ public function delete(?string $name): void /** * Sorts all key/value pairs contained in this object in place and returns undefined. * - * The sort order is according to unicode code points of the keys. This method + * The sort order is according to Unicode code points of the keys. This method * uses a stable sorting algorithm (i.e. the relative order between * key/value pairs with equal keys will be preserved). */ @@ -512,24 +512,17 @@ public function sort(): void $this->updateQuery($this->pairs->sort()); } - /** - * Apply the callback if the given "condition" is (or resolves to) true. - * - * @param (callable($this): bool)|bool $condition - * @param callable($this): (self|null) $onSuccess - * @param ?callable($this): (self|null) $onFail - */ - public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static { if (!is_bool($condition)) { $condition = $condition($this); } return match (true) { - $condition => $onSuccess($this), - null !== $onFail => $onFail($this), + $condition => $onSuccess($this) ?? $this, + null !== $onFail => $onFail($this) ?? $this, default => $this, - } ?? $this; + }; } /** diff --git a/components/Modifier.php b/components/Modifier.php index a285dc71..fdd7fd19 100644 --- a/components/Modifier.php +++ b/components/Modifier.php @@ -24,6 +24,7 @@ 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; use League\Uri\Contracts\UriInterface; @@ -54,7 +55,7 @@ * @method static withQuery(Stringable|string|null $query) returns a new instance with the specified query. * @method static withFragment(Stringable|string|null $fragment) returns a new instance with the specified fragment. */ -class Modifier implements Stringable, JsonSerializable, UriAccess +class Modifier implements Stringable, JsonSerializable, UriAccess, Conditionable { final public function __construct(protected readonly Psr7UriInterface|UriInterface $uri) { @@ -139,14 +140,7 @@ final public function __call(string $name, array $arguments): static }; } - /** - * Apply the callback if the given "condition" is (or resolves to) true. - * - * @param (callable($this): bool)|bool $condition - * @param callable($this): (self|null) $onSuccess - * @param ?callable($this): (self|null) $onFail - */ - final public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + final public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static { if (!is_bool($condition)) { $condition = $condition($this); diff --git a/docs/uri/7.0/rfc3986.md b/docs/uri/7.0/rfc3986.md index d979333d..71a5d295 100644 --- a/docs/uri/7.0/rfc3986.md +++ b/docs/uri/7.0/rfc3986.md @@ -105,8 +105,7 @@ echo $uri = //returns 'file:///etc/fstab'

fromRfc8089 is added since version 7.4.0

-Accessing URI properties -------- +## Accessing URI properties Let's examine the result of building a URI: @@ -122,6 +121,7 @@ echo $uri->getAuthority(); //displays "foo:bar@www.example.com:81" 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); @@ -147,8 +147,113 @@ $uri->getComponents(); The returned value for each URI component is kept encoded. If you need the decoded value you should use the [league/uri-component](/components) to extract and manipulate each individual component. -Modifying URI properties -------- +

getOrigin is added in version 7.6.0

+ +The `getOrigin` method returns the URI origin used for comparison when calling the `isCrossOrigin` and `isSameOrigin` methods. +The algorithm used is defined by the [WHATWG URL Living standard](https://url.spec.whatwg.org/#origin) + +~~~php +echo Uri::new('https://uri.thephpleague.com/uri/6.0/info/')->getOrigin(); //display 'https://uri.thephpleague.com'; +echo Uri::new('blob:https://mozilla.org:443')->getOrigin(); //display 'https://mozilla.org' +Uri::new('file:///usr/bin/php')->getOrigin(); //returns null +Uri::new('data:text/plain,Bonjour%20le%20monde%21')->getOrigin(); //returns null +~~~ + +

For absolute URI with the file scheme the method will return null (as this is left to the implementation decision)

+Because the origin property does not exist in the RFC3986 specification this additional steps is implemented: + +- For non-absolute URI the method will return `null` + +~~~php +Uri::new('/path/to/endpoint')->getOrigin(); //returns null +~~~ + +## URI information + +The class also exposes a list of public methods which returns the URI state. + +### Uri::isAbsolute + +Tells whether the URI represents an absolute URI. + +~~~php +Uri::fromServer($_SERVER)->isAbsoulte(); //returns true +Uri::new("/🍣🍺")->isAbsolute(); //returns false +~~~ + +### Uri::isAbsolutePath + +Tells whether the URI represents an absolute URI path. + +~~~php +Uri::fromServer($_SERVER)->isAbsolutePath(); //returns false +Uri::new("/🍣🍺")->isAbsolutePath(); //returns true +~~~ + +### Uri::isNetworkPath + +Tells whether the URI represents a network path URI. + +~~~php +Uri::new("//example.com/toto")->isNetworkPath(); //returns true +Uri::new("/🍣🍺")->isNetworkPath(); //returns false +~~~ + +### Uri::isOpaque + +Tells whether the given URI object represents an opaque URI. An URI is said to be +opaque if and only if it is absolute but does not have an authority + +~~~php +Uri::new("email:john@example.com?subject=🏳️‍🌈")->isOpaque(); //returns true +Uri::new("/🍣🍺")->isOpaque(); //returns false +~~~ + +### Uri::isRelativePath + +Tells whether the given URI object represents a relative path. + +~~~php +Uri::new("🏳️‍🌈")->isRelativePath(); //returns true +Uri::new("/🍣🍺")->isRelativePath(); //returns false +~~~ + +### Uri::isSameDocument + +Tells whether the given URI object represents the same document. + +~~~php +Uri::new("example.com?foo=bar#🏳️‍🌈")->isSameDocument("exAMpLE.com?foo=bar#🍣🍺"); //returns true +~~~ + +### Uri::hasIDN + +Tells whether the given URI object contains a IDN host. + +~~~php +Uri::new("https://bébé.be")->hasIdn(); //returns true +~~~ + +### Uri::isCrossOrigin and Uri::isSameOrigin + +Tells whether the given URI object represents different origins. +According to [RFC9110](https://www.rfc-editor.org/rfc/rfc9110#section-4.3.1) The "origin" +for a given URI is the triple of scheme, host, and port after normalizing +the scheme and host to lowercase and normalizing the port to remove +any leading zeros. + +~~~php +isCrossOrigin('http://Bébé.BE./path'); // returns false + +Uri::new('https://example.com/123') + ->isSameOrigin('https://www.example.com/'); // returns false +~~~ + +The method takes into account i18n while comparing both URI if the PHP's `idn_*` functions can be used. + +## Modifying URI properties Use the modifying methods exposed by all URI instances to replace one of the URI component. If the modifications do not alter the current object, it is returned as is, otherwise, @@ -194,9 +299,28 @@ echo Uri::new('https://uri.thephpleague.com/components/7.0/modifiers/') // returns 'https://uri.thephpleague.com/default'; ``` +## URI resolution + +

Available since version 7.6.0

+ +The `Uri::resolve` resolves a URI as a browser would for a relative URI while the `Uri::relativize` +does the opposite. + +~~~php +$baseUri = Uri::new('http://www.ExaMPle.com'); +$uri = 'http://www.example.com/?foo=toto#~typo'; + +$relativeUri = $baseUri->relativize($uri); +echo $relativeUri; // display "/?foo=toto#~typo +echo $baseUri->resolve($relativeUri); +echo $baseUri; // display 'http://www.example.com' +// display 'http://www.example.com/?foo=toto#~typo' +echo $baseUri->getUri()::class; //display \League\Uri\Uri +~~~ + +## URI normalization and comparison -URI normalization -------- +### Non destructive normalization Out of the box the package normalizes any given URI according to the non-destructive rules of [RFC3986](https://tools.ietf.org/html/rfc3986). @@ -217,3 +341,41 @@ echo $uri; //displays http://xn--bb-bjab.be?# ~~~

The last example depends on the presence of the idn_to_* functions, otherwise the code will trigger a MissingFeature exception

+ +### Destructive normalization + +

Available since version 7.6.0

+ +The `normalize` method applies extra normalization that may modifier the URI definitions, those extra rules are: + +- removing dot segments from the path +- sorting the query pairs +- normalizing the IPv6 and IPv4 host +- url decode all non reserved characters in the path and the query + +```php +echo Uri::new('eXAMPLE://a/./b/../b/%63/%7bfoo%7d')->normalize()->toString(); +echo Uri::new('eXAMPLE://a/./b/../b/%63/%7bfoo%7d')->toNormalizedString(); +// both calls display example://a/b/c/%7Bfoo%7D +``` + +If you are only interested in the normalized string version of the URI you can call the `toNormalizedString` +which is the equivalent to calling `toString` after calling `normalize`. + +### URI comparison + +Once normalized a URI can be compare using the two new comparison methods, `isSameDocument` and `equals` methods. + +The two methods uses the normalized string representation of two URI to tell whether they are referencing the +same resource. + +```php + +$uri = Uri::new('example://a/b/c/%7Bfoo%7D?foo=bar'); +$uri->isSameDocument('eXAMPLE://a/./b/../b/%63/%7bfoo%7d'); // returns true +$uri->equals('eXAMPLE://a/./b/../b/%63/%7bfoo%7d'); // returns true +$uri->equals('eXAMPLE://a/./b/../b/%63/%7bfoo%7d', excludeFragment: false); // returns false +``` + +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/Conditionable.php b/interfaces/Contracts/Conditionable.php new file mode 100644 index 00000000..385a78cc --- /dev/null +++ b/interfaces/Contracts/Conditionable.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace League\Uri\Contracts; + +interface Conditionable +{ + /** + * Apply the callback if the given "condition" is (or resolves to) true. + * + * @param (callable(static): bool)|bool $condition + * @param callable(static): (static|null) $onSuccess + * @param ?callable(static): (static|null) $onFail + */ + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static; +} diff --git a/interfaces/Contracts/UriInterface.php b/interfaces/Contracts/UriInterface.php index ae335ddf..3b36ba13 100644 --- a/interfaces/Contracts/UriInterface.php +++ b/interfaces/Contracts/UriInterface.php @@ -24,8 +24,22 @@ * * @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/BaseUri.php b/uri/BaseUri.php index a71d0071..28d4c57c 100644 --- a/uri/BaseUri.php +++ b/uri/BaseUri.php @@ -14,6 +14,7 @@ namespace League\Uri; use JsonSerializable; +use League\Uri\Contracts\Conditionable; use League\Uri\Contracts\UriAccess; use League\Uri\Contracts\UriInterface; use League\Uri\Exceptions\MissingFeature; @@ -43,7 +44,7 @@ /** * @phpstan-import-type ComponentMap from UriInterface */ -class BaseUri implements Stringable, JsonSerializable, UriAccess +class BaseUri implements Stringable, JsonSerializable, UriAccess, Conditionable { /** @var array */ final protected const WHATWG_SPECIAL_SCHEMES = ['ftp' => 1, 'http' => 1, 'https' => 1, 'ws' => 1, 'wss' => 1]; @@ -362,14 +363,7 @@ public function relativize(Stringable|string $uri): static ); } - /** - * Apply the callback if the given "condition" is (or resolves to) true. - * - * @param (callable($this): bool)|bool $condition - * @param callable($this): (self|null) $onSuccess - * @param ?callable($this): (self|null) $onFail - */ - final public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + final public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static { if (!is_bool($condition)) { $condition = $condition($this); diff --git a/uri/Http.php b/uri/Http.php index 228a7be4..68b31eb1 100644 --- a/uri/Http.php +++ b/uri/Http.php @@ -15,6 +15,7 @@ use Deprecated; use JsonSerializable; +use League\Uri\Contracts\Conditionable; use League\Uri\Contracts\UriException; use League\Uri\Contracts\UriInterface; use League\Uri\Exceptions\SyntaxError; @@ -27,7 +28,7 @@ /** * @phpstan-import-type InputComponentMap from UriString */ -final class Http implements Stringable, Psr7UriInterface, JsonSerializable +final class Http implements Stringable, Psr7UriInterface, JsonSerializable, Conditionable { private readonly UriInterface $uri; @@ -224,14 +225,7 @@ private function newInstance(UriInterface $uri): self }; } - /** - * Apply the callback if the given "condition" is (or resolves to) true. - * - * @param (callable($this): bool)|bool $condition - * @param callable($this): (self|null) $onSuccess - * @param ?callable($this): (self|null) $onFail - */ - public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static { if (!is_bool($condition)) { $condition = $condition($this); diff --git a/uri/HttpTest.php b/uri/HttpTest.php index 9ed3448b..cd28ff02 100644 --- a/uri/HttpTest.php +++ b/uri/HttpTest.php @@ -90,7 +90,7 @@ public function testCreateFromComponents(): void public function testCreateFromBaseUri(): void { self::assertEquals( - Http::new('http://0:0@0.0.0.0/0?0#0'), + Http::new('http://0:0@0/0?0#0'), Http::fromBaseUri('0?0#0', 'http://0:0@0/') ); } diff --git a/uri/Uri.php b/uri/Uri.php index 21511dd7..a021b4bd 100644 --- a/uri/Uri.php +++ b/uri/Uri.php @@ -15,23 +15,30 @@ use Deprecated; use finfo; +use League\Uri\Contracts\Conditionable; use League\Uri\Contracts\UriComponentInterface; use League\Uri\Contracts\UriException; use League\Uri\Contracts\UriInterface; use League\Uri\Exceptions\ConversionFailed; use League\Uri\Exceptions\MissingFeature; use League\Uri\Exceptions\SyntaxError; -use League\Uri\Idna\Converter as IdnConverter; +use League\Uri\Idna\Converter as IdnaConverter; +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; use Stringable; +use Throwable; 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 +49,19 @@ use function ltrim; use function preg_match; use function preg_replace_callback; +use function preg_split; +use function rawurldecode; use function rawurlencode; 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,12 +69,13 @@ 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 * @phpstan-import-type InputComponentMap from UriString */ -final class Uri implements UriInterface +final class Uri implements UriInterface, Conditionable { /** * RFC3986 invalid characters. @@ -169,6 +182,13 @@ final class Uri implements UriInterface */ private const REGEXP_WINDOW_PATH = ',^(?[a-zA-Z][:|\|]),'; + /** + * Unreserved characters. + * + * @see https://www.rfc-editor.org/rfc/rfc3986.html#section-2.3 + */ + private const REGEXP_UNRESERVED_CHARACTERS = ',%(2[1-9A-Fa-f]|[3-7][0-9A-Fa-f]|61|62|64|65|66|7[AB]|5F),'; + /** * Supported schemes and corresponding default port. * @@ -199,6 +219,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 +236,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 +259,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(); } /** @@ -320,7 +348,7 @@ private function formatRegisteredName(string $host): string return match (1) { preg_match(self::REGEXP_HOST_REGNAME, $formattedHost) => $formattedHost, preg_match(self::REGEXP_HOST_GEN_DELIMS, $formattedHost) => throw new SyntaxError('The host `'.$host.'` is invalid : a registered name cannot contain URI delimiters or spaces.'), - default => IdnConverter::toAsciiOrFail($host), + default => IdnaConverter::toAsciiOrFail($host), }; } @@ -380,13 +408,17 @@ private function formatPort(?int $port = null): ?int } /** - * Create a new instance from a string.or a stringable structure or returns null on failure. + * Create a new instance from a string or a stringable structure or returns null on failure. */ - public static function tryNew(Stringable|string $uri = ''): ?self + public static function tryNew(Stringable|string|null $uri = ''): ?self { + if (null === $uri) { + return null; + } + try { return self::new($uri); - } catch (UriException) { + } catch (Throwable) { return null; } } @@ -423,11 +455,11 @@ public static function fromBaseUri( Stringable|string|null $baseUri = null ): self { $uri = self::new($uri); - $baseUri = BaseUri::from($baseUri ?? $uri); + $baseUri = self::tryNew($baseUri) ?? $uri; /** @var self $uri */ $uri = match (true) { - $baseUri->isAbsolute() => $baseUri->resolve($uri)->getUri(), + $baseUri->isAbsolute() => $baseUri->resolve($uri), default => throw new SyntaxError('the URI `'.$baseUri.'` must be absolute.'), }; @@ -867,6 +899,58 @@ 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 + { + try { + if ('blob' !== $this->scheme) { + if (!isset(static::WHATWG_SPECIAL_SCHEMES[$this->scheme])) { + return null; + } + + return $this + ->withFragment(null) + ->withQuery(null) + ->withPath('') + ->withUserInfo(null) + ->withHost($this->normalizeHost()) + ->toString(); + } + + $components = UriString::parse($this->path); + $scheme = strtolower($components['scheme'] ?? ''); + if (!isset(static::WHATWG_SPECIAL_SCHEMES[$scheme])) { + return null; + } + + return self::fromComponents($components)->origin; + } catch (UriException) { + return null; + } + } + + private function normalizeHost(): ?string + { + if (null === $this->host) { + return null; + } + + $host = $this->host; + $hostIp = IPv4Converter::fromEnvironment()->toDecimal($host); + + return IdnaConverter::toUnicode((string)IPv6Converter::compress(match (true) { + '' === $host, + null === $hostIp, + $host === $hostIp => $host, + default => $hostIp, + }))->domain(); + } + /** * URI validation for URI schemes which allows only scheme and path components. */ @@ -1037,14 +1121,12 @@ public function getFragment(): ?string return $this->fragment; } - /** - * Apply the callback if the given "condition" is (or resolves to) true. - * - * @param (callable($this): bool)|bool $condition - * @param callable($this): (self|null) $onSuccess - * @param ?callable($this): (self|null) $onFail - */ - public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): self + public function getOrigin(): ?self + { + return null === $this->origin ? null : Uri::new($this->origin); + } + + public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static { if (!is_bool($condition)) { $condition = $condition($this); @@ -1219,6 +1301,415 @@ 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(UriInterface|Stringable|string $uri): bool + { + if (null === $this->origin) { + return true; + } + + if (!$uri instanceof UriInterface) { + $uri = self::tryNew($uri); + } + + if (null === $uri || null === ($origin = $uri->getOrigin())) { + return true; + } + + return $this->origin !== (string) $origin; + } + + 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); + } + + return match(true) { + null === $uri => false, + $excludeFragment => $uri->withFragment(null)->toNormalizedString() === $this->withFragment(null)->toNormalizedString(), + default => $uri->toNormalizedString() === $this->toNormalizedString(), + }; + } + + 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 + { + return $this + ->withHost($this->normalizeHost()) + ->withPath($this->normalizePath()) + ->withQuery($this->decodeUnreservedCharacters($this->sortQuery($this->query))) + ->withFragment($this->decodeUnreservedCharacters($this->fragment)); + } + + private function normalizePath(): string + { + $authority = $this->authority; + $path = $this->path; + if ('/' === ($path[0] ?? '') || '' !== $this->scheme.$authority) { + $path = self::removeDotSegments($path); + } + + $path = (string) $this->decodeUnreservedCharacters($path); + if (null !== $authority && '' === $path) { + return '/'; + } + + return $path; + } + + private function decodeUnreservedCharacters(?string $str): ?string + { + return match (true) { + null === $str, + '' === $str => $str, + default => preg_replace_callback( + self::REGEXP_UNRESERVED_CHARACTERS, + static fn (array $matches): string => rawurldecode($matches[0]), + $str + ) ?? '', + }; + } + + 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] && null !== $this->getAuthority()) { + $path = '/'.$path; + } + + return $this + ->withPath($path) + ->withQuery($query) + ->withFragment($uri->getFragment()); + } + + /** + * 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) + ->withUserInfo(null) + ->withPort(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], + ]; + } }