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);