diff --git a/components/Components/DataPath.php b/components/Components/DataPath.php index fa870ff9..2383b3de 100644 --- a/components/Components/DataPath.php +++ b/components/Components/DataPath.php @@ -272,10 +272,13 @@ public function decoded(): string return $this->path->decoded(); } - public function save(string $path, string $mode = 'w'): SplFileObject + /** + * @param ?resource $context + */ + public function save(string $path, string $mode = 'w', $context = null): SplFileObject { $data = $this->isBinaryData ? base64_decode($this->document, true) : rawurldecode($this->document); - $file = new SplFileObject($path, $mode); + $file = new SplFileObject($path, $mode, context: $context); $file->fwrite((string) $data); return $file; diff --git a/composer.json b/composer.json index 664d993b..96a8cc44 100644 --- a/composer.json +++ b/composer.json @@ -29,19 +29,20 @@ "ext-fileinfo": "*", "ext-gmp": "*", "ext-intl": "*", - "friendsofphp/php-cs-fixer": "^3.64.0", + "ext-mbstring": "*", + "friendsofphp/php-cs-fixer": "^3.65.0", "guzzlehttp/psr7": "^2.7.0", - "laminas/laminas-diactoros": "^3.4.0", + "laminas/laminas-diactoros": "^3.5.0", "nyholm/psr7": "^1.8.2", "phpbench/phpbench": "^1.3.1", - "phpstan/phpstan": "^1.12.4", + "phpstan/phpstan": "^1.12.13", "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.0", - "phpunit/phpunit": "^10.5.17 || ^11.3.6", + "phpstan/phpstan-phpunit": "^1.4.2", + "phpstan/phpstan-strict-rules": "^1.6.1", + "phpunit/phpunit": "^10.5.17 || ^11.5.2", "psr/http-factory": "^1.1.0", "psr/http-message": "^1.1.0 || ^2.0", - "symfony/var-dumper": "^6.4.11", + "symfony/var-dumper": "^6.4.15", "uri-templates/uritemplate-test": "dev-master" }, "repositories": [ diff --git a/docs/uri/7.0/index.md b/docs/uri/7.0/index.md index 07e61069..440082ad 100644 --- a/docs/uri/7.0/index.md +++ b/docs/uri/7.0/index.md @@ -46,9 +46,12 @@ as an IPv4 address. In order to create Data URI from the content of a file you are required to also install the `fileinfo` extension otherwise an exception will be thrown. -To use the `toAnchor` method you need to have the `ext-dom` extension +To convert a URI into an HTML anchor tag you need to have the `ext-dom` extension installed in your system. +To enable URI normalization, the `ext-mbstring` extension or a polyfill +like `symfony/polyfill-mbstring` must be present in your system. + Installation -------- diff --git a/docs/uri/7.0/rfc3986.md b/docs/uri/7.0/rfc3986.md index feda815f..80be27b6 100644 --- a/docs/uri/7.0/rfc3986.md +++ b/docs/uri/7.0/rfc3986.md @@ -180,6 +180,18 @@ echo $uri->toAnchorTag('my link'); // display 'my link' ``` +You can also generate the Link `tag` and/or `header` depending on how you want your URI link to be rendered: + +```php +use League\Uri\Uri; + +$uri = Uri::new('https://example.com/my/css/v1.3'); +echo $uri->toLinkTag(['rel' => 'stylesheet']); +//display ' +echo $uri->toLinkFieldValue(['rel' => 'stylesheet']); +//display 'https://example.com/my/css/v1.3 ;rel=stylesheet' +``` + ## Accessing URI properties Let's examine the result of building a URI: diff --git a/interfaces/CHANGELOG.md b/interfaces/CHANGELOG.md index 7bc4396b..f17069df 100644 --- a/interfaces/CHANGELOG.md +++ b/interfaces/CHANGELOG.md @@ -14,6 +14,7 @@ All Notable changes to `League\Uri\Interfaces` will be documented in this file - `UriInterface::equals` - `UriInterface::toNormalizedString` - `UriInterface::getUser` +- `League\Uri\IPv6\Converter::isIpv6` ### Fixed diff --git a/interfaces/Contracts/UriEncoder.php b/interfaces/Contracts/UriRenderer.php similarity index 53% rename from interfaces/Contracts/UriEncoder.php rename to interfaces/Contracts/UriRenderer.php index b37286a2..b3ebf461 100644 --- a/interfaces/Contracts/UriEncoder.php +++ b/interfaces/Contracts/UriRenderer.php @@ -16,11 +16,15 @@ use DOMException; use JsonSerializable; use League\Uri\UriString; +use RuntimeException; +use SplFileInfo; +use SplFileObject; +use Stringable; /** * @phpstan-import-type ComponentMap from UriString */ -interface UriEncoder extends JsonSerializable +interface UriRenderer extends JsonSerializable { /** * Returns the string representation as a URI reference. @@ -51,19 +55,37 @@ public function toDisplayString(): ?string; */ public function jsonSerialize(): string; + /** + * Returns the markdown string representation of the anchor tag with the current instance as its href attribute. + */ + public function toMarkdown(?string $linkTextTemplate = null): string; + /** * Returns the HTML string representation of the anchor tag with the current instance as its href attribute. * - * @param list|string|null $class + * @param iterable $attributes an ordered map of key value. you must quote the value if needed * * @throws DOMException */ - public function toAnchorTag(?string $linkText = null, array|string|null $class = null, ?string $target = null): string; + public function toAnchorTag(?string $linkTextTemplate = null, iterable $attributes = []): string; /** - * Returns the markdown string representation of the anchor tag with the current instance as its href attribute. + * Returns the Link tag content for the current instance. + * + * @param iterable $attributes an ordered map of key value. you must quote the value if needed + * + * @throws DOMException + */ + public function toLinkTag(iterable $attributes = []): string; + + /** + * Returns the Link header content for a single item. + * + * @param iterable $parameters an ordered map of key value. you must quote the value if needed + * + * @see https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.6 */ - public function toMarkdown(?string $linkText = null): string; + public function toLinkFieldValue(iterable $parameters = []): string; /** * Returns the Unix filesystem path. The method returns null for any other scheme except the file scheme. @@ -81,4 +103,26 @@ public function toWindowsPath(): ?string; * @return ComponentMap */ public function toComponents(): array; + + /** + * Returns a string representation of a File URI according to RFC8089. + * + * The method will return null if the URI scheme is not the `file` scheme + * + * @see https://datatracker.ietf.org/doc/html/rfc8089 + */ + public function toRfc8089(): ?string; + + /** + * Save the data to a specific file. + * + * The method returns the number of bytes written to the file + * or null for any other scheme except the data scheme + * + * @param SplFileInfo|SplFileObject|resource|Stringable|string $destination + * @param ?resource $context + * + * @throws RuntimeException if the content can not be stored. + */ + public function toFileContents(mixed $destination, $context = null): ?int; } diff --git a/interfaces/IPv6/Converter.php b/interfaces/IPv6/Converter.php index f645c1da..43fc4dce 100644 --- a/interfaces/IPv6/Converter.php +++ b/interfaces/IPv6/Converter.php @@ -134,4 +134,12 @@ private static function parse(Stringable|string|null $host): array default => ['ipAddress' => null, 'zoneIdentifier' => null], }; } + + /** + * Tells whether the host is an IPv6. + */ + public static function isIpv6(Stringable|string|null $host): bool + { + return null !== self::parse($host)['ipAddress']; + } } diff --git a/interfaces/IPv6/ConverterTest.php b/interfaces/IPv6/ConverterTest.php index 195ef191..08faba34 100644 --- a/interfaces/IPv6/ConverterTest.php +++ b/interfaces/IPv6/ConverterTest.php @@ -81,4 +81,23 @@ public static function invalidIpv6(): iterable yield 'IPv6 with zoneIdentifier' => ['invalidIp' => 'fe80::a%25en1']; } + + #[DataProvider('providerInvalidHost')] + public function testParseWithInvalidHost(?string $input): void + { + self::assertFalse(Converter::isIpv6($input)); + } + + public static function providerInvalidHost(): array + { + return [ + 'null host' => ['input' => null], + 'empty host' => ['input' => ''], + 'non ip host' => ['input' => 'ulb.ac.be'], + 'invalid host (0)' => ['input' => '192.168.1.1'], + 'invalid host (1)' => ['input' => '[192.168.1.1]'], + 'invalid host (2)' => ['input' => 'v42.fdfsffd'], + 'invalid host (3)' => ['input' => '::1'], + ]; + } } diff --git a/uri/BaseUri.php b/uri/BaseUri.php index 28d4c57c..6f622a36 100644 --- a/uri/BaseUri.php +++ b/uri/BaseUri.php @@ -24,6 +24,7 @@ use Psr\Http\Message\UriFactoryInterface; use Psr\Http\Message\UriInterface as Psr7UriInterface; use Stringable; +use Throwable; use function array_map; use function array_pop; @@ -71,6 +72,15 @@ public static function from(Stringable|string $uri, ?UriFactoryInterface $uriFac return new static(static::formatHost(static::filterUri($uri, $uriFactory)), $uriFactory); } + public static function tryFrom(Stringable|string $uri, ?UriFactoryInterface $uriFactory = null): ?static + { + try { + return self::from($uri, $uriFactory); + } catch (Throwable) { + return null; + } + } + public function withUriFactory(UriFactoryInterface $uriFactory): static { return new static($this->uri, $uriFactory); diff --git a/uri/CHANGELOG.md b/uri/CHANGELOG.md index adc2246d..a7638591 100644 --- a/uri/CHANGELOG.md +++ b/uri/CHANGELOG.md @@ -6,33 +6,15 @@ All Notable changes to `League\Uri` will be documented in this file ### Added +- Added methods to align with the upcoming `Uri\Rfc3986Uri` PHP native class (see: https://wiki.php.net/rfc/url_parsing_api#proposal) +- `Uri::tryNew` returns a new `Uri` instance on success or null on failure (ie: a Relax version of `Uri::new`). +- `Http::tryNew` returns a new `Uri` instance on success or null on failure (ie: a Relax version of `Http::new`). +- `BaseUri::tryfrom` returns a new `BaseUri` instance on success or null on failure (ie: a Relax version of `BaseUri::from`). - `BaseUri::when` conditional method to ease component building logic. - `Http::when` conditional method to ease component building logic. -- `Http::tryNew` returns a new `Uri` instance on success or null on failure. - `Uri::when` conditional method to ease component building logic. -- `Uri::tryNew` returns a new `Uri` instance on success or null on failure. -- `Uri::resolve` -- `Uri::relativize` -- `Uri::isAbsolute` -- `Uri::isNetworkPath` -- `Uri::isAbsolutePath` -- `Uri::isRelativePath` -- `Uri::isSameDocument` -- `Uri::equals` -- `Uri::toNormalizedString` -- `Uri::getOrigin` -- `Uri::isSameOrigin` -- `Uri::isCrossOrigin` -- `Uri::todisplayString` shows the URI in a human-readable format which may be an invalid URI. -- `Uri::toUnixPath` returns the URI path as a Unix Path or `null` -- `Uri::toWindowsPath` returns the URI path as a Windows Path or `null` -- `Uri::toRfc8089` return the URI in a RFC8089 formator `null` -- `Uri::toAnchor` returns the HTML anchor string using the instance as the href attribute value -- `Uri::toMarkdown` returns the markdown link construct using the instance as the href attribute value -- `Uri::getUser` to be inline with PHP native URI interface -- `Uri::withUser` to be inline with PHP native URI interface -- `Uri::withPassword` to be inline with PHP native URI interface -- `Uri::__serialize` and `Uri::__unserialize` methods +- `Uri` implements the new `League\Uri\Contract\UriInspector` interface +- `Uri` implements the new `League\Uri\Contract\UriRenderer` interface ### Fixed diff --git a/uri/Uri.php b/uri/Uri.php index 9a640add..6f099087 100644 --- a/uri/Uri.php +++ b/uri/Uri.php @@ -13,16 +13,17 @@ namespace League\Uri; +use Closure; use Deprecated; use DOMDocument; use DOMException; use finfo; use League\Uri\Contracts\Conditionable; use League\Uri\Contracts\UriComponentInterface; -use League\Uri\Contracts\UriEncoder; use League\Uri\Contracts\UriException; use League\Uri\Contracts\UriInspector; use League\Uri\Contracts\UriInterface; +use League\Uri\Contracts\UriRenderer; use League\Uri\Exceptions\ConversionFailed; use League\Uri\Exceptions\MissingFeature; use League\Uri\Exceptions\SyntaxError; @@ -31,11 +32,16 @@ use League\Uri\IPv6\Converter as IPv6Converter; use League\Uri\UriTemplate\TemplateCanNotBeExpanded; use Psr\Http\Message\UriInterface as Psr7UriInterface; +use RuntimeException; use SensitiveParameter; +use SplFileInfo; +use SplFileObject; use Stringable; use Throwable; +use TypeError; use function array_filter; +use function array_keys; use function array_map; use function array_pop; use function array_reduce; @@ -44,22 +50,31 @@ use function count; use function end; use function explode; +use function feof; use function file_get_contents; use function filter_var; +use function fread; use function implode; use function in_array; use function inet_pton; use function is_array; use function is_bool; +use function is_float; +use function iterator_to_array; +use function json_encode; use function ltrim; use function preg_match; use function preg_replace_callback; use function preg_split; use function rawurldecode; use function rawurlencode; +use function restore_error_handler; +use function round; +use function set_error_handler; use function str_contains; use function str_repeat; use function str_replace; +use function str_starts_with; use function strcmp; use function strlen; use function strpos; @@ -69,18 +84,21 @@ use function uksort; use const FILEINFO_MIME; +use const FILEINFO_MIME_TYPE; use const FILTER_FLAG_IPV4; use const FILTER_FLAG_IPV6; use const FILTER_NULL_ON_FAILURE; use const FILTER_VALIDATE_BOOLEAN; use const FILTER_VALIDATE_IP; +use const JSON_PRESERVE_ZERO_FRACTION; +use const PHP_ROUND_HALF_EVEN; use const PREG_SPLIT_NO_EMPTY; /** * @phpstan-import-type ComponentMap from UriString * @phpstan-import-type InputComponentMap from UriString */ -final class Uri implements Conditionable, UriInterface, UriEncoder, UriInspector +final class Uri implements Conditionable, UriInterface, UriRenderer, UriInspector { /** * RFC3986 invalid characters. @@ -446,6 +464,22 @@ public static function new(Stringable|string $uri = ''): self ); } + /** + * Returns a new instance from a URI and a Base URI.or null on failure. + * + * The returned URI must be absolute. + * + * @see https://wiki.php.net/rfc/url_parsing_api + */ + public static function parse(Stringable|string $uri, Stringable|string|null $baseUri = null): ?self + { + try { + return self::fromBaseUri($uri, $baseUri); + } catch (Throwable) { + return null; + } + } + /** * Creates a new instance from a URI and a Base URI. * @@ -514,32 +548,70 @@ public static function fromComponents(array $components = []): self /** * Create a new instance from a data file path. * - * @param resource|null $context + * @param SplFileInfo|SplFileObject|resource|Stringable|string $path + * @param ?resource $context * * @throws MissingFeature If ext/fileinfo is not installed * @throws SyntaxError If the file does not exist or is not readable */ - public static function fromFileContents(Stringable|string $path, $context = null): self + public static function fromFileContents(mixed $path, $context = null): self { FeatureDetection::supportsFileDetection(); + $finfo = new finfo(FILEINFO_MIME_TYPE); + $bufferSize = 8192; + + /** @var Closure(SplFileobject): array{0:string, 1:string} $fromFileObject */ + $fromFileObject = function (SplFileObject $path) use ($finfo, $bufferSize): array { + $raw = $path->fread($bufferSize); + if (false === $raw) { + throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); + } + $mimetype = (string) $finfo->buffer($raw); + while (!$path->eof()) { + $raw .= $path->fread($bufferSize); + } - $path = (string) $path; - $fileArguments = [$path, false]; - $mimeArguments = [$path, FILEINFO_MIME]; - if (null !== $context) { - $fileArguments[] = $context; - $mimeArguments[] = $context; - } + return [$mimetype, $raw]; + }; - set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); - $raw = file_get_contents(...$fileArguments); - restore_error_handler(); + /** @var Closure(resource): array{0:string, 1:string} $fromResource */ + $fromResource = function ($stream) use ($finfo, $path, $bufferSize): array { + set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); + $raw = fread($stream, $bufferSize); + if (false === $raw) { + throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); + } + $mimetype = (string) $finfo->buffer($raw); + while (!feof($stream)) { + $raw .= fread($stream, $bufferSize); + } + restore_error_handler(); - if (false === $raw) { - throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); - } + return [$mimetype, $raw]; + }; - $mimetype = (string) (new finfo(FILEINFO_MIME))->file(...$mimeArguments); + /** @var Closure(Stringable|string, resource|null): array{0:string, 1:string} $fromPath */ + $fromPath = function (Stringable|string $path, $context) use ($finfo): array { + $path = (string) $path; + set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); + $raw = file_get_contents(filename: $path, context: $context); + restore_error_handler(); + if (false === $raw) { + throw new SyntaxError('The file `'.$path.'` does not exist or is not readable.'); + } + $mimetype = (string) $finfo->file(filename: $path, flags: FILEINFO_MIME, context: $context); + + return [$mimetype, $raw]; + }; + + [$mimetype, $raw] = match (true) { + $path instanceof SplFileObject => $fromFileObject($path), + $path instanceof SplFileInfo => $fromFileObject($path->openFile(mode: 'rb', context: $context)), + is_resource($path) => $fromResource($path), + $path instanceof Stringable, + is_string($path) => $fromPath($path, $context), + default => throw new TypeError('The path `'.$path.'` is not a valid resource.'), + }; return Uri::fromComponents([ 'scheme' => 'data', @@ -1016,6 +1088,9 @@ public function toString(): string return $this->uri; } + /** + * * @see https://wiki.php.net/rfc/url_parsing_api + */ public function toNormalizedString(): string { return $this->normalize()->toString(); @@ -1040,47 +1115,97 @@ public function toDisplayString(): string return UriString::build($components); } + /** + * Returns the markdown string representation of the anchor tag with the current instance as its href attribute. + */ + public function toMarkdown(?string $linkTextTemplate = null): string + { + return '['.strtr($linkTextTemplate ?? '{uri}', ['{uri}' => $this->toDisplayString()]).']('.$this->toString().')'; + } + /** * Returns the HTML string representation of the anchor tag with the current instance as its href attribute. * - * @param list|string|null $class + * @param iterable $attributes an ordered map of key value. you must quote the value if needed * * @throws DOMException */ - public function toAnchorTag(?string $linkText = null, array|string|null $class = null, ?string $target = null): string + public function toAnchorTag(?string $linkTextTemplate = null, iterable $attributes = []): string { $doc = new DOMDocument('1.0', 'utf-8'); $doc->preserveWhiteSpace = false; $doc->formatOutput = true; $anchor = $doc->createElement('a'); $anchor->setAttribute('href', $this->toString()); - if (null !== $class) { - $anchor->setAttribute('class', is_array($class) ? implode(' ', $class) : $class); + foreach ($attributes as $name => $value) { + if ('href' !== strtolower($name) && null !== $value) { + $anchor->setAttribute($name, $value); + } } - if (null !== $target) { - $anchor->setAttribute('target', $target); + $anchor->appendChild($doc->createTextNode(strtr($linkTextTemplate ?? '{uri}', ['{uri}' => $this->toDisplayString()]))); + $html = $doc->saveHTML($anchor); + if (false === $html) { + throw new DOMException('The anchor tag generation failed.'); } - $textNode = $doc->createTextNode($linkText ?? $this->toDisplayString()); - if (false === $textNode) { - throw new DOMException('The link generation failed.'); + return $html; + } + + /** + * Returns the Link tag content for the current instance. + * + * @param iterable $attributes an ordered map of key value. you must quote the value if needed + * + * @throws DOMException + */ + public function toLinkTag(iterable $attributes = []): string + { + $doc = new DOMDocument('1.0', 'utf-8'); + $doc->preserveWhiteSpace = false; + $doc->formatOutput = true; + $link = $doc->createElement('link'); + $link->setAttribute('href', $this->toString()); + foreach ($attributes as $name => $value) { + if ('href' !== strtolower($name) && null !== $value) { + $link->setAttribute($name, $value); + } } - $anchor->appendChild($textNode); - $anchor = $doc->saveHTML($anchor); - if (false === $anchor) { + + $html = $doc->saveHTML($link); + if (false === $html) { throw new DOMException('The link generation failed.'); } - return $anchor; + return $html; } /** - * Returns the markdown string representation of the anchor tag with the current instance as its href attribute. + * Returns the Link header content for a single item. + * + * @param iterable $parameters an ordered map of key value. you must quote the value if needed + * + * @see https://www.rfc-editor.org/rfc/rfc7230.html#section-3.2.6 */ - public function toMarkdown(?string $linkText = null): string + public function toLinkFieldValue(iterable $parameters = []): string { - return '['.($linkText ?? $this->toDisplayString()).']('.$this->toString().')'; + $value = '<'.$this->toString().'>'; + if (!is_array($parameters)) { + $parameters = iterator_to_array($parameters); + } + + if ([] === $parameters) { + return $value; + } + + $formatter = static fn (string|int|float|bool $member, string $offset): string => match (true) { + true === $member => ';'.$offset, + false === $member => ';'.$offset.'=?0', + is_float($member) => ';'.$offset.'='.json_encode(round($member, 3, PHP_ROUND_HALF_EVEN), JSON_PRESERVE_ZERO_FRACTION), + default => ';'.$offset.'='.$member, + }; + + return $value.' '.implode('', array_map($formatter, $parameters, array_keys($parameters))); } /** @@ -1152,6 +1277,51 @@ public function toRfc8089(): ?string }; } + public function toFileContents(mixed $destination, $context = null): ?int + { + if ('data' !== $this->scheme) { + return null; + } + + [$mediaType, $document] = explode(',', $this->path, 2) + [0 => '', 1 => null]; + if (null === $document) { + throw new RuntimeException('Unable to extract the document part from the URI path.'); + } + + $data = match (true) { + str_ends_with((string) $mediaType, ';base64') => (string) base64_decode($document, true), + default => rawurldecode($document), + }; + + $res = match (true) { + $destination instanceof SplFileObject => $destination->fwrite($data), + $destination instanceof SplFileInfo => $destination->openFile(mode:'wb', context: $context)->fwrite($data), + is_resource($destination) => fwrite($destination, $data), + $destination instanceof Stringable, + is_string($destination) => (function () use ($destination, $data, $context): int|false { + set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); + $rsrc = fopen((string) $destination, mode:'wb', context: $context); + if (false === $rsrc) { + restore_error_handler(); + throw new RuntimeException('Unable to open the destination file: '.$destination); + } + + $bytes = fwrite($rsrc, $data); + fclose($rsrc); + restore_error_handler(); + + return $bytes; + })(), + default => throw new TypeError('Unsupported destination type; expected SplFileObject, SplFileInfo, resource or a string; '.(is_object($destination) ? $destination::class : gettype($destination)).' given.'), + }; + + if (false === $res) { + throw new RuntimeException('Unable to write to the destination file.'); + } + + return $res; + } + /** * @return ComponentMap */ @@ -1179,6 +1349,9 @@ public function getAuthority(): ?string return $this->authority; } + /** + * * @see https://wiki.php.net/rfc/url_parsing_api + */ public function getUser(): ?string { return $this->user; @@ -1517,21 +1690,8 @@ public function equals(UriInterface|Stringable|string $uri, bool $excludeFragmen } /** - * Tells whether the URI contains an Internationalized Domain Name (IDN). - */ - public function hasIdn(): bool - { - return IdnaConverter::isIdn($this->host); - } - - /** - * Tells whether the URI contains an IPv4 regardless if it is mapped or native. + * * @see https://wiki.php.net/rfc/url_parsing_api */ - public function hasIPv4(): bool - { - return IPv4Converter::fromEnvironment()->isIpv4($this->host); - } - public function normalize(): UriInterface { return $this @@ -1543,14 +1703,13 @@ public function normalize(): UriInterface private function normalizePath(): string { - $authority = $this->authority; $path = $this->path; - if ('/' === ($path[0] ?? '') || '' !== $this->scheme.$authority) { + if ('/' === ($path[0] ?? '') || '' !== $this->scheme.$this->authority) { $path = self::removeDotSegments($path); } $path = (string) $this->decodeUnreservedCharacters($path); - if (null !== $authority && '' === $path) { + if (null !== $this->authority && '' === $path) { return '/'; } @@ -1643,6 +1802,8 @@ private static function removeDotSegments(string $path): string * * This method MUST be transparent when dealing with error and exceptions. * It MUST not alter or silence them apart from validating its own parameters. + * + * @see https://wiki.php.net/rfc/url_parsing_api */ public function resolve(Stringable|string $uri): UriInterface { @@ -1800,7 +1961,7 @@ private static function formatRelativePath(string $path, string $basePath): stri /** * returns the path segments. * - * @return string[] + * @return array */ private static function getSegments(string $path): array { @@ -1811,6 +1972,9 @@ private static function getSegments(string $path): array }); } + /** + * @return ComponentMap + */ public function __serialize(): array { return $this->toComponents(); diff --git a/uri/UriTest.php b/uri/UriTest.php index f5fb4faa..e9f1d246 100644 --- a/uri/UriTest.php +++ b/uri/UriTest.php @@ -23,7 +23,12 @@ use Psr\Http\Message\UriInterface as Psr7UriInterface; use TypeError; +use function base64_encode; +use function dirname; +use function file_get_contents; use function serialize; +use function stream_context_create; +use function unlink; use function unserialize; #[CoversClass(Uri::class)] @@ -34,11 +39,12 @@ class UriTest extends TestCase private Uri $uri; - protected function setUp(): void + private string $rootPath; + + public function setUp(): void { - $this->uri = Uri::new( - 'http://login:pass@secure.example.com:443/test/query.php?kingkong=toto#doc3' - ); + $this->rootPath = dirname(__DIR__).'/test_files'; + $this->uri = Uri::new('http://login:pass@secure.example.com:443/test/query.php?kingkong=toto#doc3'); } protected function tearDown(): void @@ -1015,14 +1021,9 @@ public static function providesUriToMarkdown(): iterable #[Test] #[DataProvider('providesUriToHTML')] - public function it_will_generate_the_html_code_for_the_instance( - string $uri, - ?string $content, - ?string $class, - ?string $target, - string $expected - ): void { - self::assertSame($expected, Uri::new($uri)->toAnchorTag($content, $class, $target)); + public function it_will_generate_the_html_code_for_the_instance(string $uri, ?string $content, array $parameters, string $expected): void + { + self::assertSame($expected, Uri::new($uri)->toAnchorTag($content, $parameters)); } public static function providesUriToHTML(): iterable @@ -1030,48 +1031,51 @@ public static function providesUriToHTML(): iterable yield 'empty string' => [ 'uri' => '', 'content' => '', - 'class' => null, - 'target' => null, + 'parameters' => [], 'expected' => '', ]; yield 'URI with a specific content' => [ 'uri' => 'http://example.com/foo/bar', 'content' => 'this is a link', - 'class' => null, - 'target' => null, + 'parameters' => [], 'expected' => 'this is a link', ]; yield 'URI without content' => [ 'uri' => 'http://Bébé.be', 'content' => null, - 'class' => null, - 'target' => null, + 'parameters' => [], 'expected' => 'http://bébé.be', ]; yield 'URI without content and with class' => [ 'uri' => 'http://Bébé.be', 'content' => null, - 'class' => 'foo bar', - 'target' => null, + 'parameters' => [ + 'class' => 'foo bar', + 'target' => null, + ], 'expected' => 'http://bébé.be', ]; yield 'URI without content and with target' => [ 'uri' => 'http://Bébé.be', 'content' => null, - 'class' => null, - 'target' => '_blank', + 'parameters' => [ + 'class' => null, + 'target' => '_blank', + ], 'expected' => 'http://bébé.be', ]; yield 'URI without content, with target and class' => [ 'uri' => 'http://Bébé.be', 'content' => null, - 'class' => 'foo bar', - 'target' => '_blank', + 'parameters' => [ + 'class' => 'foo bar', + 'target' => '_blank', + ], 'expected' => 'http://bébé.be', ]; } @@ -1111,4 +1115,40 @@ public function it_can_be_serialized_by_php(): void self::assertTrue($uri->equals($newUri, excludeFragment: false)); } + + #[Test] + public function it_can_save_data_uri_binary_encoded(): void + { + $newFilePath = $this->rootPath.'/temp.gif'; + $uri = Uri::fromFileContents($this->rootPath.'/red-nose.gif'); + $uri->toFileContents($newFilePath); + + self::assertSame($uri->toString(), Uri::fromFileContents($newFilePath)->toString()); + + // Ensure file handle of \SplFileObject gets closed. + unlink($newFilePath); + } + + #[Test] + public function it_can_save_to_file_with_raw_data(): void + { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "Accept-language: en\r\nCookie: foo=bar\r\n", + ], + ]); + + $newFilePath = $this->rootPath.'/temp.txt'; + + $uri = Uri::fromFileContents($this->rootPath.'/hello-world.txt', $context); + $uri->toFileContents($newFilePath); + self::assertSame((string) $uri, (string) Uri::fromFileContents($newFilePath)); + + $data = file_get_contents($newFilePath); + self::assertStringContainsString(base64_encode((string) $data), $uri->getPath()); + + // Ensure file handle of \SplFileObject gets closed. + unlink($newFilePath); + } } diff --git a/uri/composer.json b/uri/composer.json index d88b6343..62162a1a 100644 --- a/uri/composer.json +++ b/uri/composer.json @@ -64,7 +64,8 @@ "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", "league/uri-components" : "Needed to easily manipulate URI objects components", "php-64bit": "to improve IPV4 host parsing", - "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present", + "symfony/polyfill-mbstring": "to handle URI normalization if the ext-mbstring is not present" }, "extra": { "branch-alias": {