diff --git a/docs/uri/7.0/uri-template.md b/docs/uri/7.0/uri-template.md
index 5d21072..4951eeb 100644
--- a/docs/uri/7.0/uri-template.md
+++ b/docs/uri/7.0/uri-template.md
@@ -85,6 +85,31 @@ echo $uriTemplate->expandOrFail($params);
expandToUriOrFail() and expandToUrlOrFail() are available since
version 7.6.0
+### Literal Expansion
+
+The characters located outside of the template expressions are literals. As per
+[RFC6570 section 3.1](https://www.rfc-editor.org/rfc/rfc6570#section-3.1) a literal character is
+copied as is when it is allowed anywhere in the URI syntax, in other words when it belongs to the
+`unreserved / reserved / pct-encoded` set. Any other character is percent encoded using its UTF-8
+octets.
+
+~~~php
+$uriTemplate = new UriTemplate('https://example.com/café/{hotel}');
+
+echo $uriTemplate->expand(['hotel' => 'Rest & Relax']), PHP_EOL;
+// display https://example.com/caf%C3%A9/Rest%20%26%20Relax
+~~~
+
+Percent encoded triplets already present in the template are left untouched, they are never
+encoded a second time.
+
+~~~php
+$uriTemplate = new UriTemplate('https://example.com/caf%C3%A9/{hotel}');
+
+echo $uriTemplate->expand(['hotel' => 'Rest & Relax']), PHP_EOL;
+// display https://example.com/caf%C3%A9/Rest%20%26%20Relax
+~~~
+
## Template Variables
For maximum interoperability you should make sure your variables are
diff --git a/uri/UriTemplate/Template.php b/uri/UriTemplate/Template.php
index 6e0d459..e6314c0 100644
--- a/uri/UriTemplate/Template.php
+++ b/uri/UriTemplate/Template.php
@@ -20,14 +20,18 @@ use Stringable;
use function array_filter;
use function array_map;
-use function array_reduce;
use function array_unique;
-use function preg_match_all;
+use function array_values;
+use function implode;
use function preg_replace;
-use function str_replace;
+use function preg_replace_callback;
+use function preg_split;
+use function rawurlencode;
+use function str_starts_with;
use function strpbrk;
-use const PREG_SET_ORDER;
+use const PREG_SPLIT_DELIM_CAPTURE;
+use const PREG_SPLIT_NO_EMPTY;
/**
* @internal The class exposes the internal representation of a Template and its usage
@@ -39,19 +43,36 @@ final class Template implements Stringable
*/
private const REGEXP_EXPRESSION_DETECTOR = '/(?\{[^}]*})/x';
- /** @var array */
- private readonly array $expressions;
+ /**
+ * Literal characters which must be percent encoded in the expanded
+ * template: everything outside "unreserved / reserved / pct-encoded", a
+ * "%" starting no triplet included.
+ *
+ * @link https://www.rfc-editor.org/rfc/rfc6570#section-3.1
+ */
+ private const REGEXP_LITERAL_TO_ENCODE = '/[^A-Za-z\d\-._~:\/?#\[\]@!$&\'()*+,;=%]+|%(?![A-Fa-f\d]{2})/';
+
+ /** @var array */
+ private readonly array $parts;
/** @var array */
public readonly array $variableNames;
- private function __construct(public readonly string $value, Expression ...$expressions)
+ private function __construct(public readonly string $value, string|Expression ...$parts)
{
- $this->expressions = $expressions;
+ $this->parts = $parts;
+
+ $expressions = [];
+ foreach ($parts as $part) {
+ if ($part instanceof Expression) {
+ $expressions[$part->value] = $part;
+ }
+ }
+
$this->variableNames = array_unique(
array_merge(
...array_map(
static fn (Expression $expression): array => $expression->variableNames,
- $expressions
+ array_values($expressions)
)
)
);
@@ -72,17 +93,35 @@ final class Template implements Stringable
$remainder = preg_replace(self::REGEXP_EXPRESSION_DETECTOR, '', $template);
false === strpbrk($remainder, '{}') || throw new SyntaxError('The template "'.$template.'" contains invalid expressions.');
- preg_match_all(self::REGEXP_EXPRESSION_DETECTOR, $template, $founds, PREG_SET_ORDER);
+ /** @var array $segments */
+ $segments = preg_split(self::REGEXP_EXPRESSION_DETECTOR, $template, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
- return new self($template, ...array_values(
- array_reduce($founds, function (array $carry, array $found): array {
- if (!isset($carry[$found['expression']])) {
- $carry[$found['expression']] = Expression::new($found['expression']);
- }
+ $parts = [];
+ $expressions = [];
+ foreach ($segments as $segment) {
+ if (!str_starts_with($segment, '{')) {
+ $parts[] = self::encodeLiteral($segment);
+ continue;
+ }
- return $carry;
- }, [])
- ));
+ $parts[] = $expressions[$segment] ??= Expression::new($segment);
+ }
+
+ return new self($template, ...$parts);
+ }
+
+ /**
+ * Percent encodes the template characters which are not allowed to be copied as-is into a URI.
+ *
+ * @link https://www.rfc-editor.org/rfc/rfc6570#section-3.1
+ */
+ private static function encodeLiteral(string $literal): string
+ {
+ return (string) preg_replace_callback(
+ self::REGEXP_LITERAL_TO_ENCODE,
+ static fn (array $matches): string => rawurlencode($matches[0]),
+ $literal
+ );
}
/**
@@ -116,11 +155,10 @@ final class Template implements Stringable
private function expandAll(VariableBag $variables): string
{
- return array_reduce(
- $this->expressions,
- fn (string $uri, Expression $expr): string => str_replace($expr->value, $expr->expand($variables), $uri),
- $this->value
- );
+ return implode('', array_map(
+ static fn (string|Expression $part): string => $part instanceof Expression ? $part->expand($variables) : $part,
+ $this->parts
+ ));
}
public function __toString(): string
diff --git a/uri/UriTemplate/TemplateTest.php b/uri/UriTemplate/TemplateTest.php
index 85f7dd2..28f94c6 100644
--- a/uri/UriTemplate/TemplateTest.php
+++ b/uri/UriTemplate/TemplateTest.php
@@ -174,6 +174,63 @@ final class TemplateTest extends TestCase
];
}
+ #[DataProvider('providesLiteralEncoding')]
+ #[Test]
+ public function testItEncodesTheTemplateLiterals(string $notation, array $variables, string $expected): void
+ {
+ self::assertSame($expected, Template::new($notation)->expand($variables));
+ }
+
+ /**
+ * @see https://www.rfc-editor.org/rfc/rfc6570#section-3.1
+ */
+ public static function providesLiteralEncoding(): iterable
+ {
+ $variables = [
+ 'var' => 'value',
+ 'path' => '/foo/bar',
+ 'hello' => 'Hello World!',
+ ];
+
+ return [
+ 'a character disallowed in a URI is encoded as its UTF-8 octets' => [
+ 'notation' => 'café/{var}',
+ 'variables' => $variables,
+ 'expected' => 'caf%C3%A9/value',
+ ],
+ 'a percent encoded triplet is copied as is' => [
+ 'notation' => 'x%20y/{var}',
+ 'variables' => $variables,
+ 'expected' => 'x%20y/value',
+ ],
+ 'a percent encoded triplet is copied as is on both sides of an expression' => [
+ 'notation' => 'x%20y{var}z%20w',
+ 'variables' => $variables,
+ 'expected' => 'x%20yvaluez%20w',
+ ],
+ 'a space is encoded' => [
+ 'notation' => 'a b/{var}',
+ 'variables' => $variables,
+ 'expected' => 'a%20b/value',
+ ],
+ 'a percent sign which starts no triplet is encoded' => [
+ 'notation' => '100%/{var}',
+ 'variables' => $variables,
+ 'expected' => '100%25/value',
+ ],
+ 'reserved characters are copied as is' => [
+ 'notation' => "/a[0]:b@c!d\$e&f'g(h)i*j+k,l;m=n?o#{var}",
+ 'variables' => $variables,
+ 'expected' => "/a[0]:b@c!d\$e&f'g(h)i*j+k,l;m=n?o#value",
+ ],
+ 'the expanded expressions are never encoded twice' => [
+ 'notation' => 'café{+path}{#hello}',
+ 'variables' => $variables,
+ 'expected' => 'caf%C3%A9/foo/bar#Hello%20World!',
+ ],
+ ];
+ }
+
public function testExpandOrFailIfAtLeastOneVariableIsMissing(): void
{
$this->expectException(TemplateCanNotBeExpanded::class);