diff --git a/BaseUri.php b/BaseUri.php index a95d04d5..25c4995d 100644 --- a/BaseUri.php +++ b/BaseUri.php @@ -54,39 +54,6 @@ final protected function __construct( $this->origin = $this->computeOrigin($this->uri, $this->nullValue); } - final protected function computeOrigin(Psr7UriInterface|UriInterface $uri, ?string $nullValue): Psr7UriInterface|UriInterface|null - { - $scheme = $uri->getScheme(); - if ('blob' !== $scheme) { - return match (true) { - isset(static::WHATWG_SPECIAL_SCHEMES[$scheme]) => $uri - ->withFragment($nullValue) - ->withQuery($nullValue) - ->withPath('') - ->withUserInfo($nullValue), - default => null, - }; - } - - $components = UriString::parse($uri->getPath()); - if ($uri instanceof Psr7UriInterface) { - /** @var ComponentMap $components */ - $components = array_map(fn ($component) => null === $component ? '' : $component, $components); - } - - return match (true) { - null !== $components['scheme'] && isset(static::WHATWG_SPECIAL_SCHEMES[strtolower($components['scheme'])]) => $uri - ->withFragment($nullValue) - ->withQuery($nullValue) - ->withPath('') - ->withHost($components['host']) - ->withPort($components['port']) - ->withScheme($components['scheme']) - ->withUserInfo($nullValue), - default => null, - }; - } - public static function from(Stringable|string $uri, ?UriFactoryInterface $uriFactory = null): static { return new static(static::formatHost(static::filterUri($uri, $uriFactory)), $uriFactory); @@ -135,22 +102,37 @@ public function origin(): ?self */ public function isCrossOrigin(Stringable|string $uri): bool { - return null === $this->origin - || null === ($uriOrigin = $this->computeOrigin(static::filterUri($uri), null)) + if (null === $this->origin) { + return true; + } + + $uri = static::filterUri($uri); + $uriOrigin = $this->computeOrigin($uri, $uri instanceof Psr7UriInterface ? '' : null); + + return null === $uriOrigin || $uriOrigin->__toString() !== $this->origin->__toString(); } + /** + * Tells whether the URI is absolute. + */ public function isAbsolute(): bool { return $this->nullValue !== $this->uri->getScheme(); } + /** + * Tells whether the URI is a network path. + */ public function isNetworkPath(): bool { return $this->nullValue === $this->uri->getScheme() && $this->nullValue !== $this->uri->getAuthority(); } + /** + * Tells whether the URI is an absolute path. + */ public function isAbsolutePath(): bool { return $this->nullValue === $this->uri->getScheme() @@ -158,6 +140,9 @@ public function isAbsolutePath(): bool && '/' === ($this->uri->getPath()[0] ?? ''); } + /** + * Tells whether the URI is a relative path. + */ public function isRelativePath(): bool { return $this->nullValue === $this->uri->getScheme() @@ -173,56 +158,6 @@ public function isSameDocument(Stringable|string $uri): bool return $this->normalize(static::filterUri($uri)) === $this->normalize($this->uri); } - /** - * Normalizes a URI for comparison. - */ - final protected function normalize(Psr7UriInterface|UriInterface $uri): string - { - $null = $uri instanceof Psr7UriInterface ? '' : null; - - $path = $uri->getPath(); - if ('/' === ($path[0] ?? '') || '' !== $uri->getScheme().$uri->getAuthority()) { - $path = $this->removeDotSegments($path); - } - - $query = $uri->getQuery(); - $pairs = null === $query ? [] : explode('&', $query); - sort($pairs); - - static $regexpEncodedChars = ',%(2[D|E]|3\d|4[1-9|A-F]|5[\d|AF]|6[1-9|A-F]|7[\d|E]),i'; - $value = preg_replace_callback( - $regexpEncodedChars, - static fn (array $matches): string => rawurldecode($matches[0]), - [$path, implode('&', $pairs)] - ) ?? ['', $null]; - - [$path, $query] = $value + ['', $null]; - if ($null !== $uri->getAuthority() && '' === $path) { - $path = '/'; - } - - return $uri - ->withHost(Uri::fromComponents(['host' => $uri->getHost()])->getHost()) - ->withPath($path) - ->withQuery([] === $pairs ? $null : $query) - ->withFragment($null) - ->__toString(); - } - - /** - * Input URI normalization to allow Stringable and string URI. - */ - final protected static function filterUri(Stringable|string $uri, UriFactoryInterface|null $uriFactory = null): Psr7UriInterface|UriInterface - { - return match (true) { - $uri instanceof UriAccess => $uri->getUri(), - $uri instanceof Psr7UriInterface, - $uri instanceof UriInterface => $uri, - $uriFactory instanceof UriFactoryInterface => $uriFactory->createUri((string) $uri), - default => Uri::new($uri), - }; - } - /** * Resolves a URI against a base URI using RFC3986 rules. * @@ -232,7 +167,7 @@ final protected static function filterUri(Stringable|string $uri, UriFactoryInte * This method MUST be transparent when dealing with error and exceptions. * It MUST not alter or silence them apart from validating its own parameters. */ - final public function resolve(Stringable|string $uri): static + public function resolve(Stringable|string $uri): static { $uri = static::formatHost(static::filterUri($uri, $this->uriFactory)); $null = $uri instanceof Psr7UriInterface ? '' : null; @@ -275,7 +210,122 @@ final public function resolve(Stringable|string $uri): static } /** - * Remove dot segments from the URI path. + * Relativize a URI according to a base URI. + * + * This method MUST retain the state of the submitted URI instance, and return + * a URI instance of the same type that contains the applied modifications. + * + * This method MUST be transparent when dealing with error and exceptions. + * It MUST not alter of silence them apart from validating its own parameters. + */ + public function relativize(Stringable|string $uri): static + { + $uri = static::formatHost(static::filterUri($uri, $this->uriFactory)); + if ($this->canNotBeRelativize($uri)) { + return new static($uri, $this->uriFactory); + } + + $null = $uri instanceof Psr7UriInterface ? '' : null; + $uri = $uri->withScheme($null)->withPort(null)->withUserInfo($null)->withHost($null); + $targetPath = $uri->getPath(); + $basePath = $this->uri->getPath(); + + return new static( + match (true) { + $targetPath !== $basePath => $uri->withPath(static::relativizePath($targetPath, $basePath)), + static::componentEquals('query', $uri) => $uri->withPath('')->withQuery($null), + $null === $uri->getQuery() => $uri->withPath(static::formatPathWithEmptyBaseQuery($targetPath)), + default => $uri->withPath(''), + }, + $this->uriFactory + ); + } + + final protected function computeOrigin(Psr7UriInterface|UriInterface $uri, ?string $nullValue): Psr7UriInterface|UriInterface|null + { + $scheme = $uri->getScheme(); + if ('blob' !== $scheme) { + return match (true) { + isset(static::WHATWG_SPECIAL_SCHEMES[$scheme]) => $uri + ->withFragment($nullValue) + ->withQuery($nullValue) + ->withPath('') + ->withUserInfo($nullValue), + default => null, + }; + } + + $components = UriString::parse($uri->getPath()); + if ($uri instanceof Psr7UriInterface) { + /** @var ComponentMap $components */ + $components = array_map(fn ($component) => null === $component ? '' : $component, $components); + } + + return match (true) { + null !== $components['scheme'] && isset(static::WHATWG_SPECIAL_SCHEMES[strtolower($components['scheme'])]) => $uri + ->withFragment($nullValue) + ->withQuery($nullValue) + ->withPath('') + ->withHost($components['host']) + ->withPort($components['port']) + ->withScheme($components['scheme']) + ->withUserInfo($nullValue), + default => null, + }; + } + + /** + * Normalizes a URI for comparison; this URI string representation is not suitable for usage as per RFC guidelines. + */ + final protected function normalize(Psr7UriInterface|UriInterface $uri): string + { + $null = $uri instanceof Psr7UriInterface ? '' : null; + + $path = $uri->getPath(); + if ('/' === ($path[0] ?? '') || '' !== $uri->getScheme().$uri->getAuthority()) { + $path = $this->removeDotSegments($path); + } + + $query = $uri->getQuery(); + $pairs = null === $query ? [] : explode('&', $query); + sort($pairs); + + static $regexpEncodedChars = ',%(2[D|E]|3\d|4[1-9|A-F]|5[\d|AF]|6[1-9|A-F]|7[\d|E]),i'; + $value = preg_replace_callback( + $regexpEncodedChars, + static fn (array $matches): string => rawurldecode($matches[0]), + [$path, implode('&', $pairs)] + ) ?? ['', $null]; + + [$path, $query] = $value + ['', $null]; + if ($null !== $uri->getAuthority() && '' === $path) { + $path = '/'; + } + + return $uri + ->withHost(Uri::fromComponents(['host' => $uri->getHost()])->getHost()) + ->withPath($path) + ->withQuery([] === $pairs ? $null : $query) + ->withFragment($null) + ->__toString(); + } + + /** + * Input URI normalization to allow Stringable and string URI. + */ + final protected static function filterUri(Stringable|string $uri, UriFactoryInterface|null $uriFactory = null): Psr7UriInterface|UriInterface + { + return match (true) { + $uri instanceof UriAccess => $uri->getUri(), + $uri instanceof Psr7UriInterface, + $uri instanceof UriInterface => $uri, + $uriFactory instanceof UriFactoryInterface => $uriFactory->createUri((string) $uri), + default => Uri::new($uri), + }; + } + + /** + * Remove dot segments from the URI path as per RFC specification. */ final protected function removeDotSegments(string $path): string { @@ -283,8 +333,22 @@ final protected function removeDotSegments(string $path): string return $path; } + $reducer = function (array $carry, string $segment): array { + if ('..' === $segment) { + array_pop($carry); + + return $carry; + } + + if (!isset(static::DOT_SEGMENTS[$segment])) { + $carry[] = $segment; + } + + return $carry; + }; + $oldSegments = explode('/', $path); - $newPath = implode('/', array_reduce($oldSegments, static::reducer(...), [])); + $newPath = implode('/', array_reduce($oldSegments, $reducer(...), [])); if (isset(static::DOT_SEGMENTS[end($oldSegments)])) { $newPath .= '/'; } @@ -299,26 +363,6 @@ final protected function removeDotSegments(string $path): string return $newPath; } - /** - * Remove dot segments. - * - * @return array - */ - final protected static function reducer(array $carry, string $segment): array - { - if ('..' === $segment) { - array_pop($carry); - - return $carry; - } - - if (!isset(static::DOT_SEGMENTS[$segment])) { - $carry[] = $segment; - } - - return $carry; - } - /** * Resolves an URI path and query component. * @@ -366,63 +410,27 @@ final protected function resolvePathAndQuery(Psr7UriInterface|UriInterface $uri) return [$targetPath, $uri->getQuery()]; } - /** - * Relativize a URI according to a base URI. - * - * This method MUST retain the state of the submitted URI instance, and return - * a URI instance of the same type that contains the applied modifications. - * - * This method MUST be transparent when dealing with error and exceptions. - * It MUST not alter of silence them apart from validating its own parameters. - */ - final public function relativize(Stringable|string $uri): static - { - $uri = static::formatHost(static::filterUri($uri, $this->uriFactory)); - if ($this->canNotBeRelativize($uri)) { - return new static($uri, $this->uriFactory); - } - - $null = $uri instanceof Psr7UriInterface ? '' : null; - $uri = $uri->withScheme($null)->withPort(null)->withUserInfo($null)->withHost($null); - $targetPath = $uri->getPath(); - $basePath = $this->uri->getPath(); - - return new static( - match (true) { - $targetPath !== $basePath => $uri->withPath(static::relativizePath($targetPath, $basePath)), - static::componentEquals('query', $uri) => $uri->withPath('')->withQuery($null), - $null === $uri->getQuery() => $uri->withPath(static::formatPathWithEmptyBaseQuery($targetPath)), - default => $uri->withPath(''), - }, - $this->uriFactory - ); - } - /** * Tells whether the component value from both URI object equals. - */ - final protected function componentEquals(string $property, Psr7UriInterface|UriInterface $uri): bool - { - return static::getComponent($property, $uri) === static::getComponent($property, $this->uri); - } - - /** - * Returns the component value from the submitted URI object. * * @pqram 'query'|'authority'|'scheme' $property */ - final protected static function getComponent(string $property, Psr7UriInterface|UriInterface $uri): ?string + final protected function componentEquals(string $property, Psr7UriInterface|UriInterface $uri): bool { - $component = match ($property) { - 'query' => $uri->getQuery(), - 'authority' => $uri->getAuthority(), - default => $uri->getScheme(), - }; + $getComponent = function (string $property, Psr7UriInterface|UriInterface $uri): ?string { + $component = match ($property) { + 'query' => $uri->getQuery(), + 'authority' => $uri->getAuthority(), + default => $uri->getScheme(), + }; - return match (true) { - $uri instanceof UriInterface, '' !== $component => $component, - default => null, + return match (true) { + $uri instanceof UriInterface, '' !== $component => $component, + default => null, + }; }; + + return $getComponent($property, $uri) === $getComponent($property, $this->uri); } /** @@ -449,9 +457,9 @@ final protected static function formatHost(Psr7UriInterface|UriInterface $uri): */ final protected function canNotBeRelativize(Psr7UriInterface|UriInterface $uri): bool { - return static::from($uri)->isRelativePath() - || !static::componentEquals('scheme', $uri) - || !static::componentEquals('authority', $uri); + return !static::componentEquals('scheme', $uri) + || !static::componentEquals('authority', $uri) + || static::from($uri)->isRelativePath(); } /** @@ -484,11 +492,11 @@ final protected static function relativizePath(string $path, string $basePath): */ final protected static function getSegments(string $path): array { - if ('' !== $path && '/' === $path[0]) { - $path = substr($path, 1); - } - - return explode('/', $path); + return explode('/', match (true) { + '' === $path, + '/' !== $path[0] => $path, + default => substr($path, 1), + }); } /** diff --git a/Http.php b/Http.php index 3aeb35f7..2b3dc7f0 100644 --- a/Http.php +++ b/Http.php @@ -14,8 +14,10 @@ namespace League\Uri; use JsonSerializable; +use League\Uri\Contracts\UriException; use League\Uri\Contracts\UriInterface; use League\Uri\Exceptions\SyntaxError; +use League\Uri\UriTemplate\TemplateCanNotBeExpanded; use Psr\Http\Message\UriInterface as Psr7UriInterface; use Stringable; @@ -76,6 +78,12 @@ public static function fromBaseUri(Stringable|string $uri, Stringable|string|nul return new self(Uri::fromBaseUri($uri, $baseUri)); } + /** + * Creates a new instance from a template. + * + * @throws TemplateCanNotBeExpanded if the variables are invalid or missing + * @throws UriException if the variables are invalid or missing + */ public static function fromTemplate(Stringable|string $template, iterable $variables = []): self { return new self(Uri::fromTemplate($template, $variables)); diff --git a/Uri.php b/Uri.php index 1892a823..28f70d81 100644 --- a/Uri.php +++ b/Uri.php @@ -56,7 +56,7 @@ use const FILTER_VALIDATE_IP; /** - * @phpstan-import-type ComponentMap from UriInterface + * @phpstan-import-type ComponentMap from UriString * @phpstan-import-type InputComponentMap from UriString */ final class Uri implements UriInterface @@ -406,7 +406,25 @@ private function formatPort(?int $port = null): ?int */ public static function new(Stringable|string $uri = ''): self { - $components = $uri instanceof UriInterface ? $uri->getComponents() : UriString::parse($uri); + $components = match (true) { + $uri instanceof UriInterface => $uri->getComponents(), + $uri instanceof Psr7UriInterface => (function (Psr7UriInterface $uri): array { + $normalize = fn ($component) => '' !== $component ? $component : null; + $userInfo = $uri->getUserInfo(); + [$user, $pass] = '' !== $userInfo ? explode(':', $userInfo, 2) : ['', '']; + return [ + 'scheme' => $normalize($uri->getScheme()), + 'user' => $normalize($user), + 'pass' => $normalize($pass), + 'host' => $normalize($uri->getHost()), + 'port' => $uri->getPort(), + 'path' => $uri->getPath(), + 'query' => $normalize($uri->getQuery()), + 'fragment' => $normalize($uri->getFragment()), + ]; + })($uri), + default => UriString::parse($uri), + }; return new self( $components['scheme'], @@ -445,7 +463,7 @@ public static function fromBaseUri(Stringable|string $uri, Stringable|string|nul * @throws TemplateCanNotBeExpanded if the variables are invalid or missing * @throws UriException if the resulting expansion can not be converted to a UriInterface instance */ - public static function fromTemplate(Stringable|string $template, iterable $variables = []): self + public static function fromTemplate(UriTemplate|Stringable|string $template, iterable $variables = []): self { return match (true) { $template instanceof UriTemplate => self::fromComponents($template->expand($variables)->getComponents()), @@ -984,7 +1002,7 @@ public function jsonSerialize(): string */ public function getComponents(): array { - [$user, $pass] = null !== $this->userInfo ? explode(':', $this->userInfo) : [null, null]; + [$user, $pass] = null !== $this->userInfo ? explode(':', $this->userInfo, 2) : [null, null]; return [ 'scheme' => $this->scheme, diff --git a/UriTemplate.php b/UriTemplate.php index 625cf04b..f7eeaf3d 100644 --- a/UriTemplate.php +++ b/UriTemplate.php @@ -31,7 +31,7 @@ * @author Ignace Nyamagana Butera * @since 6.1.0 */ -final class UriTemplate implements Stringable +final class UriTemplate { private readonly Template $template; private readonly VariableBag $defaultVariables; @@ -40,7 +40,7 @@ final class UriTemplate implements Stringable * @throws SyntaxError if the template syntax is invalid * @throws TemplateCanNotBeExpanded if the template or the variables are invalid */ - public function __construct(Template|Stringable|string $template, iterable $defaultVariables = []) + public function __construct(Stringable|string $template, iterable $defaultVariables = []) { $this->template = $template instanceof Template ? $template : Template::new($template); $this->defaultVariables = $this->filterVariables($defaultVariables); @@ -52,13 +52,14 @@ private function filterVariables(iterable $variables): VariableBag $variables = new VariableBag($variables); } - $offsets = array_fill_keys($this->template->variableNames, 1); - return $variables - ->filter(fn ($value, string|int $name) => array_key_exists($name, $offsets)); + ->filter(fn ($value, string|int $name) => array_key_exists( + $name, + array_fill_keys($this->template->variableNames, 1) + )); } - public function __toString(): string + public function getTemplate(): string { return $this->template->value; } @@ -118,18 +119,4 @@ public function expandOrFail(iterable $variables = []): UriInterface $this->filterVariables($variables)->replace($this->defaultVariables) )); } - - /** - * DEPRECATION WARNING! This method will be removed in the next major point release. - * - * @deprecated Since version 7.0.0 - * @codeCoverageIgnore - * @see UriTemplate::__toString - * - * Returns the template string - */ - public function getTemplate(): string - { - return $this->__toString(); - } } diff --git a/UriTemplate/TemplateTest.php b/UriTemplate/TemplateTest.php index fd3bddf0..1d425fc5 100644 --- a/UriTemplate/TemplateTest.php +++ b/UriTemplate/TemplateTest.php @@ -95,6 +95,7 @@ public static function uriTemplateSpecificationDataProvider(): iterable public function testItCanBeInstantiatedWithAValidNotation(string $notation): void { self::assertSame($notation, Template::new($notation)->value); + self::assertSame($notation, (string) Template::new($notation)); } public static function providesValidNotation(): iterable @@ -180,4 +181,11 @@ public static function providesExpansion(): iterable ], ]; } + + public function testExpandOrFailIfAtLeastOneVariableIsMissing(): void + { + $this->expectException(TemplateCanNotBeExpanded::class); + + Template::new('{var}{baz}')->expandOrFail(['var' => 'bar']); + } } diff --git a/UriTemplateTest.php b/UriTemplateTest.php index fbdfef94..3538f7e1 100644 --- a/UriTemplateTest.php +++ b/UriTemplateTest.php @@ -35,7 +35,7 @@ public function testGetTemplate(): void $uriTemplate = new UriTemplate($template, $variables); - self::assertSame($template, (string) $uriTemplate); + self::assertSame($template, $uriTemplate->getTemplate()); } public function testGetDefaultVariables(): void @@ -356,9 +356,9 @@ public function testExpansionWithMultipleSameExpression(): void public function testExpandOrFailIfVariablesAreMissing(): void { - $this->expectException(UriTemplate\TemplateCanNotBeExpanded::class); + $this->expectException(TemplateCanNotBeExpanded::class); - (new UriTemplate('{var}'))->expandOrFail([]); + (new UriTemplate('{var}'))->expandOrFail(); } public function testExpandOrFailIfAtLeastOneVariableIsMissing(): void