diff --git a/README.md b/README.md index 7ff841d..1ce3145 100644 --- a/README.md +++ b/README.md @@ -16,34 +16,68 @@ Install with [Composer](https://getcomposer.org/); composer require phrity/net-uri ``` -## Modifiers - -Out of the box, it will behave as specified by PSR standards. -To change behaviour, there are some modifiers available. -These can be added as last argument in all `get` and `with` methods, plus the `toString` method. - -`REQUIRE_PORT` +## Uri class methods -By PSR standard, if port is default for scheme it will be hidden. -This options will attempt to always show the port. -If set, it will be shown even if default. If not set, it will use default port if resolvable. +Implemts [PSR-7 UriInterface](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface) +and provides some extra metods and options. [More info here](docs/Uri.md). -`ABSOLUTE_PATH` - -Will cause paths to use absolute form, i.e. starting with `/`. - -`NORMALIZE_PATH` +```php +use Phrity\Net\Uri; +$uri = new Uri('http://example.com/path/to/file.html?query1=1#fragment'); + +// PSR-7 getters +$uri->getScheme(); +$uri->getHost(); +$uri->getPort(); +$uri->getPath(); +$uri->getQuery(); +$uri->getFragment(); +$uri->getAuthority(); +$uri->getUserInfo(); + +// PSR-7 setters +$uri->withScheme('https'); +$uri->withHost('example2.com'); +$uri->withPort(8080); +$uri->withPath('/path/to/another/file.html'); +$uri->withQuery('query2=2'); +$uri->withFragment('another-fragment'); +$uri->withUserInfo('username', 'password'); + +// Additional methods +$uri->toString(); +$uri->__toString(); +$uri->jsonSerialize(); +$uri->getQueryItems(); +$uri->getQueryItem('query1'); +$uri->withQueryItems(['query1' => '1', 'query2' => '2']); +$uri->withQueryItem('query1', '1'); +$uri->getComponents(); +$uri->withComponents(['scheme' => 'https', 'host' => 'example2.com']); +``` -Will attempt to normalize paths, e.g. `./a/./path/../to//something` will transform to `a/to/something`. +## UriFactory class methods -`IDN_ENCODE` + `IDN_ENCODE` +Implemts [PSR-17 UriFactoryInterface](https://www.php-fig.org/psr/psr-17/#26-urifactoryinterface) +and provides some extra metods and options. [More info here](docs/UriFactory.md). -Will IDN-encode host using non-ASCII characters. Only available with [Intl extension](https://www.php.net/manual/en/intl.installation.php). +```php +use Phrity\Net\UriFactory; +$factory = new UriFactory(); +$factory->createUri('http://example.com/path/to/file.html'); +$factory->createUriFromInterface(new GuzzleHttp\Psr7\Uri('http://example.com/path/to/file.html')); +``` -`RFC1738` +## Modifiers -Will use RFC 1738 encoding (spaces encoded as `+`). Encoding by RFC 3986 (spaces encoded as `%20`) is default. +Out of the box, it will behave as specified by PSR standards. +To change behaviour, there are some modifiers available. +These can be added as last argument in all `get` and `with` methods, plus the `toString` method. +* `REQUIRE_PORT` - Attempt to show port, even if default +* `ABSOLUTE_PATH` - Will cause path to use absolute form, i.e. starting with `/` +* `NORMALIZE_PATH` - Will attempt to normalize path +* `IDN_ENCODE` / `IDN_DECODE` - Encode or decode IDN-format for non-ASCII host ### Examples @@ -63,84 +97,6 @@ $uri = new Uri('https://ηßöø必Дあ.com'); $uri->getHost(Uri::IDN_ENCODE); // => 'xn--zca0cg32z7rau82strvd.com' ``` - -## Classes - -There are two available classes, `Uri` and `UriFactory`. - -### The Uri class - -```php -class Phrity\Net\Uri implements JsonSerializable, Stringable, Psr\Http\Message\UriInterface -{ - // Constructor - - public function __construct(string $uri_string = ''); - - // PSR-7 getters - - public function getScheme(int $flags = 0): string; - public function getAuthority(int $flags = 0): string; - public function getUserInfo(int $flags = 0): string; - public function getHost(int $flags = 0): string; - public function getPort(int $flags = 0): int|null; - public function getPath(int $flags = 0): string; - public function getQuery(int $flags = 0): string; - public function getFragment(int $flags = 0): string; - - // PSR-7 setters - - public function withScheme(string $scheme, int $flags = 0): UriInterface; - public function withUserInfo(string $user, string|null $password = null, int $flags = 0): UriInterface; - public function withHost(string $host, int $flags = 0): UriInterface; - public function withPort(int|null $port, int $flags = 0): UriInterface; - public function withPath(string $path, int $flags = 0): UriInterface; - public function withQuery(string $query, int $flags = 0): UriInterface; - public function withFragment(string $fragment, int $flags = 0): UriInterface; - - // PSR-7 string representation & Stringable - - public function __toString(): string; - - // JsonSerializable - - public function jsonSerialize(): string; - - // Additional query methods - - public function getQueryItems(int $flags = 0): array; - public function getQueryItem(string $name, int $flags = 0): array|string|null; - public function withQueryItems(array $items, int $flags = 0): UriInterface; - public function withQueryItem(string $name, array|string|null $value, int $flags = 0): UriInterface; - - // Additional methods - - public function getComponents(int $flags = 0): array; - public function with(array $components, int $flags = 0): UriInterface; - public function toString(int $flags = 0): string; -} -``` - -### The UriFactory class - -```php -class Phrity\Net\UriFactory implements Psr\Http\Message\UriFactoryInterface -{ - // Constructor - - public function __construct(); - - // PSR-17 factory - - public function createUri(string $uri = ''): UriInterface; - - // Additional methods - - public function createUriFromInterface(UriInterface $uri): UriInterface; -} -``` - - ## Versions | Version | PHP | | diff --git a/composer.json b/composer.json index ff79009..79301ac 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "require-dev": { "phpunit/phpunit": "^9.0 | ^10.0 | ^11.0", "php-coveralls/php-coveralls": "^2.0", + "phrity/util-errorhandler": "^1.0", "squizlabs/php_codesniffer": "^3.0" }, "suggest": { diff --git a/docs/Uri.md b/docs/Uri.md index 82b5e19..7161d9f 100644 --- a/docs/Uri.md +++ b/docs/Uri.md @@ -1,23 +1,67 @@ -# URI class +# Uri class -## Constructor +## Introduction -Constructor takes an optional URI as string. +The Uri class represents a [Uniform Resource Identifier](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier) +used to identify an abstract or physical resource. +The class is fully compatible with the [PSR-7 UriInterface](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface). + +## Class synopsis ```php -use Phrity\Net\Uri; +class Phrity\Net\Uri implements JsonSerializable, Stringable, Psr\Http\Message\UriInterface +{ + // Constructor -$uri = new Uri(); -echo "{$uri} \n"; // -> "" + public function __construct(string $uri_string = ''); -$uri = new Uri('http://example.com'); -echo "{$uri} \n"; // -> "http://example.com" + // PSR-7 methods -$uri = new Uri('https://user:pwd@domain.tld:1234/path/to/file.html?query=1#fragment'); -echo "{$uri} \n"; // -> "https://user:pwd@domain.tld:1234/path/to/file.html?query=1#fragment" + public function getScheme(int $flags = 0): string; + public function withScheme(string $scheme, int $flags = 0): Psr\Http\Message\UriInterface; + + public function getAuthority(int $flags = 0): string; + + public function getUserInfo(int $flags = 0): string; + public function withUserInfo(string $user, string|null $password = null, int $flags = 0): Psr\Http\Message\UriInterface; + + public function getHost(int $flags = 0): string; + public function withHost(string $host, int $flags = 0): Psr\Http\Message\UriInterface; + + public function getPort(int $flags = 0): int|null; + public function withPort(int|null $port, int $flags = 0): Psr\Http\Message\UriInterface; + + public function getPath(int $flags = 0): string; + public function withPath(string $path, int $flags = 0): Psr\Http\Message\UriInterface; + + public function getQuery(int $flags = 0): string; + public function withQuery(string $query, int $flags = 0): Psr\Http\Message\UriInterface; + + public function getFragment(int $flags = 0): string; + public function withFragment(string $fragment, int $flags = 0): Psr\Http\Message\UriInterface; + + // Stringable and JsonSerializable methods + + public function __toString(): string; + public function jsonSerialize(): string; + + // Extension: Component methods + + public function getComponents(int $flags = 0): array; + public function withComponents(array $components, int $flags = 0): Psr\Http\Message\UriInterface; + + // Extension: Query methods -$uri = new Uri('path/to/file.html'); -echo "{$uri} \n"; // -> "path/to/file.html " + public function getQueryItems(int $flags = 0): array; + public function getQueryItem(string $name, int $flags = 0): array|string|null; + public function withQueryItems(array $items, int $flags = 0): Psr\Http\Message\UriInterface; + public function withQueryItem(string $name, array|string|null $value, int $flags = 0): Psr\Http\Message\UriInterface; + + // Extension: String method + + public function toString(int $flags = 0): string; + +} ``` ## PSR-7 component methods @@ -26,7 +70,7 @@ These methods are compatible with the [PSR-7 UriInterface](https://www.php-fig.o ### Scheme -This library is not restricted to certain schemas, but allow all schemas in correct format. +This library is not restricted to certain schemes, but allow all schemes in correct format. ```php $uri = new Uri('my-own-scheme:'); @@ -34,9 +78,6 @@ echo "{$uri} \n"; // -> "my-own-scheme:" $uri = new Uri('this is not allowed as scheme:'); echo "{$uri} \n"; // -> InvalidArgumentException - -$uri = new Uri('http://example.com'); -$uri->getScheme(); // -> "http" ``` #### `getScheme(int $flags = 0): string` @@ -53,7 +94,7 @@ $uri->getScheme(); // -> "" #### `withScheme(string $scheme, int $flags = 0): UriInterface` -Method return a new Uri with specified scheme set. +Method return a new Uri instance with specified scheme set. ```php $uri = new Uri('http://example.com'); @@ -88,7 +129,7 @@ $uri->getHost(); // -> "" #### `withHost(string $host, int $flags = 0): UriInterface` -Method return a new Uri with specified host set. +Method return a new Uri instance with specified host set. ```php $uri = new Uri('domain.tld'); @@ -99,19 +140,19 @@ echo "{$clone} \n"; // -> "//new-host.com/domain.tld" ### Port -Port of connectiom. If default port is used, it is typically hidden. +Port componenet of URI. If default port is used, it is typically hidden. ```php $uri = new Uri('http://domain.tld:80'); echo "{$uri} \n"; // -> "http://domain.tld" $uri = new Uri('http://domain.tld:1234'); -echo "{$uri} \n"; // -> "127.0.0.1" +echo "{$uri} \n"; // -> "http://domain.tld:1234" ``` #### `getPort(int $flags = 0): int|null` -Method return port, or null if using default port or port is not set. +Method return port, or `null` if using default port or port is not set. ```php $uri = new Uri('http://domain.tld:80'); @@ -123,7 +164,7 @@ $uri->getPort(); // -> 1234 #### `withPort(int|null $port, int $flags = 0): UriInterface` -Method return a new Uri with specified port set. +Method return a new Uri instance with specified port set. ```php $uri = new Uri('http://domain.tld:80'); @@ -161,7 +202,7 @@ $uri->getPath(); // -> "path/to/file" #### `withPath(string $path, int $flags = 0): UriInterface` -Method return a new Uri with specified path set. +Method return a new Uri instance with specified path set. ```php $uri = new Uri('http://domain.tld/path/to/file'); @@ -190,7 +231,7 @@ $uri->getQuery(); // -> "a=1&b=2" #### `withQuery(string $query, int $flags = 0): UriInterface` -Method return a new Uri with specified query set. +Method return a new Uri instance with specified query set. ```php $uri = new Uri('http://domain.tld?a=1&b=2'); @@ -219,7 +260,7 @@ $uri->getFragment(); // -> "my+fragment" #### `withFragment(string $fragment, int $flags = 0): UriInterface` -Method return a new Uri with specified fragment set. +Method return a new Uri instance with specified fragment set. ```php $uri = new Uri('http://domain.tld#my+fragment'); @@ -228,11 +269,6 @@ $clone->getFragment(); // -> "new-fragment" echo "{$clone} \n"; // -> "http://domain.tld#new-fragment" ``` - - - - - ### UserInfo The user info part of URI may consist of username and password. @@ -256,7 +292,7 @@ echo "{$uri->getUserInfo()} \n"; // -> "user:pwd" #### `withUserInfo(string $user, string|null $password = null, int $flags = 0): UriInterface` -Method return a new Uri with specified scheme set. +Method return a new Uri instance with specified user info set. ```php $uri = new Uri('http://example.com'); @@ -293,7 +329,7 @@ All `get`, `with` and the `toString()` methods accept option flags. ### Host options -#### The `IDN_ENCODE` and `IDN_DECODE` options +#### The `IDN_ENCODE` option Using `IDN_ENCODE` option will IDN-encode host using non-ASCII characters. Only available with [Intl extension](https://www.php.net/manual/en/intl.installation.php). @@ -312,7 +348,9 @@ $clone->getHost(); // -> "xn--7ca5b9p776i" echo "{$clone} \n"; // -> "https://xn--7ca5b9p776i" ``` -Using `IDN_DECODE` option will IDN-decode host previously encoded to ASCII-only characters. +#### The `IDN_DECODE` option + +Using `IDN_DECODE` option will IDN-decode host previously encoded to ASCII-only characters. Only available with [Intl extension](https://www.php.net/manual/en/intl.installation.php). ```php @@ -333,7 +371,11 @@ echo "{$clone} \n"; // -> "https://œüç∂" #### The `REQUIRE_PORT` option -Using this option will require port when it noramlly would be hidden. +By PSR-7 standard, if port is default for scheme it will be hidden. +This options will attempt to always show the port. +If not set, it will use default port if resolvable. + +Using this option will require port when it normally would be hidden. ```php $uri = new Uri('http://domain.tld:80'); @@ -351,7 +393,7 @@ $uri->toString(Uri::REQUIRE_PORT); // -> "http://domain.tld:80" #### The `ABSOLUTE_PATH` option -This option will enforce absolute path. +Will cause path to use absolute form, i.e. starting with `/`. ```php $uri = new Uri('path/to/file'); @@ -367,7 +409,7 @@ $clone->toString(); // -> "/some/other/path" #### The `NORMALIZE_PATH` option -This option will normalize path. +Will attempt to normalize path, e.g. `./a/./path/../to//something` will transform to `a/to/something`. ```php $uri = new Uri('a/./path/../to//something'); @@ -381,7 +423,6 @@ $clone->getPath(); // -> "path/somewhere/" $clone->toString(); // -> "path/somewhere/" ``` - ## Extension methods These methods are now part of the PSR-7 UriInterface. @@ -405,7 +446,7 @@ Unlike the regular `__toString()` method, this method accept option flags. ```php $uri = new Uri('https://ηßöø必Дあ.com/a/./path/../to//something'); echo $uri->__toString(); // -> "https://ηßöø必дあ.com/a/./path/../to//something -echo $uri->toString(Uri::IDN_ENCODE | Uri::REQUIRE_PORT | Uri::ABSOLUTE_PATH | Uri::NORMALIZE_PATH); // -> "https://xn--zca0cg32z7rau82strvd.com:443/a/to/something" +echo $uri->toString(Uri::IDN_ENCODE | Uri::REQUIRE_PORT | Uri::NORMALIZE_PATH); // -> "https://xn--zca0cg32z7rau82strvd.com:443/a/to/something" ``` #### `jsonSerialize(): string` @@ -447,7 +488,7 @@ $uri->getQueryItem('c'); // -> null #### `withQueryItems(array $items, int $flags = 0): UriInterface` -Method return a new Uri with specified query items. +Method return a new Uri instance with specified query items. The associative array of query items to add will be merged on existing items. Providing value `null` on an item will remove it. @@ -460,7 +501,7 @@ echo $clone; // -> "http://example.com?a%5Ba1%5D=1&a%5Ba2%5D=2%2B&a%5Ba3%5D=3%2B #### `withQueryItem(string $name, array|string|null $value, int $flags = 0): UriInterface` -Method return a new Uri with specified query name/value. +Method return a new Uri instance with specified query name/value. The added query item will be merged on existing items. Providing value `null` on an item will remove it. @@ -483,13 +524,13 @@ $uri = new Uri('https://domain.tld:1234/path/to/file.html?query=1'); $uri->getComponents(); // -> ["scheme" => "https", "host" => "domain.tld", "port" => 1234, "path" => "/path/to/file.html", "query" => "query=1"] ``` -#### `with(array $components, int $flags = 0): UriInterface` +#### `withComponents(array $components, int $flags = 0): UriInterface` -Method return a new Uri with specified components. +Method return a new Uri instance with specified components. ```php $uri = new Uri('http://domain.tld'); -$clone = $uri->with(['scheme' => 'https', 'path' => 'path/to/file.html', 'query' => 'query=1']); +$clone = $uri->withComponents['scheme' => 'https', 'path' => 'path/to/file.html', 'query' => 'query=1']); echo $clone; // -> "https://domain.tld/path/to/file.html?query=1" ``` diff --git a/docs/UriFactory.md b/docs/UriFactory.md new file mode 100644 index 0000000..455b5d0 --- /dev/null +++ b/docs/UriFactory.md @@ -0,0 +1,44 @@ +# UriFactory class + +## Constructor + +Constructor takes no arguments. + +```php +use Phrity\Net\UriFactory; + +$factory = new UriFactory(); +``` + +## PSR-7 method + +This methods are compatible with the [PSR-17 UriFactoryInterface](https://www.php-fig.org/psr/psr-17/#26-urifactoryinterface). + +### Uri creators + +#### `createUri(string $uri = ''): UriInterface` + +Method return a new Uri instance, empty or bu parsing provided URI string. + +```php +$factory = new UriFactory(); +$uri = $factory->createUri(); +echo "{$uri} \n"; // -> "" +$uri = $factory->createUri('http://example.com'); +echo "{$uri} \n"; // -> "http://example.com" +``` + +#### `createUriFromInterface(UriInterface $uri): UriInterface` + +Method return a new Uri instance, based on any class implementing [PSR-7 UriInterface](https://www.php-fig.org/psr/psr-7/#35-psrhttpmessageuriinterface). + +```php +$uri_string = 'http://example.com'; +$factory = new UriFactory(); +$uri = $factory->createUriFromInterface(new GuzzleHttp\Psr7\Uri($uri_string)); +$uri = $factory->createUriFromInterface(new Laminas\Diactoros\Uri($uri_string)); +$uri = $factory->createUriFromInterface(League\Uri\Uri::createFromString($uri_string)); +$uri = $factory->createUriFromInterface(new Nyholm\Psr7\Uri($uri_string)); +$uri = $factory->createUriFromInterface(new Phrity\Net\Uri($uri_string)); +$uri = $factory->createUriFromInterface((new Slim\Psr7\Factory\UriFactory)->createUri($uri_string)); +``` diff --git a/phpunit.xml.dist b/phpunit.xml similarity index 100% rename from phpunit.xml.dist rename to phpunit.xml diff --git a/src/Uri.php b/src/Uri.php index fa6ba66..d8a5429 100644 --- a/src/Uri.php +++ b/src/Uri.php @@ -23,11 +23,9 @@ class Uri implements JsonSerializable, Stringable, 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 IDN_ENCODE = 8; // IDN-encode host - public const IDN_DECODE = 16; // IDN-decode host public const IDNA = 8; // @deprecated, replaced by IDN_ENCODE - public const RFC1738_ENCODE = 32; // Enforce RFC1738 encoding - public const RFC3986_ENCODE = 64; // Enforce RFC3986 encoding + public const IDN_ENCODE = 16; // IDN-encode host + public const IDN_DECODE = 32; // IDN-decode host private const RE_MAIN = '!^(?P(?P[^:/?#]+):)?(?P//(?P[^/?#]*))?' . '(?P[^?#]*)(?P\?(?P[^#]*))?(?P#(?P.*))?$!'; @@ -145,6 +143,10 @@ public function getUserInfo(int $flags = 0): string */ public function getHost(int $flags = 0): string { + if ($flags & self::IDNA) { + trigger_error("Flag IDNA is deprecated; use IDN_ENCODE instead", E_USER_DEPRECATED); + return $this->idnEncode($this->host); + } if ($flags & self::IDN_ENCODE) { return $this->idnEncode($this->host); } @@ -358,7 +360,7 @@ public function toString(int $flags = 0, string $format = '{scheme}{authority}{p } /** - * Get compontsns as array; as parse_url() metohd + * Get compontsns as array; as parse_url() method * @param int $flags Optional modifier flags * @return array */ @@ -380,7 +382,7 @@ public function getComponents(int $flags = 0): array * Return an instance with the specified compontents set. * @return static A new instance with the specified components */ - public function with(array $components, int $flags = 0): UriInterface + public function withComponents(array $components, int $flags = 0): UriInterface { $clone = $this->clone($flags); foreach ($components as $component => $value) { @@ -445,7 +447,12 @@ public function getQueryItem(string $name, int $flags = 0): array|string|null public function withQueryItems(array $items, int $flags = 0): UriInterface { $clone = $this->clone($flags); - $clone->setQuery(http_build_query($this->merge($this->getQueryItems($flags), $items)), $flags); + $clone->setQuery(http_build_query( + $this->queryMerge($this->getQueryItems($flags), $items), + '', + null, + PHP_QUERY_RFC3986 + ), $flags); return $clone; } @@ -484,6 +491,10 @@ protected function setScheme(string $scheme, int $flags = 0): void protected function setHost(string $host, int $flags = 0): void { $this->authority = $this->authority || $host !== ''; + if ($flags & self::IDNA) { + trigger_error("Flag IDNA is deprecated; use IDN_ENCODE instead", E_USER_DEPRECATED); + $host = $this->idnEncode($host); + } if ($flags & self::IDN_ENCODE) { $host = $this->idnEncode($host); } @@ -501,27 +512,27 @@ protected function setPath(string $path, int $flags = 0): void if ($flags & self::ABSOLUTE_PATH && substr($path, 0, 1) !== '/') { $path = "/{$path}"; } - $this->path = $this->encode($path, $flags); + $this->path = $this->uriEncode($path, $flags); } protected function setQuery(string $query, int $flags = 0): void { - $this->query = $this->encode($query, $flags, '?'); + $this->query = $this->uriEncode($query, $flags, '?'); } protected function setFragment(string $fragment, int $flags = 0): void { - $this->fragment = $this->encode($fragment, $flags, '?'); + $this->fragment = $this->uriEncode($fragment, $flags, '?'); } protected function setUser(string $user, int $flags = 0): void { - $this->user = $this->encode($user, $flags, '?'); + $this->user = $this->uriEncode($user, $flags, '?'); } protected function setPassword(string|null $pass, int $flags = 0): void { - $this->pass = $pass === null ? null : $this->encode($pass, $flags, '?'); + $this->pass = $pass === null ? null : $this->uriEncode($pass, $flags, '?'); } protected function setUserInfo(string $user = '', string|null $pass = null, int $flags = 0): void @@ -549,7 +560,7 @@ private function parse(string $uri_string = ''): void if (empty($auth) && $main['authority'] !== '') { throw new InvalidArgumentException("Invalid 'authority'."); } - if ($this->isEmpty($auth['host']) && !$this->isEmpty($auth['user'])) { + if ($auth['host'] === '' && $auth['user'] !== '') { throw new InvalidArgumentException("Invalid 'authority'."); } $this->setUser(isset($auth['user']) ? $auth['user'] : ''); @@ -568,28 +579,23 @@ private function clone(int $flags = 0): self return $clone; } - private function encode(string $source, int $flags = 0, string $keep = ''): string + private function uriEncode(string $source, int $flags = 0, string $keep = ''): string { - $encode = $flags & self::RFC1738_ENCODE ? 'urlencode' : 'rawurlencode'; $exclude = "[^%\/:=&!\$'()*+,;@{$keep}]+"; $exp = "/(%{$exclude})|({$exclude})/"; - return preg_replace_callback($exp, function ($matches) use ($encode) { + 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) . $encode(substr($matches[0], 3)); + return substr($matches[0], 0, 3) . rawurlencode(substr($matches[0], 3)); } else { - return $encode($matches[0]); + return rawurlencode($matches[0]); } }, $source); } - private function formatComponent(mixed $value, string $before = '', string $after = ''): string - { - return $this->isEmpty($value) ? '' : "{$before}{$value}{$after}"; - } - - private function isEmpty(mixed $value): bool + private function formatComponent(string|int|null $value, string $before = '', string $after = ''): string { - return is_null($value) || $value === ''; + $string = strval($value); + return $string === '' ? '' : "{$before}{$string}{$after}"; } private function normalizePath(string $path): string @@ -638,15 +644,17 @@ private function idnDecode(string $value): string return idn_to_utf8($value, IDNA_NONTRANSITIONAL_TO_UNICODE, INTL_IDNA_VARIANT_UTS46); } - private function merge(array $a, array $b): array + private function queryMerge(array $a, array $b): array { foreach ($b as $key => $value) { if (is_int($key)) { $a[] = $value; - } elseif (array_key_exists($key, $a) && is_array($a[$key]) && is_array($b[$key])) { - $a[$key] = $this->merge($a[$key], $b[$key]); + } elseif (is_array($value)) { + $a[$key] = $this->queryMerge($a[$key] ?? [], $b[$key] ?? []); + } elseif (is_scalar($value)) { + $a[$key] = rawurldecode($b[$key]); } else { - $a[$key] = $b[$key]; + unset($a[$key]); } } return $a; diff --git a/tests/UriExtensionsTest.php b/tests/UriExtensionsTest.php index aa550d8..892341d 100644 --- a/tests/UriExtensionsTest.php +++ b/tests/UriExtensionsTest.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; +use Phrity\Util\ErrorHandler; use Psr\Http\Message\UriInterface; use JsonSerializable; use Stringable; @@ -175,10 +176,10 @@ public function testIdnDecodeHost(): void $this->assertSame('', $clone->getHost()); } - public function testWithMethod(): void + public function testwithComponentsMethod(): void { $uri = new Uri('http://domain.tld:80/path?query=1#fragment'); - $clone = $uri->with([ + $clone = $uri->withComponents([ 'scheme' => 'https', 'userInfo' => ['user', 'password'], 'host' => 'new.domain.tld', @@ -194,12 +195,12 @@ public function testWithMethod(): void ); } - public function testWithMethodInvalidComponent(): void + public function testwithComponentsMethodInvalidComponent(): void { $uri = new Uri('http://domain.tld:80/path?query=1#fragment'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage("Invalid URI component: 'invalid'"); - $clone = $uri->with([ + $clone = $uri->withComponents([ 'invalid' => 'invalid', ]); } @@ -234,7 +235,20 @@ public function testComponents(): void $this->assertEquals(parse_url($uri_str), $uri->getComponents()); } - public function testQueryHelpers(): void + public function testQueryHelperNonAscii(): void + { + $uri = new Uri('http://domain.tld:80/path?aaa=ö +-:;%C3%B6'); + $this->assertEquals('aaa=%C3%B6%20+-:;%C3%B6', $uri->getQuery()); + $this->assertEquals(['aaa' => 'ö -:;ö'], $uri->getQueryItems()); + $this->assertEquals('ö -:;ö', $uri->getQueryItem('aaa')); + + $uri = $uri->withQueryItem('aaa', 'å -+:;%C3%A5'); + $this->assertEquals('aaa=%C3%A5%20-%2B%3A%3B%C3%A5', $uri->getQuery()); + $this->assertEquals(['aaa' => 'å -+:;å'], $uri->getQueryItems()); + $this->assertEquals('å -+:;å', $uri->getQueryItem('aaa')); + } + + public function testQueryHelperArrays(): void { $uri = new Uri('http://domain.tld:80/path?arr%5B0%5D=arr1&arr%5B1%5D=arr2#fragment'); $this->assertEquals([ @@ -265,4 +279,20 @@ public function testQueryHelpers(): void 'arr' => ['arr1', 'arr2', 'arr3'], ], $uri->getQueryItems()); } + + public function testDeprecation(): void + { + $handler = new ErrorHandler(); + $uri = new Uri('https://xn--zca0cg32z7rau82strvd.com'); + $handler->withAll(function () use ($uri) { + $uri->getHost(Uri::IDNA); + }, function ($errors) { + $this->assertEquals('Flag IDNA is deprecated; use IDN_ENCODE instead', $errors[0]->getMessage()); + }); + $handler->withAll(function () use ($uri) { + $uri->withHost('xn--zca0cg32z7rau82strvd.com', Uri::IDNA); + }, function ($errors) { + $this->assertEquals('Flag IDNA is deprecated; use IDN_ENCODE instead', $errors[0]->getMessage()); + }); + } }