Net > Uri * @see https://www.rfc-editor.org/rfc/rfc3986 * @see https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface */ namespace Phrity\Net; use InvalidArgumentException; use Psr\Http\Message\UriInterface; /** * Net\Uri class. */ class Uri implements UriInterface { public const REQUIRE_PORT = 1; // Always include port, explicit or default public const ABSOLUTE_PATH = 2; // Enforce absolute path public const NORMALIZE_PATH = 4; // Normalize path public const IDNA = 8; // IDNA-convert host private const RE_MAIN = '!^(?P(?P[^:/?#]+):)?(?P//(?P[^/?#]*))?' . '(?P[^?#]*)(?P\?(?P[^#]*))?(?P#(?P.*))?$!'; private const RE_AUTH = '!^(?P(?P[^:/?#]+)(?P:(?P[^:/?#]+))?@)?' . '(?P[^:/?#]*|\[[^/?#]*\])(?P:(?P[0-9]*))?$!'; private static $port_defaults = [ 'acap' => 674, 'afp' => 548, 'dict' => 2628, 'dns' => 53, 'ftp' => 21, 'git' => 9418, 'gopher' => 70, 'http' => 80, 'https' => 443, 'imap' => 143, 'ipp' => 631, 'ipps' => 631, 'irc' => 194, 'ircs' => 6697, 'ldap' => 389, 'ldaps' => 636, 'mms' => 1755, 'msrp' => 2855, 'mtqp' => 1038, 'nfs' => 111, 'nntp' => 119, 'nntps' => 563, 'pop' => 110, 'prospero' => 1525, 'redis' => 6379, 'rsync' => 873, 'rtsp' => 554, 'rtsps' => 322, 'rtspu' => 5005, 'sftp' => 22, 'smb' => 445, 'snmp' => 161, 'ssh' => 22, 'svn' => 3690, 'telnet' => 23, 'ventrilo' => 3784, 'vnc' => 5900, 'wais' => 210, 'ws' => 80, 'wss' => 443, ]; private $scheme; private $authority; private $host; private $port; private $user; private $pass; private $path; private $query; private $fragment; /** * Create new URI instance using a string * @param string $uri_string URI as string * @throws \InvalidArgumentException If the given URI cannot be parsed */ public function __construct(string $uri_string = '', int $flags = 0) { $this->parse($uri_string); } // ---------- PSR-7 getters --------------------------------------------------------------------------------------- /** * Retrieve the scheme component of the URI. * @return string The URI scheme */ public function getScheme(int $flags = 0): string { return $this->getComponent('scheme') ?? ''; } /** * Retrieve the authority component of the URI. * @return string The URI authority, in "[user-info@]host[:port]" format */ public function getAuthority(int $flags = 0): string { $host = $this->formatComponent($this->getHost($flags)); if ($this->isEmpty($host)) { return ''; } $userinfo = $this->formatComponent($this->getUserInfo(), '', '@'); $port = $this->formatComponent($this->getPort($flags), ':'); return "{$userinfo}{$host}{$port}"; } /** * Retrieve the user information component of the URI. * @return string The URI user information, in "username[:password]" format */ public function getUserInfo(int $flags = 0): string { $user = $this->formatComponent($this->getComponent('user')); $pass = $this->formatComponent($this->getComponent('pass'), ':'); return $this->isEmpty($user) ? '' : "{$user}{$pass}"; } /** * Retrieve the host component of the URI. * @return string The URI host */ public function getHost(int $flags = 0): string { $host = $this->getComponent('host') ?? ''; if ($flags & self::IDNA) { $host = $this->idna($host); } return $host; } /** * Retrieve the port component of the URI. * @return null|int The URI port */ public function getPort(int $flags = 0): ?int { $port = $this->getComponent('port'); $scheme = $this->getComponent('scheme'); $default = isset(self::$port_defaults[$scheme]) ? self::$port_defaults[$scheme] : null; if ($flags & self::REQUIRE_PORT) { return !$this->isEmpty($port) ? $port : $default; } return $this->isEmpty($port) || $port === $default ? null : $port; } /** * Retrieve the path component of the URI. * @return string The URI path */ public function getPath(int $flags = 0): string { $path = $this->getComponent('path') ?? ''; if ($flags & self::NORMALIZE_PATH) { $path = $this->normalizePath($path); } if ($flags & self::ABSOLUTE_PATH && substr($path, 0, 1) !== '/') { $path = "/{$path}"; } return $path; } /** * Retrieve the query string of the URI. * @return string The URI query string */ public function getQuery(int $flags = 0): string { return $this->getComponent('query') ?? ''; } /** * Retrieve the fragment component of the URI. * @return string The URI fragment */ public function getFragment(int $flags = 0): string { return $this->getComponent('fragment') ?? ''; } // ---------- PSR-7 setters --------------------------------------------------------------------------------------- /** * Return an instance with the specified scheme. * @param string $scheme The scheme to use with the new instance * @return static A new instance with the specified scheme * @throws \InvalidArgumentException for invalid schemes * @throws \InvalidArgumentException for unsupported schemes */ public function withScheme($scheme, int $flags = 0): UriInterface { $clone = clone $this; if ($flags & self::REQUIRE_PORT) { $clone->setComponent('port', $this->getPort(self::REQUIRE_PORT)); $default = isset(self::$port_defaults[$scheme]) ? self::$port_defaults[$scheme] : null; } $clone->setComponent('scheme', $scheme); return $clone; } /** * Return an instance with the specified user information. * @param string $user The user name to use for authority * @param null|string $password The password associated with $user * @return static A new instance with the specified user information */ public function withUserInfo($user, $password = null, int $flags = 0): UriInterface { $clone = clone $this; $clone->setComponent('user', $user); $clone->setComponent('pass', $password); return $clone; } /** * Return an instance with the specified host. * @param string $host The hostname to use with the new instance * @return static A new instance with the specified host * @throws \InvalidArgumentException for invalid hostnames */ public function withHost($host, int $flags = 0): UriInterface { $clone = clone $this; if ($flags & self::IDNA) { $host = $this->idna($host); } $clone->setComponent('host', $host); return $clone; } /** * Return an instance with the specified port. * @param null|int $port The port to use with the new instance * @return static A new instance with the specified port * @throws \InvalidArgumentException for invalid ports */ public function withPort($port, int $flags = 0): UriInterface { $clone = clone $this; $clone->setComponent('port', $port); return $clone; } /** * Return an instance with the specified path. * @param string $path The path to use with the new instance * @return static A new instance with the specified path * @throws \InvalidArgumentException for invalid paths */ public function withPath($path, int $flags = 0): UriInterface { $clone = clone $this; if ($flags & self::NORMALIZE_PATH) { $path = $this->normalizePath($path); } if ($flags & self::ABSOLUTE_PATH && substr($path, 0, 1) !== '/') { $path = "/{$path}"; } $clone->setComponent('path', $path); return $clone; } /** * Return an instance with the specified query string. * @param string $query The query string to use with the new instance * @return static A new instance with the specified query string * @throws \InvalidArgumentException for invalid query strings */ public function withQuery($query, int $flags = 0): UriInterface { $clone = clone $this; $clone->setComponent('query', $query); return $clone; } /** * Return an instance with the specified URI fragment. * @param string $fragment The fragment to use with the new instance * @return static A new instance with the specified fragment */ public function withFragment($fragment, int $flags = 0): UriInterface { $clone = clone $this; $clone->setComponent('fragment', $fragment); return $clone; } // ---------- PSR-7 string ---------------------------------------------------------------------------------------- /** * Return the string representation as a URI reference. * @return string */ public function __toString(): string { return $this->toString(); } // ---------- Extensions ------------------------------------------------------------------------------------------ /** * Return the string representation as a URI reference. * @return string */ public function toString(int $flags = 0): string { $scheme = $this->formatComponent($this->getComponent('scheme'), '', ':'); $authority = $this->authority ? "//{$this->formatComponent($this->getAuthority($flags))}" : ''; $path_flags = ($this->authority && $this->path ? self::ABSOLUTE_PATH : 0) | $flags; $path = $this->formatComponent($this->getPath($path_flags)); $query = $this->formatComponent($this->getComponent('query'), '?'); $fragment = $this->formatComponent($this->getComponent('fragment'), '#'); return "{$scheme}{$authority}{$path}{$query}{$fragment}"; } // ---------- Private helper methods ------------------------------------------------------------------------------ private function parse(string $uri_string = ''): void { if ($uri_string === '') { return; } preg_match(self::RE_MAIN, $uri_string, $main); $this->authority = !empty($main['authorityc']); $this->setComponent('scheme', isset($main['schemec']) ? $main['scheme'] : ''); $this->setComponent('path', isset($main['path']) ? $main['path'] : ''); $this->setComponent('query', isset($main['queryc']) ? $main['query'] : ''); $this->setComponent('fragment', isset($main['fragmentc']) ? $main['fragment'] : ''); if ($this->authority) { preg_match(self::RE_AUTH, $main['authority'], $auth); if (empty($auth) && $main['authority'] !== '') { throw new InvalidArgumentException("Invalid 'authority'."); } if ($this->isEmpty($auth['host']) && !$this->isEmpty($auth['user'])) { throw new InvalidArgumentException("Invalid 'authority'."); } $this->setComponent('user', isset($auth['user']) ? $auth['user'] : ''); $this->setComponent('pass', isset($auth['passc']) ? $auth['pass'] : ''); $this->setComponent('host', isset($auth['host']) ? $auth['host'] : ''); $this->setComponent('port', isset($auth['portc']) ? $auth['port'] : ''); } } private function encode(string $source, string $keep = ''): string { $exclude = "[^%\/:=&!\$'()*+,;@{$keep}]+"; $exp = "/(%{$exclude})|({$exclude})/"; return preg_replace_callback($exp, function ($matches) { if ($e = preg_match('/^(%[0-9a-fA-F]{2})/', $matches[0], $m)) { return substr($matches[0], 0, 3) . rawurlencode(substr($matches[0], 3)); } else { return rawurlencode($matches[0]); } }, $source); } private function setComponent(string $component, $value): void { $value = $this->parseCompontent($component, $value); $this->$component = $value; } private function parseCompontent(string $component, $value) { if ($this->isEmpty($value)) { return null; } switch ($component) { case 'scheme': $this->assertString($component, $value); $this->assertpattern($component, $value, '/^[a-z][a-z0-9-+.]*$/i'); return mb_strtolower($value); case 'host': // IP-literal / IPv4address / reg-name $this->assertString($component, $value); $this->authority = $this->authority || !$this->isEmpty($value); return mb_strtolower($value); case 'port': $this->assertInteger($component, $value); if ($value < 0 || $value > 65535) { throw new InvalidArgumentException("Invalid port number"); } return (int)$value; case 'path': $this->assertString($component, $value); $value = $this->encode($value); return $value; case 'user': case 'pass': case 'query': case 'fragment': $this->assertString($component, $value); $value = $this->encode($value, '?'); return $value; } } private function getComponent(string $component) { return isset($this->$component) ? $this->$component : null; } private function formatComponent($value, string $before = '', string $after = ''): string { return $this->isEmpty($value) ? '' : "{$before}{$value}{$after}"; } private function isEmpty($value): bool { return is_null($value) || $value === ''; } private function assertString(string $component, $value): void { if (!is_string($value)) { throw new InvalidArgumentException("Invalid '{$component}': Should be a string"); } } private function assertInteger(string $component, $value): void { if (!is_numeric($value) || intval($value) != $value) { throw new InvalidArgumentException("Invalid '{$component}': Should be an integer"); } } private function assertPattern(string $component, string $value, string $pattern): void { if (preg_match($pattern, $value) == 0) { throw new InvalidArgumentException("Invalid '{$component}': Should match {$pattern}"); } } private function normalizePath(string $path): string { $result = []; preg_match_all('!([^/]*/|[^/]*$)!', $path, $items); foreach ($items[0] as $item) { switch ($item) { case '': case './': case '.': break; // just skip case '/': if (empty($result)) { array_push($result, $item); // add } break; case '..': case '../': if (empty($result) || end($result) == '../') { array_push($result, $item); // add } else { array_pop($result); // remove previous } break; default: array_push($result, $item); // add } } return implode('', $result); } private function idna(string $value): string { if ($value === '' || !is_callable('idn_to_ascii')) { return $value; // Can't convert, but don't cause exception } return idn_to_ascii($value, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46); } }