Upstream thephpleague/uri-src state of the touched files before 6b1d90a020

This commit is contained in:
upstream committed 2026-09-04 08:43:47 +02:00
1 parent c0b6c4bc5f
commit cb44762108
3 files changed
+631

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
---
layout: default
title: RFC 6570 compliant URI template
description: The UriTemplate class enables expanding a URI template and its submitted parameters into an URI object.
---
URI Template
=======
The `League\Uri\UriTemplate` class enables expanding a URI object based on a URI template and its
submitted parameters following [RFC 6570 URI Template](http://tools.ietf.org/html/rfc6570).
## Template Expansion
### RFC Expansion
The `UriTemplate::expand` public method expands a URI template to generate a valid URI conforming
to RFC3986.
~~~php
<?php
use League\Uri\UriTemplate;
$template = 'https://example.com/hotels/{hotel}/bookings/{booking}';
$params = ['booking' => '42', 'hotel' => 'Rest & Relax'];
$uriTemplate = new UriTemplate($template);
$uri = $uriTemplate->expand($params); // instance of League\Uri\Uri
$rfc3986Uri = $uriTemplate->expandToUri($params); // instance of Uri\Rfc3986\Uri
$whatWgUrl = $uriTemplate->expandToUrl($params); // instance of Uri\Whatwg\Url
$psr7Uri = $uriTemplate->expandToPsr7Uri($params); // instance of Psr7 UriInterface
echo $uri; //display https://example.com/hotels/Rest%20%26%20Relax/bookings/42"
~~~
<p class="message-notice"><code>expandToUri()</code>, <code>expandToUrl()</code> and <code>expandToPsr7Uri()</code> are available since
version <code>7.6.0</code></p>
<p class="message-info">Since version <code>7.6</code> the <code>expand()</code> method takes a second optional parameter
which serves as a base URI. The generated URI is resolved against the Base URI.</p>
~~~php
<?php
use League\Uri\UriTemplate;
$template = '/hotels/{hotel}/bookings/{booking}';
$params = ['booking' => '42', 'hotel' => 'Rest & Relax'];
$uriTemplate = new UriTemplate($template);
$uri = $uriTemplate->expand($params, 'https://example.com'); // instance of League\Uri\Uri
$rfc3986Uri = $uriTemplate->expandToUri($params, 'https://example.com'); // instance of Uri\Rfc3986\Uri
$whatWgUrl = $uriTemplate->expandToUrl($params, 'https://example.com'); // instance of Uri\Whatwg\Url
$whatWgUrl = $uriTemplate->expandToPsr7Uri($params, 'https://example.com'); // instance of League\Uri\Http
echo $uri; //display https://example.com/hotels/Rest%20%26%20Relax/bookings/42"
~~~
### Strict Expansion
By default, if variables are missing or are not provided an empty string is used as replacement
string as per the RFC. If you want to force correct expansion you can use the `expandOrFail`
method. It behaves exactly like the `expand` method but will additionnally throw an
exception if there are missing required variables.
~~~php
$template = 'https://api.twitter.com/{version}/search/{term}/{?q*,limit}';
$params = [
'term' => ['john', 'doe'],
'q' => ['a', 'b'],
'limit' => '10',
];
$uriTemplate = new UriTemplate($template);
echo $uriTemplate->expand($params), PHP_EOL;
// display https://api.twitter.com//search/john,doe/?q=a&q=b&limit=10 with missing version
echo $uriTemplate->expandOrFail($params);
// will throw a TemplateCanNotBeExpanded exception with the following message
// Missing variables `version`
~~~
<p class="message-notice"><code>expandToUriOrFail()</code> and <code>expandToUrlOrFail()</code> are available since
version <code>7.6.0</code></p>
## Template Variables
<p class="message-notice">For maximum interoperability you should make sure your variables are
strings or stringable objects otherwise the value will be cast to string following PHP rules
except for boolean values <code>true</code> and <code>false</code> which will be converted
to <code>1</code> and <code>0</code> respectively.</p>
### Default Variables
The constructor takes an optional set of default variables that can be applied by default when
expanding the URI template.
~~~php
$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
$params = [
'term' => 'john',
'q' => ['a', 'b'],
'limit' => '10',
];
$uriTemplate = new UriTemplate($template, ['version' => 1.1]);
echo $uriTemplate->expand($params);
//displays https://api.twitter.com/1.1/search/j/john/?q=a&q=b&limit=10
~~~
### Runtime Variables
The default variables are overwritten by those supplied to the `expand` method.
~~~php
$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
$params = [
'term' => 'john',
'q' => ['a', 'b'],
'limit' => '10',
'version' => '2.0'
];
$uriTemplate = new UriTemplate($template, ['version' => '1.1']);
echo $uriTemplate->expand($params), PHP_EOL;
//displays https://api.twitter.com/2.0/search/j/john/?q=a&q=b&limit=10
~~~
The `expandToUri()`, `expandToUrl()` and `expandToPsr7Uri()` methods will act exactly like the `expand()` method
but will instead return a `Uri\Rfc3986\Uri`, a `Uri\Whatwg\Url` or a `Psr7\Http\Message\UriInterface` object respectively.
<p class="message-warning">a WHATWG URL must always be absolute if the URI template is not you
MUST provide a base URL to <code>expandToUrl()</code> otherwise an <code>Uri\WhatWg\InvalidUrlException</code>
exception will be thrown.</p>
~~~php
$template = '/{version}/search/{term:1}/{term}/{?q*,limit}';
//the template represents a non-absolute URL
$params = [
'term' => 'john',
'q' => ['a', 'b'],
'limit' => '10',
'version' => '2.0'
];
$uriTemplate = new UriTemplate($template, ['version' => '1.1']);
echo $uriTemplate->expandToUrl($params, 'https://api.twitter.com')->toAsciiString(); //works
echo $uriTemplate->expandToUrl($params); //will throw
~~~
<p class="message-info">You may return a different <code>Psr7\Http\Message\UriInterface</code> object if you
provide to the <code>expandToPsr7Uri</code> your own <code>Psr\Http\Message\UriFactoryInterface</code> class
as the third optional argument.</p>
~~~php
use League\Uri\UriTemplate;
use Laminas\Diactoros\UriFactory;
$template = '/{version}/search/{term:1}/{term}/{?q*,limit}';
//the template represents a non-absolute URL
$params = [
'term' => 'john',
'q' => ['a', 'b'],
'limit' => '10',
'version' => '2.0'
];
$uriTemplate = new UriTemplate($template, ['version' => '1.1']);
$uri = $uriTemplate->expandToPsr7Uri($params, 'https://api.twitter.com', new UriFactory());
$uri::class; // returns 'Laminas\Diactoros\Uri'
~~~
By default, if no factory is provided, the returned PSR-7 `UriInterface` object is a `League\Uri\Http` instance.
### Updating Variables
At any given time you may update your default variables but since the `UriTemplate`
is an immutable object instead of modifying the current instance, a new
instance with the modified default variables will be returned.
~~~php
$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
$params = [
'term' => 'john',
'q' => ['a', 'b'],
'limit' => '10',
'version' => '2.0'
];
$uriTemplate = new UriTemplate($template, ['version' => '1.0', 'foo' => 'bar']);
$uriTemplate->getDefaultVariables(); //returns new VariableBag(['version' => '1.0'])
$newUriTemplate = $uriTemplate->withDefaultVariables(['version' => '1.1']);
$newUriTemplate->getDefaultVariables(); //returns new VariableBag(['version' => '1.1'])
~~~
<p class="message-warning">Following RFC6570 requirements means not support for
nested array like the one used with <code>http_build_query</code></p>
~~~php
$template = 'https://example.com/hotels/{hotel}/book{?query*}';
$params = [
'hotel' => 'Rest & Relax',
'query' => [
'period' => [
'start' => '2020-01-12',
'end' => '2020-01-15',
],
],
];
$uriTemplate = new UriTemplate($template);
$uriTemplate->expand($params);
// will throw a League\Uri\UriTemplate\TemplateCanNotBeExpanded when trying to expand the `period` value.
~~~
## Limitations
### Prefix Modifier
While the RFC does not forbid this, the `UriTemplate` class will throw an exception
if an attempt is made to use the prefix modifier with a list of value. Other
implementations will silently ignore the modifier **but** this package will
trigger the exception to alert the user that something might be wrong and
that the generated URI might not be the one expected.
~~~php
$template = 'https://api.twitter.com/{version}/search/{term:1}/{term}/{?q*,limit}';
$params = [
'term' => ['john', 'doe'],
'q' => ['a', 'b'],
'limit' => '10',
'version' => '2.0'
];
$uriTemplate = new UriTemplate($template);
echo $uriTemplate->expand($params), PHP_EOL;
// throw a League\Uri\UriTemplate\TemplateCanNotBeExpanded because the term variable is a list and not a string.
~~~
### Braces Usage
The following implementation disallows the use of braces `{` or `}` outside of being URI
template expression delimiters. If not used as the boundary of an expression an
exception will be triggered.
~~~php
$template = 'https://example.com/hotels/{/book{?query*}';
$uriTemplate = new UriTemplate($template);
// will throw a League\Uri\Exceptions\SyntaxError on instantiation
~~~
If your template do require them you should URL encode them.
~~~php
$template = 'https://example.com/hotels/%7B/{hotel}';
$params = ['booking' => 42, 'hotel' => 'Rest & Relax'];
$uriTemplate = new UriTemplate($template);
echo $uriTemplate->expand($params), PHP_EOL;
// https://example.com/hotels/%7B/Rest%20%26%20Relax
~~~
## Interoperability
<p class="message-notice">Available since <code>version 7.6</code></p>
To allow easier integration with other PHP packages and especially [PSR-13](https://www.php-fig.org/psr/psr-13/)
the `UriTemplate` class implements the `Stringable` interface.
~~~php
use League\Uri\UriTemplate;
use Symfony\Component\WebLink\Link;
$uriTemplate = new UriTemplate('https://google.com/search{?q*}');
$link = (new Link())
->withHref($uriTemplate)
->withRel('next')
->withAttribute('me', 'you');
// Once serialized will return
// '<https://google.com/search{?q*}>; rel="next"; me="you"'
~~~
The `Symfony\Component\WebLink\Link` package implements `PSR-13` interfaces.
+148
View File
@@ -0,0 +1,148 @@
<?php
/**
* League.Uri (https://uri.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Uri\UriTemplate;
use BackedEnum;
use Deprecated;
use League\Uri\Exceptions\SyntaxError;
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 preg_replace;
use function str_replace;
use function strpbrk;
use const PREG_SET_ORDER;
/**
* @internal The class exposes the internal representation of a Template and its usage
*/
final class Template implements Stringable
{
/**
* Expression regular expression pattern.
*/
private const REGEXP_EXPRESSION_DETECTOR = '/(?<expression>\{[^}]*})/x';
/** @var array<Expression> */
private readonly array $expressions;
/** @var array<string> */
public readonly array $variableNames;
private function __construct(public readonly string $value, Expression ...$expressions)
{
$this->expressions = $expressions;
$this->variableNames = array_unique(
array_merge(
...array_map(
static fn (Expression $expression): array => $expression->variableNames,
$expressions
)
)
);
}
/**
* @throws SyntaxError if the template contains invalid expressions
* @throws SyntaxError if the template contains invalid variable specification
*/
public static function new(BackedEnum|Stringable|string $template): self
{
if ($template instanceof BackedEnum) {
$template = $template->value;
}
$template = (string) $template;
/** @var string $remainder */
$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);
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']);
}
return $carry;
}, [])
));
}
/**
* @throws TemplateCanNotBeExpanded if the variables are invalid
*/
public function expand(iterable $variables = []): string
{
if (!$variables instanceof VariableBag) {
$variables = new VariableBag($variables);
}
return $this->expandAll($variables);
}
/**
* @throws TemplateCanNotBeExpanded if the variables are invalid or missing
*/
public function expandOrFail(iterable $variables = []): string
{
if (!$variables instanceof VariableBag) {
$variables = new VariableBag($variables);
}
$missing = array_filter($this->variableNames, fn (string $name): bool => !isset($variables[$name]));
if ([] !== $missing) {
throw TemplateCanNotBeExpanded::dueToMissingVariables(...$missing);
}
return $this->expandAll($variables);
}
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
);
}
public function __toString(): string
{
return $this->value;
}
/**
* DEPRECATION WARNING! This method will be removed in the next major point release.
*
* @throws SyntaxError if the template contains invalid expressions
* @throws SyntaxError if the template contains invalid variable specification
* @deprecated Since version 7.0.0
* @codeCoverageIgnore
* @see Template::new()
*
* Create a new instance from a string.
*
*/
#[Deprecated(message:'use League\Uri\UriTemplate\Template::new() instead', since:'league/uri:7.0.0')]
public static function createFromString(Stringable|string $template): self
{
return self::new($template);
}
}
+189
View File
@@ -0,0 +1,189 @@
<?php
/**
* League.Uri (https://uri.thephpleague.com)
*
* (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace League\Uri\UriTemplate;
use JsonException;
use League\Uri\Exceptions\SyntaxError;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use RuntimeException;
use Throwable;
use const JSON_THROW_ON_ERROR;
#[CoversClass(Template::class)]
final class TemplateTest extends TestCase
{
private static string $rootPath = __DIR__.'/../../vendor/uri-templates/uritemplate-test';
/** @var array<string> */
private static array $testFilenames = [
'spec-examples.json',
'negative-tests.json',
'extended-tests.json',
];
#[DataProvider('uriTemplateSpecificationDataProvider')]
#[Test]
public function testItCompliesWithUriTemplatesExpansionTests(
array $variables,
string $input,
string|array|false $expected
): void {
if (false === $expected) {
$this->expectException(Throwable::class);
}
$result = Template::new($input)->expand(new VariableBag($variables));
if (is_array($expected)) {
self::assertContains($result, $expected);
} else {
self::assertSame($expected, $result);
}
}
/**
* @throws JsonException
* @throws RuntimeException
* @return iterable<string, array{
* variables:array{string, string|int},
* input:string,
* expected:string|array<string>|false
* }>
*/
public static function uriTemplateSpecificationDataProvider(): iterable
{
foreach (static::$testFilenames as $path) {
$path = static::$rootPath.'/'.ltrim($path, '/');
if (false === $content = file_get_contents($path)) {
throw new RuntimeException("unable to connect to the path `$path`.");
}
/** @var array $records */
$records = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
foreach ($records as $title => $testSuite) {
$level = $testSuite['level'] ?? null;
$variables = $testSuite['variables'];
foreach ($testSuite['testcases'] as $offset => [$input, $expected]) {
yield $title.' - '.$level.' # '.($offset + 1) => [
'variables' => $variables,
'input' => $input,
'expected' => $expected,
];
}
}
}
}
#[DataProvider('providesValidNotation')]
public function testItCanBeInstantiatedWithAValidNotation(string $notation): void
{
self::assertSame($notation, Template::new($notation)->value);
self::assertSame($notation, (string) Template::new($notation));
}
public static function providesValidNotation(): iterable
{
return [
'complex template' => ['http://example.com{+path}{/segments}{?query,more*,foo[]*}'],
'template without expression' => ['foobar'],
];
}
#[DataProvider('providesInvalidNotation')]
public function testItFailsToInstantiatedWithAnInvalidNotation(string $notation): void
{
self::expectException(SyntaxError::class);
Template::new($notation);
}
public static function providesInvalidNotation(): iterable
{
return [
['fooba{r'],
['fooba}r'],
['fooba}{r'],
['{foo{bar'],
['{foo}}bar'],
];
}
#[DataProvider('expectedVariableNames')]
public function testGetVariableNames(string $template, array $expected): void
{
self::assertSame($expected, Template::new($template)->variableNames);
}
public static function expectedVariableNames(): iterable
{
return [
[
'template' => '',
'expected' => [],
],
[
'template' => '{foo}{bar}{420}',
'expected' => ['foo', 'bar', '420'],
],
[
'template' => '{foo}{foo:2}{+foo}',
'expected' => ['foo'],
],
[
'template' => '{bar}{foo}',
'expected' => ['bar', 'foo'],
],
];
}
#[DataProvider('providesExpansion')]
public function testItCanExpandVariables(string $notation, array $variables, string $expected): void
{
self::assertSame($expected, Template::new($notation)->expand($variables));
}
public static function providesExpansion(): iterable
{
return [
'with variables' => [
'notation' => 'foobar{var}',
'variables' => ['var' => 'yolo'],
'expected' => 'foobaryolo',
],
'with no variables' => [
'notation' => 'foobar',
'variables' => [],
'expected' => 'foobar',
],
];
}
public function testExpandOrFailIfAtLeastOneVariableIsMissing(): void
{
$this->expectException(TemplateCanNotBeExpanded::class);
Template::new('{var}{baz}')->expandOrFail(['var' => 'bar']);
}
#[Test]
public function it_can_expand_with_expand_or_fail_when_all_variables_are_present(): void
{
self::assertSame('barfoo', Template::new('{var}{baz}')->expandOrFail(['var' => 'bar', 'baz' => 'foo']));
}
}