src/Net/Url.php
<?php
/**
* This file is a part of the datatypes package.
*
* https://github.com/themichaelhall/datatypes
*/
declare(strict_types=1);
namespace DataTypes\Net;
use DataTypes\Net\Exceptions\HostInvalidArgumentException;
use DataTypes\Net\Exceptions\SchemeInvalidArgumentException;
use DataTypes\Net\Exceptions\UrlInvalidArgumentException;
use DataTypes\Net\Exceptions\UrlPathInvalidArgumentException;
use DataTypes\Net\Exceptions\UrlPathLogicException;
/**
* Class representing a Url.
*
* @since 1.0.0
*/
class Url implements UrlInterface
{
/**
* Returns true if the url equals other url, false otherwise.
*
* @since 1.2.0
*
* @param UrlInterface $url The other url.
*
* @return bool True if the url equals other url, false otherwise.
*/
public function equals(UrlInterface $url): bool
{
return $this->getScheme()->equals($url->getScheme()) && $this->getHost()->equals($url->getHost()) && $this->getPort() === $url->getPort() && $this->getPath()->equals($url->getPath()) && $this->getQueryString() === $url->getQueryString() && $this->getFragment() === $url->getFragment();
}
/**
* Returns the fragment of the url or null if the url has no fragment.
*
* @since 1.0.0
*
* @return string|null The fragment of the url or null if the url has no fragment.
*/
public function getFragment(): ?string
{
return $this->fragment;
}
/**
* Returns the host of the url.
*
* @since 1.0.0
*
* @return HostInterface The host of the url.
*/
public function getHost(): HostInterface
{
return $this->host;
}
/**
* Returns the host and port of the url as a string.
*
* @since 1.0.0
*
* @return string The host and port of the url.
*/
public function getHostAndPort(): string
{
if ($this->port !== $this->scheme->getDefaultPort()) {
return $this->host . ':' . $this->port;
}
return $this->host->__toString();
}
/**
* Returns the path of the url.
*
* @since 1.0.0
*
* @return UrlPathInterface The path of the url.
*/
public function getPath(): UrlPathInterface
{
return $this->path;
}
/**
* Returns the path and query string of the url as a string.
*
* @since 2.1.0
*
* @return string the path and query string of the url.
*/
public function getPathAndQueryString(): string
{
if ($this->queryString !== null) {
return $this->path->__toString() . '?' . $this->queryString;
}
return $this->path->__toString();
}
/**
* Returns the port of the url.
*
* @since 1.0.0
*
* @return int The port of the url.
*/
public function getPort(): int
{
return $this->port;
}
/**
* Returns the query string of the url or null if the url has no query string.
*
* @since 1.0.0
*
* @return string|null The query string of the url or null if the url has no query string.
*/
public function getQueryString(): ?string
{
return $this->queryString;
}
/**
* Returns the scheme of the url.
*
* @since 1.0.0
*
* @return SchemeInterface The scheme of the url.
*/
public function getScheme(): SchemeInterface
{
return $this->scheme;
}
/**
* Returns a copy of the Url instance with the specified fragment.
*
* @since 1.0.0
*
* @param string|null $fragment The fragment or null for no fragment.
*
* @throws UrlInvalidArgumentException If the fragment parameter is invalid.
*
* @return UrlInterface The url instance.
*/
public function withFragment(?string $fragment = null): UrlInterface
{
if (!self::validateFragment($fragment, $error)) {
throw new UrlInvalidArgumentException($error);
}
return new self($this->scheme, $this->host, $this->port, $this->path, $this->queryString, $fragment);
}
/**
* Returns a copy of the Url instance with the specified host.
*
* @since 1.0.0
*
* @param HostInterface $host The host.
*
* @return UrlInterface The Url instance.
*/
public function withHost(HostInterface $host): UrlInterface
{
return new self($this->scheme, $host, $this->port, $this->path, $this->queryString, $this->fragment);
}
/**
* Returns a copy of the Url instance with the specified port.
*
* @since 1.0.0
*
* @param int $port The port.
*
* @throws UrlInvalidArgumentException If the port is out of range.
*
* @return UrlInterface The Url instance.
*/
public function withPort(int $port): UrlInterface
{
if (!self::validatePort($port, $error)) {
throw new UrlInvalidArgumentException($error);
}
return new self($this->scheme, $this->host, $port, $this->path, $this->queryString, $this->fragment);
}
/**
* Returns a copy of the Url instance with the specified path.
*
* @since 1.0.0
*
* @param UrlPathInterface $path The path.
*
* @return UrlInterface The Url instance.
*/
public function withPath(UrlPathInterface $path): UrlInterface
{
return new self($this->scheme, $this->host, $this->port, $this->path->withUrlPath($path), $this->queryString, $this->fragment);
}
/**
* Returns a copy of the Url instance with the specified query string.
*
* @since 1.0.0
*
* @param string|null $queryString The query string or null for no query string.
*
* @throws UrlInvalidArgumentException If the query parameter is invalid.
*
* @return UrlInterface The url instance.
*/
public function withQueryString(?string $queryString = null): UrlInterface
{
if (!self::validateQueryString($queryString, $error)) {
throw new UrlInvalidArgumentException($error);
}
return new self($this->scheme, $this->host, $this->port, $this->path, $queryString, $this->fragment);
}
/**
* Returns a copy of the Url instance with the specified scheme.
*
* @since 1.0.0
*
* @param SchemeInterface $scheme The scheme.
* @param bool $keepDefaultPort If true, port is changed to the schemes default port if port is current schemes default port, if false port is not changed.
*
* @return UrlInterface The Url instance.
*/
public function withScheme(SchemeInterface $scheme, bool $keepDefaultPort = true): UrlInterface
{
return new self($scheme, $this->host, ($keepDefaultPort && $this->port === $this->scheme->getDefaultPort() ? $scheme->getDefaultPort() : $this->port), $this->path, $this->queryString, $this->fragment);
}
/**
* Returns the url as a string.
*
* @since 1.0.0
*
* @return string The url as a string.
*/
public function __toString(): string
{
return $this->scheme . '://' . $this->getHostAndPort() . $this->path . ($this->queryString !== null ? '?' . $this->queryString : '') . ($this->fragment !== null ? '#' . $this->fragment : '');
}
/**
* Creates a url from url parts.
*
* @since 1.0.0
*
* @param SchemeInterface $scheme The scheme.
* @param HostInterface $host The host.
* @param int|null $port The port or null if default port for the scheme should be used.
* @param UrlPathInterface|null $path The path or null if root path should be used.
* @param string|null $queryString The query string or null if no query string should be used.
* @param string|null $fragment The fragment or null if no fragment should be used.
*
* @throws UrlInvalidArgumentException If any of the parameters are invalid.
*
* @return UrlInterface The url.
*/
public static function fromParts(SchemeInterface $scheme, HostInterface $host, ?int $port = null, UrlPathInterface $path = null, ?string $queryString = null, ?string $fragment = null): UrlInterface
{
if ($port === null) {
$port = $scheme->getDefaultPort();
}
if ($path === null) {
$path = UrlPath::parse('/');
}
if (!self::validateParts($port, $path, $queryString, $fragment, $error)) {
throw new UrlInvalidArgumentException($error);
}
return new self($scheme, $host, $port, $path, $queryString, $fragment);
}
/**
* Checks if a url is valid.
*
* @since 1.0.0
*
* @param string $string The url.
*
* @return bool True if the url parameter is a valid url, false otherwise.
*/
public static function isValid(string $string): bool
{
return self::doParse(null, $string) !== null;
}
/**
* Checks if a relative url is valid.
*
* @since 1.0.0
*
* @param string $string The url.
* @param UrlInterface $baseUrl The base url.
*
* @return bool True if the url parameter is a valid url, false otherwise.
*/
public static function isValidRelative(string $string, UrlInterface $baseUrl): bool
{
try {
return self::doParse($baseUrl, $string) !== null;
} catch (UrlPathLogicException) {
return false;
}
}
/**
* Parses a url.
*
* @since 1.0.0
*
* @param string $string The url.
*
* @throws UrlInvalidArgumentException If the url parameter is not a valid url.
*
* @return UrlInterface The Url instance.
*/
public static function parse(string $string): UrlInterface
{
$result = self::doParse(null, $string, $error);
if ($result === null) {
throw new UrlInvalidArgumentException($error);
}
return $result;
}
/**
* Parses a relative url and combines it with a base url.
*
* @since 1.0.0
*
* @param string $string The url
* @param UrlInterface $baseUrl The base url.
*
* @throws UrlInvalidArgumentException If the url parameter is not a valid relative url.
*
* @return UrlInterface The Url instance.
*/
public static function parseRelative(string $string, UrlInterface $baseUrl): UrlInterface
{
$result = self::doParse($baseUrl, $string, $error);
if ($result === null) {
throw new UrlInvalidArgumentException($error);
}
return $result;
}
/**
* Parses a url.
*
* @since 1.0.0
*
* @param string $string The url.
*
* @return UrlInterface|null The Url instance if the url parameter is a valid url, null otherwise.
*/
public static function tryParse(string $string): ?UrlInterface
{
return self::doParse(null, $string);
}
/**
* Parses a relative url and combines it with a base url.
*
* @since 1.0.0
*
* @param string $string The url.
* @param UrlInterface $baseUrl The base url.
*
* @return UrlInterface|null The Url instance if the url parameter is a valid url, null otherwise.
*/
public static function tryParseRelative(string $string, UrlInterface $baseUrl): ?UrlInterface
{
try {
return self::doParse($baseUrl, $string);
} catch (UrlPathLogicException) {
return null;
}
}
/**
* Constructs a Url.
*
* @param SchemeInterface $scheme The scheme.
* @param HostInterface $host The host.
* @param int $port The port.
* @param UrlPathInterface $path The path.
* @param string|null $queryString The query string.
* @param string|null $fragment The fragment.
*/
private function __construct(SchemeInterface $scheme, HostInterface $host, int $port, UrlPathInterface $path, ?string $queryString, ?string $fragment)
{
$this->scheme = $scheme;
$this->host = $host;
$this->port = $port;
$this->path = $path;
$this->queryString = $queryString;
$this->fragment = $fragment;
}
/**
* Tries to parse a url and returns the result or error text.
*
* @param UrlInterface|null $baseUrl The base url or null if no base url is present.
* @param string $str The url to parse.
* @param string|null $error The error text if parsing was not successful, undefined otherwise.
*
* @return self|null The url if parsing was successful, null otherwise.
*/
private static function doParse(?UrlInterface $baseUrl, string $str, ?string &$error = null): ?self
{
if ($baseUrl === null && $str === '') {
$error = 'Url "" is empty.';
return null;
}
self::splitUrlString($str, $schemeString, $authorityString, $pathString);
$scheme = $baseUrl?->getScheme();
if (!self::parseScheme($schemeString, $scheme, $error)) {
$error = 'Url "' . $str . '" is invalid: ' . $error;
return null;
}
$host = $baseUrl?->getHost();
$port = $baseUrl?->getPort();
if (!self::parseAuthority($authorityString, $host, $port, $error)) {
$error = 'Url "' . $str . '" is invalid: ' . $error;
return null;
}
if ($port === null) {
$port = $scheme->getDefaultPort();
}
$path = $baseUrl?->getPath();
$queryString = $baseUrl?->getQueryString();
$fragment = $baseUrl?->getFragment();
if (!self::parsePath($pathString, $path, $queryString, $fragment, $error)) {
$error = 'Url "' . $str . '" is invalid: ' . $error;
return null;
}
return new self($scheme, $host, $port, $path, $queryString, $fragment);
}
/**
* Splits a url in its main components.
*
* @param string $urlString The url.
* @param string|null $schemeString The scheme or null if scheme is not present.
* @param string|null $authorityString The authority part or null if authority part is not present.
* @param string|null $pathString The path or null if path is not present.
*/
private static function splitUrlString(string $urlString, ?string &$schemeString = null, ?string &$authorityString = null, ?string &$pathString = null): void
{
$schemeString = null;
$authorityString = null;
$pathString = null;
$parts = explode('://', $urlString, 2);
if (count($parts) === 2) {
// Absolute url.
$schemeString = $parts[0];
$parts = explode('/', $parts[1], 2);
$authorityString = $parts[0];
$pathString = '/' . (count($parts) === 2 ? $parts[1] : '');
return;
}
if (str_starts_with($urlString, '//')) {
// Relative url beginning with "//".
$parts = explode('/', substr($urlString, 2), 2);
$authorityString = $parts[0];
$pathString = '/' . (count($parts) === 2 ? $parts[1] : '');
return;
}
// Relative url as a path.
$pathString = $urlString;
}
/**
* Parse scheme.
*
* @param string|null $schemeString The scheme that is to be parsed or null if no scheme is present.
* @param SchemeInterface|null $scheme The updated scheme if parsing was successful.
* @param string|null $error The error text if parsing was not successful, undefined otherwise.
*
* @return bool True if parsing was successful, false otherwise.
*/
private static function parseScheme(?string $schemeString, ?SchemeInterface &$scheme, ?string &$error = null): bool
{
if ($schemeString === null) {
if ($scheme === null) {
$error = 'Scheme is missing.';
return false;
}
return true;
}
try {
$scheme = Scheme::parse($schemeString);
} catch (SchemeInvalidArgumentException $e) {
$error = $e->getMessage();
return false;
}
return true;
}
/**
* Parse authority part.
*
* @param string|null $authorityString The authority part that is to be parsed or null if no authority part is present.
* @param HostInterface|null $host The updated host if parsing was successful.
* @param int|null $port The updated port if parsing was successful.
* @param string|null $error The error text if parsing was not successful, undefined otherwise.
*
* @return bool True if parsing was successful, false otherwise.
*/
private static function parseAuthority(?string $authorityString, ?HostInterface &$host, ?int &$port, ?string &$error = null): bool
{
if ($authorityString === null && $host !== null) {
return true;
}
// Throw away username and password if present.
$parts = explode('@', $authorityString, 2);
if (count($parts) > 1) {
$authorityString = $parts[1];
}
$parts = explode(':', $authorityString, 2);
$port = null;
if (count($parts) === 2 && $parts[1] !== '') {
if (preg_match('/[^0-9]/', $parts[1], $matches)) {
$error = 'Port "' . $parts[1] . '" contains invalid character "' . $matches[0] . '".';
return false;
}
$port = intval($parts[1]);
if (!self::validatePort($port, $error)) {
return false;
}
}
try {
$host = Host::parse($parts[0]);
} catch (HostInvalidArgumentException $e) {
$error = $e->getMessage();
return false;
}
return true;
}
/**
* Parse path.
*
* @param string $pathString The path that is to be parsed.
* @param UrlPathInterface|null $path The updated path if parsing was successful.
* @param string|null $queryString The updated query string if parsing was successful.
* @param string|null $fragment The updated fragment if parsing was successful.
* @param string|null $error The error text if parsing was not successful, undefined otherwise.
*
* @return bool True if parsing was successful, false otherwise.
*/
private static function parsePath(string $pathString, ?UrlPathInterface &$path, ?string &$queryString, ?string &$fragment, ?string &$error = null): bool
{
$oldFragment = $fragment;
$parts = explode('#', $pathString, 2);
$pathString = $parts[0];
$fragment = count($parts) > 1 ? $parts[1] : null;
if (!self::validateFragment($fragment, $error)) {
return false;
}
$oldQueryString = $queryString;
$parts = explode('?', $pathString, 2);
$pathString = $parts[0];
$queryString = count($parts) > 1 ? $parts[1] : null;
if (!self::validateQueryString($queryString, $error)) {
return false;
}
if ($pathString === '') {
if ($fragment === null && $queryString === null) {
$fragment = $oldFragment;
}
if ($queryString === null) {
$queryString = $oldQueryString;
}
}
if (!self::parseUrlPath($pathString, $path, $error)) {
return false;
}
return true;
}
/**
* Try to validate or parse url path.
*
* @param string $pathString The path that is to be parsed.
* @param UrlPathInterface|null $path The updated path if parsing was successful.
* @param string|null $error The error text if parsing was not successful, undefined otherwise.
*
* @return bool True if parsing was successful, false otherwise.
*/
private static function parseUrlPath(string $pathString, ?UrlPathInterface &$path, ?string &$error = null): bool
{
if ($pathString === '' && $path !== null) {
return true;
}
$oldPath = $path;
try {
$path = UrlPath::parse($pathString);
} catch (UrlPathInvalidArgumentException $e) {
$error = $e->getMessage();
return false;
}
if ($oldPath !== null) {
$path = $oldPath->withUrlPath($path);
}
return true;
}
/**
* Validates parts of url.
*
* @param int $port The port.
* @param UrlPathInterface $path The path.
* @param string|null $queryString The query string or null if no query string should be used.
* @param string|null $fragment The fragment or null if no fragment should be used.
* @param string|null $error The error text if validation was not successful, undefined otherwise.
*
* @return bool True if validation was successful, false otherwise.
*/
private static function validateParts(int $port, UrlPathInterface $path, ?string $queryString, ?string $fragment, ?string &$error): bool
{
if (!self::validatePort($port, $error)) {
return false;
}
if ($path->isRelative()) {
$error = 'Url path "' . $path . '" is relative.';
return false;
}
if (!self::validateQueryString($queryString, $error)) {
return false;
}
if (!self::validateFragment($fragment, $error)) {
return false;
}
return true;
}
/**
* Validates a port.
*
* @param int $port The port.
* @param string|null $error The error text if validation was not successful, undefined otherwise.
*
* @return bool True if validation was successful, false otherwise.
*/
private static function validatePort(int $port, ?string &$error): bool
{
if ($port < 0) {
$error = 'Port ' . $port . ' is out of range: Minimum port number is 0.';
return false;
}
if ($port > 65535) {
$error = 'Port ' . $port . ' is out of range: Maximum port number is 65535.';
return false;
}
return true;
}
/**
* Validates a query string.
*
* @param string|null $queryString The query string.
* @param string|null $error The error text if validation was not successful, undefined otherwise.
*
* @return bool True if validation was successful, false otherwise.
*/
private static function validateQueryString(?string $queryString, ?string &$error): bool
{
if ($queryString === null) {
return true;
}
if (preg_match('/[^0-9a-zA-Z._~!\$&\'()*+,;=:@\[\]\/?%-]/', $queryString, $matches)) {
$error = 'Query string "' . $queryString . '" contains invalid character "' . $matches[0] . '".';
return false;
}
return true;
}
/**
* Validates a fragment.
*
* @param string|null $fragment The fragment.
* @param string|null $error The error text if validation was not successful, undefined otherwise.
*
* @return bool True if validation was successful, false otherwise.
*/
private static function validateFragment(?string $fragment, ?string &$error): bool
{
if ($fragment === null) {
return true;
}
if (preg_match('/[^0-9a-zA-Z._~!\$&\'()*+,;=:@\[\]\/?%-]/', $fragment, $matches)) {
$error = 'Fragment "' . $fragment . '" contains invalid character "' . $matches[0] . '".';
return false;
}
return true;
}
/**
* @var SchemeInterface The scheme.
*/
private SchemeInterface $scheme;
/**
* @var HostInterface The host.
*/
private HostInterface $host;
/**
* @var int The port.
*/
private int $port;
/**
* @var UrlPathInterface The path.
*/
private UrlPathInterface $path;
/**
* @var string|null The query string.
*/
private ?string $queryString;
/**
* @var string|null The fragment.
*/
private ?string $fragment;
}