├── HttpFactory.php ├── SchemeType.php ├── LICENSE ├── UriTemplate ├── TemplateCanNotBeExpanded.php ├── VarSpecifier.php ├── Expression.php ├── VariableBag.php ├── Template.php └── Operator.php ├── UriResolver.php ├── composer.json ├── UriInfo.php ├── UriScheme.php ├── Builder.php ├── UriTemplate.php ├── Http.php ├── Urn.php ├── BaseUri.php └── Uri.php /HttpFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Psr\Http\Message\UriFactoryInterface; 17 | use Psr\Http\Message\UriInterface; 18 | 19 | final class HttpFactory implements UriFactoryInterface 20 | { 21 | public function createUri(string $uri = ''): UriInterface 22 | { 23 | return Http::new($uri); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /SchemeType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | enum SchemeType 17 | { 18 | case Opaque; 19 | case Hierarchical; 20 | case Unknown; 21 | 22 | public function isOpaque(): bool 23 | { 24 | return self::Opaque === $this; 25 | } 26 | 27 | public function isHierarchical(): bool 28 | { 29 | return self::Hierarchical === $this; 30 | } 31 | 32 | public function isUnknown(): bool 33 | { 34 | return self::Unknown === $this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 ignace nyamagana butera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /UriTemplate/TemplateCanNotBeExpanded.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\UriTemplate; 15 | 16 | use InvalidArgumentException; 17 | use League\Uri\Contracts\UriException; 18 | 19 | class TemplateCanNotBeExpanded extends InvalidArgumentException implements UriException 20 | { 21 | public readonly array $variablesNames; 22 | 23 | public function __construct(string $message = '', string ...$variableNames) 24 | { 25 | parent::__construct($message, 0, null); 26 | 27 | $this->variablesNames = $variableNames; 28 | } 29 | 30 | public static function dueToUnableToProcessValueListWithPrefix(string $variableName): self 31 | { 32 | return new self('The ":" modifier cannot be applied on "'.$variableName.'" since it is a list of values.', $variableName); 33 | } 34 | 35 | public static function dueToNestedListOfValue(string $variableName): self 36 | { 37 | return new self('The "'.$variableName.'" cannot be a nested list.', $variableName); 38 | } 39 | 40 | public static function dueToMissingVariables(string ...$variableNames): self 41 | { 42 | return new self('The following required variables are missing: `'.implode('`, `', $variableNames).'`.', ...$variableNames); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /UriResolver.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Deprecated; 17 | use League\Uri\Contracts\UriInterface; 18 | use Psr\Http\Message\UriInterface as Psr7UriInterface; 19 | 20 | /** 21 | * @deprecated since version 7.0.0 22 | * @codeCoverageIgnore 23 | * @see BaseUri 24 | */ 25 | final class UriResolver 26 | { 27 | /** 28 | * Resolves a URI against a base URI using RFC3986 rules. 29 | * 30 | * This method MUST retain the state of the submitted URI instance, and return 31 | * a URI instance of the same type that contains the applied modifications. 32 | * 33 | * This method MUST be transparent when dealing with error and exceptions. 34 | * It MUST not alter or silence them apart from validating its own parameters. 35 | */ 36 | #[Deprecated(message:'use League\Uri\BaseUri::resolve() instead', since:'league/uri:7.0.0')] 37 | public static function resolve(Psr7UriInterface|UriInterface $uri, Psr7UriInterface|UriInterface $baseUri): Psr7UriInterface|UriInterface 38 | { 39 | return BaseUri::from($baseUri)->resolve($uri)->getUri(); 40 | } 41 | 42 | /** 43 | * Relativizes a URI according to a base URI. 44 | * 45 | * This method MUST retain the state of the submitted URI instance, and return 46 | * a URI instance of the same type that contains the applied modifications. 47 | * 48 | * This method MUST be transparent when dealing with error and exceptions. 49 | * It MUST not alter or silence them apart from validating its own parameters. 50 | */ 51 | #[Deprecated(message:'use League\Uri\BaseUri::relativize() instead', since:'league/uri:7.0.0')] 52 | public static function relativize(Psr7UriInterface|UriInterface $uri, Psr7UriInterface|UriInterface $baseUri): Psr7UriInterface|UriInterface 53 | { 54 | return BaseUri::from($baseUri)->relativize($uri)->getUri(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /UriTemplate/VarSpecifier.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\UriTemplate; 15 | 16 | use League\Uri\Exceptions\SyntaxError; 17 | 18 | use function preg_match; 19 | 20 | /** 21 | * @internal The class exposes the internal representation of a Var Specifier 22 | * @link https://www.rfc-editor.org/rfc/rfc6570#section-2.3 23 | */ 24 | final class VarSpecifier 25 | { 26 | /** 27 | * Variables specification regular expression pattern. 28 | * 29 | * @link https://tools.ietf.org/html/rfc6570#section-2.3 30 | */ 31 | private const REGEXP_VARSPEC = '/^(?(?:[A-z0-9_\.]|%[0-9a-fA-F]{2})+)(?\:(?\d+)|\*)?$/'; 32 | 33 | private const MODIFIER_POSITION_MAX_POSITION = 10_000; 34 | 35 | private function __construct( 36 | public readonly string $name, 37 | public readonly string $modifier, 38 | public readonly int $position 39 | ) { 40 | } 41 | 42 | public static function new(string $specification): self 43 | { 44 | 1 === preg_match(self::REGEXP_VARSPEC, $specification, $parsed) || throw new SyntaxError('The variable specification "'.$specification.'" is invalid.'); 45 | $properties = ['name' => $parsed['name'], 'modifier' => $parsed['modifier'] ?? '', 'position' => $parsed['position'] ?? '']; 46 | 47 | if ('' !== $properties['position']) { 48 | $properties['position'] = (int) $properties['position']; 49 | $properties['modifier'] = ':'; 50 | } 51 | 52 | if ('' === $properties['position']) { 53 | $properties['position'] = 0; 54 | } 55 | 56 | if (self::MODIFIER_POSITION_MAX_POSITION <= $properties['position']) { 57 | throw new SyntaxError('The variable specification "'.$specification.'" is invalid the position modifier must be lower than 10000.'); 58 | } 59 | 60 | return new self($properties['name'], $properties['modifier'], $properties['position']); 61 | } 62 | 63 | public function toString(): string 64 | { 65 | return $this->name.$this->modifier.match (true) { 66 | 0 < $this->position => $this->position, 67 | default => '', 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/uri", 3 | "type": "library", 4 | "description" : "URI manipulation library", 5 | "keywords": [ 6 | "url", 7 | "uri", 8 | "urn", 9 | "uri-template", 10 | "rfc2141", 11 | "rfc3986", 12 | "rfc3987", 13 | "rfc8141", 14 | "rfc6570", 15 | "psr-7", 16 | "parse_url", 17 | "http", 18 | "https", 19 | "ws", 20 | "ftp", 21 | "data-uri", 22 | "file-uri", 23 | "middleware", 24 | "parse_str", 25 | "query-string", 26 | "querystring", 27 | "hostname" 28 | ], 29 | "license": "MIT", 30 | "homepage": "https://uri.thephpleague.com", 31 | "authors": [ 32 | { 33 | "name" : "Ignace Nyamagana Butera", 34 | "email" : "nyamsprod@gmail.com", 35 | "homepage" : "https://nyamsprod.com" 36 | } 37 | ], 38 | "support": { 39 | "forum": "https://thephpleague.slack.com", 40 | "docs": "https://uri.thephpleague.com", 41 | "issues": "https://github.com/thephpleague/uri-src/issues" 42 | }, 43 | "funding": [ 44 | { 45 | "type": "github", 46 | "url": "https://github.com/sponsors/nyamsprod" 47 | } 48 | ], 49 | "require": { 50 | "php": "^8.1", 51 | "league/uri-interfaces": "^7.7", 52 | "psr/http-factory": "^1" 53 | }, 54 | "autoload": { 55 | "psr-4": { 56 | "League\\Uri\\": "" 57 | } 58 | }, 59 | "conflict": { 60 | "league/uri-schemes": "^1.0" 61 | }, 62 | "suggest": { 63 | "ext-bcmath": "to improve IPV4 host parsing", 64 | "ext-dom": "to convert the URI into an HTML anchor tag", 65 | "ext-fileinfo": "to create Data URI from file contennts", 66 | "ext-gmp": "to improve IPV4 host parsing", 67 | "ext-intl": "to handle IDN host with the best performance", 68 | "ext-uri": "to use the PHP native URI class", 69 | "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", 70 | "league/uri-components" : "to provide additional tools to manipulate URI objects components", 71 | "league/uri-polyfill" : "to backport the PHP URI extension for older versions of PHP", 72 | "php-64bit": "to improve IPV4 host parsing", 73 | "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present", 74 | "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification" 75 | }, 76 | "extra": { 77 | "branch-alias": { 78 | "dev-master": "7.x-dev" 79 | } 80 | }, 81 | "config": { 82 | "sort-packages": true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /UriTemplate/Expression.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\UriTemplate; 15 | 16 | use Deprecated; 17 | use League\Uri\Exceptions\SyntaxError; 18 | use Stringable; 19 | 20 | use function array_filter; 21 | use function array_map; 22 | use function array_unique; 23 | use function explode; 24 | use function implode; 25 | 26 | /** 27 | * @internal The class exposes the internal representation of an Expression and its usage 28 | * @link https://www.rfc-editor.org/rfc/rfc6570#section-2.2 29 | */ 30 | final class Expression 31 | { 32 | /** @var array */ 33 | private readonly array $varSpecifiers; 34 | /** @var array */ 35 | public readonly array $variableNames; 36 | public readonly string $value; 37 | 38 | private function __construct(public readonly Operator $operator, VarSpecifier ...$varSpecifiers) 39 | { 40 | $this->varSpecifiers = $varSpecifiers; 41 | $this->variableNames = array_unique( 42 | array_map( 43 | static fn (VarSpecifier $varSpecifier): string => $varSpecifier->name, 44 | $varSpecifiers 45 | ) 46 | ); 47 | $this->value = '{'.$operator->value.implode(',', array_map( 48 | static fn (VarSpecifier $varSpecifier): string => $varSpecifier->toString(), 49 | $varSpecifiers 50 | )).'}'; 51 | } 52 | 53 | /** 54 | * @throws SyntaxError if the expression is invalid 55 | */ 56 | public static function new(Stringable|string $expression): self 57 | { 58 | $parts = Operator::parseExpression($expression); 59 | 60 | return new Expression($parts['operator'], ...array_map( 61 | static fn (string $varSpec): VarSpecifier => VarSpecifier::new($varSpec), 62 | explode(',', $parts['variables']) 63 | )); 64 | } 65 | 66 | /** 67 | * DEPRECATION WARNING! This method will be removed in the next major point release. 68 | * 69 | * @throws SyntaxError if the expression is invalid 70 | * @see Expression::new() 71 | * 72 | * @deprecated Since version 7.0.0 73 | * @codeCoverageIgnore 74 | */ 75 | #[Deprecated(message:'use League\Uri\UriTemplate\Exppression::new() instead', since:'league/uri:7.0.0')] 76 | public static function createFromString(Stringable|string $expression): self 77 | { 78 | return self::new($expression); 79 | } 80 | 81 | public function expand(VariableBag $variables): string 82 | { 83 | $expanded = implode( 84 | $this->operator->separator(), 85 | array_filter( 86 | array_map( 87 | fn (VarSpecifier $varSpecifier): string => $this->operator->expand($varSpecifier, $variables), 88 | $this->varSpecifiers 89 | ), 90 | static fn ($value): bool => '' !== $value 91 | ) 92 | ); 93 | 94 | return match ('') { 95 | $expanded => '', 96 | default => $this->operator->first().$expanded, 97 | }; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /UriInfo.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Deprecated; 17 | use League\Uri\Contracts\UriInterface; 18 | use Psr\Http\Message\UriInterface as Psr7UriInterface; 19 | 20 | /** 21 | * @deprecated since version 7.0.0 22 | * @codeCoverageIgnore 23 | * @see BaseUri 24 | */ 25 | final class UriInfo 26 | { 27 | /** 28 | * @codeCoverageIgnore 29 | */ 30 | private function __construct() 31 | { 32 | } 33 | 34 | /** 35 | * Tells whether the URI represents an absolute URI. 36 | */ 37 | #[Deprecated(message:'use League\Uri\BaseUri::isAbsolute() instead', since:'league/uri:7.0.0')] 38 | public static function isAbsolute(Psr7UriInterface|UriInterface $uri): bool 39 | { 40 | return BaseUri::from($uri)->isAbsolute(); 41 | } 42 | 43 | /** 44 | * Tell whether the URI represents a network path. 45 | */ 46 | #[Deprecated(message:'use League\Uri\BaseUri::isNetworkPath() instead', since:'league/uri:7.0.0')] 47 | public static function isNetworkPath(Psr7UriInterface|UriInterface $uri): bool 48 | { 49 | return BaseUri::from($uri)->isNetworkPath(); 50 | } 51 | 52 | /** 53 | * Tells whether the URI represents an absolute path. 54 | */ 55 | #[Deprecated(message:'use League\Uri\BaseUri::isAbsolutePath() instead', since:'league/uri:7.0.0')] 56 | public static function isAbsolutePath(Psr7UriInterface|UriInterface $uri): bool 57 | { 58 | return BaseUri::from($uri)->isAbsolutePath(); 59 | } 60 | 61 | /** 62 | * Tell whether the URI represents a relative path. 63 | * 64 | */ 65 | #[Deprecated(message:'use League\Uri\BaseUri::isRelativePath() instead', since:'league/uri:7.0.0')] 66 | public static function isRelativePath(Psr7UriInterface|UriInterface $uri): bool 67 | { 68 | return BaseUri::from($uri)->isRelativePath(); 69 | } 70 | 71 | /** 72 | * Tells whether both URI refers to the same document. 73 | */ 74 | #[Deprecated(message:'use League\Uri\BaseUri::isSameDocument() instead', since:'league/uri:7.0.0')] 75 | public static function isSameDocument(Psr7UriInterface|UriInterface $uri, Psr7UriInterface|UriInterface $baseUri): bool 76 | { 77 | return BaseUri::from($baseUri)->isSameDocument($uri); 78 | } 79 | 80 | /** 81 | * Returns the URI origin property as defined by WHATWG URL living standard. 82 | * 83 | * {@see https://url.spec.whatwg.org/#origin} 84 | * 85 | * For URI without a special scheme the method returns null 86 | * For URI with the file scheme the method will return null (as this is left to the implementation decision) 87 | * For URI with a special scheme the method returns the scheme followed by its authority (without the userinfo part) 88 | */ 89 | #[Deprecated(message:'use League\Uri\BaseUri::origin() instead', since:'league/uri:7.0.0')] 90 | public static function getOrigin(Psr7UriInterface|UriInterface $uri): ?string 91 | { 92 | return BaseUri::from($uri)->origin()?->__toString(); 93 | } 94 | 95 | /** 96 | * Tells whether two URI do not share the same origin. 97 | * 98 | * @see UriInfo::getOrigin() 99 | */ 100 | #[Deprecated(message:'use League\Uri\BaseUri::isCrossOrigin() instead', since:'league/uri:7.0.0')] 101 | public static function isCrossOrigin(Psr7UriInterface|UriInterface $uri, Psr7UriInterface|UriInterface $baseUri): bool 102 | { 103 | return BaseUri::from($baseUri)->isCrossOrigin($uri); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /UriTemplate/VariableBag.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\UriTemplate; 15 | 16 | use ArrayAccess; 17 | use Closure; 18 | use Countable; 19 | use IteratorAggregate; 20 | use Stringable; 21 | use Traversable; 22 | 23 | use function array_filter; 24 | use function is_bool; 25 | use function is_scalar; 26 | 27 | use const ARRAY_FILTER_USE_BOTH; 28 | 29 | /** 30 | * @internal The class exposes the internal representation of variable bags 31 | * 32 | * @phpstan-type InputValue string|bool|int|float|array 33 | * 34 | * @implements ArrayAccess 35 | * @implements IteratorAggregate 36 | */ 37 | final class VariableBag implements ArrayAccess, Countable, IteratorAggregate 38 | { 39 | /** 40 | * @var array> 41 | */ 42 | private array $variables = []; 43 | 44 | /** 45 | * @param iterable $variables 46 | */ 47 | public function __construct(iterable $variables = []) 48 | { 49 | foreach ($variables as $name => $value) { 50 | $this->assign((string) $name, $value); 51 | } 52 | } 53 | 54 | public function count(): int 55 | { 56 | return count($this->variables); 57 | } 58 | 59 | public function getIterator(): Traversable 60 | { 61 | yield from $this->variables; 62 | } 63 | 64 | public function offsetExists(mixed $offset): bool 65 | { 66 | return array_key_exists($offset, $this->variables); 67 | } 68 | 69 | public function offsetUnset(mixed $offset): void 70 | { 71 | unset($this->variables[$offset]); 72 | } 73 | 74 | public function offsetSet(mixed $offset, mixed $value): void 75 | { 76 | $this->assign($offset, $value); /* @phpstan-ignore-line */ 77 | } 78 | 79 | public function offsetGet(mixed $offset): mixed 80 | { 81 | return $this->fetch($offset); 82 | } 83 | 84 | /** 85 | * Tells whether the bag is empty or not. 86 | */ 87 | public function isEmpty(): bool 88 | { 89 | return [] === $this->variables; 90 | } 91 | 92 | /** 93 | * Tells whether the bag is empty or not. 94 | */ 95 | public function isNotEmpty(): bool 96 | { 97 | return [] !== $this->variables; 98 | } 99 | 100 | public function equals(mixed $value): bool 101 | { 102 | return $value instanceof self 103 | && $this->variables === $value->variables; 104 | } 105 | 106 | /** 107 | * Fetches the variable value if none found returns null. 108 | * 109 | * @return null|string|array 110 | */ 111 | public function fetch(string $name): null|string|array 112 | { 113 | return $this->variables[$name] ?? null; 114 | } 115 | 116 | /** 117 | * @param Stringable|InputValue $value 118 | */ 119 | public function assign(string $name, Stringable|string|bool|int|float|array|null $value): void 120 | { 121 | $this->variables[$name] = $this->normalizeValue($value, $name, true); 122 | } 123 | 124 | /** 125 | * @param Stringable|InputValue $value 126 | * 127 | * @throws TemplateCanNotBeExpanded if the value contains nested list 128 | */ 129 | private function normalizeValue( 130 | Stringable|string|float|int|bool|array|null $value, 131 | string $name, 132 | bool $isNestedListAllowed 133 | ): array|string { 134 | return match (true) { 135 | is_bool($value) => true === $value ? '1' : '0', 136 | (null === $value || is_scalar($value) || $value instanceof Stringable) => (string) $value, 137 | !$isNestedListAllowed => throw TemplateCanNotBeExpanded::dueToNestedListOfValue($name), 138 | default => array_map(fn ($var): array|string => self::normalizeValue($var, $name, false), $value), 139 | }; 140 | } 141 | 142 | /** 143 | * Replaces elements from passed variables into the current instance. 144 | */ 145 | public function replace(VariableBag $variables): self 146 | { 147 | return new self($this->variables + $variables->variables); 148 | } 149 | 150 | /** 151 | * Filters elements using the closure. 152 | */ 153 | public function filter(Closure $fn): self 154 | { 155 | return new self(array_filter($this->variables, $fn, ARRAY_FILTER_USE_BOTH)); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /UriTemplate/Template.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\UriTemplate; 15 | 16 | use Deprecated; 17 | use League\Uri\Exceptions\SyntaxError; 18 | use Stringable; 19 | 20 | use function array_filter; 21 | use function array_map; 22 | use function array_reduce; 23 | use function array_unique; 24 | use function preg_match_all; 25 | use function preg_replace; 26 | use function str_replace; 27 | use function strpbrk; 28 | 29 | use const PREG_SET_ORDER; 30 | 31 | /** 32 | * @internal The class exposes the internal representation of a Template and its usage 33 | */ 34 | final class Template implements Stringable 35 | { 36 | /** 37 | * Expression regular expression pattern. 38 | */ 39 | private const REGEXP_EXPRESSION_DETECTOR = '/(?\{[^}]*})/x'; 40 | 41 | /** @var array */ 42 | private readonly array $expressions; 43 | /** @var array */ 44 | public readonly array $variableNames; 45 | 46 | private function __construct(public readonly string $value, Expression ...$expressions) 47 | { 48 | $this->expressions = $expressions; 49 | $this->variableNames = array_unique( 50 | array_merge( 51 | ...array_map( 52 | static fn (Expression $expression): array => $expression->variableNames, 53 | $expressions 54 | ) 55 | ) 56 | ); 57 | } 58 | 59 | /** 60 | * @throws SyntaxError if the template contains invalid expressions 61 | * @throws SyntaxError if the template contains invalid variable specification 62 | */ 63 | public static function new(Stringable|string $template): self 64 | { 65 | $template = (string) $template; 66 | /** @var string $remainder */ 67 | $remainder = preg_replace(self::REGEXP_EXPRESSION_DETECTOR, '', $template); 68 | false === strpbrk($remainder, '{}') || throw new SyntaxError('The template "'.$template.'" contains invalid expressions.'); 69 | 70 | preg_match_all(self::REGEXP_EXPRESSION_DETECTOR, $template, $founds, PREG_SET_ORDER); 71 | 72 | return new self($template, ...array_values( 73 | array_reduce($founds, function (array $carry, array $found): array { 74 | if (!isset($carry[$found['expression']])) { 75 | $carry[$found['expression']] = Expression::new($found['expression']); 76 | } 77 | 78 | return $carry; 79 | }, []) 80 | )); 81 | } 82 | 83 | /** 84 | * @throws TemplateCanNotBeExpanded if the variables are invalid 85 | */ 86 | public function expand(iterable $variables = []): string 87 | { 88 | if (!$variables instanceof VariableBag) { 89 | $variables = new VariableBag($variables); 90 | } 91 | 92 | return $this->expandAll($variables); 93 | } 94 | 95 | /** 96 | * @throws TemplateCanNotBeExpanded if the variables are invalid or missing 97 | */ 98 | public function expandOrFail(iterable $variables = []): string 99 | { 100 | if (!$variables instanceof VariableBag) { 101 | $variables = new VariableBag($variables); 102 | } 103 | 104 | $missing = array_filter($this->variableNames, fn (string $name): bool => !isset($variables[$name])); 105 | if ([] !== $missing) { 106 | throw TemplateCanNotBeExpanded::dueToMissingVariables(...$missing); 107 | } 108 | 109 | return $this->expandAll($variables); 110 | } 111 | 112 | private function expandAll(VariableBag $variables): string 113 | { 114 | return array_reduce( 115 | $this->expressions, 116 | fn (string $uri, Expression $expr): string => str_replace($expr->value, $expr->expand($variables), $uri), 117 | $this->value 118 | ); 119 | } 120 | 121 | public function __toString(): string 122 | { 123 | return $this->value; 124 | } 125 | 126 | /** 127 | * DEPRECATION WARNING! This method will be removed in the next major point release. 128 | * 129 | * @throws SyntaxError if the template contains invalid expressions 130 | * @throws SyntaxError if the template contains invalid variable specification 131 | * @deprecated Since version 7.0.0 132 | * @codeCoverageIgnore 133 | * @see Template::new() 134 | * 135 | * Create a new instance from a string. 136 | * 137 | */ 138 | #[Deprecated(message:'use League\Uri\UriTemplate\Template::new() instead', since:'league/uri:7.0.0')] 139 | public static function createFromString(Stringable|string $template): self 140 | { 141 | return self::new($template); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /UriScheme.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use ValueError; 17 | 18 | /* 19 | * Supported schemes and corresponding default port. 20 | * 21 | * @see https://github.com/python-hyper/hyperlink/blob/master/src/hyperlink/_url.py for the curating list definition 22 | * @see https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml 23 | * @see https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml 24 | */ 25 | enum UriScheme: string 26 | { 27 | case About = 'about'; 28 | case Acap = 'acap'; 29 | case Bitcoin = 'bitcoin'; 30 | case Geo = 'geo'; 31 | case Blob = 'blob'; 32 | case Afp = 'afp'; 33 | case Data = 'data'; 34 | case Dict = 'dict'; 35 | case Dns = 'dns'; 36 | case File = 'file'; 37 | case Ftp = 'ftp'; 38 | case Git = 'git'; 39 | case Gopher = 'gopher'; 40 | case Http = 'http'; 41 | case Https = 'https'; 42 | case Imap = 'imap'; 43 | case Imaps = 'imaps'; 44 | case Ipp = 'ipp'; 45 | case Ipps = 'ipps'; 46 | case Irc = 'irc'; 47 | case Ircs = 'ircs'; 48 | case Javascript = 'javascript'; 49 | case Ldap = 'ldap'; 50 | case Ldaps = 'ldaps'; 51 | case Magnet = 'magnet'; 52 | case Mailto = 'mailto'; 53 | case Mms = 'mms'; 54 | case Msrp = 'msrp'; 55 | case Msrps = 'msrps'; 56 | case Mtqp = 'mtqp'; 57 | case News = 'news'; 58 | case Nfs = 'nfs'; 59 | case Nntp = 'nntp'; 60 | case Nntps = 'nntps'; 61 | case Pkcs11 = 'pkcs11'; 62 | case Pop = 'pop'; 63 | case Prospero = 'prospero'; 64 | case Redis = 'redis'; 65 | case Rsync = 'rsync'; 66 | case Rtsp = 'rtsp'; 67 | case Rtsps = 'rtsps'; 68 | case Rtspu = 'rtspu'; 69 | case Sftp = 'sftp'; 70 | case Wss = 'wss'; 71 | case Ws = 'ws'; 72 | case Sip = 'sip'; 73 | case Sips = 'sips'; 74 | case Smb = 'smb'; 75 | case Smtp = 'smtp'; 76 | case Snmp = 'snmp'; 77 | case Ssh = 'ssh'; 78 | case Steam = 'steam'; 79 | case Svn = 'svn'; 80 | case Tel = 'tel'; 81 | case Telnet = 'telnet'; 82 | case Tn3270 = 'tn3270'; 83 | case Urn = 'urn'; 84 | case Ventrilo = 'ventrilo'; 85 | case Vnc = 'vnc'; 86 | case Wais = 'wais'; 87 | case Xmpp = 'xmpp'; 88 | 89 | public function port(): ?int 90 | { 91 | return match ($this) { 92 | self::Acap => 674, 93 | self::Afp => 548, 94 | self::Dict => 2628, 95 | self::Dns => 53, 96 | self::Ftp => 21, 97 | self::Http, self::Ws => 80, 98 | self::Https, self::Wss => 443, 99 | self::Git => 9418, 100 | self::Gopher => 70, 101 | self::Imap => 143, 102 | self::Imaps => 993, 103 | self::Ipp, self::Ipps => 631, 104 | self::Irc => 194, 105 | self::Ircs => 6697, 106 | self::Ldap => 389, 107 | self::Ldaps => 636, 108 | self::Mms => 1755, 109 | self::Msrp, self::Msrps => 2855, 110 | self::Mtqp => 1038, 111 | self::Nfs => 111, 112 | self::Nntp => 119, 113 | self::Nntps => 563, 114 | self::Pop => 110, 115 | self::Prospero => 1525, 116 | self::Redis => 6379, 117 | self::Rsync => 873, 118 | self::Rtsp => 554, 119 | self::Rtsps => 322, 120 | self::Rtspu => 5005, 121 | self::Sftp, self::Ssh => 22, 122 | self::Smb => 445, 123 | self::Smtp => 25, 124 | self::Snmp => 161, 125 | self::Svn => 3690, 126 | self::Telnet, self::Tn3270 => 23, 127 | self::Ventrilo => 3784, 128 | self::Vnc => 5900, 129 | self::Wais => 210, 130 | self::Xmpp => 80, 131 | default => null, 132 | }; 133 | } 134 | 135 | public function type(): SchemeType 136 | { 137 | return match ($this) { 138 | self::Urn, 139 | self::About, 140 | self::Bitcoin, 141 | self::Blob, 142 | self::Data, 143 | self::Geo, 144 | self::Javascript, 145 | self::Magnet, 146 | self::Mailto, 147 | self::Pkcs11, 148 | self::Sip, 149 | self::Sips, 150 | self::Tel => SchemeType::Opaque, 151 | self::File => SchemeType::Hierarchical, 152 | self::News => SchemeType::Unknown, 153 | default => match (true) { 154 | null !== $this->port() => SchemeType::Hierarchical, 155 | default => SchemeType::Unknown, 156 | }, 157 | }; 158 | } 159 | 160 | public function isWhatWgSpecial(): bool 161 | { 162 | return match ($this) { 163 | self::Ftp, 164 | self::Http, 165 | self::Https, 166 | self::Ws, 167 | self::Wss => true, 168 | default => false, 169 | }; 170 | } 171 | 172 | /** 173 | * @return list 174 | */ 175 | public static function fromPort(?int $port): array 176 | { 177 | null === $port || 0 <= $port || throw new ValueError('The submitted port cannot be negative.'); 178 | 179 | static $reverse = []; 180 | if ([] === $reverse) { 181 | foreach (self::cases() as $case) { 182 | $defaultPort = $case->port(); 183 | if (null === $defaultPort) { 184 | continue; 185 | } 186 | $reverse[$defaultPort] ??= []; 187 | $reverse[$defaultPort][] = $case; 188 | 189 | } 190 | } 191 | 192 | return $reverse[$port] ?? []; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /UriTemplate/Operator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\UriTemplate; 15 | 16 | use League\Uri\Encoder; 17 | use League\Uri\Exceptions\SyntaxError; 18 | use Stringable; 19 | 20 | use function implode; 21 | use function is_array; 22 | use function preg_match; 23 | use function rawurlencode; 24 | use function str_contains; 25 | use function substr; 26 | 27 | /** 28 | * Processing behavior according to the expression type operator. 29 | * 30 | * @internal The class exposes the internal representation of an Operator and its usage 31 | * 32 | * @link https://www.rfc-editor.org/rfc/rfc6570#section-2.2 33 | * @link https://tools.ietf.org/html/rfc6570#appendix-A 34 | */ 35 | enum Operator: string 36 | { 37 | /** 38 | * Expression regular expression pattern. 39 | * 40 | * @link https://tools.ietf.org/html/rfc6570#section-2.2 41 | */ 42 | private const REGEXP_EXPRESSION = '/^\{(?:(?[\.\/;\?&\=,\!@\|\+#])?(?[^\}]*))\}$/'; 43 | 44 | /** 45 | * Reserved Operator characters. 46 | * 47 | * @link https://tools.ietf.org/html/rfc6570#section-2.2 48 | */ 49 | private const RESERVED_OPERATOR = '=,!@|'; 50 | 51 | case None = ''; 52 | case ReservedChars = '+'; 53 | case Label = '.'; 54 | case Path = '/'; 55 | case PathParam = ';'; 56 | case Query = '?'; 57 | case QueryPair = '&'; 58 | case Fragment = '#'; 59 | 60 | public function first(): string 61 | { 62 | return match ($this) { 63 | self::None, self::ReservedChars => '', 64 | default => $this->value, 65 | }; 66 | } 67 | 68 | public function separator(): string 69 | { 70 | return match ($this) { 71 | self::None, self::ReservedChars, self::Fragment => ',', 72 | self::Query, self::QueryPair => '&', 73 | default => $this->value, 74 | }; 75 | } 76 | 77 | public function isNamed(): bool 78 | { 79 | return match ($this) { 80 | self::Query, self::PathParam, self::QueryPair => true, 81 | default => false, 82 | }; 83 | } 84 | 85 | /** 86 | * Removes percent encoding on reserved characters (used with + and # modifiers). 87 | */ 88 | public function decode(string $var): string 89 | { 90 | return match ($this) { 91 | Operator::ReservedChars, Operator::Fragment => (string) Encoder::encodeQueryOrFragment($var), 92 | default => rawurlencode($var), 93 | }; 94 | } 95 | 96 | /** 97 | * @throws SyntaxError if the expression is invalid 98 | * @throws SyntaxError if the operator used in the expression is invalid 99 | * @throws SyntaxError if the contained variable specifiers are invalid 100 | * 101 | * @return array{operator:Operator, variables:string} 102 | */ 103 | public static function parseExpression(Stringable|string $expression): array 104 | { 105 | $expression = (string) $expression; 106 | if (1 !== preg_match(self::REGEXP_EXPRESSION, $expression, $parts)) { 107 | throw new SyntaxError('The expression "'.$expression.'" is invalid.'); 108 | } 109 | 110 | /** @var array{operator:string, variables:string} $parts */ 111 | $parts = $parts + ['operator' => '']; 112 | if ('' !== $parts['operator'] && str_contains(self::RESERVED_OPERATOR, $parts['operator'])) { 113 | throw new SyntaxError('The operator used in the expression "'.$expression.'" is reserved.'); 114 | } 115 | 116 | return [ 117 | 'operator' => self::from($parts['operator']), 118 | 'variables' => $parts['variables'], 119 | ]; 120 | } 121 | 122 | /** 123 | * Replaces an expression with the given variables. 124 | * 125 | * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied 126 | * @throws TemplateCanNotBeExpanded if the variables contains nested array values 127 | */ 128 | public function expand(VarSpecifier $varSpecifier, VariableBag $variables): string 129 | { 130 | $value = $variables->fetch($varSpecifier->name); 131 | if (null === $value) { 132 | return ''; 133 | } 134 | 135 | [$expanded, $actualQuery] = $this->inject($value, $varSpecifier); 136 | if (!$actualQuery) { 137 | return $expanded; 138 | } 139 | 140 | if ('&' !== $this->separator() && '' === $expanded) { 141 | return $varSpecifier->name; 142 | } 143 | 144 | return $varSpecifier->name.'='.$expanded; 145 | } 146 | 147 | /** 148 | * @param string|array $value 149 | * 150 | * @return array{0:string, 1:bool} 151 | */ 152 | private function inject(array|string $value, VarSpecifier $varSpec): array 153 | { 154 | if (is_array($value)) { 155 | return $this->replaceList($value, $varSpec); 156 | } 157 | 158 | if (':' === $varSpec->modifier) { 159 | $value = substr($value, 0, $varSpec->position); 160 | } 161 | 162 | return [$this->decode($value), $this->isNamed()]; 163 | } 164 | 165 | /** 166 | * Expands an expression using a list of values. 167 | * 168 | * @param array $value 169 | * 170 | * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied 171 | * 172 | * @return array{0:string, 1:bool} 173 | */ 174 | private function replaceList(array $value, VarSpecifier $varSpec): array 175 | { 176 | if (':' === $varSpec->modifier) { 177 | throw TemplateCanNotBeExpanded::dueToUnableToProcessValueListWithPrefix($varSpec->name); 178 | } 179 | 180 | if ([] === $value) { 181 | return ['', false]; 182 | } 183 | 184 | $pairs = []; 185 | $isList = array_is_list($value); 186 | $useQuery = $this->isNamed(); 187 | foreach ($value as $key => $var) { 188 | if (!$isList) { 189 | $key = rawurlencode((string) $key); 190 | } 191 | 192 | $var = $this->decode($var); 193 | if ('*' === $varSpec->modifier) { 194 | if (!$isList) { 195 | $var = $key.'='.$var; 196 | } elseif ($key > 0 && $useQuery) { 197 | $var = $varSpec->name.'='.$var; 198 | } 199 | } 200 | 201 | $pairs[$key] = $var; 202 | } 203 | 204 | if ('*' === $varSpec->modifier) { 205 | if (!$isList) { 206 | // Don't prepend the value name when using the `explode` modifier with an associative array. 207 | $useQuery = false; 208 | } 209 | 210 | return [implode($this->separator(), $pairs), $useQuery]; 211 | } 212 | 213 | if (!$isList) { 214 | // When an associative array is encountered and the `explode` modifier is not set, then 215 | // the result must be a comma separated list of keys followed by their respective values. 216 | $retVal = []; 217 | foreach ($pairs as $offset => $data) { 218 | $retVal[$offset] = $offset.','.$data; 219 | } 220 | $pairs = $retVal; 221 | } 222 | 223 | return [implode(',', $pairs), $useQuery]; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Builder.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use League\Uri\Contracts\UriComponentInterface; 17 | use League\Uri\Exceptions\SyntaxError; 18 | use SensitiveParameter; 19 | use Stringable; 20 | use Uri\Rfc3986\Uri as Rfc3986Uri; 21 | use Uri\WhatWg\Url as WhatWgUrl; 22 | 23 | use function str_replace; 24 | use function strpos; 25 | 26 | final class Builder 27 | { 28 | public function __construct( 29 | private ?string $scheme = null, 30 | private ?string $username = null, 31 | #[SensitiveParameter] private ?string $password = null, 32 | private ?string $host = null, 33 | private ?int $port = null, 34 | private ?string $path = null, 35 | private ?string $query = null, 36 | private ?string $fragment = null, 37 | ) { 38 | $this 39 | ->scheme($scheme) 40 | ->userInfo($username, $password) 41 | ->host($host) 42 | ->port($port) 43 | ->path($path) 44 | ->query($query) 45 | ->fragment($fragment); 46 | } 47 | 48 | /** 49 | * @throws SyntaxError 50 | */ 51 | public function scheme(Stringable|string|null $scheme): self 52 | { 53 | $scheme = $this->filterString($scheme); 54 | if ($scheme !== $this->scheme) { 55 | UriString::isValidScheme($scheme) || throw new SyntaxError('The scheme `'.$scheme.'` is invalid.'); 56 | 57 | $this->scheme = $scheme; 58 | } 59 | 60 | return $this; 61 | } 62 | 63 | public function userInfo( 64 | Stringable|string|null $user, 65 | #[SensitiveParameter] Stringable|string|null $password = null 66 | ): static { 67 | $username = Encoder::encodeUser($this->filterString($user)); 68 | $password = Encoder::encodePassword($this->filterString($password)); 69 | if ($username !== $this->username || $password !== $this->password) { 70 | $this->username = $username; 71 | $this->password = $password; 72 | } 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * @throws SyntaxError 79 | */ 80 | public function host(Stringable|string|null $host): self 81 | { 82 | $host = $this->filterString($host); 83 | if ($host !== $this->host) { 84 | null === $host 85 | || HostRecord::isValid($host) 86 | || throw new SyntaxError('The host `'.$host.'` is invalid.'); 87 | 88 | $this->host = $host; 89 | } 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * @throws SyntaxError 96 | */ 97 | public function port(?int $port): self 98 | { 99 | if ($port !== $this->port) { 100 | null === $port 101 | || ($port >= 0 && $port < 65535) 102 | || throw new SyntaxError('The port value must be null or an integer between 0 and 65535.'); 103 | 104 | $this->port = $port; 105 | } 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * @throws SyntaxError 112 | */ 113 | public function path(Stringable|string|null $path): self 114 | { 115 | if ($path !== $this->path) { 116 | $this->path = null !== $path ? Encoder::encodePath($this->filterString($path)) : null; 117 | } 118 | 119 | return $this; 120 | } 121 | 122 | /** 123 | * @throws SyntaxError 124 | */ 125 | public function query(Stringable|string|null $query): self 126 | { 127 | if ($query !== $this->query) { 128 | $this->query = Encoder::encodeQueryOrFragment($this->filterString($query)); 129 | } 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * @throws SyntaxError 136 | */ 137 | public function fragment(Stringable|string|null $fragment): self 138 | { 139 | if ($fragment !== $this->fragment) { 140 | $this->fragment = Encoder::encodeQueryOrFragment($this->filterString($fragment)); 141 | } 142 | 143 | return $this; 144 | } 145 | 146 | public function build(Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUri = null): Uri 147 | { 148 | $authority = $this->buildAuthority(); 149 | $path = $this->buildPath($authority); 150 | $uriString = UriString::buildUri( 151 | $this->scheme, 152 | $authority, 153 | $path, 154 | Encoder::encodeQueryOrFragment($this->query), 155 | Encoder::encodeQueryOrFragment($this->fragment) 156 | ); 157 | 158 | return Uri::new(null === $baseUri ? $uriString : UriString::resolve($uriString, match (true) { 159 | $baseUri instanceof Rfc3986Uri => $baseUri->toString(), 160 | $baseUri instanceof WhatWgUrl => $baseUri->toAsciiString(), 161 | default => (string) $baseUri, 162 | })); 163 | } 164 | 165 | /** 166 | * @throws SyntaxError 167 | */ 168 | private function buildAuthority(): ?string 169 | { 170 | if (null === $this->host) { 171 | (null === $this->username && null === $this->password && null === $this->port) 172 | || throw new SyntaxError('The User Information and/or the Port component(s) are set without a Host component being present.'); 173 | 174 | return null; 175 | } 176 | 177 | $authority = $this->host; 178 | if (null !== $this->username || null !== $this->password) { 179 | $userInfo = Encoder::encodeUser($this->username); 180 | if (null !== $this->password) { 181 | $userInfo .= ':'.Encoder::encodePassword($this->password); 182 | } 183 | 184 | $authority = $userInfo.'@'.$authority; 185 | } 186 | 187 | if (null !== $this->port) { 188 | return $authority.':'.$this->port; 189 | } 190 | 191 | return $authority; 192 | } 193 | 194 | /** 195 | * @throws SyntaxError 196 | */ 197 | private function buildPath(?string $authority): ?string 198 | { 199 | if (null === $this->path || '' === $this->path) { 200 | return $this->path; 201 | } 202 | 203 | $path = Encoder::encodePath($this->path); 204 | if (null !== $authority) { 205 | // If there is an authority, the path must start with a `/` 206 | return str_starts_with($path, '/') ? $path : '/'.$path; 207 | } 208 | 209 | // If there is no authority, the path cannot start with `//` 210 | if (str_starts_with($path, '//')) { 211 | return '/.'.$path; 212 | } 213 | 214 | $colonPos = strpos($path, ':'); 215 | if (false !== $colonPos && null === $this->scheme) { 216 | // In the absence of a scheme and of an authority, 217 | // the first path segment cannot contain a colon (":") character.' 218 | $slashPos = strpos($path, '/'); 219 | (false !== $slashPos && $colonPos > $slashPos) || throw new SyntaxError( 220 | 'In absence of the scheme and authority components, the first path segment cannot contain a colon (":") character.' 221 | ); 222 | } 223 | 224 | return $path; 225 | } 226 | 227 | /** 228 | * Filter a string. 229 | * 230 | * @throws SyntaxError if the submitted data cannot be converted to string 231 | */ 232 | private function filterString(Stringable|string|null $str): ?string 233 | { 234 | if (null === $str) { 235 | return null; 236 | } 237 | 238 | if ($str instanceof UriComponentInterface) { 239 | return $str->value(); 240 | } 241 | 242 | $str = str_replace(' ', '%20', (string) $str); 243 | 244 | return UriString::containsRfc3987Chars($str) 245 | ? $str 246 | : throw new SyntaxError('The component value `'.$str.'` contains invalid characters.'); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /UriTemplate.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Deprecated; 17 | use League\Uri\Contracts\UriException; 18 | use League\Uri\Contracts\UriInterface; 19 | use League\Uri\Exceptions\MissingFeature; 20 | use League\Uri\Exceptions\SyntaxError; 21 | use League\Uri\UriTemplate\Template; 22 | use League\Uri\UriTemplate\TemplateCanNotBeExpanded; 23 | use League\Uri\UriTemplate\VariableBag; 24 | use Psr\Http\Message\UriFactoryInterface; 25 | use Psr\Http\Message\UriInterface as Psr7UriInterface; 26 | use Stringable; 27 | use Uri\InvalidUriException; 28 | use Uri\Rfc3986\Uri as Rfc3986Uri; 29 | use Uri\WhatWg\InvalidUrlException; 30 | use Uri\WhatWg\Url as WhatWgUrl; 31 | 32 | use function array_fill_keys; 33 | use function array_key_exists; 34 | use function class_exists; 35 | 36 | /** 37 | * Defines the URI Template syntax and the process for expanding a URI Template into a URI reference. 38 | * 39 | * @link https://tools.ietf.org/html/rfc6570 40 | * @package League\Uri 41 | * @author Ignace Nyamagana Butera 42 | * @since 6.1.0 43 | * 44 | * @phpstan-import-type InputValue from VariableBag 45 | */ 46 | final class UriTemplate implements Stringable 47 | { 48 | private readonly Template $template; 49 | private readonly VariableBag $defaultVariables; 50 | 51 | /** 52 | * @throws SyntaxError if the template syntax is invalid 53 | * @throws TemplateCanNotBeExpanded if the template or the variables are invalid 54 | */ 55 | public function __construct(Stringable|string $template, iterable $defaultVariables = []) 56 | { 57 | $this->template = $template instanceof Template ? $template : Template::new($template); 58 | $this->defaultVariables = $this->filterVariables($defaultVariables); 59 | } 60 | 61 | private function filterVariables(iterable $variables): VariableBag 62 | { 63 | if (!$variables instanceof VariableBag) { 64 | $variables = new VariableBag($variables); 65 | } 66 | 67 | return $variables 68 | ->filter(fn ($value, string|int $name) => array_key_exists( 69 | $name, 70 | array_fill_keys($this->template->variableNames, 1) 71 | )); 72 | } 73 | 74 | /** 75 | * Returns the string representation of the UriTemplate. 76 | */ 77 | public function __toString(): string 78 | { 79 | return $this->template->value; 80 | } 81 | 82 | /** 83 | * Returns the distinct variables placeholders used in the template. 84 | * 85 | * @return array 86 | */ 87 | public function getVariableNames(): array 88 | { 89 | return $this->template->variableNames; 90 | } 91 | 92 | /** 93 | * @return array 94 | */ 95 | public function getDefaultVariables(): array 96 | { 97 | return iterator_to_array($this->defaultVariables); 98 | } 99 | 100 | /** 101 | * Returns a new instance with the updated default variables. 102 | * 103 | * This method MUST retain the state of the current instance, and return 104 | * an instance that contains the modified default variables. 105 | * 106 | * If present, variables whose name is not part of the current template 107 | * possible variable names are removed. 108 | * 109 | * @throws TemplateCanNotBeExpanded if the variables are invalid 110 | */ 111 | public function withDefaultVariables(iterable $defaultVariables): self 112 | { 113 | $defaultVariables = $this->filterVariables($defaultVariables); 114 | if ($this->defaultVariables->equals($defaultVariables)) { 115 | return $this; 116 | } 117 | 118 | return new self($this->template, $defaultVariables); 119 | } 120 | 121 | private function templateExpanded(iterable $variables = []): string 122 | { 123 | return $this->template->expand($this->filterVariables($variables)->replace($this->defaultVariables)); 124 | } 125 | 126 | private function templateExpandedOrFail(iterable $variables = []): string 127 | { 128 | return $this->template->expandOrFail($this->filterVariables($variables)->replace($this->defaultVariables)); 129 | } 130 | 131 | /** 132 | * @throws TemplateCanNotBeExpanded if the variables are invalid 133 | * @throws UriException if the resulting expansion cannot be converted to a UriInterface instance 134 | */ 135 | public function expand(iterable $variables = [], Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUri = null): UriInterface 136 | { 137 | $expanded = $this->templateExpanded($variables); 138 | 139 | return null === $baseUri ? Uri::new($expanded) : (Uri::parse($expanded, $baseUri) ?? throw new SyntaxError('Unable to expand URI')); 140 | } 141 | 142 | /** 143 | * @throws MissingFeature if no Uri\Rfc3986\Uri class is found 144 | * @throws TemplateCanNotBeExpanded if the variables are invalid 145 | * @throws InvalidUriException if the base URI cannot be converted to a Uri\Rfc3986\Uri instance 146 | * @throws InvalidUriException if the resulting expansion cannot be converted to a Uri\Rfc3986\Uri instance 147 | */ 148 | public function expandToUri(iterable $variables = [], Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUri = null): Rfc3986Uri 149 | { 150 | class_exists(Rfc3986Uri::class) || throw new MissingFeature('Support for '.Rfc3986Uri::class.' requires PHP8.5+ or a polyfill. Run "composer require league/uri-polyfill" or use you own polyfill.'); 151 | 152 | return new Rfc3986Uri($this->templateExpanded($variables), $this->newRfc3986Uri($baseUri)); 153 | } 154 | 155 | /** 156 | * @throws MissingFeature if no Uri\Whatwg\Url class is found 157 | * @throws TemplateCanNotBeExpanded if the variables are invalid 158 | * @throws InvalidUrlException if the base URI cannot be converted to a Uri\Whatwg\Url instance 159 | * @throws InvalidUrlException if the resulting expansion cannot be converted to a Uri\Whatwg\Url instance 160 | */ 161 | public function expandToUrl(iterable $variables = [], Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUrl = null, array|null &$errors = []): WhatWgUrl 162 | { 163 | class_exists(WhatWgUrl::class) || throw new MissingFeature('Support for '.WhatWgUrl::class.' requires PHP8.5+ or a polyfill. Run "composer require league/uri-polyfill" or use you own polyfill.'); 164 | 165 | return new WhatWgUrl($this->templateExpanded($variables), $this->newWhatWgUrl($baseUrl), $errors); 166 | } 167 | 168 | /** 169 | * @throws TemplateCanNotBeExpanded if the variables are invalid 170 | * @throws UriException if the resulting expansion cannot be converted to a UriInterface instance 171 | */ 172 | public function expandToPsr7Uri( 173 | iterable $variables = [], 174 | Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUrl = null, 175 | UriFactoryInterface $uriFactory = new HttpFactory() 176 | ): Psr7UriInterface { 177 | $uriString = $this->templateExpandedOrFail($variables); 178 | 179 | return $uriFactory->createUri( 180 | null === $baseUrl 181 | ? $uriString 182 | : UriString::resolve($uriString, match (true) { 183 | $baseUrl instanceof Rfc3986Uri => $baseUrl->toRawString(), 184 | $baseUrl instanceof WhatWgUrl => $baseUrl->toUnicodeString(), 185 | default => $baseUrl, 186 | }) 187 | ); 188 | } 189 | 190 | /** 191 | * @throws TemplateCanNotBeExpanded if the variables are invalid or missing 192 | * @throws UriException if the resulting expansion cannot be converted to a UriInterface instance 193 | */ 194 | public function expandOrFail(iterable $variables = [], Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUri = null): UriInterface 195 | { 196 | $expanded = $this->templateExpandedOrFail($variables); 197 | 198 | return null === $baseUri ? Uri::new($expanded) : (Uri::parse($expanded, $baseUri) ?? throw new SyntaxError('Unable to expand URI')); 199 | } 200 | 201 | /** 202 | * @throws MissingFeature if no Uri\Rfc3986\Uri class is found 203 | * @throws TemplateCanNotBeExpanded if the variables are invalid 204 | * @throws InvalidUriException if the base URI cannot be converted to a Uri\Rfc3986\Uri instance 205 | * @throws InvalidUriException if the resulting expansion cannot be converted to a Uri\Rfc3986\Uri instance 206 | */ 207 | public function expandToUriOrFail(iterable $variables = [], Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUri = null): Rfc3986Uri 208 | { 209 | class_exists(Rfc3986Uri::class) || throw new MissingFeature('Support for '.Rfc3986Uri::class.' requires PHP8.5+ or a polyfill. Run "composer require league/uri-polyfill" or use you own polyfill.'); 210 | 211 | return new Rfc3986Uri($this->templateExpandedOrFail($variables), $this->newRfc3986Uri($baseUri)); 212 | } 213 | 214 | /** 215 | * @throws MissingFeature if no Uri\Whatwg\Url class is found 216 | * @throws TemplateCanNotBeExpanded if the variables are invalid 217 | * @throws InvalidUrlException if the base URI cannot be converted to a Uri\Whatwg\Url instance 218 | * @throws InvalidUrlException if the resulting expansion cannot be converted to a Uri\Whatwg\Url instance 219 | */ 220 | public function expandToUrlOrFail(iterable $variables = [], Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUrl = null, array|null &$errors = []): WhatWgUrl 221 | { 222 | class_exists(WhatWgUrl::class) || throw new MissingFeature('Support for '.WhatWgUrl::class.' requires PHP8.5+ or a polyfill. Run "composer require league/uri-polyfill" or use you own polyfill.'); 223 | 224 | return new WhatWgUrl($this->templateExpandedOrFail($variables), $this->newWhatWgUrl($baseUrl), $errors); 225 | } 226 | 227 | /** 228 | * @throws TemplateCanNotBeExpanded if the variables are invalid 229 | * @throws UriException if the resulting expansion cannot be converted to a UriInterface instance 230 | */ 231 | public function expandToPsr7UriOrFail( 232 | iterable $variables = [], 233 | Rfc3986Uri|WhatWgUrl|Stringable|string|null $baseUrl = null, 234 | UriFactoryInterface $uriFactory = new HttpFactory() 235 | ): Psr7UriInterface { 236 | $uriString = $this->templateExpandedOrFail($variables); 237 | 238 | return $uriFactory->createUri( 239 | null === $baseUrl 240 | ? $uriString 241 | : UriString::resolve($uriString, match (true) { 242 | $baseUrl instanceof Rfc3986Uri => $baseUrl->toRawString(), 243 | $baseUrl instanceof WhatWgUrl => $baseUrl->toUnicodeString(), 244 | default => $baseUrl, 245 | }) 246 | ); 247 | } 248 | 249 | /** 250 | * @throws InvalidUrlException 251 | */ 252 | private function newWhatWgUrl(Rfc3986Uri|WhatWgUrl|Stringable|string|null $url = null): ?WhatWgUrl 253 | { 254 | return match (true) { 255 | null === $url => null, 256 | $url instanceof WhatWgUrl => $url, 257 | $url instanceof Rfc3986Uri => new WhatWgUrl($url->toRawString()), 258 | default => new WhatWgUrl((string) $url), 259 | }; 260 | } 261 | 262 | /** 263 | * @throws InvalidUriException 264 | */ 265 | private function newRfc3986Uri(Rfc3986Uri|WhatWgUrl|Stringable|string|null $uri = null): ?Rfc3986Uri 266 | { 267 | return match (true) { 268 | null === $uri => null, 269 | $uri instanceof Rfc3986Uri => $uri, 270 | $uri instanceof WhatWgUrl => new Rfc3986Uri($uri->toAsciiString()), 271 | default => new Rfc3986Uri((string) $uri), 272 | }; 273 | } 274 | 275 | /** 276 | * DEPRECATION WARNING! This method will be removed in the next major point release. 277 | * 278 | * @deprecated Since version 7.6.0 279 | * @codeCoverageIgnore 280 | * @see UriTemplate::toString() 281 | * 282 | * Create a new instance from the environment. 283 | */ 284 | #[Deprecated(message:'use League\Uri\UriTemplate::__toString() instead', since:'league/uri:7.6.0')] 285 | public function getTemplate(): string 286 | { 287 | return $this->__toString(); 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Http.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Deprecated; 17 | use JsonSerializable; 18 | use League\Uri\Contracts\Conditionable; 19 | use League\Uri\Contracts\UriException; 20 | use League\Uri\Contracts\UriInterface; 21 | use League\Uri\Exceptions\SyntaxError; 22 | use League\Uri\UriTemplate\TemplateCanNotBeExpanded; 23 | use Psr\Http\Message\UriInterface as Psr7UriInterface; 24 | use Stringable; 25 | use Uri\Rfc3986\Uri as Rfc3986Uri; 26 | use Uri\WhatWg\Url as WhatWgUrl; 27 | 28 | use function is_bool; 29 | use function ltrim; 30 | 31 | /** 32 | * @phpstan-import-type InputComponentMap from UriString 33 | */ 34 | final class Http implements Stringable, Psr7UriInterface, JsonSerializable, Conditionable 35 | { 36 | private readonly UriInterface $uri; 37 | 38 | private function __construct(UriInterface $uri) 39 | { 40 | if (null === $uri->getScheme() && '' === $uri->getHost()) { 41 | throw new SyntaxError('An URI without scheme cannot contain an empty host string according to PSR-7: '.$uri); 42 | } 43 | 44 | $port = $uri->getPort(); 45 | if (null !== $port && ($port < 0 || $port > 65535)) { 46 | throw new SyntaxError('The URI port is outside the established TCP and UDP port ranges: '.$uri); 47 | } 48 | 49 | $this->uri = $this->normalizePsr7Uri($uri); 50 | } 51 | 52 | /** 53 | * PSR-7 UriInterface makes the following normalization. 54 | * 55 | * Safely stringify input when possible for League UriInterface compatibility. 56 | * 57 | * Query, Fragment and User Info when undefined are normalized to the empty string 58 | */ 59 | private function normalizePsr7Uri(UriInterface $uri): UriInterface 60 | { 61 | $components = []; 62 | if ('' === $uri->getFragment()) { 63 | $components['fragment'] = null; 64 | } 65 | 66 | if ('' === $uri->getQuery()) { 67 | $components['query'] = null; 68 | } 69 | 70 | if ('' === $uri->getUserInfo()) { 71 | $components['user'] = null; 72 | $components['pass'] = null; 73 | } 74 | 75 | return match ($components) { 76 | [] => $uri, 77 | default => Uri::fromComponents([...$uri->toComponents(), ...$components]), 78 | }; 79 | } 80 | 81 | /** 82 | * Create a new instance from a string or a stringable object. 83 | */ 84 | public static function new(Rfc3986Uri|WhatwgUrl|Stringable|string $uri = ''): self 85 | { 86 | return new self(Uri::new($uri)); 87 | } 88 | 89 | /** 90 | * Create a new instance from a string or a stringable structure or returns null on failure. 91 | */ 92 | public static function tryNew(Rfc3986Uri|WhatwgUrl|Stringable|string $uri = ''): ?self 93 | { 94 | try { 95 | return self::new($uri); 96 | } catch (UriException) { 97 | return null; 98 | } 99 | } 100 | 101 | /** 102 | * Create a new instance from a hash of parse_url parts. 103 | * 104 | * @param InputComponentMap $components a hash representation of the URI similar 105 | * to PHP parse_url function result 106 | */ 107 | public static function fromComponents(array $components): self 108 | { 109 | $components += [ 110 | 'scheme' => null, 'user' => null, 'pass' => null, 'host' => null, 111 | 'port' => null, 'path' => '', 'query' => null, 'fragment' => null, 112 | ]; 113 | 114 | if ('' === $components['user']) { 115 | $components['user'] = null; 116 | } 117 | 118 | if ('' === $components['pass']) { 119 | $components['pass'] = null; 120 | } 121 | 122 | if ('' === $components['query']) { 123 | $components['query'] = null; 124 | } 125 | 126 | if ('' === $components['fragment']) { 127 | $components['fragment'] = null; 128 | } 129 | 130 | return new self(Uri::fromComponents($components)); 131 | } 132 | 133 | /** 134 | * Create a new instance from the environment. 135 | */ 136 | public static function fromServer(array $server): self 137 | { 138 | return new self(Uri::fromServer($server)); 139 | } 140 | 141 | /** 142 | * Creates a new instance from a template. 143 | * 144 | * @throws TemplateCanNotBeExpanded if the variables are invalid or missing 145 | * @throws UriException if the variables are invalid or missing 146 | */ 147 | public static function fromTemplate(Stringable|string $template, iterable $variables = []): self 148 | { 149 | return new self(Uri::fromTemplate($template, $variables)); 150 | } 151 | 152 | /** 153 | * Returns a new instance from a URI and a Base URI.or null on failure. 154 | * 155 | * The returned URI must be absolute if a base URI is provided 156 | */ 157 | public static function parse(WhatWgUrl|Rfc3986Uri|Stringable|string $uri, WhatWgUrl|Rfc3986Uri|Stringable|string|null $baseUri = null): ?self 158 | { 159 | return null !== ($uri = Uri::parse($uri, $baseUri)) ? new self($uri) : null; 160 | } 161 | 162 | public function getScheme(): string 163 | { 164 | return $this->uri->getScheme() ?? ''; 165 | } 166 | 167 | public function getAuthority(): string 168 | { 169 | return $this->uri->getAuthority() ?? ''; 170 | } 171 | 172 | public function getUserInfo(): string 173 | { 174 | return $this->uri->getUserInfo() ?? ''; 175 | } 176 | 177 | public function getHost(): string 178 | { 179 | return $this->uri->getHost() ?? ''; 180 | } 181 | 182 | public function getPort(): ?int 183 | { 184 | return $this->uri->getPort(); 185 | } 186 | 187 | public function getPath(): string 188 | { 189 | $path = $this->uri->getPath(); 190 | 191 | return match (true) { 192 | str_starts_with($path, '//') => '/'.ltrim($path, '/'), 193 | default => $path, 194 | }; 195 | } 196 | 197 | public function getQuery(): string 198 | { 199 | return $this->uri->getQuery() ?? ''; 200 | } 201 | 202 | public function getFragment(): string 203 | { 204 | return $this->uri->getFragment() ?? ''; 205 | } 206 | 207 | public function __toString(): string 208 | { 209 | return $this->uri->toString(); 210 | } 211 | 212 | public function jsonSerialize(): string 213 | { 214 | return $this->uri->toString(); 215 | } 216 | 217 | /** 218 | * Safely stringify input when possible for League UriInterface compatibility. 219 | */ 220 | private function filterInput(string $str): ?string 221 | { 222 | return match ('') { 223 | $str => null, 224 | default => $str, 225 | }; 226 | } 227 | 228 | private function newInstance(UriInterface $uri): self 229 | { 230 | return match ($this->uri->toString()) { 231 | $uri->toString() => $this, 232 | default => new self($uri), 233 | }; 234 | } 235 | 236 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static 237 | { 238 | if (!is_bool($condition)) { 239 | $condition = $condition($this); 240 | } 241 | 242 | return match (true) { 243 | $condition => $onSuccess($this), 244 | null !== $onFail => $onFail($this), 245 | default => $this, 246 | } ?? $this; 247 | } 248 | 249 | public function withScheme(string $scheme): self 250 | { 251 | return $this->newInstance($this->uri->withScheme($this->filterInput($scheme))); 252 | } 253 | 254 | public function withUserInfo(string $user, ?string $password = null): self 255 | { 256 | return $this->newInstance($this->uri->withUserInfo($this->filterInput($user), $password)); 257 | } 258 | 259 | public function withHost(string $host): self 260 | { 261 | return $this->newInstance($this->uri->withHost($this->filterInput($host))); 262 | } 263 | 264 | public function withPort(?int $port): self 265 | { 266 | return $this->newInstance($this->uri->withPort($port)); 267 | } 268 | 269 | public function withPath(string $path): self 270 | { 271 | return $this->newInstance($this->uri->withPath($path)); 272 | } 273 | 274 | public function withQuery(string $query): self 275 | { 276 | return $this->newInstance($this->uri->withQuery($this->filterInput($query))); 277 | } 278 | 279 | public function withFragment(string $fragment): self 280 | { 281 | return $this->newInstance($this->uri->withFragment($this->filterInput($fragment))); 282 | } 283 | 284 | /** 285 | * DEPRECATION WARNING! This method will be removed in the next major point release. 286 | * 287 | * @deprecated Since version 7.6.0 288 | * @codeCoverageIgnore 289 | * @see Http::parse() 290 | * 291 | * Create a new instance from a URI and a Base URI. 292 | * 293 | * The returned URI must be absolute. 294 | */ 295 | #[Deprecated(message:'use League\Uri\Http::parse() instead', since:'league/uri:7.6.0')] 296 | public static function fromBaseUri(Rfc3986Uri|WhatwgUrl|Stringable|string $uri, Rfc3986Uri|WhatwgUrl|Stringable|string|null $baseUri = null): self 297 | { 298 | return new self(Uri::fromBaseUri($uri, $baseUri)); 299 | } 300 | 301 | /** 302 | * DEPRECATION WARNING! This method will be removed in the next major point release. 303 | * 304 | * @deprecated Since version 7.0.0 305 | * @codeCoverageIgnore 306 | * @see Http::new() 307 | * 308 | * Create a new instance from a string. 309 | */ 310 | #[Deprecated(message:'use League\Uri\Http::new() instead', since:'league/uri:7.0.0')] 311 | public static function createFromString(Stringable|string $uri = ''): self 312 | { 313 | return self::new($uri); 314 | } 315 | 316 | /** 317 | * DEPRECATION WARNING! This method will be removed in the next major point release. 318 | * 319 | * @deprecated Since version 7.0.0 320 | * @codeCoverageIgnore 321 | * @see Http::fromComponents() 322 | * 323 | * Create a new instance from a hash of parse_url parts. 324 | * 325 | * @param InputComponentMap $components a hash representation of the URI similar 326 | * to PHP parse_url function result 327 | */ 328 | #[Deprecated(message:'use League\Uri\Http::fromComponents() instead', since:'league/uri:7.0.0')] 329 | public static function createFromComponents(array $components): self 330 | { 331 | return self::fromComponents($components); 332 | } 333 | 334 | /** 335 | * DEPRECATION WARNING! This method will be removed in the next major point release. 336 | * 337 | * @deprecated Since version 7.0.0 338 | * @codeCoverageIgnore 339 | * @see Http::fromServer() 340 | * 341 | * Create a new instance from the environment. 342 | */ 343 | #[Deprecated(message:'use League\Uri\Http::fromServer() instead', since:'league/uri:7.0.0')] 344 | public static function createFromServer(array $server): self 345 | { 346 | return self::fromServer($server); 347 | } 348 | 349 | /** 350 | * DEPRECATION WARNING! This method will be removed in the next major point release. 351 | * 352 | * @deprecated Since version 7.0.0 353 | * @codeCoverageIgnore 354 | * @see Http::new() 355 | * 356 | * Create a new instance from a URI object. 357 | */ 358 | #[Deprecated(message:'use League\Uri\Http::new() instead', since:'league/uri:7.0.0')] 359 | public static function createFromUri(Psr7UriInterface|UriInterface $uri): self 360 | { 361 | return self::new($uri); 362 | } 363 | 364 | /** 365 | * DEPRECATION WARNING! This method will be removed in the next major point release. 366 | * 367 | * @deprecated Since version 7.0.0 368 | * @codeCoverageIgnore 369 | * @see Http::fromBaseUri() 370 | * 371 | * Create a new instance from a URI and a Base URI. 372 | * 373 | * The returned URI must be absolute. 374 | */ 375 | #[Deprecated(message:'use League\Uri\Http::fromBaseUri() instead', since:'league/uri:7.0.0')] 376 | public static function createFromBaseUri(Stringable|string $uri, Stringable|string|null $baseUri = null): self 377 | { 378 | return self::fromBaseUri($uri, $baseUri); 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /Urn.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Closure; 17 | use JsonSerializable; 18 | use League\Uri\Contracts\Conditionable; 19 | use League\Uri\Contracts\UriComponentInterface; 20 | use League\Uri\Contracts\UriInterface; 21 | use League\Uri\Exceptions\SyntaxError; 22 | use League\Uri\UriTemplate\Template; 23 | use Stringable; 24 | use Uri\Rfc3986\Uri as Rfc3986Uri; 25 | use Uri\WhatWg\Url as WhatWgUrl; 26 | 27 | use function is_bool; 28 | use function preg_match; 29 | use function str_replace; 30 | use function strtolower; 31 | 32 | /** 33 | * @phpstan-type UrnSerialize array{0: array{urn: non-empty-string}, 1: array{}} 34 | * @phpstan-import-type InputComponentMap from UriString 35 | * @phpstan-type UrnMap array{ 36 | * scheme: 'urn', 37 | * nid: string, 38 | * nss: string, 39 | * r_component: ?string, 40 | * q_component: ?string, 41 | * f_component: ?string, 42 | * } 43 | */ 44 | final class Urn implements Conditionable, Stringable, JsonSerializable 45 | { 46 | /** 47 | * RFC8141 regular expression URN splitter. 48 | * 49 | * The regexp does not perform any look-ahead. 50 | * Not all invalid URN are caught. Some 51 | * post-regexp-validation checks 52 | * are mandatory. 53 | * 54 | * @link https://datatracker.ietf.org/doc/html/rfc8141#section-2 55 | * 56 | * @var string 57 | */ 58 | private const REGEXP_URN_PARTS = '/^ 59 | urn: 60 | (?[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?): # NID 61 | (?.*?) # NSS 62 | (?\?\+(?.*?))? # r-component 63 | (?\?\=(?.*?))? # q-component 64 | (?:\#(?.*))? # f-component 65 | $/xi'; 66 | 67 | /** 68 | * RFC8141 namespace identifier regular expression. 69 | * 70 | * @link https://datatracker.ietf.org/doc/html/rfc8141#section-2 71 | * 72 | * @var string 73 | */ 74 | private const REGEX_NID_SEQUENCE = '/^[a-z0-9]([a-z0-9-]{0,30})[a-z0-9]$/xi'; 75 | 76 | /** @var non-empty-string */ 77 | private readonly string $uriString; 78 | /** @var non-empty-string */ 79 | private readonly string $nid; 80 | /** @var non-empty-string */ 81 | private readonly string $nss; 82 | /** @var non-empty-string|null */ 83 | private readonly ?string $rComponent; 84 | /** @var non-empty-string|null */ 85 | private readonly ?string $qComponent; 86 | /** @var non-empty-string|null */ 87 | private readonly ?string $fComponent; 88 | 89 | /** 90 | * @param Rfc3986Uri|WhatWgUrl|Stringable|string $urn the percent-encoded URN 91 | */ 92 | public static function parse(Rfc3986Uri|WhatWgUrl|Stringable|string $urn): ?Urn 93 | { 94 | try { 95 | return self::fromString($urn); 96 | } catch (SyntaxError) { 97 | return null; 98 | } 99 | } 100 | 101 | /** 102 | * @param Rfc3986Uri|WhatWgUrl|Stringable|string $urn the percent-encoded URN 103 | * @see self::fromString() 104 | * 105 | * @throws SyntaxError if the URN is invalid 106 | */ 107 | public static function new(Rfc3986Uri|WhatWgUrl|Stringable|string $urn): self 108 | { 109 | return self::fromString($urn); 110 | } 111 | 112 | /** 113 | * @param Rfc3986Uri|WhatWgUrl|Stringable|string $urn the percent-encoded URN 114 | * 115 | * @throws SyntaxError if the URN is invalid 116 | */ 117 | public static function fromString(Rfc3986Uri|WhatWgUrl|Stringable|string $urn): self 118 | { 119 | $urn = match (true) { 120 | $urn instanceof Rfc3986Uri => $urn->toRawString(), 121 | $urn instanceof WhatWgUrl => $urn->toAsciiString(), 122 | default => (string) $urn, 123 | }; 124 | 125 | UriString::containsRfc3986Chars($urn) || throw new SyntaxError('The URN is malformed, it contains invalid characters.'); 126 | 1 === preg_match(self::REGEXP_URN_PARTS, $urn, $matches) || throw new SyntaxError('The URN string is invalid.'); 127 | 128 | return new self( 129 | nid: $matches['nid'], 130 | nss: $matches['nss'], 131 | rComponent: (isset($matches['frc']) && '' !== $matches['frc']) ? $matches['rcomponent'] : null, 132 | qComponent: (isset($matches['fqc']) && '' !== $matches['fqc']) ? $matches['qcomponent'] : null, 133 | fComponent: $matches['fcomponent'] ?? null, 134 | ); 135 | } 136 | 137 | /** 138 | * Create a new instance from a hash representation of the URI similar 139 | * to PHP parse_url function result. 140 | * 141 | * @param InputComponentMap $components a hash representation of the URI similar to PHP parse_url function result 142 | */ 143 | public static function fromComponents(array $components = []): self 144 | { 145 | $components += [ 146 | 'scheme' => null, 'user' => null, 'pass' => null, 'host' => null, 147 | 'port' => null, 'path' => '', 'query' => null, 'fragment' => null, 148 | ]; 149 | 150 | return self::fromString(UriString::build($components)); 151 | } 152 | 153 | /** 154 | * @param Stringable|string $nss the percent-encoded NSS 155 | * 156 | * @throws SyntaxError if the URN is invalid 157 | */ 158 | public static function fromRfc2141(Stringable|string $nid, Stringable|string $nss): self 159 | { 160 | return new self((string) $nid, (string) $nss); 161 | } 162 | 163 | /** 164 | * @param string $nss the percent-encoded NSS 165 | * @param ?string $rComponent the percent-encoded r-component 166 | * @param ?string $qComponent the percent-encoded q-component 167 | * @param ?string $fComponent the percent-encoded f-component 168 | * 169 | * @throws SyntaxError if one of the URN part is invalid 170 | */ 171 | private function __construct( 172 | string $nid, 173 | string $nss, 174 | ?string $rComponent = null, 175 | ?string $qComponent = null, 176 | ?string $fComponent = null, 177 | ) { 178 | ('' !== $nid && 1 === preg_match(self::REGEX_NID_SEQUENCE, $nid)) || throw new SyntaxError('The URN is malformed, the NID is invalid.'); 179 | ('' !== $nss && Encoder::isPathEncoded($nss)) || throw new SyntaxError('The URN is malformed, the NSS is invalid.'); 180 | 181 | /** @param Closure(string): ?non-empty-string $closure */ 182 | $validateComponent = static fn (?string $value, Closure $closure, string $name): ?string => match (true) { 183 | null === $value, 184 | ('' !== $value && 1 !== preg_match('/[#?]/', $value) && $closure($value)) => $value, 185 | default => throw new SyntaxError('The URN is malformed, the `'.$name.'` component is invalid.'), 186 | }; 187 | 188 | $this->nid = $nid; 189 | $this->nss = $nss; 190 | $this->rComponent = $validateComponent($rComponent, Encoder::isPathEncoded(...), 'r-component'); 191 | $this->qComponent = $validateComponent($qComponent, Encoder::isQueryEncoded(...), 'q-component'); 192 | $this->fComponent = $validateComponent($fComponent, Encoder::isFragmentEncoded(...), 'f-component'); 193 | $this->uriString = $this->setUriString(); 194 | } 195 | 196 | /** 197 | * @return non-empty-string 198 | */ 199 | private function setUriString(): string 200 | { 201 | $str = $this->toRfc2141(); 202 | if (null !== $this->rComponent) { 203 | $str .= '?+'.$this->rComponent; 204 | } 205 | 206 | if (null !== $this->qComponent) { 207 | $str .= '?='.$this->qComponent; 208 | } 209 | 210 | if (null !== $this->fComponent) { 211 | $str .= '#'.$this->fComponent; 212 | } 213 | 214 | return $str; 215 | } 216 | 217 | /** 218 | * Returns the NID. 219 | * 220 | * @return non-empty-string 221 | */ 222 | public function getNid(): string 223 | { 224 | return $this->nid; 225 | } 226 | 227 | /** 228 | * Returns the percent-encoded NSS. 229 | * 230 | * @return non-empty-string 231 | */ 232 | public function getNss(): string 233 | { 234 | return $this->nss; 235 | } 236 | 237 | /** 238 | * Returns the percent-encoded r-component string or null if it is not set. 239 | * 240 | * @return ?non-empty-string 241 | */ 242 | public function getRComponent(): ?string 243 | { 244 | return $this->rComponent; 245 | } 246 | 247 | /** 248 | * Returns the percent-encoded q-component string or null if it is not set. 249 | * 250 | * @return ?non-empty-string 251 | */ 252 | public function getQComponent(): ?string 253 | { 254 | return $this->qComponent; 255 | } 256 | 257 | /** 258 | * Returns the percent-encoded f-component string or null if it is not set. 259 | * 260 | * @return ?non-empty-string 261 | */ 262 | public function getFComponent(): ?string 263 | { 264 | return $this->fComponent; 265 | } 266 | 267 | /** 268 | * Returns the RFC8141 URN string representation. 269 | * 270 | * @return non-empty-string 271 | */ 272 | public function toString(): string 273 | { 274 | return $this->uriString; 275 | } 276 | 277 | /** 278 | * Returns the RFC2141 URN string representation. 279 | * 280 | * @return non-empty-string 281 | */ 282 | public function toRfc2141(): string 283 | { 284 | return 'urn:'.$this->nid.':'.$this->nss; 285 | } 286 | 287 | /** 288 | * Returns the human-readable string representation of the URN as an IRI. 289 | * 290 | * @see https://datatracker.ietf.org/doc/html/rfc3987 291 | */ 292 | public function toDisplayString(): string 293 | { 294 | return UriString::toIriString($this->uriString); 295 | } 296 | 297 | /** 298 | * Returns the RFC8141 URN string representation. 299 | * 300 | * @see self::toString() 301 | * 302 | * @return non-empty-string 303 | */ 304 | public function __toString(): string 305 | { 306 | return $this->toString(); 307 | } 308 | 309 | /** 310 | * Returns the RFC8141 URN string representation. 311 | * @see self::toString() 312 | * 313 | * @return non-empty-string 314 | */ 315 | public function jsonSerialize(): string 316 | { 317 | return $this->toString(); 318 | } 319 | 320 | /** 321 | * Returns the RFC3986 representation of the current URN. 322 | * 323 | * If a template URI is used the following variables as present 324 | * {nid} for the namespace identifier 325 | * {nss} for the namespace specific string 326 | * {r_component} for the r-component without its delimiter 327 | * {q_component} for the q-component without its delimiter 328 | * {f_component} for the f-component without its delimiter 329 | */ 330 | public function resolve(UriTemplate|Template|string|null $template = null): UriInterface 331 | { 332 | return null !== $template ? Uri::fromTemplate($template, $this->toComponents()) : Uri::new($this->uriString); 333 | } 334 | 335 | public function hasRComponent(): bool 336 | { 337 | return null !== $this->rComponent; 338 | } 339 | 340 | public function hasQComponent(): bool 341 | { 342 | return null !== $this->qComponent; 343 | } 344 | 345 | public function hasFComponent(): bool 346 | { 347 | return null !== $this->fComponent; 348 | } 349 | 350 | public function hasOptionalComponent(): bool 351 | { 352 | return null !== $this->rComponent 353 | || null !== $this->qComponent 354 | || null !== $this->fComponent; 355 | } 356 | 357 | /** 358 | * Return an instance with the specified NID. 359 | * 360 | * This method MUST retain the state of the current instance, and return 361 | * an instance that contains the specified NID. 362 | * 363 | * @throws SyntaxError for invalid component or transformations 364 | * that would result in an object in invalid state. 365 | */ 366 | public function withNid(Stringable|string $nid): self 367 | { 368 | $nid = (string) $nid; 369 | 370 | return $this->nid === $nid ? $this : new self( 371 | nid: $nid, 372 | nss: $this->nss, 373 | rComponent: $this->rComponent, 374 | qComponent: $this->qComponent, 375 | fComponent: $this->fComponent, 376 | ); 377 | } 378 | 379 | /** 380 | * Return an instance with the specified NSS. 381 | * 382 | * This method MUST retain the state of the current instance, and return 383 | * an instance that contains the specified NSS. 384 | * 385 | * @throws SyntaxError for invalid component or transformations 386 | * that would result in an object in invalid state. 387 | */ 388 | public function withNss(Stringable|string $nss): self 389 | { 390 | $nss = Encoder::encodePath($nss); 391 | 392 | return $this->nss === $nss ? $this : new self( 393 | nid: $this->nid, 394 | nss: $nss, 395 | rComponent: $this->rComponent, 396 | qComponent: $this->qComponent, 397 | fComponent: $this->fComponent, 398 | ); 399 | } 400 | 401 | /** 402 | * Return an instance with the specified r-component. 403 | * 404 | * This method MUST retain the state of the current instance, and return 405 | * an instance that contains the specified r-component. 406 | * 407 | * The component is removed if the value is null. 408 | * 409 | * @throws SyntaxError for invalid component or transformations 410 | * that would result in an object in invalid state. 411 | */ 412 | public function withRComponent(Stringable|string|null $component): self 413 | { 414 | if ($component instanceof UriComponentInterface) { 415 | $component = $component->value(); 416 | } 417 | 418 | if (null !== $component) { 419 | $component = self::formatComponent(Encoder::encodePath($component)); 420 | } 421 | 422 | return $this->rComponent === $component ? $this : new self( 423 | nid: $this->nid, 424 | nss: $this->nss, 425 | rComponent: $component, 426 | qComponent: $this->qComponent, 427 | fComponent: $this->fComponent, 428 | ); 429 | } 430 | 431 | private static function formatComponent(?string $component): ?string 432 | { 433 | return null === $component ? null : str_replace(['?', '#'], ['%3F', '%23'], $component); 434 | } 435 | 436 | /** 437 | * Return an instance with the specified q-component. 438 | * 439 | * This method MUST retain the state of the current instance, and return 440 | * an instance that contains the specified q-component. 441 | * 442 | * The component is removed if the value is null. 443 | * 444 | * @throws SyntaxError for invalid component or transformations 445 | * that would result in an object in invalid state. 446 | */ 447 | public function withQComponent(Stringable|string|null $component): self 448 | { 449 | if ($component instanceof UriComponentInterface) { 450 | $component = $component->value(); 451 | } 452 | 453 | $component = self::formatComponent(Encoder::encodeQueryOrFragment($component)); 454 | 455 | return $this->qComponent === $component ? $this : new self( 456 | nid: $this->nid, 457 | nss: $this->nss, 458 | rComponent: $this->rComponent, 459 | qComponent: $component, 460 | fComponent: $this->fComponent, 461 | ); 462 | } 463 | 464 | /** 465 | * Return an instance with the specified f-component. 466 | * 467 | * This method MUST retain the state of the current instance, and return 468 | * an instance that contains the specified f-component. 469 | * 470 | * The component is removed if the value is null. 471 | * 472 | * @throws SyntaxError for invalid component or transformations 473 | * that would result in an object in invalid state. 474 | */ 475 | public function withFComponent(Stringable|string|null $component): self 476 | { 477 | if ($component instanceof UriComponentInterface) { 478 | $component = $component->value(); 479 | } 480 | 481 | $component = self::formatComponent(Encoder::encodeQueryOrFragment($component)); 482 | 483 | return $this->fComponent === $component ? $this : new self( 484 | nid: $this->nid, 485 | nss: $this->nss, 486 | rComponent: $this->rComponent, 487 | qComponent: $this->qComponent, 488 | fComponent: $component, 489 | ); 490 | } 491 | 492 | public function normalize(): self 493 | { 494 | $copy = new self( 495 | nid: strtolower($this->nid), 496 | nss: (string) Encoder::normalizePath($this->nss), 497 | rComponent: null === $this->rComponent ? $this->rComponent : Encoder::normalizePath($this->rComponent), 498 | qComponent: Encoder::normalizeQuery($this->qComponent), 499 | fComponent: Encoder::normalizeFragment($this->fComponent), 500 | ); 501 | 502 | return $copy->uriString === $this->uriString ? $this : $copy; 503 | } 504 | 505 | public function equals(Urn|Rfc3986Uri|WhatWgUrl|Stringable|string $other, UrnComparisonMode $urnComparisonMode = UrnComparisonMode::ExcludeComponents): bool 506 | { 507 | if (!$other instanceof Urn) { 508 | $other = self::parse($other); 509 | } 510 | 511 | return (null !== $other) && match ($urnComparisonMode) { 512 | UrnComparisonMode::ExcludeComponents => $other->normalize()->toRfc2141() === $this->normalize()->toRfc2141(), 513 | UrnComparisonMode::IncludeComponents => $other->normalize()->toString() === $this->normalize()->toString(), 514 | }; 515 | } 516 | 517 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static 518 | { 519 | if (!is_bool($condition)) { 520 | $condition = $condition($this); 521 | } 522 | 523 | return match (true) { 524 | $condition => $onSuccess($this), 525 | null !== $onFail => $onFail($this), 526 | default => $this, 527 | } ?? $this; 528 | } 529 | 530 | /** 531 | * @return UrnSerialize 532 | */ 533 | public function __serialize(): array 534 | { 535 | return [['urn' => $this->toString()], []]; 536 | } 537 | 538 | /** 539 | * @param UrnSerialize $data 540 | * 541 | * @throws SyntaxError 542 | */ 543 | public function __unserialize(array $data): void 544 | { 545 | [$properties] = $data; 546 | $uri = self::fromString($properties['urn'] ?? throw new SyntaxError('The `urn` property is missing from the serialized object.')); 547 | 548 | $this->nid = $uri->nid; 549 | $this->nss = $uri->nss; 550 | $this->rComponent = $uri->rComponent; 551 | $this->qComponent = $uri->qComponent; 552 | $this->fComponent = $uri->fComponent; 553 | $this->uriString = $uri->uriString; 554 | } 555 | 556 | /** 557 | * @return UrnMap 558 | */ 559 | public function toComponents(): array 560 | { 561 | return [ 562 | 'scheme' => 'urn', 563 | 'nid' => $this->nid, 564 | 'nss' => $this->nss, 565 | 'r_component' => $this->rComponent, 566 | 'q_component' => $this->qComponent, 567 | 'f_component' => $this->fComponent, 568 | ]; 569 | } 570 | 571 | /** 572 | * @return UrnMap 573 | */ 574 | public function __debugInfo(): array 575 | { 576 | return $this->toComponents(); 577 | } 578 | } 579 | -------------------------------------------------------------------------------- /BaseUri.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Deprecated; 17 | use JsonSerializable; 18 | use League\Uri\Contracts\UriAccess; 19 | use League\Uri\Contracts\UriInterface; 20 | use League\Uri\Exceptions\MissingFeature; 21 | use League\Uri\Idna\Converter as IdnaConverter; 22 | use League\Uri\IPv4\Converter as IPv4Converter; 23 | use League\Uri\IPv6\Converter as IPv6Converter; 24 | use Psr\Http\Message\UriFactoryInterface; 25 | use Psr\Http\Message\UriInterface as Psr7UriInterface; 26 | use Stringable; 27 | 28 | use function array_pop; 29 | use function array_reduce; 30 | use function count; 31 | use function explode; 32 | use function implode; 33 | use function in_array; 34 | use function preg_match; 35 | use function rawurldecode; 36 | use function sort; 37 | use function str_contains; 38 | use function str_repeat; 39 | use function str_replace; 40 | use function strpos; 41 | use function substr; 42 | 43 | /** 44 | * @phpstan-import-type ComponentMap from UriInterface 45 | * @deprecated since version 7.6.0 46 | * 47 | * @see Modifier 48 | * @see Uri 49 | */ 50 | class BaseUri implements Stringable, JsonSerializable, UriAccess 51 | { 52 | /** @var array */ 53 | final protected const WHATWG_SPECIAL_SCHEMES = ['ftp' => 1, 'http' => 1, 'https' => 1, 'ws' => 1, 'wss' => 1]; 54 | 55 | /** @var array */ 56 | final protected const DOT_SEGMENTS = ['.' => 1, '..' => 1]; 57 | 58 | protected readonly Psr7UriInterface|UriInterface|null $origin; 59 | protected readonly ?string $nullValue; 60 | 61 | /** 62 | * @param UriFactoryInterface|null $uriFactory Deprecated, will be removed in the next major release 63 | */ 64 | final protected function __construct( 65 | protected readonly Psr7UriInterface|UriInterface $uri, 66 | protected readonly ?UriFactoryInterface $uriFactory 67 | ) { 68 | $this->nullValue = $this->uri instanceof Psr7UriInterface ? '' : null; 69 | $this->origin = $this->computeOrigin($this->uri, $this->nullValue); 70 | } 71 | 72 | public static function from(Stringable|string $uri, ?UriFactoryInterface $uriFactory = null): static 73 | { 74 | $uri = static::formatHost(static::filterUri($uri, $uriFactory)); 75 | return new static($uri, $uriFactory); 76 | } 77 | 78 | public function withUriFactory(UriFactoryInterface $uriFactory): static 79 | { 80 | return new static($this->uri, $uriFactory); 81 | } 82 | 83 | public function withoutUriFactory(): static 84 | { 85 | return new static($this->uri, null); 86 | } 87 | 88 | public function getUri(): Psr7UriInterface|UriInterface 89 | { 90 | return $this->uri; 91 | } 92 | 93 | public function getUriString(): string 94 | { 95 | return $this->uri->__toString(); 96 | } 97 | 98 | public function jsonSerialize(): string 99 | { 100 | return $this->uri->__toString(); 101 | } 102 | 103 | public function __toString(): string 104 | { 105 | return $this->uri->__toString(); 106 | } 107 | 108 | public function origin(): ?self 109 | { 110 | return match (null) { 111 | $this->origin => null, 112 | default => new self($this->origin, $this->uriFactory), 113 | }; 114 | } 115 | 116 | /** 117 | * Returns the Unix filesystem path. 118 | * 119 | * The method will return null if a scheme is present and is not the `file` scheme 120 | */ 121 | public function unixPath(): ?string 122 | { 123 | return match ($this->uri->getScheme()) { 124 | 'file', $this->nullValue => rawurldecode($this->uri->getPath()), 125 | default => null, 126 | }; 127 | } 128 | 129 | /** 130 | * Returns the Windows filesystem path. 131 | * 132 | * The method will return null if a scheme is present and is not the `file` scheme 133 | */ 134 | public function windowsPath(): ?string 135 | { 136 | static $regexpWindowsPath = ',^(?[a-zA-Z]:),'; 137 | 138 | if (!in_array($this->uri->getScheme(), ['file', $this->nullValue], true)) { 139 | return null; 140 | } 141 | 142 | $originalPath = $this->uri->getPath(); 143 | $path = $originalPath; 144 | if ('/' === ($path[0] ?? '')) { 145 | $path = substr($path, 1); 146 | } 147 | 148 | if (1 === preg_match($regexpWindowsPath, $path, $matches)) { 149 | $root = $matches['root']; 150 | $path = substr($path, strlen($root)); 151 | 152 | return $root.str_replace('/', '\\', rawurldecode($path)); 153 | } 154 | 155 | $host = $this->uri->getHost(); 156 | 157 | return match ($this->nullValue) { 158 | $host => str_replace('/', '\\', rawurldecode($originalPath)), 159 | default => '\\\\'.$host.'\\'.str_replace('/', '\\', rawurldecode($path)), 160 | }; 161 | } 162 | 163 | /** 164 | * Returns a string representation of a File URI according to RFC8089. 165 | * 166 | * The method will return null if the URI scheme is not the `file` scheme 167 | */ 168 | public function toRfc8089(): ?string 169 | { 170 | $path = $this->uri->getPath(); 171 | 172 | return match (true) { 173 | 'file' !== $this->uri->getScheme() => null, 174 | in_array($this->uri->getAuthority(), ['', null, 'localhost'], true) => 'file:'.match (true) { 175 | '' === $path, 176 | '/' === $path[0] => $path, 177 | default => '/'.$path, 178 | }, 179 | default => (string) $this->uri, 180 | }; 181 | } 182 | 183 | /** 184 | * Tells whether the `file` scheme base URI represents a local file. 185 | */ 186 | public function isLocalFile(): bool 187 | { 188 | return match (true) { 189 | 'file' !== $this->uri->getScheme() => false, 190 | in_array($this->uri->getAuthority(), ['', null, 'localhost'], true) => true, 191 | default => false, 192 | }; 193 | } 194 | 195 | /** 196 | * Tells whether the URI is opaque or not. 197 | * 198 | * A URI is opaque if and only if it is absolute 199 | * and does not have an authority path. 200 | */ 201 | public function isOpaque(): bool 202 | { 203 | return $this->nullValue === $this->uri->getAuthority() 204 | && $this->isAbsolute(); 205 | } 206 | 207 | /** 208 | * Tells whether two URI do not share the same origin. 209 | */ 210 | public function isCrossOrigin(Stringable|string $uri): bool 211 | { 212 | if (null === $this->origin) { 213 | return true; 214 | } 215 | 216 | $uri = static::filterUri($uri); 217 | $uriOrigin = $this->computeOrigin($uri, $uri instanceof Psr7UriInterface ? '' : null); 218 | 219 | return match(true) { 220 | null === $uriOrigin, 221 | $uriOrigin->__toString() !== $this->origin->__toString() => true, 222 | default => false, 223 | }; 224 | } 225 | 226 | /** 227 | * Tells whether the URI is absolute. 228 | */ 229 | public function isAbsolute(): bool 230 | { 231 | return $this->nullValue !== $this->uri->getScheme(); 232 | } 233 | 234 | /** 235 | * Tells whether the URI is a network path. 236 | */ 237 | public function isNetworkPath(): bool 238 | { 239 | return $this->nullValue === $this->uri->getScheme() 240 | && $this->nullValue !== $this->uri->getAuthority(); 241 | } 242 | 243 | /** 244 | * Tells whether the URI is an absolute path. 245 | */ 246 | public function isAbsolutePath(): bool 247 | { 248 | return $this->nullValue === $this->uri->getScheme() 249 | && $this->nullValue === $this->uri->getAuthority() 250 | && '/' === ($this->uri->getPath()[0] ?? ''); 251 | } 252 | 253 | /** 254 | * Tells whether the URI is a relative path. 255 | */ 256 | public function isRelativePath(): bool 257 | { 258 | return $this->nullValue === $this->uri->getScheme() 259 | && $this->nullValue === $this->uri->getAuthority() 260 | && '/' !== ($this->uri->getPath()[0] ?? ''); 261 | } 262 | 263 | /** 264 | * Tells whether both URI refers to the same document. 265 | */ 266 | public function isSameDocument(Stringable|string $uri): bool 267 | { 268 | return self::normalizedUri($this->uri)->equals(self::normalizedUri($uri)); 269 | } 270 | 271 | private static function normalizedUri(Stringable|string $uri): Uri 272 | { 273 | // Normalize the URI according to RFC3986 274 | $uri = ($uri instanceof Uri ? $uri : Uri::new($uri))->normalize(); 275 | 276 | return $uri 277 | //Normalization as per WHATWG URL standard 278 | //only meaningful for WHATWG Special URI scheme protocol 279 | ->when( 280 | condition: '' === $uri->getPath() && null !== $uri->getAuthority(), 281 | onSuccess: fn (Uri $uri) => $uri->withPath('/'), 282 | ) 283 | //Sorting as per WHATWG URLSearchParams class 284 | //not included on any equivalence algorithm 285 | ->when( 286 | condition: null !== ($query = $uri->getQuery()) && str_contains($query, '&'), 287 | onSuccess: function (Uri $uri) use ($query) { 288 | $pairs = explode('&', (string) $query); 289 | sort($pairs); 290 | 291 | return $uri->withQuery(implode('&', $pairs)); 292 | } 293 | ); 294 | } 295 | 296 | /** 297 | * Tells whether the URI contains an Internationalized Domain Name (IDN). 298 | */ 299 | public function hasIdn(): bool 300 | { 301 | return IdnaConverter::isIdn($this->uri->getHost()); 302 | } 303 | 304 | /** 305 | * Tells whether the URI contains an IPv4 regardless if it is mapped or native. 306 | */ 307 | public function hasIPv4(): bool 308 | { 309 | return IPv4Converter::fromEnvironment()->isIpv4($this->uri->getHost()); 310 | } 311 | 312 | /** 313 | * Resolves a URI against a base URI using RFC3986 rules. 314 | * 315 | * This method MUST retain the state of the submitted URI instance, and return 316 | * a URI instance of the same type that contains the applied modifications. 317 | * 318 | * This method MUST be transparent when dealing with error and exceptions. 319 | * It MUST not alter or silence them apart from validating its own parameters. 320 | */ 321 | public function resolve(Stringable|string $uri): static 322 | { 323 | $resolved = UriString::resolve($uri, $this->uri->__toString()); 324 | 325 | return new static(match ($this->uriFactory) { 326 | null => Uri::new($resolved), 327 | default => $this->uriFactory->createUri($resolved), 328 | }, $this->uriFactory); 329 | } 330 | 331 | /** 332 | * Relativize a URI according to a base URI. 333 | * 334 | * This method MUST retain the state of the submitted URI instance, and return 335 | * a URI instance of the same type that contains the applied modifications. 336 | * 337 | * This method MUST be transparent when dealing with error and exceptions. 338 | * It MUST not alter of silence them apart from validating its own parameters. 339 | */ 340 | public function relativize(Stringable|string $uri): static 341 | { 342 | $uri = static::formatHost(static::filterUri($uri, $this->uriFactory)); 343 | if ($this->canNotBeRelativize($uri)) { 344 | return new static($uri, $this->uriFactory); 345 | } 346 | 347 | $null = $uri instanceof Psr7UriInterface ? '' : null; 348 | $uri = $uri->withScheme($null)->withPort(null)->withUserInfo($null)->withHost($null); 349 | $targetPath = $uri->getPath(); 350 | $basePath = $this->uri->getPath(); 351 | 352 | return new static( 353 | match (true) { 354 | $targetPath !== $basePath => $uri->withPath(static::relativizePath($targetPath, $basePath)), 355 | static::componentEquals('query', $uri) => $uri->withPath('')->withQuery($null), 356 | $null === $uri->getQuery() => $uri->withPath(static::formatPathWithEmptyBaseQuery($targetPath)), 357 | default => $uri->withPath(''), 358 | }, 359 | $this->uriFactory 360 | ); 361 | } 362 | 363 | final protected function computeOrigin(Psr7UriInterface|UriInterface $uri, ?string $nullValue): Psr7UriInterface|UriInterface|null 364 | { 365 | if ($uri instanceof Uri) { 366 | $origin = $uri->getOrigin(); 367 | if (null === $origin) { 368 | return null; 369 | } 370 | 371 | return Uri::tryNew($origin); 372 | } 373 | 374 | $origin = Uri::tryNew($uri)?->getOrigin(); 375 | if (null === $origin) { 376 | return null; 377 | } 378 | 379 | $components = UriString::parse($origin); 380 | 381 | return $uri 382 | ->withFragment($nullValue) 383 | ->withQuery($nullValue) 384 | ->withPath('') 385 | ->withScheme('localhost') 386 | ->withHost((string) $components['host']) 387 | ->withPort($components['port']) 388 | ->withScheme((string) $components['scheme']) 389 | ->withUserInfo($nullValue); 390 | } 391 | 392 | /** 393 | * Input URI normalization to allow Stringable and string URI. 394 | */ 395 | final protected static function filterUri(Stringable|string $uri, UriFactoryInterface|null $uriFactory = null): Psr7UriInterface|UriInterface 396 | { 397 | return match (true) { 398 | $uri instanceof UriAccess => $uri->getUri(), 399 | $uri instanceof Psr7UriInterface, 400 | $uri instanceof UriInterface => $uri, 401 | $uriFactory instanceof UriFactoryInterface => $uriFactory->createUri((string) $uri), 402 | default => Uri::new($uri), 403 | }; 404 | } 405 | 406 | /** 407 | * Tells whether the component value from both URI object equals. 408 | * 409 | * @pqram 'query'|'authority'|'scheme' $property 410 | */ 411 | final protected function componentEquals(string $property, Psr7UriInterface|UriInterface $uri): bool 412 | { 413 | $getComponent = function (string $property, Psr7UriInterface|UriInterface $uri): ?string { 414 | $component = match ($property) { 415 | 'query' => $uri->getQuery(), 416 | 'authority' => $uri->getAuthority(), 417 | default => $uri->getScheme(), 418 | }; 419 | 420 | return match (true) { 421 | $uri instanceof UriInterface, '' !== $component => $component, 422 | default => null, 423 | }; 424 | }; 425 | 426 | return $getComponent($property, $uri) === $getComponent($property, $this->uri); 427 | } 428 | 429 | /** 430 | * Filter the URI object. 431 | */ 432 | final protected static function formatHost(Psr7UriInterface|UriInterface $uri): Psr7UriInterface|UriInterface 433 | { 434 | $host = $uri->getHost(); 435 | try { 436 | $converted = IPv4Converter::fromEnvironment()->toDecimal($host); 437 | } catch (MissingFeature) { 438 | $converted = null; 439 | } 440 | 441 | if (false === filter_var($converted, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 442 | $converted = IPv6Converter::compress($host); 443 | } 444 | 445 | return match (true) { 446 | null !== $converted => $uri->withHost($converted), 447 | '' === $host, 448 | $uri instanceof UriInterface => $uri, 449 | default => $uri->withHost((string) Uri::fromComponents(['host' => $host])->getHost()), 450 | }; 451 | } 452 | 453 | /** 454 | * Tells whether the submitted URI object can be relativized. 455 | */ 456 | final protected function canNotBeRelativize(Psr7UriInterface|UriInterface $uri): bool 457 | { 458 | return !static::componentEquals('scheme', $uri) 459 | || !static::componentEquals('authority', $uri) 460 | || static::from($uri)->isRelativePath(); 461 | } 462 | 463 | /** 464 | * Relatives the URI for an authority-less target URI. 465 | */ 466 | final protected static function relativizePath(string $path, string $basePath): string 467 | { 468 | $baseSegments = static::getSegments($basePath); 469 | $targetSegments = static::getSegments($path); 470 | $targetBasename = array_pop($targetSegments); 471 | array_pop($baseSegments); 472 | foreach ($baseSegments as $offset => $segment) { 473 | if (!isset($targetSegments[$offset]) || $segment !== $targetSegments[$offset]) { 474 | break; 475 | } 476 | unset($baseSegments[$offset], $targetSegments[$offset]); 477 | } 478 | $targetSegments[] = $targetBasename; 479 | 480 | return static::formatPath( 481 | str_repeat('../', count($baseSegments)).implode('/', $targetSegments), 482 | $basePath 483 | ); 484 | } 485 | 486 | /** 487 | * returns the path segments. 488 | * 489 | * @return string[] 490 | */ 491 | final protected static function getSegments(string $path): array 492 | { 493 | return explode('/', match (true) { 494 | '' === $path, 495 | '/' !== $path[0] => $path, 496 | default => substr($path, 1), 497 | }); 498 | } 499 | 500 | /** 501 | * Formatting the path to keep a valid URI. 502 | */ 503 | final protected static function formatPath(string $path, string $basePath): string 504 | { 505 | $colonPosition = strpos($path, ':'); 506 | $slashPosition = strpos($path, '/'); 507 | 508 | return match (true) { 509 | '' === $path => match (true) { 510 | '' === $basePath, 511 | '/' === $basePath => $basePath, 512 | default => './', 513 | }, 514 | false === $colonPosition => $path, 515 | false === $slashPosition, 516 | $colonPosition < $slashPosition => "./$path", 517 | default => $path, 518 | }; 519 | } 520 | 521 | /** 522 | * Formatting the path to keep a resolvable URI. 523 | */ 524 | final protected static function formatPathWithEmptyBaseQuery(string $path): string 525 | { 526 | $targetSegments = static::getSegments($path); 527 | $basename = $targetSegments[array_key_last($targetSegments)]; 528 | 529 | return '' === $basename ? './' : $basename; 530 | } 531 | 532 | /** 533 | * Normalizes a URI for comparison; this URI string representation is not suitable for usage as per RFC guidelines. 534 | * 535 | * @deprecated since version 7.6.0 536 | * 537 | * @codeCoverageIgnore 538 | */ 539 | #[Deprecated(message:'no longer used by the isSameDocument method', since:'league/uri-interfaces:7.6.0')] 540 | final protected function normalize(Psr7UriInterface|UriInterface $uri): string 541 | { 542 | $newUri = $uri->withScheme($uri instanceof Psr7UriInterface ? '' : null); 543 | if ('' === $newUri->__toString()) { 544 | return ''; 545 | } 546 | 547 | return UriString::normalize($newUri); 548 | } 549 | 550 | 551 | /** 552 | * Remove dot segments from the URI path as per RFC specification. 553 | * 554 | * @deprecated since version 7.6.0 555 | * 556 | * @codeCoverageIgnore 557 | */ 558 | #[Deprecated(message:'no longer used by the isSameDocument method', since:'league/uri-interfaces:7.6.0')] 559 | final protected function removeDotSegments(string $path): string 560 | { 561 | if (!str_contains($path, '.')) { 562 | return $path; 563 | } 564 | 565 | $reducer = function (array $carry, string $segment): array { 566 | if ('..' === $segment) { 567 | array_pop($carry); 568 | 569 | return $carry; 570 | } 571 | 572 | if (!isset(static::DOT_SEGMENTS[$segment])) { 573 | $carry[] = $segment; 574 | } 575 | 576 | return $carry; 577 | }; 578 | 579 | $oldSegments = explode('/', $path); 580 | $newPath = implode('/', array_reduce($oldSegments, $reducer(...), [])); 581 | if (isset(static::DOT_SEGMENTS[$oldSegments[array_key_last($oldSegments)]])) { 582 | $newPath .= '/'; 583 | } 584 | 585 | // @codeCoverageIgnoreStart 586 | // added because some PSR-7 implementations do not respect RFC3986 587 | if (str_starts_with($path, '/') && !str_starts_with($newPath, '/')) { 588 | return '/'.$newPath; 589 | } 590 | // @codeCoverageIgnoreEnd 591 | 592 | return $newPath; 593 | } 594 | 595 | /** 596 | * Resolves an URI path and query component. 597 | * 598 | * @return array{0:string, 1:string|null} 599 | * 600 | * @deprecated since version 7.6.0 601 | * 602 | * @codeCoverageIgnore 603 | */ 604 | #[Deprecated(message:'no longer used by the isSameDocument method', since:'league/uri-interfaces:7.6.0')] 605 | final protected function resolvePathAndQuery(Psr7UriInterface|UriInterface $uri): array 606 | { 607 | $targetPath = $uri->getPath(); 608 | $null = $uri instanceof Psr7UriInterface ? '' : null; 609 | 610 | if (str_starts_with($targetPath, '/')) { 611 | return [$targetPath, $uri->getQuery()]; 612 | } 613 | 614 | if ('' === $targetPath) { 615 | $targetQuery = $uri->getQuery(); 616 | if ($null === $targetQuery) { 617 | $targetQuery = $this->uri->getQuery(); 618 | } 619 | 620 | $targetPath = $this->uri->getPath(); 621 | //@codeCoverageIgnoreStart 622 | //because some PSR-7 Uri implementations allow this RFC3986 forbidden construction 623 | if (null !== $this->uri->getAuthority() && !str_starts_with($targetPath, '/')) { 624 | $targetPath = '/'.$targetPath; 625 | } 626 | //@codeCoverageIgnoreEnd 627 | 628 | return [$targetPath, $targetQuery]; 629 | } 630 | 631 | $basePath = $this->uri->getPath(); 632 | if (null !== $this->uri->getAuthority() && '' === $basePath) { 633 | $targetPath = '/'.$targetPath; 634 | } 635 | 636 | if ('' !== $basePath) { 637 | $segments = explode('/', $basePath); 638 | array_pop($segments); 639 | if ([] !== $segments) { 640 | $targetPath = implode('/', $segments).'/'.$targetPath; 641 | } 642 | } 643 | 644 | return [$targetPath, $uri->getQuery()]; 645 | } 646 | } 647 | -------------------------------------------------------------------------------- /Uri.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri; 15 | 16 | use Closure; 17 | use Deprecated; 18 | use finfo; 19 | use League\Uri\Contracts\Conditionable; 20 | use League\Uri\Contracts\FragmentDirective; 21 | use League\Uri\Contracts\UriComponentInterface; 22 | use League\Uri\Contracts\UriException; 23 | use League\Uri\Contracts\UriInterface; 24 | use League\Uri\Exceptions\MissingFeature; 25 | use League\Uri\Exceptions\SyntaxError; 26 | use League\Uri\Idna\Converter as IdnaConverter; 27 | use League\Uri\IPv4\Converter as IPv4Converter; 28 | use League\Uri\IPv6\Converter as IPv6Converter; 29 | use League\Uri\UriTemplate\TemplateCanNotBeExpanded; 30 | use Psr\Http\Message\UriInterface as Psr7UriInterface; 31 | use RuntimeException; 32 | use SensitiveParameter; 33 | use SplFileInfo; 34 | use SplFileObject; 35 | use Stringable; 36 | use Throwable; 37 | use TypeError; 38 | use Uri\Rfc3986\Uri as Rfc3986Uri; 39 | use Uri\WhatWg\Url as WhatWgUrl; 40 | 41 | use function array_filter; 42 | use function array_key_last; 43 | use function array_map; 44 | use function array_pop; 45 | use function array_shift; 46 | use function base64_decode; 47 | use function base64_encode; 48 | use function basename; 49 | use function count; 50 | use function dirname; 51 | use function explode; 52 | use function fclose; 53 | use function feof; 54 | use function file_get_contents; 55 | use function filter_var; 56 | use function fopen; 57 | use function fread; 58 | use function fwrite; 59 | use function gettype; 60 | use function implode; 61 | use function in_array; 62 | use function is_bool; 63 | use function is_object; 64 | use function is_resource; 65 | use function is_string; 66 | use function preg_match; 67 | use function preg_replace; 68 | use function preg_replace_callback; 69 | use function rawurldecode; 70 | use function rawurlencode; 71 | use function restore_error_handler; 72 | use function set_error_handler; 73 | use function sprintf; 74 | use function str_contains; 75 | use function str_repeat; 76 | use function str_replace; 77 | use function str_starts_with; 78 | use function strlen; 79 | use function strpos; 80 | use function strspn; 81 | use function strtolower; 82 | use function substr; 83 | use function trim; 84 | 85 | use const FILEINFO_MIME; 86 | use const FILEINFO_MIME_TYPE; 87 | use const FILTER_FLAG_IPV4; 88 | use const FILTER_NULL_ON_FAILURE; 89 | use const FILTER_VALIDATE_BOOLEAN; 90 | use const FILTER_VALIDATE_EMAIL; 91 | use const FILTER_VALIDATE_IP; 92 | 93 | /** 94 | * @phpstan-import-type ComponentMap from UriString 95 | * @phpstan-import-type InputComponentMap from UriString 96 | */ 97 | final class Uri implements Conditionable, UriInterface 98 | { 99 | /** 100 | * RFC3986 invalid characters. 101 | * 102 | * @link https://tools.ietf.org/html/rfc3986#section-2.2 103 | * 104 | * @var string 105 | */ 106 | private const REGEXP_INVALID_CHARS = '/[\x00-\x1f\x7f]/'; 107 | 108 | /** 109 | * RFC3986 IPvFuture host and port component. 110 | * 111 | * @var string 112 | */ 113 | private const REGEXP_HOST_PORT = ',^(?(\[.*]|[^:])*)(:(?[^/?#]*))?$,x'; 114 | 115 | /** 116 | * Regular expression pattern to for file URI. 117 | * contains the volume but not the volume separator. 118 | * The volume separator may be URL-encoded (`|` as `%7C`) by formatPath(), 119 | * so we account for that here. 120 | * 121 | * @var string 122 | */ 123 | private const REGEXP_FILE_PATH = ',^(?/)?(?[a-zA-Z])(?:[:|\|]|%7C)(?.*)?,'; 124 | 125 | /** 126 | * Mimetype regular expression pattern. 127 | * 128 | * @link https://tools.ietf.org/html/rfc2397 129 | * 130 | * @var string 131 | */ 132 | private const REGEXP_MIMETYPE = ',^\w+/[-.\w]+(?:\+[-.\w]+)?$,'; 133 | 134 | /** 135 | * Base64 content regular expression pattern. 136 | * 137 | * @link https://tools.ietf.org/html/rfc2397 138 | * 139 | * @var string 140 | */ 141 | private const REGEXP_BINARY = ',(;|^)base64$,'; 142 | 143 | /** 144 | * Windows filepath regular expression pattern. 145 | * contains both the volume and volume separator. 146 | * 147 | * @var string 148 | */ 149 | private const REGEXP_WINDOW_PATH = ',^(?[a-zA-Z][:|\|]),'; 150 | 151 | /** 152 | * Maximum number of cached items. 153 | * 154 | * @var int 155 | */ 156 | private const MAXIMUM_CACHED_ITEMS = 100; 157 | 158 | /** 159 | * All ASCII letters sorted by typical frequency of occurrence. 160 | * 161 | * @var string 162 | */ 163 | private const ASCII = "\x20\x65\x69\x61\x73\x6E\x74\x72\x6F\x6C\x75\x64\x5D\x5B\x63\x6D\x70\x27\x0A\x67\x7C\x68\x76\x2E\x66\x62\x2C\x3A\x3D\x2D\x71\x31\x30\x43\x32\x2A\x79\x78\x29\x28\x4C\x39\x41\x53\x2F\x50\x22\x45\x6A\x4D\x49\x6B\x33\x3E\x35\x54\x3C\x44\x34\x7D\x42\x7B\x38\x46\x77\x52\x36\x37\x55\x47\x4E\x3B\x4A\x7A\x56\x23\x48\x4F\x57\x5F\x26\x21\x4B\x3F\x58\x51\x25\x59\x5C\x09\x5A\x2B\x7E\x5E\x24\x40\x60\x7F\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0D\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F"; 164 | 165 | private readonly ?string $scheme; 166 | private readonly ?string $user; 167 | private readonly ?string $pass; 168 | private readonly ?string $userInfo; 169 | private readonly ?string $host; 170 | private readonly ?int $port; 171 | private readonly ?string $authority; 172 | private readonly string $path; 173 | private readonly ?string $query; 174 | private readonly ?string $fragment; 175 | private readonly string $uriAsciiString; 176 | private readonly string $uriUnicodeString; 177 | private readonly ?string $origin; 178 | 179 | private function __construct( 180 | ?string $scheme, 181 | ?string $user, 182 | #[SensitiveParameter] ?string $pass, 183 | ?string $host, 184 | ?int $port, 185 | string $path, 186 | ?string $query, 187 | ?string $fragment 188 | ) { 189 | $this->scheme = $this->formatScheme($scheme); 190 | $this->user = Encoder::encodeUser($user); 191 | $this->pass = Encoder::encodePassword($pass); 192 | $this->host = $this->formatHost($host); 193 | $this->port = $this->formatPort($port); 194 | $this->authority = UriString::buildAuthority([ 195 | 'scheme' => $this->scheme, 196 | 'user' => $this->user, 197 | 'pass' => $this->pass, 198 | 'host' => $this->host, 199 | 'port' => $this->port, 200 | ]); 201 | $this->path = $this->formatPath($path); 202 | $this->query = Encoder::encodeQueryOrFragment($query); 203 | $this->fragment = Encoder::encodeQueryOrFragment($fragment); 204 | $this->userInfo = null !== $this->pass ? $this->user.':'.$this->pass : $this->user; 205 | $this->uriAsciiString = UriString::buildUri($this->scheme, $this->authority, $this->path, $this->query, $this->fragment); 206 | $this->assertValidRfc3986Uri(); 207 | $this->assertValidState(); 208 | $this->origin = $this->setOrigin(); 209 | $host = $this->getUnicodeHost(); 210 | $this->uriUnicodeString = $host === $this->host 211 | ? $this->uriAsciiString 212 | : UriString::buildUri( 213 | $this->scheme, 214 | UriString::buildAuthority([...$this->toComponents(), ...['host' => $host]]), 215 | $this->path, 216 | $this->query, 217 | $this->fragment 218 | ); 219 | } 220 | 221 | /** 222 | * Format the Scheme and Host component. 223 | * 224 | * @throws SyntaxError if the scheme is invalid 225 | */ 226 | private function formatScheme(?string $scheme): ?string 227 | { 228 | if (null === $scheme) { 229 | return null; 230 | } 231 | 232 | $formattedScheme = strtolower($scheme); 233 | static $cache = []; 234 | if (isset($cache[$formattedScheme])) { 235 | return $formattedScheme; 236 | } 237 | 238 | null !== UriScheme::tryFrom($formattedScheme) 239 | || UriString::isValidScheme($formattedScheme) 240 | || throw new SyntaxError('The scheme `'.$scheme.'` is invalid.'); 241 | 242 | 243 | $cache[$formattedScheme] = 1; 244 | if (self::MAXIMUM_CACHED_ITEMS < count($cache)) { 245 | array_shift($cache); 246 | } 247 | 248 | return $formattedScheme; 249 | } 250 | 251 | /** 252 | * Validate and Format the Host component. 253 | */ 254 | private function formatHost(?string $host): ?string 255 | { 256 | return HostRecord::from($host)->toAscii(); 257 | } 258 | 259 | /** 260 | * Format the Port component. 261 | * 262 | * @throws SyntaxError 263 | */ 264 | private function formatPort(?int $port = null): ?int 265 | { 266 | $defaultPort = null !== $this->scheme 267 | ? UriScheme::tryFrom($this->scheme)?->port() 268 | : null; 269 | 270 | return match (true) { 271 | null === $port, $defaultPort === $port => null, 272 | 0 > $port => throw new SyntaxError('The port `'.$port.'` is invalid.'), 273 | default => $port, 274 | }; 275 | } 276 | 277 | /** 278 | * Create a new instance from a string or a stringable structure or returns null on failure. 279 | */ 280 | public static function tryNew(Rfc3986Uri|WhatWgUrl|Urn|Stringable|string $uri = ''): ?self 281 | { 282 | try { 283 | return self::new($uri); 284 | } catch (Throwable) { 285 | return null; 286 | } 287 | } 288 | 289 | /** 290 | * Create a new instance from a string. 291 | */ 292 | public static function new(Rfc3986Uri|WhatWgUrl|Urn|Stringable|string $uri = ''): self 293 | { 294 | if ($uri instanceof Rfc3986Uri) { 295 | return new self( 296 | $uri->getRawScheme(), 297 | $uri->getRawUsername(), 298 | $uri->getRawPassword(), 299 | $uri->getRawHost(), 300 | $uri->getPort(), 301 | $uri->getRawPath(), 302 | $uri->getRawQuery(), 303 | $uri->getRawFragment() 304 | ); 305 | } 306 | 307 | if ($uri instanceof WhatWgUrl) { 308 | return new self( 309 | $uri->getScheme(), 310 | $uri->getUsername(), 311 | $uri->getPassword(), 312 | $uri->getAsciiHost(), 313 | $uri->getPort(), 314 | $uri->getPath(), 315 | $uri->getQuery(), 316 | $uri->getFragment(), 317 | ); 318 | } 319 | 320 | $uri = (string) $uri; 321 | trim($uri) === $uri || throw new SyntaxError(sprintf('The uri `%s` contains invalid characters', $uri)); 322 | 323 | return new self(...UriString::parse(str_replace(' ', '%20', $uri))); 324 | } 325 | 326 | /** 327 | * Returns a new instance from a URI and a Base URI.or null on failure. 328 | * 329 | * The returned URI must be absolute if a base URI is provided 330 | */ 331 | public static function parse(Rfc3986Uri|WhatWgUrl|Urn|Stringable|string $uri, Rfc3986Uri|WhatWgUrl|Urn|Stringable|string|null $baseUri = null): ?self 332 | { 333 | try { 334 | if (null === $baseUri) { 335 | return self::new($uri); 336 | } 337 | 338 | if ($uri instanceof Rfc3986Uri) { 339 | $uri = $uri->toRawString(); 340 | } 341 | 342 | if ($uri instanceof WhatWgUrl) { 343 | $uri = $uri->toAsciiString(); 344 | } 345 | 346 | if ($baseUri instanceof Rfc3986Uri) { 347 | $baseUri = $baseUri->toRawString(); 348 | } 349 | 350 | if ($baseUri instanceof WhatWgUrl) { 351 | $baseUri = $baseUri->toAsciiString(); 352 | } 353 | 354 | return self::new(UriString::resolve($uri, $baseUri)); 355 | } catch (Throwable) { 356 | return null; 357 | } 358 | } 359 | 360 | /** 361 | * Creates a new instance from a template. 362 | * 363 | * @throws TemplateCanNotBeExpanded if the variables are invalid or missing 364 | * @throws UriException if the resulting expansion cannot be converted to a UriInterface instance 365 | */ 366 | public static function fromTemplate(UriTemplate|Stringable|string $template, iterable $variables = []): self 367 | { 368 | return match (true) { 369 | $template instanceof UriTemplate => self::new($template->expand($variables)), 370 | $template instanceof UriTemplate\Template => self::new($template->expand($variables)), 371 | default => self::new(UriTemplate\Template::new($template)->expand($variables)), 372 | }; 373 | } 374 | 375 | /** 376 | * Create a new instance from a hash representation of the URI similar 377 | * to PHP parse_url function result. 378 | * 379 | * @param InputComponentMap $components a hash representation of the URI similar to PHP parse_url function result 380 | */ 381 | public static function fromComponents(array $components = []): self 382 | { 383 | $components += [ 384 | 'scheme' => null, 'user' => null, 'pass' => null, 'host' => null, 385 | 'port' => null, 'path' => '', 'query' => null, 'fragment' => null, 386 | ]; 387 | 388 | if (null === $components['path']) { 389 | $components['path'] = ''; 390 | } 391 | 392 | return new self( 393 | $components['scheme'], 394 | $components['user'], 395 | $components['pass'], 396 | $components['host'], 397 | $components['port'], 398 | $components['path'], 399 | $components['query'], 400 | $components['fragment'] 401 | ); 402 | } 403 | 404 | /** 405 | * Create a new instance from a data file path. 406 | * 407 | * @param SplFileInfo|SplFileObject|resource|Stringable|string $path 408 | * @param ?resource $context 409 | * 410 | * @throws MissingFeature If ext/fileinfo is not installed 411 | * @throws SyntaxError If the file does not exist or is not readable 412 | */ 413 | public static function fromFileContents(mixed $path, $context = null): self 414 | { 415 | FeatureDetection::supportsFileDetection(); 416 | $finfo = new finfo(FILEINFO_MIME_TYPE); 417 | $bufferSize = 8192; 418 | 419 | /** @var Closure(SplFileobject): array{0:string, 1:string} $fromFileObject */ 420 | $fromFileObject = function (SplFileObject $path) use ($finfo, $bufferSize): array { 421 | $raw = $path->fread($bufferSize); 422 | false !== $raw || throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); 423 | 424 | $mimetype = (string) $finfo->buffer($raw); 425 | while (!$path->eof()) { 426 | $raw .= $path->fread($bufferSize); 427 | } 428 | 429 | return [$mimetype, $raw]; 430 | }; 431 | 432 | /** @var Closure(resource): array{0:string, 1:string} $fromResource */ 433 | $fromResource = function ($stream) use ($finfo, $path, $bufferSize): array { 434 | set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); 435 | $raw = fread($stream, $bufferSize); 436 | false !== $raw || throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); 437 | 438 | $mimetype = (string) $finfo->buffer($raw); 439 | while (!feof($stream)) { 440 | $raw .= fread($stream, $bufferSize); 441 | } 442 | restore_error_handler(); 443 | 444 | return [$mimetype, $raw]; 445 | }; 446 | 447 | /** @var Closure(Stringable|string, resource|null): array{0:string, 1:string} $fromPath */ 448 | $fromPath = function (Stringable|string $path, $context) use ($finfo): array { 449 | $path = (string) $path; 450 | set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); 451 | $raw = file_get_contents(filename: $path, context: $context); 452 | restore_error_handler(); 453 | false !== $raw || throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); 454 | $mimetype = (string) $finfo->file(filename: $path, flags: FILEINFO_MIME, context: $context); 455 | 456 | return [$mimetype, $raw]; 457 | }; 458 | 459 | [$mimetype, $raw] = match (true) { 460 | $path instanceof SplFileObject => $fromFileObject($path), 461 | $path instanceof SplFileInfo => $fromFileObject($path->openFile(mode: 'rb', context: $context)), 462 | is_resource($path) => $fromResource($path), 463 | $path instanceof Stringable, 464 | is_string($path) => $fromPath($path, $context), 465 | default => throw new TypeError('The path `'.$path.'` is not a valid resource.'), 466 | }; 467 | 468 | return Uri::fromComponents([ 469 | 'scheme' => 'data', 470 | 'path' => str_replace(' ', '', $mimetype.';base64,'.base64_encode($raw)), 471 | ]); 472 | } 473 | 474 | /** 475 | * Create a new instance from a data URI string. 476 | * 477 | * @throws SyntaxError If the parameter syntax is invalid 478 | */ 479 | public static function fromData(Stringable|string $data, string $mimetype = '', string $parameters = ''): self 480 | { 481 | static $regexpMimetype = ',^\w+/[-.\w]+(?:\+[-.\w]+)?$,'; 482 | 483 | $mimetype = match (true) { 484 | '' === $mimetype => 'text/plain', 485 | 1 === preg_match($regexpMimetype, $mimetype) => $mimetype, 486 | default => throw new SyntaxError('Invalid mimeType, `'.$mimetype.'`.'), 487 | }; 488 | 489 | $data = (string) $data; 490 | if ('' === $parameters) { 491 | return self::fromComponents([ 492 | 'scheme' => 'data', 493 | 'path' => self::formatDataPath($mimetype.','.rawurlencode($data)), 494 | ]); 495 | } 496 | 497 | $isInvalidParameter = static function (string $parameter): bool { 498 | $properties = explode('=', $parameter); 499 | 500 | return 2 !== count($properties) || 'base64' === strtolower($properties[0]); 501 | }; 502 | 503 | if (str_starts_with($parameters, ';')) { 504 | $parameters = substr($parameters, 1); 505 | } 506 | 507 | return match ([]) { 508 | array_filter(explode(';', $parameters), $isInvalidParameter) => self::fromComponents([ 509 | 'scheme' => 'data', 510 | 'path' => self::formatDataPath($mimetype.';'.$parameters.','.rawurlencode($data)), 511 | ]), 512 | default => throw new SyntaxError(sprintf('Invalid mediatype parameters, `%s`.', $parameters)) 513 | }; 514 | } 515 | 516 | /** 517 | * Create a new instance from a Unix path string. 518 | */ 519 | public static function fromUnixPath(Stringable|string $path): self 520 | { 521 | $path = implode('/', array_map(rawurlencode(...), explode('/', (string) $path))); 522 | 523 | return Uri::fromComponents(match (true) { 524 | '/' !== ($path[0] ?? '') => ['path' => $path], 525 | default => ['path' => $path, 'scheme' => 'file', 'host' => ''], 526 | }); 527 | } 528 | 529 | /** 530 | * Create a new instance from a local Windows path string. 531 | */ 532 | public static function fromWindowsPath(Stringable|string $path): self 533 | { 534 | $root = ''; 535 | $path = (string) $path; 536 | if (1 === preg_match(self::REGEXP_WINDOW_PATH, $path, $matches)) { 537 | $root = substr($matches['root'], 0, -1).':'; 538 | $path = substr($path, strlen($root)); 539 | } 540 | $path = str_replace('\\', '/', $path); 541 | $path = implode('/', array_map(rawurlencode(...), explode('/', $path))); 542 | 543 | //Local Windows absolute path 544 | if ('' !== $root) { 545 | return Uri::fromComponents(['path' => '/'.$root.$path, 'scheme' => 'file', 'host' => '']); 546 | } 547 | 548 | //UNC Windows Path 549 | if (!str_starts_with($path, '//')) { 550 | return Uri::fromComponents(['path' => $path]); 551 | } 552 | 553 | [$host, $path] = explode('/', substr($path, 2), 2) + [1 => '']; 554 | 555 | return Uri::fromComponents(['host' => $host, 'path' => '/'.$path, 'scheme' => 'file']); 556 | } 557 | 558 | /** 559 | * Creates a new instance from a RFC8089 compatible URI. 560 | * 561 | * @see https://datatracker.ietf.org/doc/html/rfc8089 562 | */ 563 | public static function fromRfc8089(Stringable|string $uri): static 564 | { 565 | $fileUri = self::new((string) preg_replace(',^(file:/)([^/].*)$,i', 'file:///$2', (string) $uri)); 566 | $scheme = $fileUri->getScheme(); 567 | 568 | return match (true) { 569 | 'file' !== $scheme => throw new SyntaxError('As per RFC8089, the URI scheme must be `file`.'), 570 | 'localhost' === $fileUri->getAuthority() => $fileUri->withHost(''), 571 | default => $fileUri, 572 | }; 573 | } 574 | 575 | /** 576 | * Create a new instance from the environment. 577 | */ 578 | public static function fromServer(array $server): self 579 | { 580 | $components = ['scheme' => self::fetchScheme($server)]; 581 | [$components['user'], $components['pass']] = self::fetchUserInfo($server); 582 | [$components['host'], $components['port']] = self::fetchHostname($server); 583 | [$components['path'], $components['query']] = self::fetchRequestUri($server); 584 | 585 | return Uri::fromComponents($components); 586 | } 587 | 588 | /** 589 | * Returns the environment scheme. 590 | */ 591 | private static function fetchScheme(array $server): string 592 | { 593 | $server += ['HTTPS' => '']; 594 | 595 | return match (true) { 596 | false !== filter_var($server['HTTPS'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) => 'https', 597 | default => 'http', 598 | }; 599 | } 600 | 601 | /** 602 | * Returns the environment user info. 603 | * 604 | * @return non-empty-array {0: ?string, 1: ?string} 605 | */ 606 | private static function fetchUserInfo(array $server): array 607 | { 608 | $server += ['PHP_AUTH_USER' => null, 'PHP_AUTH_PW' => null, 'HTTP_AUTHORIZATION' => '']; 609 | $user = $server['PHP_AUTH_USER']; 610 | $pass = $server['PHP_AUTH_PW']; 611 | if (str_starts_with(strtolower($server['HTTP_AUTHORIZATION']), 'basic')) { 612 | $userinfo = base64_decode(substr($server['HTTP_AUTHORIZATION'], 6), true); 613 | false !== $userinfo || throw new SyntaxError('The user info could not be detected'); 614 | [$user, $pass] = explode(':', $userinfo, 2) + [1 => null]; 615 | } 616 | 617 | if (null !== $user) { 618 | $user = rawurlencode($user); 619 | } 620 | 621 | if (null !== $pass) { 622 | $pass = rawurlencode($pass); 623 | } 624 | 625 | return [$user, $pass]; 626 | } 627 | 628 | /** 629 | * Returns the environment host. 630 | * 631 | * @throws SyntaxError If the host cannot be detected 632 | * 633 | * @return array{0:string|null, 1:int|null} 634 | */ 635 | private static function fetchHostname(array $server): array 636 | { 637 | $server += ['SERVER_PORT' => null]; 638 | if (null !== $server['SERVER_PORT']) { 639 | $server['SERVER_PORT'] = (int) $server['SERVER_PORT']; 640 | } 641 | 642 | if (isset($server['HTTP_HOST']) && 1 === preg_match(self::REGEXP_HOST_PORT, $server['HTTP_HOST'], $matches)) { 643 | $matches += ['host' => null, 'port' => null]; 644 | if (null !== $matches['port']) { 645 | $matches['port'] = (int) $matches['port']; 646 | } 647 | 648 | return [$matches['host'], $matches['port'] ?? $server['SERVER_PORT']]; 649 | } 650 | 651 | isset($server['SERVER_ADDR']) || throw new SyntaxError('The host could not be detected'); 652 | if (false === filter_var($server['SERVER_ADDR'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 653 | return ['['.$server['SERVER_ADDR'].']', $server['SERVER_PORT']]; 654 | } 655 | 656 | return [$server['SERVER_ADDR'], $server['SERVER_PORT']]; 657 | } 658 | 659 | /** 660 | * Returns the environment path. 661 | * 662 | * @return list 663 | */ 664 | private static function fetchRequestUri(array $server): array 665 | { 666 | $server += ['IIS_WasUrlRewritten' => null, 'UNENCODED_URL' => '', 'PHP_SELF' => '', 'QUERY_STRING' => null]; 667 | if ('1' === $server['IIS_WasUrlRewritten'] && '' !== $server['UNENCODED_URL']) { 668 | return explode('?', $server['UNENCODED_URL'], 2) + [1 => null]; 669 | } 670 | 671 | if (isset($server['REQUEST_URI'])) { 672 | [$path] = explode('?', $server['REQUEST_URI'], 2); 673 | $query = ('' !== $server['QUERY_STRING']) ? $server['QUERY_STRING'] : null; 674 | 675 | return [$path, $query]; 676 | } 677 | 678 | return [$server['PHP_SELF'], $server['QUERY_STRING']]; 679 | } 680 | 681 | /** 682 | * Format the Path component. 683 | */ 684 | private function formatPath(string $path): string 685 | { 686 | $path = match ($this->scheme) { 687 | 'data' => Encoder::encodePath(self::formatDataPath($path)), 688 | 'file' => self::formatFilePath(Encoder::encodePath($path)), 689 | default => Encoder::encodePath($path), 690 | }; 691 | 692 | if ('' === $path) { 693 | return $path; 694 | } 695 | 696 | if (null !== $this->authority) { 697 | // If there is an authority, the path must start with a `/` 698 | return str_starts_with($path, '/') ? $path : '/'.$path; 699 | } 700 | 701 | // If there is no authority, the path cannot start with `//` 702 | if (str_starts_with($path, '//')) { 703 | return '/.'.$path; 704 | } 705 | 706 | $colonPos = strpos($path, ':'); 707 | if (false !== $colonPos && null === $this->scheme) { 708 | // In the absence of a scheme and of an authority, 709 | // the first path segment cannot contain a colon (":") character.' 710 | $slashPos = strpos($path, '/'); 711 | (false !== $slashPos && $colonPos > $slashPos) || throw new SyntaxError( 712 | 'In absence of the scheme and authority components, the first path segment cannot contain a colon (":") character.' 713 | ); 714 | } 715 | 716 | return $path; 717 | } 718 | 719 | /** 720 | * Filter the Path component. 721 | * 722 | * @link https://tools.ietf.org/html/rfc2397 723 | * 724 | * @throws SyntaxError If the path is not compliant with RFC2397 725 | */ 726 | private static function formatDataPath(string $path): string 727 | { 728 | if ('' == $path) { 729 | return 'text/plain;charset=us-ascii,'; 730 | } 731 | 732 | if (strlen($path) !== strspn($path, self::ASCII) || !str_contains($path, ',')) { 733 | throw new SyntaxError('The path `'.$path.'` is invalid according to RFC2937.'); 734 | } 735 | 736 | $parts = explode(',', $path, 2) + [1 => null]; 737 | $mediatype = explode(';', (string) $parts[0], 2) + [1 => null]; 738 | $data = (string) $parts[1]; 739 | $mimetype = $mediatype[0]; 740 | if (null === $mimetype || '' === $mimetype) { 741 | $mimetype = 'text/plain'; 742 | } 743 | 744 | $parameters = $mediatype[1]; 745 | if (null === $parameters || '' === $parameters) { 746 | $parameters = 'charset=us-ascii'; 747 | } 748 | 749 | self::assertValidPath($mimetype, $parameters, $data); 750 | 751 | return $mimetype.';'.$parameters.','.$data; 752 | } 753 | 754 | /** 755 | * Assert the path is a compliant with RFC2397. 756 | * 757 | * @link https://tools.ietf.org/html/rfc2397 758 | * 759 | * @throws SyntaxError If the mediatype or the data are not compliant with the RFC2397 760 | */ 761 | private static function assertValidPath(string $mimetype, string $parameters, string $data): void 762 | { 763 | 1 === preg_match(self::REGEXP_MIMETYPE, $mimetype) || throw new SyntaxError('The path mimetype `'.$mimetype.'` is invalid.'); 764 | $isBinary = 1 === preg_match(self::REGEXP_BINARY, $parameters, $matches); 765 | if ($isBinary) { 766 | $parameters = substr($parameters, 0, - strlen($matches[0])); 767 | } 768 | 769 | $res = array_filter(array_filter(explode(';', $parameters), self::validateParameter(...))); 770 | [] === $res || throw new SyntaxError('The path parameters `'.$parameters.'` is invalid.'); 771 | if (!$isBinary) { 772 | return; 773 | } 774 | 775 | $res = base64_decode($data, true); 776 | if (false === $res || $data !== base64_encode($res)) { 777 | throw new SyntaxError('The path data `'.$data.'` is invalid.'); 778 | } 779 | } 780 | 781 | /** 782 | * Validate mediatype parameter. 783 | */ 784 | private static function validateParameter(string $parameter): bool 785 | { 786 | $properties = explode('=', $parameter); 787 | 788 | return 2 != count($properties) || 'base64' === strtolower($properties[0]); 789 | } 790 | 791 | /** 792 | * Format the path component for the URI scheme file. 793 | */ 794 | private static function formatFilePath(string $path): string 795 | { 796 | return (string) preg_replace_callback( 797 | self::REGEXP_FILE_PATH, 798 | static fn (array $matches): string => $matches['delim'].$matches['volume'].(isset($matches['rest']) ? ':'.$matches['rest'] : ''), 799 | $path 800 | ); 801 | } 802 | 803 | /** 804 | * assert the URI internal state is valid. 805 | * 806 | * @link https://tools.ietf.org/html/rfc3986#section-3 807 | * @link https://tools.ietf.org/html/rfc3986#section-3.3 808 | * 809 | * @throws SyntaxError if the URI is in an invalid state, according to RFC3986 810 | */ 811 | private function assertValidRfc3986Uri(): void 812 | { 813 | if (null !== $this->authority && ('' !== $this->path && '/' !== $this->path[0])) { 814 | throw new SyntaxError('If an authority is present the path must be empty or start with a `/`.'); 815 | } 816 | 817 | if (null === $this->authority && str_starts_with($this->path, '//')) { 818 | throw new SyntaxError('If there is no authority the path `'.$this->path.'` cannot start with a `//`.'); 819 | } 820 | 821 | $pos = strpos($this->path, ':'); 822 | if (null === $this->authority 823 | && null === $this->scheme 824 | && false !== $pos 825 | && !str_contains(substr($this->path, 0, $pos), '/') 826 | ) { 827 | throw new SyntaxError('In absence of a scheme and an authority the first path segment cannot contain a colon (":") character.'); 828 | } 829 | } 830 | 831 | /** 832 | * assert the URI scheme is valid. 833 | * 834 | * @link https://w3c.github.io/FileAPI/#url 835 | * @link https://datatracker.ietf.org/doc/html/rfc2397 836 | * @link https://tools.ietf.org/html/rfc3986#section-3 837 | * @link https://tools.ietf.org/html/rfc3986#section-3.3 838 | * 839 | * @throws SyntaxError if the URI is in an invalid state, according to scheme-specific rules 840 | */ 841 | private function assertValidState(): void 842 | { 843 | $scheme = UriScheme::tryFrom((string) $this->scheme); 844 | if (null === $scheme) { 845 | return; 846 | } 847 | 848 | $schemeType = $scheme->type(); 849 | match ($scheme) { 850 | UriScheme::Blob => $this->isValidBlob(), 851 | UriScheme::Mailto => $this->isValidMailto(), 852 | UriScheme::Data, 853 | UriScheme::About, 854 | UriScheme::Javascript => $this->isUriWithSchemeAndPathOnly(), 855 | UriScheme::File => $this->isUriWithSchemeHostAndPathOnly(), 856 | UriScheme::Ftp, 857 | UriScheme::Gopher, 858 | UriScheme::Afp, 859 | UriScheme::Dict, 860 | UriScheme::Msrps, 861 | UriScheme::Msrp, 862 | UriScheme::Mtqp, 863 | UriScheme::Rsync, 864 | UriScheme::Ssh, 865 | UriScheme::Svn, 866 | UriScheme::Snmp => $this->isNonEmptyHostUriWithoutFragmentAndQuery(), 867 | UriScheme::Https, 868 | UriScheme::Http => $this->isNonEmptyHostUri(), 869 | UriScheme::Ws, 870 | UriScheme::Wss, 871 | UriScheme::Ipp, 872 | UriScheme::Ipps => $this->isNonEmptyHostUriWithoutFragment(), 873 | UriScheme::Ldap, 874 | UriScheme::Ldaps, 875 | UriScheme::Acap, 876 | UriScheme::Imaps, 877 | UriScheme::Imap, 878 | UriScheme::Redis => null === $this->fragment, 879 | UriScheme::Prospero => null === $this->fragment && null === $this->query && null === $this->userInfo, 880 | UriScheme::Urn => null !== Urn::parse($this->uriAsciiString), 881 | UriScheme::Telnet, 882 | UriScheme::Tn3270 => null === $this->fragment && null === $this->query && in_array($this->path, ['', '/'], true), 883 | UriScheme::Vnc => null !== $this->authority && null === $this->fragment && '' === $this->path, 884 | default => $schemeType->isUnknown() 885 | || ($schemeType->isOpaque() && null === $this->authority) 886 | || ($schemeType->isHierarchical() && null !== $this->authority), 887 | } || throw new SyntaxError('The uri `'.$this->uriAsciiString.'` is invalid for the `'.$this->scheme.'` scheme.'); 888 | } 889 | 890 | private function isValidBlob(): bool 891 | { 892 | static $regexpUuidRfc4122 = '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i'; 893 | 894 | if (!$this->isUriWithSchemeAndPathOnly() 895 | || '' === $this->path 896 | || !str_contains($this->path, '/') 897 | || str_ends_with($this->path, '/') 898 | || 1 !== preg_match($regexpUuidRfc4122, basename($this->path)) 899 | ) { 900 | return false; 901 | } 902 | 903 | $origin = dirname($this->path); 904 | if ('null' === $origin) { 905 | return true; 906 | } 907 | 908 | try { 909 | $components = UriString::parse($origin); 910 | 911 | return '' === $components['path'] 912 | && null === $components['query'] 913 | && null === $components['fragment'] 914 | && true === UriScheme::tryFrom((string) $components['scheme'])?->isWhatWgSpecial(); 915 | } catch (UriException) { 916 | return false; 917 | } 918 | } 919 | 920 | private function isValidMailto(): bool 921 | { 922 | if (null !== $this->authority || null !== $this->fragment || str_contains((string) $this->query, '?')) { 923 | return false; 924 | } 925 | 926 | static $mailHeaders = [ 927 | 'to', 'cc', 'bcc', 'reply-to', 'from', 'sender', 928 | 'resent-to', 'resent-cc', 'resent-bcc', 'resent-from', 'resent-sender', 929 | 'return-path', 'delivery-to', 'site-owner', 930 | ]; 931 | 932 | static $headerRegexp = '/^[a-zA-Z0-9\'`#$%&*+.^_|~!-]+$/D'; 933 | $pairs = QueryString::parseFromValue($this->query); 934 | $hasTo = false; 935 | foreach ($pairs as [$name, $value]) { 936 | $headerName = strtolower($name); 937 | if (in_array($headerName, $mailHeaders, true)) { 938 | if (null === $value || !self::validateEmailList($value)) { 939 | return false; 940 | } 941 | 942 | if (!$hasTo && 'to' === $headerName) { 943 | $hasTo = true; 944 | } 945 | continue; 946 | } 947 | 948 | if (1 !== preg_match($headerRegexp, (string) Encoder::decodeAll($name))) { 949 | return false; 950 | } 951 | } 952 | 953 | return '' === $this->path ? $hasTo : self::validateEmailList($this->path); 954 | } 955 | 956 | private static function validateEmailList(string $emails): bool 957 | { 958 | foreach (explode(',', $emails) as $email) { 959 | if (false === filter_var((string) Encoder::decodeAll($email), FILTER_VALIDATE_EMAIL)) { 960 | return false; 961 | } 962 | } 963 | 964 | return '' !== $emails; 965 | } 966 | 967 | /** 968 | * Sets the URI origin. 969 | * 970 | * The origin read-only property of the URL interface returns a string containing 971 | * the Unicode serialization of the represented URL. 972 | */ 973 | private function setOrigin(): ?string 974 | { 975 | try { 976 | if ('blob' !== $this->scheme) { 977 | if (!(UriScheme::tryFrom($this->scheme ?? '')?->isWhatWgSpecial() ?? false)) { 978 | return null; 979 | } 980 | 981 | $host = $this->host; 982 | $converted = $host; 983 | if (null !== $converted) { 984 | try { 985 | $converted = IPv4Converter::fromEnvironment()->toDecimal($host); 986 | } catch (MissingFeature) { 987 | $converted = null; 988 | } 989 | 990 | if (false === filter_var($converted, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { 991 | $converted = IPv6Converter::compress($host); 992 | } 993 | 994 | /** @var string $converted */ 995 | if ($converted !== $host) { 996 | $converted = Idna\Converter::toAscii($converted)->domain(); 997 | } 998 | } 999 | 1000 | return $this 1001 | ->withFragment(null) 1002 | ->withQuery(null) 1003 | ->withPath('') 1004 | ->withUserInfo(null) 1005 | ->withHost($converted) 1006 | ->toString(); 1007 | } 1008 | 1009 | $components = UriString::parse($this->path); 1010 | $scheme = strtolower($components['scheme'] ?? ''); 1011 | if (! (UriScheme::tryFrom($scheme)?->isWhatWgSpecial() ?? false)) { 1012 | return null; 1013 | } 1014 | 1015 | return self::fromComponents($components)->origin; 1016 | } catch (UriException) { 1017 | return null; 1018 | } 1019 | } 1020 | 1021 | /** 1022 | * URI validation for URI schemes which allows only scheme and path components. 1023 | */ 1024 | private function isUriWithSchemeAndPathOnly(): bool 1025 | { 1026 | return null === $this->authority 1027 | && null === $this->query 1028 | && null === $this->fragment; 1029 | } 1030 | 1031 | /** 1032 | * URI validation for URI schemes which allows only scheme, host and path components. 1033 | */ 1034 | private function isUriWithSchemeHostAndPathOnly(): bool 1035 | { 1036 | return null === $this->userInfo 1037 | && null === $this->port 1038 | && null === $this->query 1039 | && null === $this->fragment 1040 | && !('' != $this->scheme && null === $this->host); 1041 | } 1042 | 1043 | /** 1044 | * URI validation for URI schemes which disallow the empty '' host. 1045 | */ 1046 | private function isNonEmptyHostUri(): bool 1047 | { 1048 | return '' !== $this->host 1049 | && !(null !== $this->scheme && null === $this->host); 1050 | } 1051 | 1052 | /** 1053 | * URI validation for URIs schemes which disallow the empty '' host 1054 | * and forbids the fragment component. 1055 | */ 1056 | private function isNonEmptyHostUriWithoutFragment(): bool 1057 | { 1058 | return $this->isNonEmptyHostUri() && null === $this->fragment; 1059 | } 1060 | 1061 | /** 1062 | * URI validation for URIs schemes which disallow the empty '' host 1063 | * and forbids fragment and query components. 1064 | */ 1065 | private function isNonEmptyHostUriWithoutFragmentAndQuery(): bool 1066 | { 1067 | return $this->isNonEmptyHostUri() && null === $this->fragment && null === $this->query; 1068 | } 1069 | 1070 | public function __toString(): string 1071 | { 1072 | return $this->toString(); 1073 | } 1074 | 1075 | /** 1076 | * Returns the string representation as a URI reference. 1077 | * 1078 | * @see http://tools.ietf.org/html/rfc3986#section-4.1 1079 | * @see ::toString 1080 | */ 1081 | public function jsonSerialize(): string 1082 | { 1083 | return $this->toString(); 1084 | } 1085 | 1086 | /** 1087 | * Returns the string representation as a URI reference. 1088 | * 1089 | * @see http://tools.ietf.org/html/rfc3986#section-4.1 1090 | */ 1091 | public function toString(): string 1092 | { 1093 | return $this->toAsciiString(); 1094 | } 1095 | 1096 | /** 1097 | * Returns the string representation as a URI reference. 1098 | * 1099 | * @see http://tools.ietf.org/html/rfc3986#section-4.1 1100 | */ 1101 | public function toAsciiString(): string 1102 | { 1103 | return $this->uriAsciiString; 1104 | } 1105 | 1106 | /** 1107 | * Returns the string representation as a URI reference. 1108 | * 1109 | * The host is converted to its UNICODE representation if available 1110 | */ 1111 | public function toUnicodeString(): string 1112 | { 1113 | return $this->uriUnicodeString; 1114 | } 1115 | 1116 | /** 1117 | * Returns the human-readable string representation of the URI as an IRI. 1118 | * 1119 | * @see https://datatracker.ietf.org/doc/html/rfc3987 1120 | */ 1121 | public function toDisplayString(): string 1122 | { 1123 | return UriString::toIriString($this->toString()); 1124 | } 1125 | 1126 | /** 1127 | * Returns the Unix filesystem path. 1128 | * 1129 | * The method will return null if a scheme is present and is not the `file` scheme 1130 | */ 1131 | public function toUnixPath(): ?string 1132 | { 1133 | return match ($this->scheme) { 1134 | 'file', null => rawurldecode($this->path), 1135 | default => null, 1136 | }; 1137 | } 1138 | 1139 | /** 1140 | * Returns the Windows filesystem path. 1141 | * 1142 | * The method will return null if a scheme is present and is not the `file` scheme 1143 | */ 1144 | public function toWindowsPath(): ?string 1145 | { 1146 | static $regexpWindowsPath = ',^(?[a-zA-Z]:),'; 1147 | 1148 | if (!in_array($this->scheme, ['file', null], true)) { 1149 | return null; 1150 | } 1151 | 1152 | $originalPath = $this->path; 1153 | $path = $originalPath; 1154 | if ('/' === ($path[0] ?? '')) { 1155 | $path = substr($path, 1); 1156 | } 1157 | 1158 | if (1 === preg_match($regexpWindowsPath, $path, $matches)) { 1159 | $root = $matches['root']; 1160 | $path = substr($path, strlen($root)); 1161 | 1162 | return $root.str_replace('/', '\\', rawurldecode($path)); 1163 | } 1164 | 1165 | $host = $this->host; 1166 | 1167 | return match (null) { 1168 | $host => str_replace('/', '\\', rawurldecode($originalPath)), 1169 | default => '\\\\'.$host.'\\'.str_replace('/', '\\', rawurldecode($path)), 1170 | }; 1171 | } 1172 | 1173 | /** 1174 | * Returns a string representation of a File URI according to RFC8089. 1175 | * 1176 | * The method will return null if the URI scheme is not the `file` scheme 1177 | * 1178 | * @see https://datatracker.ietf.org/doc/html/rfc8089 1179 | */ 1180 | public function toRfc8089(): ?string 1181 | { 1182 | $path = $this->path; 1183 | 1184 | return match (true) { 1185 | 'file' !== $this->scheme => null, 1186 | in_array($this->authority, ['', null, 'localhost'], true) => 'file:'.match (true) { 1187 | '' === $path, 1188 | '/' === $path[0] => $path, 1189 | default => '/'.$path, 1190 | }, 1191 | default => $this->toString(), 1192 | }; 1193 | } 1194 | 1195 | /** 1196 | * Save the data to a specific file. 1197 | * 1198 | * The method returns the number of bytes written to the file 1199 | * or null for any other scheme except the data scheme 1200 | * 1201 | * @param SplFileInfo|SplFileObject|resource|Stringable|string $destination 1202 | * @param ?resource $context 1203 | * 1204 | * @throws RuntimeException if the content cannot be stored. 1205 | */ 1206 | public function toFileContents(mixed $destination, $context = null): ?int 1207 | { 1208 | if ('data' !== $this->scheme) { 1209 | return null; 1210 | } 1211 | 1212 | [$mediaType, $document] = explode(',', $this->path, 2) + [0 => '', 1 => null]; 1213 | null !== $document || throw new RuntimeException('Unable to extract the document part from the URI path.'); 1214 | 1215 | $data = match (true) { 1216 | str_ends_with((string) $mediaType, ';base64') => (string) base64_decode($document, true), 1217 | default => rawurldecode($document), 1218 | }; 1219 | 1220 | $res = match (true) { 1221 | $destination instanceof SplFileObject => $destination->fwrite($data), 1222 | $destination instanceof SplFileInfo => $destination->openFile(mode:'wb', context: $context)->fwrite($data), 1223 | is_resource($destination) => fwrite($destination, $data), 1224 | $destination instanceof Stringable, 1225 | is_string($destination) => (function () use ($destination, $data, $context): int|false { 1226 | set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); 1227 | $rsrc = fopen((string) $destination, mode:'wb', context: $context); 1228 | if (false === $rsrc) { 1229 | restore_error_handler(); 1230 | throw new RuntimeException('Unable to open the destination file: '.$destination); 1231 | } 1232 | 1233 | $bytes = fwrite($rsrc, $data); 1234 | fclose($rsrc); 1235 | restore_error_handler(); 1236 | 1237 | return $bytes; 1238 | })(), 1239 | default => throw new TypeError('Unsupported destination type; expected SplFileObject, SplFileInfo, resource or a string; '.(is_object($destination) ? $destination::class : gettype($destination)).' given.'), 1240 | }; 1241 | 1242 | false !== $res || throw new RuntimeException('Unable to write to the destination file.'); 1243 | 1244 | return $res; 1245 | } 1246 | 1247 | /** 1248 | * Returns an associative array containing all the URI components. 1249 | * 1250 | * @return ComponentMap 1251 | */ 1252 | public function toComponents(): array 1253 | { 1254 | return [ 1255 | 'scheme' => $this->scheme, 1256 | 'user' => $this->user, 1257 | 'pass' => $this->pass, 1258 | 'host' => $this->host, 1259 | 'port' => $this->port, 1260 | 'path' => $this->path, 1261 | 'query' => $this->query, 1262 | 'fragment' => $this->fragment, 1263 | ]; 1264 | } 1265 | 1266 | public function getScheme(): ?string 1267 | { 1268 | return $this->scheme; 1269 | } 1270 | 1271 | public function getAuthority(): ?string 1272 | { 1273 | return $this->authority; 1274 | } 1275 | 1276 | /** 1277 | * Returns the user component encoded value. 1278 | * 1279 | * @see https://wiki.php.net/rfc/url_parsing_api 1280 | */ 1281 | public function getUsername(): ?string 1282 | { 1283 | return $this->user; 1284 | } 1285 | 1286 | public function getPassword(): ?string 1287 | { 1288 | return $this->pass; 1289 | } 1290 | 1291 | public function getUserInfo(): ?string 1292 | { 1293 | return $this->userInfo; 1294 | } 1295 | 1296 | public function getHost(): ?string 1297 | { 1298 | return $this->host; 1299 | } 1300 | 1301 | public function getUnicodeHost(): ?string 1302 | { 1303 | if (null === $this->host) { 1304 | return null; 1305 | } 1306 | 1307 | $host = IdnaConverter::toUnicode($this->host)->domain(); 1308 | if ($host === $this->host) { 1309 | return $this->host; 1310 | } 1311 | 1312 | return $host; 1313 | } 1314 | 1315 | public function isIpv4Host(): bool 1316 | { 1317 | return HostRecord::isIpv4($this->host); 1318 | } 1319 | 1320 | public function isIpv6Host(): bool 1321 | { 1322 | return HostRecord::isIpv6($this->host); 1323 | } 1324 | 1325 | public function isIpvFutureHost(): bool 1326 | { 1327 | return HostRecord::isIpvFuture($this->host); 1328 | } 1329 | 1330 | public function isIpHost(): bool 1331 | { 1332 | return HostRecord::isIp($this->host); 1333 | } 1334 | 1335 | public function isRegisteredNameHost(): bool 1336 | { 1337 | return HostRecord::isRegisteredName($this->host); 1338 | } 1339 | 1340 | public function isDomainHost(): bool 1341 | { 1342 | return HostRecord::isDomain($this->host); 1343 | } 1344 | 1345 | public function getPort(): ?int 1346 | { 1347 | return $this->port; 1348 | } 1349 | 1350 | public function getPath(): string 1351 | { 1352 | return $this->path; 1353 | } 1354 | 1355 | public function getQuery(): ?string 1356 | { 1357 | return $this->query; 1358 | } 1359 | 1360 | public function getFragment(): ?string 1361 | { 1362 | return $this->fragment; 1363 | } 1364 | 1365 | public function getOrigin(): ?string 1366 | { 1367 | return $this->origin; 1368 | } 1369 | 1370 | public function when(callable|bool $condition, callable $onSuccess, ?callable $onFail = null): static 1371 | { 1372 | if (!is_bool($condition)) { 1373 | $condition = $condition($this); 1374 | } 1375 | 1376 | return match (true) { 1377 | $condition => $onSuccess($this), 1378 | null !== $onFail => $onFail($this), 1379 | default => $this, 1380 | } ?? $this; 1381 | } 1382 | 1383 | public function withScheme(Stringable|string|null $scheme): static 1384 | { 1385 | $scheme = $this->formatScheme($this->filterString($scheme)); 1386 | 1387 | return match ($scheme) { 1388 | $this->scheme => $this, 1389 | default => new self($scheme, $this->user, $this->pass, $this->host, $this->port, $this->path, $this->query, $this->fragment), 1390 | }; 1391 | } 1392 | 1393 | /** 1394 | * Filter a string. 1395 | * 1396 | * @throws SyntaxError if the submitted data cannot be converted to string 1397 | */ 1398 | private function filterString(Stringable|string|null $str): ?string 1399 | { 1400 | $str = match (true) { 1401 | $str instanceof UriComponentInterface => $str->value(), 1402 | null === $str => null, 1403 | default => (string) $str, 1404 | }; 1405 | 1406 | return match (true) { 1407 | null === $str => null, 1408 | 1 === preg_match(self::REGEXP_INVALID_CHARS, $str) => throw new SyntaxError('The component `'.$str.'` contains invalid characters.'), 1409 | default => $str, 1410 | }; 1411 | } 1412 | 1413 | public function withUserInfo( 1414 | Stringable|string|null $user, 1415 | #[SensitiveParameter] Stringable|string|null $password = null 1416 | ): static { 1417 | $user = Encoder::encodeUser($this->filterString($user)); 1418 | $pass = Encoder::encodePassword($this->filterString($password)); 1419 | $userInfo = $user; 1420 | if (null !== $password) { 1421 | $userInfo .= ':'.$pass; 1422 | } 1423 | 1424 | return match ($userInfo) { 1425 | $this->userInfo => $this, 1426 | default => new self($this->scheme, $user, $pass, $this->host, $this->port, $this->path, $this->query, $this->fragment), 1427 | }; 1428 | } 1429 | 1430 | public function withUsername(Stringable|string|null $user): static 1431 | { 1432 | return $this->withUserInfo($user, $this->pass); 1433 | } 1434 | 1435 | public function withPassword(#[SensitiveParameter] Stringable|string|null $password): static 1436 | { 1437 | return $this->withUserInfo($this->user, $password); 1438 | } 1439 | 1440 | public function withHost(Stringable|string|null $host): static 1441 | { 1442 | $host = $this->formatHost($this->filterString($host)); 1443 | 1444 | return match ($host) { 1445 | $this->host => $this, 1446 | default => new self($this->scheme, $this->user, $this->pass, $host, $this->port, $this->path, $this->query, $this->fragment), 1447 | }; 1448 | } 1449 | 1450 | public function withPort(int|null $port): static 1451 | { 1452 | $port = $this->formatPort($port); 1453 | 1454 | return match ($port) { 1455 | $this->port => $this, 1456 | default => new self($this->scheme, $this->user, $this->pass, $this->host, $port, $this->path, $this->query, $this->fragment), 1457 | }; 1458 | } 1459 | 1460 | public function withPath(Stringable|string $path): static 1461 | { 1462 | $path = $this->formatPath( 1463 | $this->filterString($path) ?? throw new SyntaxError('The path component cannot be null.') 1464 | ); 1465 | 1466 | return match ($path) { 1467 | $this->path => $this, 1468 | default => new self($this->scheme, $this->user, $this->pass, $this->host, $this->port, $path, $this->query, $this->fragment), 1469 | }; 1470 | } 1471 | 1472 | public function withQuery(Stringable|string|null $query): static 1473 | { 1474 | $query = Encoder::encodeQueryOrFragment($this->filterString($query)); 1475 | 1476 | return match ($query) { 1477 | $this->query => $this, 1478 | default => new self($this->scheme, $this->user, $this->pass, $this->host, $this->port, $this->path, $query, $this->fragment), 1479 | }; 1480 | } 1481 | 1482 | public function withFragment(Stringable|string|null $fragment): static 1483 | { 1484 | if ($fragment instanceof FragmentDirective) { 1485 | $fragment = ':~:'.$fragment->toString(); 1486 | } 1487 | 1488 | $fragment = Encoder::encodeQueryOrFragment($this->filterString($fragment)); 1489 | 1490 | return match ($fragment) { 1491 | $this->fragment => $this, 1492 | default => new self($this->scheme, $this->user, $this->pass, $this->host, $this->port, $this->path, $this->query, $fragment), 1493 | }; 1494 | } 1495 | 1496 | /** 1497 | * Tells whether the `file` scheme base URI represents a local file. 1498 | */ 1499 | public function isLocalFile(): bool 1500 | { 1501 | return match (true) { 1502 | 'file' !== $this->scheme => false, 1503 | in_array($this->authority, ['', null, 'localhost'], true) => true, 1504 | default => false, 1505 | }; 1506 | } 1507 | 1508 | /** 1509 | * Tells whether the URI is opaque or not. 1510 | * 1511 | * A URI is opaque if and only if it is absolute 1512 | * and does not have an authority path. 1513 | */ 1514 | public function isOpaque(): bool 1515 | { 1516 | return null === $this->authority 1517 | && null !== $this->scheme; 1518 | } 1519 | 1520 | /** 1521 | * Tells whether two URI do not share the same origin. 1522 | */ 1523 | public function isCrossOrigin(Rfc3986Uri|WhatWgUrl|Urn|Stringable|string $uri): bool 1524 | { 1525 | if (null === $this->origin) { 1526 | return true; 1527 | } 1528 | 1529 | $uri = self::tryNew($uri); 1530 | if (null === $uri || null === ($origin = $uri->getOrigin())) { 1531 | return true; 1532 | } 1533 | 1534 | return $this->origin !== $origin; 1535 | } 1536 | 1537 | public function isSameOrigin(Rfc3986Uri|WhatWgUrl|Urn|Stringable|string $uri): bool 1538 | { 1539 | return ! $this->isCrossOrigin($uri); 1540 | } 1541 | 1542 | /** 1543 | * Tells whether the URI is absolute. 1544 | */ 1545 | public function isAbsolute(): bool 1546 | { 1547 | return null !== $this->scheme; 1548 | } 1549 | 1550 | /** 1551 | * Tells whether the URI is a network path. 1552 | */ 1553 | public function isNetworkPath(): bool 1554 | { 1555 | return null === $this->scheme 1556 | && null !== $this->authority; 1557 | } 1558 | 1559 | /** 1560 | * Tells whether the URI is an absolute path. 1561 | */ 1562 | public function isAbsolutePath(): bool 1563 | { 1564 | return null === $this->scheme 1565 | && null === $this->authority 1566 | && '/' === ($this->path[0] ?? ''); 1567 | } 1568 | 1569 | /** 1570 | * Tells whether the URI is a relative path. 1571 | */ 1572 | public function isRelativePath(): bool 1573 | { 1574 | return null === $this->scheme 1575 | && null === $this->authority 1576 | && '/' !== ($this->path[0] ?? ''); 1577 | } 1578 | 1579 | /** 1580 | * Tells whether both URIs refer to the same document. 1581 | */ 1582 | public function isSameDocument(Rfc3986Uri|WhatWgUrl|UriInterface|Stringable|Urn|string $uri): bool 1583 | { 1584 | return $this->equals($uri); 1585 | } 1586 | 1587 | public function equals(Rfc3986Uri|WhatWgUrl|UriInterface|Stringable|Urn|string $uri, UriComparisonMode $uriComparisonMode = UriComparisonMode::ExcludeFragment): bool 1588 | { 1589 | if (!$uri instanceof UriInterface && !$uri instanceof Rfc3986Uri && !$uri instanceof WhatWgUrl) { 1590 | $uri = self::tryNew($uri); 1591 | } 1592 | 1593 | if (null === $uri) { 1594 | return false; 1595 | } 1596 | 1597 | $baseUri = $this; 1598 | if (UriComparisonMode::ExcludeFragment === $uriComparisonMode) { 1599 | $uri = $uri->withFragment(null); 1600 | $baseUri = $baseUri->withFragment(null); 1601 | } 1602 | 1603 | return $baseUri->normalize()->toString() === match (true) { 1604 | $uri instanceof Rfc3986Uri => $uri->toString(), 1605 | $uri instanceof WhatWgUrl => $uri->toAsciiString(), 1606 | default => $uri->normalize()->toString(), 1607 | }; 1608 | } 1609 | 1610 | /** 1611 | * Normalize a URI by applying non-destructive and destructive normalization 1612 | * rules as defined in RFC3986 and RFC3987. 1613 | */ 1614 | public function normalize(): static 1615 | { 1616 | $uriString = $this->toString(); 1617 | if ('' === $uriString) { 1618 | return $this; 1619 | } 1620 | 1621 | $normalizedUriString = UriString::normalize($uriString); 1622 | $normalizedUri = self::new($normalizedUriString); 1623 | if (null !== $normalizedUri->getAuthority() && ('' === $normalizedUri->getPath() && (UriScheme::tryFrom($normalizedUri->getScheme() ?? '')?->isWhatWgSpecial() ?? false))) { 1624 | $normalizedUri = $normalizedUri->withPath('/'); 1625 | } 1626 | 1627 | if ($normalizedUri->toString() === $uriString) { 1628 | return $this; 1629 | } 1630 | 1631 | return $normalizedUri; 1632 | } 1633 | 1634 | /** 1635 | * Resolves a URI against a base URI using RFC3986 rules. 1636 | * 1637 | * This method MUST retain the state of the submitted URI instance, and return 1638 | * a URI instance of the same type that contains the applied modifications. 1639 | * 1640 | * This method MUST be transparent when dealing with errors and exceptions. 1641 | * It MUST not alter or silence them apart from validating its own parameters. 1642 | */ 1643 | public function resolve(Rfc3986Uri|WhatWgUrl|UriInterface|Stringable|Urn|string $uri): static 1644 | { 1645 | return self::new(UriString::resolve( 1646 | match (true) { 1647 | $uri instanceof UriInterface, 1648 | $uri instanceof Rfc3986Uri => $uri->toString(), 1649 | $uri instanceof WhatWgUrl => $uri->toAsciiString(), 1650 | default => $uri, 1651 | }, 1652 | $this->toString() 1653 | )); 1654 | } 1655 | 1656 | /** 1657 | * Relativize a URI according to a base URI. 1658 | * 1659 | * This method MUST retain the state of the submitted URI instance, and return 1660 | * a URI instance of the same type that contains the applied modifications. 1661 | * 1662 | * This method MUST be transparent when dealing with error and exceptions. 1663 | * It MUST not alter of silence them apart from validating its own parameters. 1664 | */ 1665 | public function relativize(Rfc3986Uri|WhatWgUrl|UriInterface|Stringable|Urn|string $uri): static 1666 | { 1667 | $uri = self::new($uri); 1668 | 1669 | if ( 1670 | $this->scheme !== $uri->getScheme() || 1671 | $this->authority !== $uri->getAuthority() || 1672 | $uri->isRelativePath()) { 1673 | return $uri; 1674 | } 1675 | 1676 | $targetPath = $uri->getPath(); 1677 | $basePath = $this->path; 1678 | 1679 | $uri = $uri 1680 | ->withScheme(null) 1681 | ->withUserInfo(null) 1682 | ->withPort(null) 1683 | ->withHost(null); 1684 | 1685 | return match (true) { 1686 | $targetPath !== $basePath => $uri->withPath(self::relativizePath($targetPath, $basePath)), 1687 | $this->query === $uri->getQuery() => $uri->withPath('')->withQuery(null), 1688 | null === $uri->getQuery() => $uri->withPath(self::formatPathWithEmptyBaseQuery($targetPath)), 1689 | default => $uri->withPath(''), 1690 | }; 1691 | } 1692 | 1693 | /** 1694 | * Formatting the path to keep a resolvable URI. 1695 | */ 1696 | private static function formatPathWithEmptyBaseQuery(string $path): string 1697 | { 1698 | $targetSegments = self::getSegments($path); 1699 | $basename = $targetSegments[array_key_last($targetSegments)]; 1700 | 1701 | return '' === $basename ? './' : $basename; 1702 | } 1703 | 1704 | /** 1705 | * Relatives the URI for an authority-less target URI. 1706 | */ 1707 | private static function relativizePath(string $path, string $basePath): string 1708 | { 1709 | $baseSegments = self::getSegments($basePath); 1710 | $targetSegments = self::getSegments($path); 1711 | $targetBasename = array_pop($targetSegments); 1712 | array_pop($baseSegments); 1713 | foreach ($baseSegments as $offset => $segment) { 1714 | if (!isset($targetSegments[$offset]) || $segment !== $targetSegments[$offset]) { 1715 | break; 1716 | } 1717 | unset($baseSegments[$offset], $targetSegments[$offset]); 1718 | } 1719 | $targetSegments[] = $targetBasename; 1720 | 1721 | return static::formatRelativePath( 1722 | str_repeat('../', count($baseSegments)).implode('/', $targetSegments), 1723 | $basePath 1724 | ); 1725 | } 1726 | 1727 | /** 1728 | * Formatting the path to keep a valid URI. 1729 | */ 1730 | private static function formatRelativePath(string $path, string $basePath): string 1731 | { 1732 | $colonPosition = strpos($path, ':'); 1733 | $slashPosition = strpos($path, '/'); 1734 | 1735 | return match (true) { 1736 | '' === $path => match (true) { 1737 | '' === $basePath, 1738 | '/' === $basePath => $basePath, 1739 | default => './', 1740 | }, 1741 | false === $colonPosition => $path, 1742 | false === $slashPosition, 1743 | $colonPosition < $slashPosition => "./$path", 1744 | default => $path, 1745 | }; 1746 | } 1747 | 1748 | /** 1749 | * returns the path segments. 1750 | * 1751 | * @return array 1752 | */ 1753 | private static function getSegments(string $path): array 1754 | { 1755 | return explode('/', match (true) { 1756 | '' === $path, 1757 | '/' !== $path[0] => $path, 1758 | default => substr($path, 1), 1759 | }); 1760 | } 1761 | 1762 | /** 1763 | * @return ComponentMap 1764 | */ 1765 | public function __debugInfo(): array 1766 | { 1767 | return $this->toComponents(); 1768 | } 1769 | 1770 | /** 1771 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1772 | * 1773 | * @deprecated Since version 7.6.0 1774 | * @codeCoverageIgnore 1775 | * @see Uri::parse() 1776 | * 1777 | * Creates a new instance from a URI and a Base URI. 1778 | * 1779 | * The returned URI must be absolute. 1780 | */ 1781 | #[Deprecated(message:'use League\Uri\Uri::parse() instead', since:'league/uri:7.6.0')] 1782 | public static function fromBaseUri(WhatWgUrl|Rfc3986Uri|Stringable|string $uri, WhatWgUrl|Rfc3986Uri|Stringable|string|null $baseUri = null): self 1783 | { 1784 | $formatter = fn (WhatWgUrl|Rfc3986Uri|Stringable|string $uri): string => match (true) { 1785 | $uri instanceof Rfc3986Uri => $uri->toRawString(), 1786 | $uri instanceof WhatWgUrl => $uri->toAsciiString(), 1787 | default => str_replace(' ', '%20', (string) $uri), 1788 | }; 1789 | 1790 | return self::new( 1791 | UriString::resolve( 1792 | uri: $formatter($uri), 1793 | baseUri: null !== $baseUri ? $formatter($baseUri) : $baseUri 1794 | ) 1795 | ); 1796 | } 1797 | 1798 | /** 1799 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1800 | * 1801 | * @deprecated Since version 7.5.0 1802 | * @codeCoverageIgnore 1803 | * @see Uri::toComponents() 1804 | * 1805 | * @return ComponentMap 1806 | */ 1807 | #[Deprecated(message:'use League\Uri\Uri::toComponents() instead', since:'league/uri:7.5.0')] 1808 | public function getComponents(): array 1809 | { 1810 | return $this->toComponents(); 1811 | } 1812 | 1813 | /** 1814 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1815 | * 1816 | * @deprecated Since version 7.0.0 1817 | * @codeCoverageIgnore 1818 | * @see Uri::new() 1819 | */ 1820 | #[Deprecated(message:'use League\Uri\Uri::new() instead', since:'league/uri:7.0.0')] 1821 | public static function createFromString(Stringable|string $uri = ''): self 1822 | { 1823 | return self::new($uri); 1824 | } 1825 | 1826 | /** 1827 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1828 | * 1829 | * @deprecated Since version 7.0.0 1830 | * @codeCoverageIgnore 1831 | * @see Uri::fromComponents() 1832 | * 1833 | * @param InputComponentMap $components a hash representation of the URI similar to PHP parse_url function result 1834 | */ 1835 | #[Deprecated(message:'use League\Uri\Uri::fromComponents() instead', since:'league/uri:7.0.0')] 1836 | public static function createFromComponents(array $components = []): self 1837 | { 1838 | return self::fromComponents($components); 1839 | } 1840 | 1841 | /** 1842 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1843 | * 1844 | * @param resource|null $context 1845 | * 1846 | * @throws MissingFeature If ext/fileinfo is not installed 1847 | * @throws SyntaxError If the file does not exist or is not readable 1848 | * @see Uri::fromFileContents() 1849 | * 1850 | * @deprecated Since version 7.0.0 1851 | * @codeCoverageIgnore 1852 | */ 1853 | #[Deprecated(message:'use League\Uri\Uri::fromDataPath() instead', since:'league/uri:7.0.0')] 1854 | public static function createFromDataPath(string $path, $context = null): self 1855 | { 1856 | return self::fromFileContents($path, $context); 1857 | } 1858 | 1859 | /** 1860 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1861 | * 1862 | * @deprecated Since version 7.0.0 1863 | * @codeCoverageIgnore 1864 | * @see Uri::fromBaseUri() 1865 | * 1866 | * Creates a new instance from a URI and a Base URI. 1867 | * 1868 | * The returned URI must be absolute. 1869 | */ 1870 | #[Deprecated(message:'use League\Uri\Uri::fromBaseUri() instead', since:'league/uri:7.0.0')] 1871 | public static function createFromBaseUri( 1872 | Stringable|UriInterface|String $uri, 1873 | Stringable|UriInterface|String|null $baseUri = null 1874 | ): static { 1875 | return self::fromBaseUri($uri, $baseUri); 1876 | } 1877 | 1878 | /** 1879 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1880 | * 1881 | * @deprecated Since version 7.0.0 1882 | * @codeCoverageIgnore 1883 | * @see Uri::fromUnixPath() 1884 | * 1885 | * Create a new instance from a Unix path string. 1886 | */ 1887 | #[Deprecated(message:'use League\Uri\Uri::fromUnixPath() instead', since:'league/uri:7.0.0')] 1888 | public static function createFromUnixPath(string $uri = ''): self 1889 | { 1890 | return self::fromUnixPath($uri); 1891 | } 1892 | 1893 | /** 1894 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1895 | * 1896 | * @deprecated Since version 7.0.0 1897 | * @codeCoverageIgnore 1898 | * @see Uri::fromWindowsPath() 1899 | * 1900 | * Create a new instance from a local Windows path string. 1901 | */ 1902 | #[Deprecated(message:'use League\Uri\Uri::fromWindowsPath() instead', since:'league/uri:7.0.0')] 1903 | public static function createFromWindowsPath(string $uri = ''): self 1904 | { 1905 | return self::fromWindowsPath($uri); 1906 | } 1907 | 1908 | /** 1909 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1910 | * 1911 | * @deprecated Since version 7.0.0 1912 | * @codeCoverageIgnore 1913 | * @see Uri::new() 1914 | * 1915 | * Create a new instance from a URI object. 1916 | */ 1917 | #[Deprecated(message:'use League\Uri\Uri::new() instead', since:'league/uri:7.0.0')] 1918 | public static function createFromUri(Psr7UriInterface|UriInterface $uri): self 1919 | { 1920 | return self::new($uri); 1921 | } 1922 | 1923 | /** 1924 | * DEPRECATION WARNING! This method will be removed in the next major point release. 1925 | * 1926 | * @deprecated Since version 7.0.0 1927 | * @codeCoverageIgnore 1928 | * @see Uri::fromServer() 1929 | * 1930 | * Create a new instance from the environment. 1931 | */ 1932 | #[Deprecated(message:'use League\Uri\Uri::fromServer() instead', since:'league/uri:7.0.0')] 1933 | public static function createFromServer(array $server): self 1934 | { 1935 | return self::fromServer($server); 1936 | } 1937 | } 1938 | --------------------------------------------------------------------------------