diff --git a/CHANGELOG.md b/CHANGELOG.md index 844e276..50791ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [[v0.2.0](https://github.com/The-FireHub-Project/Foundation/compare/v0.2.0...v0.1.0)] – 2026-11-02 + +### Added +* Create URI by @dieselxxx + ## [v0.1.0] – 2026-10-21 ### Added diff --git a/README.md b/README.md index 5072594..c6f4724 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version diff --git a/src/DataStructure/Storage/Initialization/FillInit.php b/src/DataStructure/Storage/Initialization/FillInit.php index 81f1df7..786d3b5 100644 --- a/src/DataStructure/Storage/Initialization/FillInit.php +++ b/src/DataStructure/Storage/Initialization/FillInit.php @@ -62,7 +62,7 @@ public function initialize ():Generator { for ($key = 0; $key < $this->length; $key++) /** @var TKey $key */ - yield $key => $this->value; + yield $key => $this->value; // @phpstan-ignore varTag.nativeType } diff --git a/src/DataStructure/Storage/Initialization/RangeInit.php b/src/DataStructure/Storage/Initialization/RangeInit.php index 0acb9af..9ea50b1 100644 --- a/src/DataStructure/Storage/Initialization/RangeInit.php +++ b/src/DataStructure/Storage/Initialization/RangeInit.php @@ -74,7 +74,7 @@ public function initialize ():Generator { * @var TKey $key * @var TValue $value */ - yield $key++ => $value; + yield $key++ => $value; // @phpstan-ignore varTag.nativeType $value += $this->step; diff --git a/src/DataStructure/Stream.php b/src/DataStructure/Stream.php index 9f44c1b..7626d7c 100644 --- a/src/DataStructure/Stream.php +++ b/src/DataStructure/Stream.php @@ -326,17 +326,18 @@ public function skip ():Skip { * @throws \FireHub\Foundation\DataStructure\Exception\ChunkSizeException If the specified chunk size is less * than one. * - * @return static> The chunked Stream. + * @return static>> The chunked Stream. */ public function chunk (int $size):static { - return new static( - new ChunkSource( - $this->source, - $size - ) + /** @var \FireHub\Foundation\DataStructure\Stream\Source\ChunkSource> $source*/ + $source = new ChunkSource( + $this->source, + $size ); + return new static($source); + } /** @@ -384,7 +385,7 @@ public function concat (iterable $values):static { public function zip (iterable $values):static { return new static( - new ZipSource( + new ZipSource( // @phpstan-ignore argument.type $this->source, $values ) diff --git a/src/Uri.php b/src/Uri.php new file mode 100644 index 0000000..2e9147d --- /dev/null +++ b/src/Uri.php @@ -0,0 +1,567 @@ + + * @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; + +use FireHub\Core\Type\Uri as BaseUri; +use FireHub\Foundation\Uri\Component\ { + Authority, Fragment, Host, Path, Query, Scheme, UserInfo +}; +use FireHub\Foundation\Uri\Exception\InvalidUriException; +use FireHub\Runtime; + +/** + * ### Represents a URI reference + * + * Combines URI components into an immutable URI reference according to RFC 3986. + * + * Supports both absolute URIs and relative references. + * @since 1.0.0 + * + * @extends \FireHub\Core\Type\Uri + */ +final readonly class Uri extends BaseUri { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Foundation\Uri::isValidCombination() To validate the URI component combination. + * + * @param null|\FireHub\Foundation\Uri\Component\Scheme $scheme [optional]

+ * URI scheme. + *

+ * @param null|\FireHub\Foundation\Uri\Component\Authority $authority [optional]

+ * URI authority. + *

+ * @param \FireHub\Foundation\Uri\Component\Path $path [optional]

+ * URI path. + *

+ * @param null|\FireHub\Foundation\Uri\Component\Query $query [optional]

+ * URI query. + *

+ * @param null|\FireHub\Foundation\Uri\Component\Fragment $fragment [optional]

+ * 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 $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 $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 $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 $fragment [optional]

+ * 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 scheme. + */ + public function scheme ():?Scheme { + + return $this->scheme; + + } + + /** + * ### Gets the authority + * @since 1.0.0 + * + * @return null|\FireHub\Foundation\Uri\Component\Authority URI authority. + */ + public function authority ():?Authority { + + return $this->authority; + + } + + /** + * ### Gets the path + * @since 1.0.0 + * + * @return \FireHub\Foundation\Uri\Component\Path URI path. + */ + public function path ():Path { + + return $this->path; + + } + + /** + * ### Gets the query + * @since 1.0.0 + * + * @return null|\FireHub\Foundation\Uri\Component\Query URI query. + */ + public function query ():?Query { + + return $this->query; + + } + + /** + * ### Gets the fragment + * @since 1.0.0 + * + * @return null|\FireHub\Foundation\Uri\Component\Fragment URI fragment. + */ + public function fragment ():?Fragment { + + return $this->fragment; + + } + + /** + * ### Checks whether the URI is absolute + * + * An absolute URI contains a scheme and does not contain a fragment. + * @since 1.0.0 + * + * @return bool True if the URI is absolute, false otherwise. + */ + public function isAbsolute ():bool { + + return $this->scheme !== null && $this->fragment === null; + + } + + /** + * ### Checks whether the URI reference is relative + * @since 1.0.0 + * + * @return bool True if the URI reference has no scheme, false otherwise. + */ + public function isRelative ():bool { + + return $this->scheme === null; + + } + + /** + * ### Normalizes the URI + * + * Applies syntax-based normalization according to RFC 3986 and removes default ports for supported schemes. + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Scheme::normalized() To normalize the scheme. + * @uses \FireHub\Foundation\Uri\Component\Authority::normalized() To normalize the authority. + * @uses \FireHub\Foundation\Uri\Component\Path::normalized() To normalize the path. + * @uses \FireHub\Foundation\Uri\Component\Query::normalized() To normalize the query. + * @uses \FireHub\Foundation\Uri\Component\Fragment::normalized() To normalize the fragment. + * @uses \FireHub\Foundation\Uri\Component\Path::removeDotSegments() To remove dot segments. + * + * @return self Normalized URI. + */ + public function normalize ():self { + + $scheme = $this->scheme !== null + ? new Scheme($this->scheme->normalized()) + : null; + + $authority = null; + + if ($this->authority !== null) { + + $host = new Host( + $this->authority->host()->normalized() + ); + + $port = $this->authority->port(); + + $user_info = $this->authority->userInfo(); + + $authority = new Authority( + $host, + $port, + $user_info !== null + ? new UserInfo($user_info->normalized()) + : null + ); + + } + + $path = new Path($this->path->normalized()); + + if ($scheme !== null || $authority !== null) + $path = $path->removeDotSegments(); + + return new self( + $scheme, + $authority, + $path, + $this->query !== null + ? new Query($this->query->normalized()) + : null, + $this->fragment !== null + ? new Fragment($this->fragment->normalized()) + : null + ); + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Scheme::value() To get the scheme value. + * @uses \FireHub\Foundation\Uri\Component\Authority::value() To get the authority value. + * @uses \FireHub\Foundation\Uri\Component\Path::value() To get the path value. + * @uses \FireHub\Foundation\Uri\Component\Query::value() To get the query value. + * @uses \FireHub\Foundation\Uri\Component\Fragment::value() To get the fragment value. + */ + public function value ():string { + + $uri = ''; + + if ($this->scheme !== null) + $uri .= $this->scheme->value().':'; + + if ($this->authority !== null) + $uri .= '//'.$this->authority->value(); + + $uri .= $this->path->value(); + + if ($this->query !== null) + $uri .= '?'.$this->query->value(); + + if ($this->fragment !== null) + $uri .= '#'.$this->fragment->value(); + + return $uri; + + } + + /** + * ### Validates the URI component combination + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Path::value() To get the path value. + * @uses \FireHub\Foundation\Uri\Component\Path::isAbsolute() To check if the path is absolute. + * @uses \FireHub\Runtime\Str\SB\Search::startsWith() To check if the path starts with "//". + * @uses \FireHub\Runtime\Str\SB\Search::contains() To check if the path contains a colon. + * @uses \FireHub\Runtime\Str\SB\Delimiter::explode() To split the path into segments. + * + * @return bool True if the combination is valid, false otherwise. + */ + private function isValidCombination ():bool { + + $path = $this->path->value(); + + if ($this->authority !== null) + return $path === '' || $this->path->isAbsolute(); + + if (Runtime\Str\SB\Search::startsWith('//', $path)) + return false; + + if ($this->scheme === null && $path !== '' && $path[0] !== '/') { + + $first_segment = Runtime\Str\SB\Delimiter::explode($path, '/', 2)[0]; // @phpstan-ignore offsetAccess.notFound + + if (Runtime\Str\SB\Search::contains(':', $first_segment)) + return false; + + } + + return true; + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Authority.php b/src/Uri/Component/Authority.php new file mode 100644 index 0000000..9375edd --- /dev/null +++ b/src/Uri/Component/Authority.php @@ -0,0 +1,335 @@ + + * @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\Core\Meta\Enum\Side; +use FireHub\Foundation\Uri\Exception\InvalidUriException; +use FireHub\Runtime; + +/** + * ### Represents a URI authority + * + * Combines user information, host, and port into a single immutable URI authority component. + * @since 1.0.0 + * + * @extends \FireHub\Core\Type\ValueObject + */ +final readonly class Authority extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @param \FireHub\Foundation\Uri\Component\Host $host

+ * URI host. + *

+ * @param null|\FireHub\Foundation\Uri\Component\Port $port [optional]

+ * Network port. + *

+ * @param null|\FireHub\Foundation\Uri\Component\UserInfo $user_info [optional]

+ * 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

+ * 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

+ * 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_info

+ * 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 The host. + */ + public function host ():Host { + + return $this->host; + + } + + /** + * ### Gets the port + * @since 1.0.0 + * + * @return null|\FireHub\Foundation\Uri\Component\Port The port. + */ + public function port ():?Port { + + return $this->port; + + } + + /** + * ### Gets the user information + * @since 1.0.0 + * + * @return null|\FireHub\Foundation\Uri\Component\UserInfo The user information. + */ + public function userInfo ():?UserInfo { + + return $this->user_info; + + } + + /** + * ### Checks whether the authority is empty + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\Host::isEmpty() To check if the host is empty. + * + * @return bool True if the authority is empty, false otherwise. + */ + public function isEmpty ():bool { + + return $this->host->isEmpty() + && $this->port === null + && $this->user_info === null; + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Component\UserInfo::value() To get the user information. + * @uses \FireHub\Foundation\Uri\Component\Host::value() To get the host. + * @uses \FireHub\Foundation\Uri\Component\Port::value() To get the port. + */ + public function value ():string { + + $authority = ''; + + if ($this->user_info !== null) + $authority .= $this->user_info->value().'@'; + + $authority .= $this->host->value(); + + if ($this->port !== null) + $authority .= ':'.$this->port->value(); + + return $authority; + + } + + /** + * ### Parses a URI port + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Regex::match() To check if the port is valid. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim the port. + * + * @param string $port

+ * 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 Parsed port. + */ + private static function parsePort (string $port):?Port { + + if ($port === '') return null; + + if (!Runtime\Str\SB\Regex::match('/\A[0-9]+\z/', $port)) + throw new InvalidUriException; + + $normalized = Runtime\Str\SB\Transform::trim($port, Side::LEFT, '0'); + + return new Port((int)$normalized); + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Fragment.php b/src/Uri/Component/Fragment.php new file mode 100644 index 0000000..828a7f2 --- /dev/null +++ b/src/Uri/Component/Fragment.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\InvalidFragmentException; +use FireHub\Runtime; + +/** + * ### Represents a URI fragment + * @since 1.0.0 + * + * @template TValue of string + * + * @extends \FireHub\Core\Type\ValueObject + */ +final readonly class Fragment extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To validate the fragment. + * + * @param TValue $fragment

+ * 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 Encoded URI fragment. + */ + public static function fromDecoded (string $fragment):self { + + return new self(Percent::encodeFragment($fragment)); + + } + + /** + * ### Gets the decoded URI fragment + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\PercentEncoder::decode() To decode the fragment. + * + * @return string Decoded URI fragment. + */ + public function decoded ():string { + + return Percent::decode($this->fragment); + + } + + /** + * ### Checks whether the fragment is empty + * @since 1.0.0 + * + * @return bool True if the fragment is empty, false otherwise. + */ + public function isEmpty ():bool { + + return $this->fragment === ''; + + } + + /** + * ### Gets the normalized fragment + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::normalize() To normalize the fragment. + * + * @return string Normalized fragment. + */ + public function normalized ():string { + + return Percent::normalize($this->fragment); + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + public function value ():string { + + return $this->fragment; + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Host.php b/src/Uri/Component/Host.php new file mode 100644 index 0000000..808671b --- /dev/null +++ b/src/Uri/Component/Host.php @@ -0,0 +1,163 @@ + + * @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\InvalidHostException; +use FireHub\Runtime; + +/** + * ### Represents a URI host + * + * Supports registered names, IPv4 addresses, and bracketed IPv6 literals. + * @since 1.0.0 + * + * @template TValue of string + * + * @extends \FireHub\Core\Type\ValueObject + */ +final readonly class Host extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Foundation\Uri\Component\Host::isValid() To validate the host. + * + * @param TValue $host

+ * 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 + */ +final readonly class Path extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To validate the path. + * + * @param TValue $path

+ * 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 Encoded URI path. + */ + public static function fromDecoded (string $path):self { + + return new self(Percent::encodePath($path)); + + } + + /** + * ### Gets the decoded URI path + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\PercentEncoder::decode() To decode the path. + * + * @return string Decoded URI path. + */ + public function decoded ():string { + + return Percent::decode($this->path); + + } + + /** + * ### Checks whether the path represents the root + * @since 1.0.0 + * + * @return bool True if the path is exactly "/", false otherwise. + */ + public function isRoot ():bool { + + return $this->path === '/'; + + } + + /** + * ### Checks whether the path is absolute + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Search::startsWith() To check if the path starts with a slash. + * + * @return bool True if the path is absolute, false otherwise. + */ + public function isAbsolute ():bool { + + return Runtime\Str\SB\Search::startsWith('/', $this->path); + + } + + /** + * ### Checks whether the path is empty + * @since 1.0.0 + * + * @return bool True if the path is empty, false otherwise. + */ + public function isEmpty ():bool { + + return $this->path === ''; + + } + + /** + * ### Gets the normalized path + * + * Normalizes percent-encoded sequences without changing the path segment structure. + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::normalize() To normalize the path. + * + * @return string Normalized path. + */ + public function normalized ():string { + + return Percent::normalize($this->path); + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + public function value ():string { + + return $this->path; + + } + + /** + * ### Removes dot segments + * + * Removes "." and ".." segments according to RFC 3986 Section 5.2.4. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Search::startsWith() To check if a segment starts with charaters. + * @uses \FireHub\Runtime\Str\SB\Access::part() To get a part of a string. + * @uses \FireHub\Runtime\Str\SB\Regex::replace() To replace a substring. + * @uses \FireHub\Runtime\Str\SB\Search::firstPosition() To find the first occurrence of a character. + * + * @return self Path without dot segments. + */ + public function removeDotSegments ():self { + + $input = $this->path; $output = ''; + while ($input !== '') { + + if (Runtime\Str\SB\Search::startsWith('../', $input)) + $input = Runtime\Str\SB\Access::part($input, 3); + + else if (Runtime\Str\SB\Search::startsWith('./', $input)) + $input = Runtime\Str\SB\Access::part($input, 2); + + else if (Runtime\Str\SB\Search::startsWith('/./', $input)) + $input = Runtime\Str\SB\Access::part($input, 2); + + else if ($input === '/.') + $input = '/'; + + else if (Runtime\Str\SB\Search::startsWith('/../', $input)) { + + $input = Runtime\Str\SB\Access::part($input, 3); + $output = Runtime\Str\SB\Regex::replace('~/[^/]*$~', '', $output); + + } else if ($input === '/..') { + + $input = '/'; + $output = Runtime\Str\SB\Regex::replace('~/[^/]*$~', '', $output); + + } else if ($input === '.' || $input === '..') { + + $input = ''; + + } else { + + $position = $input[0] === '/' + ? Runtime\Str\SB\Search::firstPosition('/', $input, offset: 1) + : Runtime\Str\SB\Search::firstPosition('/', $input); + + if ($position === false) { + + $output .= $input; + $input = ''; + + } else { + + $output .= Runtime\Str\SB\Access::part($input, 0, $position); + $input = Runtime\Str\SB\Access::part($input, $position); + + } + + } + + } + + return new self($output); + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Port.php b/src/Uri/Component/Port.php new file mode 100644 index 0000000..7c06547 --- /dev/null +++ b/src/Uri/Component/Port.php @@ -0,0 +1,72 @@ + + * @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.2 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Component; + +use FireHub\Core\Type\ValueObject; +use FireHub\Foundation\Uri\Exception\InvalidPortException; + +/** + * ### Represents a network port + * @since 1.0.0 + * + * @template TValue of int + * + * @extends \FireHub\Core\Type\ValueObject + */ +final readonly class Port extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * + * @param int $port

+ * 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 + */ +final readonly class Query extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To validate the query. + * + * @param TValue $query

+ * 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 Encoded URI query. + */ + public static function fromDecoded (string $query):self { + + return new self(Percent::encodeQuery($query)); + + } + + /** + * ### Gets the decoded URI query + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\PercentEncoder::decode() To decode the query. + * + * @return string Decoded URI query. + */ + public function decoded ():string { + + return Percent::decode($this->query); + + } + + /** + * ### Checks whether the query is empty + * @since 1.0.0 + * + * @return bool True if the query is empty, false otherwise. + */ + public function isEmpty(): bool { + + return $this->query === ''; + + } + + /** + * ### Gets the normalized query + * + * Normalizes percent-encoded sequences without changing query parameter ordering or interpretation. + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::normalize() To normalize the query. + * + * @return string Normalized query. + */ + public function normalized ():string { + + return Percent::normalize($this->query); + + } + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + public function value(): string { + + return $this->query; + + } + +} \ No newline at end of file diff --git a/src/Uri/Component/Scheme.php b/src/Uri/Component/Scheme.php new file mode 100644 index 0000000..cc530a6 --- /dev/null +++ b/src/Uri/Component/Scheme.php @@ -0,0 +1,103 @@ + + * @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\Exception\InvalidSchemeException; +use FireHub\Runtime; + +/** + * ### Represents a URI scheme + * + * Preserves the original scheme representation. + * @since 1.0.0 + * + * @template TValue of non-empty-string + * + * @extends \FireHub\Core\Type\ValueObject + */ +final readonly class Scheme extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To validate the scheme. + * + * @param TValue $scheme

+ * 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 + */ +final readonly class UserInfo extends ValueObject { + + /** + * ### Constructor + * @since 1.0.0 + * + * @uses \FireHub\Core\Type\ValueObject::guard() As a guard. + * @uses \FireHub\Runtime\Str\SB\Regex::match() To validate the userinfo. + * + * @param TValue $user_info

+ * 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 Encoded URI user info. + */ + public static function fromDecoded (string $user_info):self { + + return new self(Percent::encodeUserInfo($user_info)); + + } + + /** + * ### Gets the decoded URI user info + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\PercentEncoder::decode() To decode the user info. + * + * @return string Decoded URI user info. + */ + public function decoded ():string { + + return Percent::decode($this->user_info); + + } + + /** + * ### Checks whether the user information is empty + * @since 1.0.0 + * + * @return bool True if the user information is empty, false otherwise. + */ + public function isEmpty(): bool { + + return $this->user_info === ''; + + } + + /** + * ### Gets the normalized user information + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::normalize() To normalize the user information. + * + * @return string Normalized user information. + */ + public function normalized ():string { + + return Percent::normalize($this->user_info); + + } + + /** + * @inheritdoc + * + * @since 1.0.0 + */ + public function value ():string { + + return $this->user_info; + + } + +} \ No newline at end of file diff --git a/src/Uri/Encoding/Percent.php b/src/Uri/Encoding/Percent.php new file mode 100644 index 0000000..5f95b95 --- /dev/null +++ b/src/Uri/Encoding/Percent.php @@ -0,0 +1,227 @@ + + * @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.2 + * @package Foundation + */ + +namespace FireHub\Foundation\Uri\Encoding; + +use FireHub\Runtime; + +/** + * ### URI percent encoder + * + * Provides component-specific percent-encoding and decoding according to RFC 3986. + * + * Encoding preserves characters allowed by the target URI component and percent-encodes all other bytes. + * + * Input values are treated as decoded data. Existing percent signs are encoded rather than interpreted as escape + * sequences. + * @since 1.0.0 + */ +final readonly class Percent { + + /** + * ### Encodes a URI path + * + * Preserves forward slashes as path separators. + * @since 1.0.0 + * + * @uses \FireHub\Foundation\Uri\Encoding\Percent::encode() To percent-encode disallowed bytes. + * + * @param string $value

+ * 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