* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ declare(strict_types=1); namespace League\Uri; use JsonSerializable; use League\Uri\Components\DataPath; use League\Uri\Components\Domain; use League\Uri\Components\HierarchicalPath; use League\Uri\Components\Host; use League\Uri\Components\Path; use League\Uri\Components\Query; use League\Uri\Contracts\PathInterface; use League\Uri\Contracts\UriAccess; use League\Uri\Contracts\UriInterface; use League\Uri\Exceptions\SyntaxError; use League\Uri\Idna\Converter as IdnConverter; use League\Uri\IPv4\Converter as IPv4Converter; use League\Uri\KeyValuePair\Converter as KeyValuePairConverter; use Psr\Http\Message\UriFactoryInterface; use Psr\Http\Message\UriInterface as Psr7UriInterface; use Stringable; use function get_object_vars; use function ltrim; use function rtrim; use function str_ends_with; use function str_starts_with; class Modifier implements Stringable, JsonSerializable, UriAccess { final public function __construct(protected readonly Psr7UriInterface|UriInterface $uri) { } public static function from(Stringable|string $uri, ?UriFactoryInterface $uriFactory = null): static { return new static(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), }); } public function getUri(): Psr7UriInterface|UriInterface { return $this->uri; } public function getUriString(): string { return $this->uri->__toString(); } public function jsonSerialize(): string { return $this->uri->__toString(); } public function __toString(): string { return $this->uri->__toString(); } /********************************* * Query modifier methods *********************************/ /** * Change the encoding of the query. * */ public function encodeQuery(KeyValuePairConverter|int $to, KeyValuePairConverter|int|null $from = null): static { $to = match (true) { !$to instanceof KeyValuePairConverter => KeyValuePairConverter::fromEncodingType($to), default => $to, }; $from = match (true) { null === $from => KeyValuePairConverter::fromRFC3986(), !$from instanceof KeyValuePairConverter => KeyValuePairConverter::fromEncodingType($from), default => $from, }; if ($to == $from) { return $this; } $originalQuery = $this->uri->getQuery(); $query = QueryString::buildFromPairs(QueryString::parseFromValue($originalQuery, $from), $to); return match (true) { null === $query, '' === $query, $originalQuery === $query => $this, default => new static($this->uri->withQuery($query)), }; } /** * Sort the URI query by keys. */ public function sortQuery(): static { return new static($this->uri->withQuery( static::normalizeComponent( Query::fromUri($this->uri)->sort()->value(), $this->uri ) )); } /** * Add the new query data to the existing URI query. */ public function appendQuery(Stringable|string|null $query): static { return new static($this->uri->withQuery( static::normalizeComponent( Query::fromUri($this->uri)->append($query)->value(), $this->uri ) )); } /** * Merge query paris with the existing URI query. * * @param iterable $pairs */ public function appendQueryPairs(iterable $pairs): self { return $this->appendQuery(Query::fromPairs($pairs)->value()); } /** * Append PHP query parameters to the existing URI query. */ public function appendQueryParameters(object|array $parameters): self { return $this->appendQuery(Query::fromVariable($parameters)->value()); } /** * Merge a new query with the existing URI query. */ public function mergeQuery(Stringable|string|null $query): static { return new static($this->uri->withQuery( static::normalizeComponent( Query::fromUri($this->uri)->merge($query)->value(), $this->uri ) )); } /** * Merge query paris with the existing URI query. * * @param iterable $pairs */ public function mergeQueryPairs(iterable $pairs): self { $currentPairs = [...Query::fromUri($this->uri)->pairs()]; $pairs = [...$pairs]; return match (true) { [] === $pairs, $currentPairs === $pairs => $this, default => $this->mergeQuery(Query::fromPairs($pairs)->value()), }; } /** * Merge PHP query parameters with the existing URI query. */ public function mergeQueryParameters(object|array $parameters): self { $parameters = match (true) { is_object($parameters) => get_object_vars($parameters), default => $parameters, }; $currentParameters = Query::fromUri($this->uri)->parameters(); return match (true) { [] === $parameters, $currentParameters === $parameters => $this, default => new static($this->uri->withQuery( self::normalizeComponent( Query::fromVariable([...$currentParameters, ...$parameters])->value(), $this->uri ) )), }; } /** * Remove query data according to their key name. */ public function removeQueryPairsByKey(string ...$keys): static { $query = Query::fromUri($this->uri); $newQuery = $query->withoutPairByKey(...$keys)->value(); return match ($query->value()) { $newQuery => $this, default => new static($this->uri->withQuery(static::normalizeComponent($newQuery, $this->uri))), }; } /** * Remove query pair according to their value. */ public function removeQueryPairsByValue(Stringable|string|int|float|bool|null ...$values): static { $query = Query::fromUri($this->uri); $newQuery = $query->withoutPairByValue(...$values)->value(); return match ($query->value()) { $newQuery => $this, default => new static($this->uri->withQuery(static::normalizeComponent($newQuery, $this->uri))), }; } /** * Remove query pair according to their key/value name. */ public function removeQueryPairsByKeyValue(string $key, Stringable|string|int|bool|null $value): static { $query = Query::fromUri($this->uri); $newQuery = $query->withoutPairByKeyValue($key, $value)->value(); return match ($newQuery) { $query->value() => $this, default => new static($this->uri->withQuery(static::normalizeComponent($newQuery, $this->uri))), }; } /** * Remove query data according to their PHP parameter key name. */ public function removeQueryParameters(string ...$keys): static { $query = Query::fromUri($this->uri); $newQuery = $query->withoutParameters(...$keys)->value(); return match ($newQuery) { $query->value() => $this, default => new static($this->uri->withQuery(static::normalizeComponent($newQuery, $this->uri))), }; } /** * Remove empty pairs from the URL query component. * * A pair is considered empty if it's name is the empty string * and its value is either the empty string or the null value */ public function removeEmptyQueryPairs(): static { return new static($this->uri->withQuery( static::normalizeComponent( Query::fromUri($this->uri)->withoutEmptyPairs()->value(), $this->uri ) )); } /** * Returns an instance where numeric indices associated to PHP's array like key are removed. * * This method MUST retain the state of the current instance, and return * an instance that contains the query component normalized so that numeric indexes * are removed from the pair key value. * * ie.: toto[3]=bar[3]&foo=bar becomes toto[]=bar[3]&foo=bar */ public function removeQueryParameterIndices(): static { $query = Query::fromUri($this->uri); $newQuery = $query->withoutNumericIndices()->value(); return match ($newQuery) { $query->value() => $this, default => new static($this->uri->withQuery(static::normalizeComponent($newQuery, $this->uri))), }; } /********************************* * Host modifier methods *********************************/ /** * Add the root label to the URI. */ public function addRootLabel(): static { $host = $this->uri->getHost(); return match (true) { null === $host, str_ends_with($host, '.') => $this, default => new static($this->uri->withHost($host.'.')), }; } /** * Append a label or a host to the current URI host. * * @throws SyntaxError If the host cannot be appended */ public function appendLabel(Stringable|string|null $label): static { $host = Host::fromUri($this->uri); $label = Host::new($label); return match (true) { null === $label->value() => $this, $host->isDomain() => new static($this->uri->withHost(static::normalizeComponent(Domain::new($host)->append($label)->toUnicode(), $this->uri))), $host->isIpv4() => new static($this->uri->withHost($host->value().'.'.ltrim($label->value(), '.'))), default => throw new SyntaxError('The URI host '.$host->toString().' cannot be appended.'), }; } /** * Convert the URI host part to its ascii value. */ public function hostToAscii(): static { $currentHost = $this->uri->getHost(); $host = IdnConverter::toAsciiOrFail((string) $currentHost); return match (true) { null === $currentHost, '' === $currentHost, $host === $currentHost => $this, default => new static($this->uri->withHost($host)), }; } /** * Convert the URI host part to its unicode value. */ public function hostToUnicode(): static { $currentHost = $this->uri->getHost(); $host = IdnConverter::toUnicode((string) $currentHost)->domain(); return match (true) { null === $currentHost, '' === $currentHost, $host === $currentHost => $this, default => new static($this->uri->withHost($host)), }; } /** * Normalizes the URI host content to a IPv4 dot-decimal notation if possible * otherwise returns the uri instance unchanged. * * @see https://url.spec.whatwg.org/#concept-ipv4-parser */ public function hostToDecimal(): static { $currentHost = $this->uri->getHost(); $hostIp = self::ipv4Converter()->toDecimal($currentHost); return match (true) { null === $currentHost, '' === $currentHost, null === $hostIp, $currentHost === $hostIp => $this, default => new static($this->uri->withHost($hostIp)), }; } /** * Normalizes the URI host content to a IPv4 octal notation if possible * otherwise returns the uri instance unchanged. * * @see https://url.spec.whatwg.org/#concept-ipv4-parser */ public function hostToOctal(): static { $currentHost = $this->uri->getHost(); $hostIp = self::ipv4Converter()->toOctal($currentHost); return match (true) { null === $currentHost, '' === $currentHost, null === $hostIp, $currentHost === $hostIp => $this, default => new static($this->uri->withHost($hostIp)), }; } /** * Normalizes the URI host content to a IPv4 octal notation if possible * otherwise returns the uri instance unchanged. * * @see https://url.spec.whatwg.org/#concept-ipv4-parser */ public function hostToHexadecimal(): static { $currentHost = $this->uri->getHost(); $hostIp = self::ipv4Converter()->toHexadecimal($currentHost); return match (true) { null === $currentHost, '' === $currentHost, null === $hostIp, $currentHost === $hostIp => $this, default => new static($this->uri->withHost($hostIp)), }; } /** * Prepend a label or a host to the current URI host. * * @throws SyntaxError If the host cannot be prepended */ public function prependLabel(Stringable|string|null $label): static { $host = Host::fromUri($this->uri); $label = Host::new($label); return match (true) { null === $label->value() => $this, $host->isIpv4() => new static($this->uri->withHost(rtrim($label->value(), '.').'.'.$host->value())), $host->isDomain() => new static($this->uri->withHost(static::normalizeComponent(Domain::new($host)->prepend($label)->toUnicode(), $this->uri))), default => throw new SyntaxError('The URI host '.$host->toString().' cannot be prepended.'), }; } /** * Remove host labels according to their offset. */ public function removeLabels(int ...$keys): static { return new static($this->uri->withHost( static::normalizeComponent( Domain::fromUri($this->uri)->withoutLabel(...$keys)->toUnicode(), $this->uri ) )); } /** * Remove the root label to the URI. */ public function removeRootLabel(): static { $host = $this->uri->getHost(); return match (true) { null === $host, '' === $host, !str_ends_with($host, '.') => $this, default => new static($this->uri->withHost(substr($host, 0, -1))), }; } /** * Slice the host from the URI. */ public function sliceLabels(int $offset, ?int $length = null): static { $currentHost = $this->uri->getHost(); $host = Domain::new($currentHost)->slice($offset, $length); return match (true) { $host->value() === $currentHost, $host->toUnicode() === $currentHost => $this, default => new static($this->uri->withHost($host->toUnicode())), }; } /** * Remove the host zone identifier. */ public function removeZoneId(): static { $host = Host::fromUri($this->uri); return match (true) { $host->hasZoneIdentifier() => new static($this->uri->withHost( static::normalizeComponent( Host::fromUri($this->uri)->withoutZoneIdentifier()->value(), $this->uri ) )), default => $this, }; } /** * Replace a label of the current URI host. */ public function replaceLabel(int $offset, Stringable|string|null $label): static { return new static($this->uri->withHost( static::normalizeComponent( Domain::fromUri($this->uri)->withLabel($offset, $label)->toUnicode(), $this->uri ) )); } /********************************* * Path modifier methods *********************************/ /** * Add a new base path to the URI path. */ public function addBasePath(Stringable|string $path): static { /** @var HierarchicalPath $path */ $path = HierarchicalPath::new($path)->withLeadingSlash(); /** @var HierarchicalPath $currentPath */ $currentPath = HierarchicalPath::fromUri($this->uri)->withLeadingSlash(); return new static(match (true) { !str_starts_with($currentPath->toString(), $path->toString()) => $this->uri->withPath($path->append($currentPath)->toString()), default => static::normalizePath($this->uri, $currentPath), }); } /** * Add a leading slash to the URI path. */ public function addLeadingSlash(): static { $path = $this->uri->getPath(); return match (true) { str_starts_with($path, '/') => $this, default => new static($this->uri->withPath('/'.$path)), }; } /** * Add a trailing slash to the URI path. */ public function addTrailingSlash(): static { $path = $this->uri->getPath(); return match (true) { str_ends_with($path, '/') => $this, default => new static($this->uri->withPath($path.'/')), }; } /** * Append a new segment or a new path to the URI path. */ public function appendSegment(Stringable|string $segment): static { return new static(static::normalizePath($this->uri, HierarchicalPath::fromUri($this->uri)->append($segment))); } /** * Convert the Data URI path to its ascii form. */ public function dataPathToAscii(): static { return new static($this->uri->withPath(DataPath::fromUri($this->uri)->toAscii()->toString())); } /** * Convert the Data URI path to its binary (base64encoded) form. */ public function dataPathToBinary(): static { return new static($this->uri->withPath(DataPath::fromUri($this->uri)->toBinary()->toString())); } /** * Prepend a new segment or a new path to the URI path. */ public function prependSegment(Stringable|string $segment): static { return new static(static::normalizePath($this->uri, HierarchicalPath::fromUri($this->uri)->prepend($segment))); } /** * Remove a base path from the URI path. */ public function removeBasePath(Stringable|string $path): static { $basePath = HierarchicalPath::new($path)->withLeadingSlash()->toString(); $currentPath = HierarchicalPath::fromUri($this->uri)->withLeadingSlash()->toString(); $newPath = substr($currentPath, strlen($basePath)); return match (true) { '/' === $basePath, !str_starts_with($currentPath, $basePath), !str_starts_with($newPath, '/') => $this, default => new static($this->uri->withPath($newPath)), }; } /** * Remove dot segments from the URI path. */ public function removeDotSegments(): static { return new static($this->uri->withPath(Path::fromUri($this->uri)->withoutDotSegments()->toString())); } /** * Remove empty segments from the URI path. */ public function removeEmptySegments(): static { return new static($this->uri->withPath(HierarchicalPath::fromUri($this->uri)->withoutEmptySegments()->toString())); } /** * Remove the leading slash from the URI path. */ public function removeLeadingSlash(): static { return new static(static::normalizePath($this->uri, Path::fromUri($this->uri)->withoutLeadingSlash())); } /** * Remove the trailing slash from the URI path. */ public function removeTrailingSlash(): static { $path = $this->uri->getPath(); return match (true) { !str_ends_with($path, '/') => $this, default => new static($this->uri->withPath(substr($path, 0, -1))), }; } /** * Remove path segments from the URI path according to their offsets. */ public function removeSegments(int ...$keys): static { return new static($this->uri->withPath(HierarchicalPath::fromUri($this->uri)->withoutSegment(...$keys)->toString())); } /** * Replace the URI path basename. */ public function replaceBasename(Stringable|string $basename): static { return new static(static::normalizePath($this->uri, HierarchicalPath::fromUri($this->uri)->withBasename($basename))); } /** * Replace the data URI path parameters. */ public function replaceDataUriParameters(Stringable|string $parameters): static { return new static($this->uri->withPath(DataPath::fromUri($this->uri)->withParameters($parameters)->toString())); } /** * Replace the URI path dirname. */ public function replaceDirname(Stringable|string $dirname): static { return new static(static::normalizePath($this->uri, HierarchicalPath::fromUri($this->uri)->withDirname($dirname))); } /** * Replace the URI path basename extension. */ public function replaceExtension(Stringable|string $extension): static { return new static($this->uri->withPath(HierarchicalPath::fromUri($this->uri)->withExtension($extension)->toString())); } /** * Replace a segment from the URI path according its offset. */ public function replaceSegment(int $offset, Stringable|string $segment): static { return new static($this->uri->withPath(HierarchicalPath::fromUri($this->uri)->withSegment($offset, $segment)->toString())); } /** * Slice the host from the URI. */ public function sliceSegments(int $offset, ?int $length = null): static { return new static(static::normalizePath($this->uri, HierarchicalPath::fromUri($this->uri)->slice($offset, $length))); } /** * Normalize a URI path. * * Make sure the path always has a leading slash if an authority is present * and the path is not the empty string. */ final protected static function normalizePath(Psr7UriInterface|UriInterface $uri, PathInterface $path): Psr7UriInterface|UriInterface { $pathString = $path->toString(); $authority = $uri->getAuthority(); return match (true) { '' === $pathString, '/' === $pathString[0], null === $authority, '' === $authority => $uri->withPath($pathString), default => $uri->withPath('/'.$pathString), }; } /** * Normalize the URI component value depending on the subject interface. * * null value MUST be converted to the empty string if a Psr7 UriInterface is being manipulated. */ final protected static function normalizeComponent(?string $component, Psr7UriInterface|UriInterface $uri): ?string { return match (true) { $uri instanceof Psr7UriInterface => (string) $component, default => $component, }; } final protected static function ipv4Converter(): IPv4Converter { static $converter; $converter = $converter ?? IPv4Converter::fromEnvironment(); return $converter; } /** * DEPRECATION WARNING! This method will be removed in the next major point release. * * @deprecated Since version 7.2.0 * @codeCoverageIgnore * @see Modifier::removeQueryParameters() * * Remove query data according to their key name. */ public function removeParams(string ...$keys): static { return $this->removeQueryParameters(...$keys); } /** * DEPRECATION WARNING! This method will be removed in the next major point release. * * @deprecated Since version 7.2.0 * @codeCoverageIgnore * @see Modifier::removeEmptyQueryPairs() * * Remove empty pairs from the URL query component. * * A pair is considered empty if it's name is the empty string * and its value is either the empty string or the null value */ public function removeEmptyPairs(): static { return $this->removeEmptyQueryPairs(); } /** * DEPRECATION WARNING! This method will be removed in the next major point release. * * @deprecated Since version 7.2.0 * @codeCoverageIgnore * @see Modifier::removeQueryPairsByKey() * * Remove query data according to their key name. */ public function removePairs(string ...$keys): static { return $this->removeQueryPairsByKey(...$keys); } /** * DEPRECATION WARNING! This method will be removed in the next major point release. * * @deprecated Since version 7.2.0 * @codeCoverageIgnore * @see Modifier::removeQueryPairsByKey() * * Remove query data according to their key name. */ public function removeQueryPairs(string ...$keys): static { return $this->removeQueryPairsByKey(...$keys); } }