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": {