Percent encode the URI template literals as per RFC6570 section 3.1
Template::expandAll swapped every expression notation in the raw template string with str_replace, so the literal text surrounding the expressions was copied verbatim and never percent encoded. The expression detection pattern is now used to split the template into an ordered list of parts: literals are encoded once at construction, expressions stay Expression objects, and the expansion simply concatenates them. Keeping the parts apart is what prevents the already encoded expansion results from being encoded a second time. Fixes the "Additional Examples 8: Literal Encoding" case of the vendored uritemplate-test compliance suite.
This commit is contained in:
1 parent
432904515e
commit
2f4f2d71c4
3 files changed
+144
-23
No files matched your search
@@ -85,6 +85,31 @@ echo $uriTemplate->expandOrFail($params);
|
||||
<p class="message-notice"><code>expandToUriOrFail()</code> and <code>expandToUrlOrFail()</code> are available since
|
||||
version <code>7.6.0</code></p>
|
||||
|
||||
### 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
|
||||
|
||||
<p class="message-notice">For maximum interoperability you should make sure your variables are
|
||||
|
||||
@@ -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,37 @@ final class Template implements Stringable
|
||||
*/
|
||||
private const REGEXP_EXPRESSION_DETECTOR = '/(?<expression>\{[^}]*})/x';
|
||||
|
||||
/** @var array<Expression> */
|
||||
private readonly array $expressions;
|
||||
/**
|
||||
* Literal characters which are not allowed to appear as-is in the expanded template.
|
||||
*
|
||||
* Every character outside of the "unreserved / reserved / pct-encoded" set has to be
|
||||
* percent encoded, a "%" which starts no percent encoded 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<string|Expression> */
|
||||
private readonly array $parts;
|
||||
/** @var array<string> */
|
||||
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 +94,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<string> $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 +156,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user