+ * URI scheme. + *
+ * @param null|\FireHub\Foundation\Uri\Component\Authority $authority [optional]+ * URI authority. + *
+ * @param \FireHub\Foundation\Uri\Component\Path+ * URI path. + *
+ * @param null|\FireHub\Foundation\Uri\Component\Query+ * URI query. + *
+ * @param null|\FireHub\Foundation\Uri\Component\Fragment+ * URI fragment. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct ( + private ?Scheme $scheme = null, + private ?Authority $authority = null, + private Path $path = new Path(''), // @phpstan-ignore parameter.defaultValue + private ?Query $query = null, + private ?Fragment $fragment = null + ) { + + $this->guard( + fn() => $this->isValidCombination(), + fn() => new InvalidUriException + ); + + } + + /** + * ### Parses a URI reference + * + * Parses an absolute URI or relative reference according to RFC 3986. + * + * Preserves the original component representations, including percent-encoded sequences and empty query or + * fragment components. + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Authority::parse() To parse the authority. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To match the URI pattern. + * + * @param string $uri+ * URI reference to parse. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI is invalid. + * @throws \FireHub\Foundation\Uri\Exception\InvalidSchemeException If scheme has invalid pattern. + * @throws \FireHub\Foundation\Uri\Exception\InvalidPortException If port is not between 0 and 65535. + * @throws \FireHub\Foundation\Uri\Exception\InvalidPathException If path has invalid pattern. + * @throws \FireHub\Foundation\Uri\Exception\InvalidQueryException If query has invalid pattern. + * @throws \FireHub\Foundation\Uri\Exception\InvalidFragmentException If fragment has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self Parsed URI reference. + */ + public static function parse (string $uri):self { + + if (!Runtime\Str\SB\Regex::match( + '/\A(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^\/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?\z/s', + $uri, + result: $matches, + flags: PREG_UNMATCHED_AS_NULL + )) throw new InvalidUriException; + + return new self( + $matches[1] !== null // @phpstan-ignore offsetAccess.notFound + ? new Scheme($matches[1]) // @phpstan-ignore argument.type + : null, + $matches[2] !== null // @phpstan-ignore offsetAccess.notFound + ? Authority::parse($matches[2]) // @phpstan-ignore argument.type + : null, + new Path($matches[3]), // @phpstan-ignore offsetAccess.notFound, argument.type + $matches[4] !== null // @phpstan-ignore offsetAccess.notFound + ? new Query($matches[4]) // @phpstan-ignore argument.type + : null, + $matches[5] !== null // @phpstan-ignore offsetAccess.notFound + ? new Fragment($matches[5]) // @phpstan-ignore argument.type + : null + ); + + } + + /** + * Returns a URI with the specified scheme. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Scheme+ * URI scheme. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI with the specified scheme. + */ + public function withScheme (Scheme $scheme):self { + + return new self( + $scheme, + $this->authority, + $this->path, + $this->query, + $this->fragment + ); + + } + + /** + * Returns a URI without the scheme. + * @since 1.0.0 + * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI without the scheme. + */ + public function withoutScheme ():self { + + return new self( + null, + $this->authority, + $this->path, + $this->query, + $this->fragment + ); + + } + + /** + * Returns a URI with the specified authority. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Authority $authority+ * URI authority. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI with the specified authortity. + */ + public function withAuthority (Authority $authority):self { + + return new self( + $this->scheme, + $authority, + $this->path, + $this->query, + $this->fragment + ); + + } + + /** + * Returns a URI without the authority. + * @since 1.0.0 + * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI without the authority. + */ + public function withoutAuthority ():self { + + return new self( + $this->scheme, + null, + $this->path, + $this->query, + $this->fragment + ); + + } + + /** + * Returns a URI with the specified path. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Path+ * URI path. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI with the specified path. + */ + public function withPath (Path $path):self { + + return new self( + $this->scheme, + $this->authority, + $path, + $this->query, + $this->fragment + ); + + } + + /** + * Returns a URI with the specified query. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Query+ * URI query. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI with the specified query. + */ + public function withQuery (Query $query):self { + + return new self( + $this->scheme, + $this->authority, + $this->path, + $query, + $this->fragment + ); + + } + + /** + * Returns a URI without the query. + * @since 1.0.0 + * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI without the query. + */ + public function withoutQuery ():self { + + return new self( + $this->scheme, + $this->authority, + $this->path, + null, + $this->fragment + ); + + } + + /** + * Returns a URI with the specified fragment. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Fragment+ * URI fragment. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI with the specified fragment. + */ + public function withFragment (Fragment $fragment):self { + + return new self( + $this->scheme, + $this->authority, + $this->path, + $this->query, + $fragment + ); + + } + + /** + * Returns a URI without the fragment. + * @since 1.0.0 + * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the URI component combination is invalid. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self New URI without the fragment. + */ + public function withoutFragment ():self { + + return new self( + $this->scheme, + $this->authority, + $this->path, + $this->query, + null + ); + + } + + /** + * ### Gets the scheme + * @since 1.0.0 + * + * @return null|\FireHub\Foundation\Uri\Component\Scheme+ * URI host. + *
+ * @param null|\FireHub\Foundation\Uri\Component\Port+ * Network port. + *
+ * @param null|\FireHub\Foundation\Uri\Component\UserInfo+ * User information. + *
+ * + * @return void + */ + public function __construct ( + private Host $host, + private ?Port $port = null, + private ?UserInfo $user_info = null + ) {} + + /** + * ### Parses a URI authority + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Search::lastPosition() To find the last occurrence of a specific character. + * @uses \FireHub\Runtime\Str\SB\Search::firstPosition() To find the first occurrence of a specific character. + * @uses \FireHub\Runtime\Str\SB\Access::part() To get the part of the string. + * @uses \FireHub\Runtime\Str\SB\Search::startsWith() To check if the string starts with a specific character. + * @uses \FireHub\Runtime\Str\SB\Search::contains() To check if the string contains a specific character. + * @uses \FireHub\Foundation\Uri\Authority::parsePort() To parse the port. + * + * @param string $authority+ * URI authority to parse. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the authority or port is invalid. + * @throws \FireHub\Foundation\Uri\Exception\InvalidPortException If port is not between 0 and 65535. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self Parsed URI authority. + */ + public static function parse (string $authority):self { + + $user_info = null; + + $separator = Runtime\Str\SB\Search::lastPosition('@', $authority); + + if ($separator !== false) { + + $user_info = new UserInfo( + Runtime\Str\SB\Access::part($authority, 0, $separator) + ); + + $authority = Runtime\Str\SB\Access::part($authority, $separator + 1); + + } + + $host = $authority; $port = null; + if (Runtime\Str\SB\Search::startsWith('[', $authority)) { + + $closing = Runtime\Str\SB\Search::firstPosition(']', $authority); + + if ($closing === false) + throw new InvalidUriException; + + $host = Runtime\Str\SB\Access::part($authority, 0, $closing + 1); + + $remaining = Runtime\Str\SB\Access::part($authority, $closing + 1); + + if ($remaining !== '') { + + if (!Runtime\Str\SB\Search::startsWith(':', $remaining)) + throw new InvalidUriException; + + $port = self::parsePort(Runtime\Str\SB\Access::part($remaining, 1)); + + } + + } else if (Runtime\Str\SB\Search::contains(':', $authority)) { + + $separator = Runtime\Str\SB\Search::lastPosition(':', $authority) ?: 0; + + $host = Runtime\Str\SB\Access::part($authority, 0, $separator); + + if (Runtime\Str\SB\Search::contains(':', $host)) + throw new InvalidUriException; + + $port = self::parsePort( + Runtime\Str\SB\Access::part($authority, $separator + 1) + ); + + } + + return new self( + new Host($host), + $port, + $user_info + ); + + } + + /** + * Returns an authority with the specified host. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Host+ * Host information. + *
+ * + * @return self New authority with the specified host information. + */ + public function withHost (Host $host):self { + + return new self( + $host, + $this->port, + $this->user_info + ); + + } + + /** + * Returns an authority with the specified port. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Port+ * Port information. + *
+ * + * @return self New authority with the specified port information. + */ + public function withPort (Port $port):self { + + return new self( + $this->host, + $port, + $this->user_info + ); + + } + + /** + * Returns an authority without the port. + * @since 1.0.0 + * + * @return self New authority without the port. + */ + public function withoutPort ():self { + + return new self( + $this->host, + null, + $this->user_info + ); + + } + + /** + * Returns an authority with the specified user information. + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\UserInfo+ * User information. + *
+ * + * @return self New authority with the specified user information. + */ + public function withUserInfo (UserInfo $user_info):self { + + return new self( + $this->host, + $this->port, + $user_info + ); + + } + + /** + * Returns an authority without the user information. + * @since 1.0.0. + * + * @return self New authority without the user information. + */ + public function withoutUserInfo ():self { + + return new self( + $this->host, + $this->port, + null + ); + + } + + /** + * ### Gets the host + * @since 1.0.0 + * + * @return \FireHub\Foundation\Uri\Component\Host+ * Raw URI port. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUriException If the port is invalid. + * @throws \FireHub\Foundation\Uri\Exception\InvalidPortException If port is not between 0 and 65535. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return null|\FireHub\Foundation\Uri\Component\Port+ * URI fragment. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidFragmentException If fragment has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct ( + private string $fragment + ) { + + $this->guard( + fn() => Runtime\Str\SB\Regex::match( + '/\A(?:[A-Za-z0-9._~!$&\'()*+,;=:@\/?-]|%[A-Fa-f0-9]{2})*\z/', + $fragment + ), + fn() => new InvalidFragmentException + ); + + } + + /** + * ### Creates a URI fragment from a decoded string + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encodeFragment() To encode the fragment. + * + * @param string $fragment+ * Decoded URI fragment. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidFragmentException If fragment has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self+ * URI host. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidHostException If host has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct ( + private string $host + ) { + + $this->guard( + fn() => self::isValid($host), + fn() => new InvalidHostException + ); + + } + + /** + * ### Checks whether the host is empty + * @since 1.0.0 + * + * @return bool True if the host is empty, false otherwise. + */ + public function isEmpty ():bool { + + return $this->host === ''; + + } + + /** + * ### Returns the normalized host + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Casing::toLower() To convert the host to lowercase. + * @uses \FireHub\Foundation\Uri\Encoding\Percent::normalize() To normalize the host. + * + * @return string Normalized host. + */ + public function normalized ():string { + + if ($this->host === '') + return ''; + + if ($this->host[0] === '[') + return Runtime\Str\SB\Casing::toLower($this->host); + + return Runtime\Str\SB\Casing::toLower( + Percent::normalize($this->host) + ); + + } + + /** + * ### Returns the original host + */ + public function value ():string { + + return $this->host; + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Host::normalized() To get the normalized host. + */ + protected function comparisonValue ():string { + + return $this->normalized(); + + } + + /** + * ### Validates a URI host + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Search::endsWith() To check if the host ends with a closing bracket. + * @uses \FireHub\Runtime\Str\SB\Access::part() To get the address part of the host. + * @uses \FireHub\Runtime\Filter::value() To validate the address part of the host. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To validate the host. + * + * @param string $host+ * URI host. + *
+ * + * @return bool True if the host is valid, false otherwise. + */ + private static function isValid (string $host):bool { + + if ($host === '') return true; + + if ($host[0] === '[') { + + if (!Runtime\Str\SB\Search::endsWith(']', $host)) return false; + + $address = Runtime\Str\SB\Access::part($host, 1, -1); + + if (Runtime\Filter::value( + $address, + FILTER_VALIDATE_IP, + FILTER_FLAG_IPV6 + ) !== false) return true; + + return Runtime\Str\SB\Regex::match( + '/\Av[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&\'()*+,;=:-]+\z/i', + $address + ); + + } + + return Runtime\Str\SB\Regex::match( + '/\A(?:[A-Za-z0-9._~!$&\'()*+,;=-]|%[A-Fa-f0-9]{2})*\z/', + $host + ); + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Path.php b/src/Uri/Component/Path.php new file mode 100644 index 0000000..e51335c --- /dev/null +++ b/src/Uri/Component/Path.php @@ -0,0 +1,235 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Component; + +use FireHub\Core\Type\ValueObject; +use FireHub\Foundation\Uri\Encoding\Percent; +use FireHub\Foundation\Uri\Exception\InvalidPathException; +use FireHub\Runtime; + +/** + * ### Represents a URI path + * + * Preserves the original path and percent-encoded sequences. + * @since 1.0.0 + * + * @template TValue of string + * + * @extends \FireHub\Core\Type\ValueObject+ * URI path. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidPathException If path has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct( + private string $path + ) { + + $this->guard( + fn() => Runtime\Str\SB\Regex::match( + '/\A(?:[A-Za-z0-9._~!$&\'()*+,;=:@\/-]|%[A-Fa-f0-9]{2})*\z/D', + $path + ), + fn() => new InvalidPathException + ); + + } + + /** + * ### Creates a URI path from a decoded string + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encodePath() To encode the path. + * + * @param string $path+ * Decoded URI path. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidPathException If path has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self+ * Network port. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidPortException If port is not between 0 and 65535. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct ( + private int $port + ) { + + $this->guard( + fn() => $port >= 0 && $port <= 65535, + fn() => new InvalidPortException( + 'Port must be between 0 and 65535.' + ) + ); + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + * + * @return int<0, 65535> Network port. + */ + public function value ():int { + + /** @var int<0, 65535> */ + return $this->port; + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Query.php b/src/Uri/Component/Query.php new file mode 100644 index 0000000..30a6d9e --- /dev/null +++ b/src/Uri/Component/Query.php @@ -0,0 +1,139 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Component; + +use FireHub\Core\Type\ValueObject; +use FireHub\Foundation\Uri\Encoding\Percent; +use FireHub\Foundation\Uri\Exception\InvalidQueryException; +use FireHub\Runtime; + +/** + * ### Represents a URI query component + * + * Preserves the raw query string, including repeated parameters and percent-encoded characters. + * @since 1.0.0 + * + * @template TValue of string + * + * @extends \FireHub\Core\Type\ValueObject+ * Raw URI query. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidQueryException If query has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct( + private string $query + ) { + + $this->guard( + fn() => Runtime\Str\SB\Regex::match( + '/\A(?:[A-Za-z0-9._~!$&\'()*+,;=:@\/?-]|%[A-Fa-f0-9]{2})*\z/', + $query + ), + fn() => new InvalidQueryException + ); + + } + + /** + * ### Creates a URI query from a decoded string + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encodeQuery() To encode the query. + * + * @param string $query+ * Decoded URI query. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidQueryException If query has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self+ * URI scheme. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidSchemeException If scheme has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct ( + private string $scheme + ) { + + $this->guard( + fn() => Runtime\Str\SB\Regex::match( + '/\A[A-Za-z][A-Za-z0-9+.-]*\z/', + $scheme + ), + fn() => new InvalidSchemeException + ); + + } + + /** + * ### Returns the normalized scheme + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Casing::toLower() To convert the scheme to lower case. + * + * @return lowercase-string&non-empty-string Normalized scheme. + */ + public function normalized ():string { + + /** @var lowercase-string&non-empty-string */ + return Runtime\Str\SB\Casing::toLower($this->scheme); + + } + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + public function value ():string { + + return $this->scheme; + + } + + /** + * ### Compares schemes case-insensitively + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Scheme::normalized() To get the normalized scheme. + * + * @return non-empty-string Normalized scheme. + */ + protected function comparisonValue ():string { + + return $this->normalized(); + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/UserInfo.php b/src/Uri/Component/UserInfo.php new file mode 100644 index 0000000..fbf0046 --- /dev/null +++ b/src/Uri/Component/UserInfo.php @@ -0,0 +1,135 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.5 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Component; + +use FireHub\Core\Type\ValueObject; +use FireHub\Foundation\Uri\Encoding\Percent; +use FireHub\Foundation\Uri\Exception\InvalidUserInfoException; +use FireHub\Runtime; + +/** + * ### Represents URI user information + * @since 1.0.0 + * + * @template TValue of string + * + * @extends \FireHub\Core\Type\ValueObject+ * Raw URI user information. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUserInfoException If userinfo has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return void + */ + public function __construct ( + private string $user_info + ) { + + $this->guard( + fn() => Runtime\Str\SB\Regex::match( + '/\A(?:[A-Za-z0-9._~!$&\'()*+,;=:-]|%[A-Fa-f0-9]{2})*\z/', + $user_info + ), + fn() => new InvalidUserInfoException + ); + + } + + /** + * ### Creates a URI user info from a decoded string + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encodeUserInfo() To encode the user info. + * + * @param string $user_info+ * Decoded URI user info. + *
+ * + * @throws \FireHub\Foundation\Uri\Exception\InvalidUserInfoException If userinfo has invalid pattern. + * @throws \FireHub\Core\Exception\FireHubException If the condition is not met. + * @throws \FireHub\Core\Type\Exception\ValueObjectException If the exception is not a FireHubException. + * + * @return self+ * Decoded URI path. + *
+ * + * @return string Percent-encoded URI path. + */ + public static function encodePath (string $value):string { + + return self::encode( + $value, + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/" + ); + + } + + /** + * ### Encodes a URI query + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encode() To percent-encode disallowed bytes. + * + * @param string $value+ * Decoded URI query. + *
+ * + * @return string Percent-encoded URI query. + */ + public static function encodeQuery (string $value):string { + + return self::encode( + $value, + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/?" + ); + + } + + /** + * ### Encodes a URI fragment + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encode() To percent-encode disallowed bytes. + * + * @param string $value+ * Decoded URI fragment. + *
+ * + * @return string Percent-encoded URI fragment. + */ + public static function encodeFragment (string $value):string { + + return self::encode( + $value, + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@/?" + ); + + } + + /** + * ### Encodes URI user information + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encode() To percent-encode disallowed bytes. + * + * @param string $value+ * Decoded URI user information. + *
+ * + * @return string Percent-encoded URI user information. + */ + public static function encodeUserInfo (string $value):string { + + return self::encode( + $value, + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:" + ); + + } + + /** + * ### Decodes a percent-encoded URI component + * + * Decodes percent-encoded octets without interpreting component delimiters or applying form-style decoding. + * + * Plus signs are preserved. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Encoding::rawUrlDecode() To decode percent-encoded octets. + * + * @param string $value+ * Percent-encoded URI component. + *
+ * + * @return string Decoded component. + */ + public static function decode (string $value):string { + + return Runtime\Encoding::rawUrlDecode($value); + + } + + /** + * ### Normalizes percent-encoded sequences + * + * Decodes unreserved characters and converts remaining percent-encoded hexadecimal digits to uppercase. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Regex::replaceUsing() To replace percent-encoded sequences. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To check if a character is unreserved. + * @uses \FireHub\Runtime\Str\SB\Casing::toUpper() To convert hexadecimal digits to uppercase. + * @uses \FireHub\Runtime\Encoding::rawUrlDecode() To decode percent-encoded octets. + * + * @param string $value+ * URI component. + *
+ * + * @return string Normalized URI component. + */ + public static function normalize (string $value):string { + + return Runtime\Str\SB\Regex::replaceUsing( + '/%([0-9A-Fa-f]{2})/', + static function (array $matches):string { + + $character = Runtime\Encoding::rawUrlDecode($matches[0]); // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Str\SB\Regex::match( + '/\A[A-Za-z0-9._~-]\z/', + $character + )) + return $character; + + return '%'.Runtime\Str\SB\Casing::toUpper($matches[1]); // @phpstan-ignore offsetAccess.notFound + + }, + $value + ); + + } + + /** + * ### Percent-encodes disallowed bytes + * + * Preserves only bytes explicitly listed in the allowed character set. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the input value. + * @uses \FireHub\Runtime\Str\SB\Search::contains() To check if a character is in the allowed set. + * @uses \FireHub\Runtime\Char\SB::ord() To get the byte value of a character. + * @uses \FireHub\Runtime\Str\SB\Transform::format() To format a string with a placeholder. + * + * @param string $value+ * Input value. + *
+ * @param string $allowed+ * Allowed ASCII characters. + *
+ * + * @return string Percent-encoded value. + */ + private static function encode (string $value, string $allowed):string { + + $encoded = ''; + + $length = Runtime\Str\SB\Inspection::length($value); + + for ($index = 0; $index < $length; $index++) { + + $character = $value[$index]; + + if (Runtime\Str\SB\Search::contains($character, $allowed)) { + + $encoded .= $character; + + continue; + + } + + $encoded .= Runtime\Str\SB\Transform::format( + '%%%02X', + Runtime\Char\SB::ord($character) + ); + + } + + return $encoded; + + } + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidFragmentException.php b/src/Uri/Exception/InvalidFragmentException.php new file mode 100644 index 0000000..2d67b5a --- /dev/null +++ b/src/Uri/Exception/InvalidFragmentException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI fragment + * @since 1.0.0 + */ +final class InvalidFragmentException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI fragment.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidHostException.php b/src/Uri/Exception/InvalidHostException.php new file mode 100644 index 0000000..9719dd9 --- /dev/null +++ b/src/Uri/Exception/InvalidHostException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI host + * @since 1.0.0 + */ +final class InvalidHostException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI host.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidPathException.php b/src/Uri/Exception/InvalidPathException.php new file mode 100644 index 0000000..cdfe73f --- /dev/null +++ b/src/Uri/Exception/InvalidPathException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI path + * @since 1.0.0 + */ +final class InvalidPathException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI path.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidPortException.php b/src/Uri/Exception/InvalidPortException.php new file mode 100644 index 0000000..c0dcda9 --- /dev/null +++ b/src/Uri/Exception/InvalidPortException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI port + * @since 1.0.0 + */ +final class InvalidPortException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI port.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidQueryException.php b/src/Uri/Exception/InvalidQueryException.php new file mode 100644 index 0000000..f9d9f32 --- /dev/null +++ b/src/Uri/Exception/InvalidQueryException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI query + * @since 1.0.0 + */ +final class InvalidQueryException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI query.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidSchemeException.php b/src/Uri/Exception/InvalidSchemeException.php new file mode 100644 index 0000000..516ad73 --- /dev/null +++ b/src/Uri/Exception/InvalidSchemeException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI scheme + * @since 1.0.0 + */ +final class InvalidSchemeException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI scheme.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidUriException.php b/src/Uri/Exception/InvalidUriException.php new file mode 100644 index 0000000..dae81a1 --- /dev/null +++ b/src/Uri/Exception/InvalidUriException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents an invalid URI + * @since 1.0.0 + */ +final class InvalidUriException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI.'; + +} \ No newline at end of file diff --git a/src/Uri/Exception/InvalidUserInfoException.php b/src/Uri/Exception/InvalidUserInfoException.php new file mode 100644 index 0000000..f763fa1 --- /dev/null +++ b/src/Uri/Exception/InvalidUserInfoException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Exception; + +use FireHub\Core\Exception\Runtime\InvalidArgumentException; + +/** + * ### Represents invalid URI user information + * @since 1.0.0 + */ +final class InvalidUserInfoException extends InvalidArgumentException { + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'Invalid URI user information.'; + +} \ No newline at end of file diff --git a/src/Uri/KnownScheme.php b/src/Uri/KnownScheme.php new file mode 100644 index 0000000..dd60a55 --- /dev/null +++ b/src/Uri/KnownScheme.php @@ -0,0 +1,153 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.1 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri; + +/** + * ### Known URI schemes + * + * Defines commonly used URI schemes for identifying resources across network protocols, communication services, and + * other resource identification systems. + * + * This enumeration is not exhaustive. Custom URI schemes are supported through the Scheme value object. + * @since 1.0.0 + */ +enum KnownScheme:string { + + /** + * Hypertext Transfer Protocol. + * @since 1.0.0 + */ + case HTTP = 'http'; + + /** + * Hypertext Transfer Protocol Secure. + * @since 1.0.0 + */ + case HTTPS = 'https'; + + /** + * WebSocket Protocol. + * @since 1.0.0 + */ + case WS = 'ws'; + + /** + * WebSocket Secure Protocol. + * @since 1.0.0 + */ + case WSS = 'wss'; + + /** + * File Transfer Protocol. + * @since 1.0.0 + */ + case FTP = 'ftp'; + + /** + * Secure Shell Protocol. + * @since 1.0.0 + */ + case SSH = 'ssh'; + + /** + * Local file resources. + * @since 1.0.0 + */ + case FILE = 'file'; + + /** + * Electronic mail addresses. + * @since 1.0.0 + */ + case MAILTO = 'mailto'; + + /** + * Telephone numbers. + * @since 1.0.0 + */ + case TEL = 'tel'; + + /** + * Short Message Service. + * @since 1.0.0 + */ + case SMS = 'sms'; + + /** + * Uniform Resource Names. + * @since 1.0.0 + */ + case URN = 'urn'; + + /** + * Inline data resources. + * @since 1.0.0 + */ + case DATA = 'data'; + + /** + * Lightweight Directory Access Protocol. + * @since 1.0.0 + */ + case LDAP = 'ldap'; + + /** + * Lightweight Directory Access Protocol over TLS. + * @since 1.0.0 + */ + case LDAPS = 'ldaps'; + + /** + * Session Initiation Protocol. + * @since 1.0.0 + */ + case SIP = 'sip'; + + /** + * Secure Session Initiation Protocol. + * @since 1.0.0 + */ + case SIPS = 'sips'; + + /** + * Internet Relay Chat. + * @since 1.0.0 + */ + case IRC = 'irc'; + + /** + * Secure Internet Relay Chat. + * @since 1.0.0 + */ + case IRCS = 'ircs'; + + /** + * Network News Transfer Protocol. + * @since 1.0.0 + */ + case NEWS = 'news'; + + /** + * Network News Transfer Protocol. + * @since 1.0.0 + */ + case NNTP = 'nntp'; + + /** + * Git repository resources. + * @since 1.0.0 + */ + case GIT = 'git'; + +} \ No newline at end of file