diff --git a/docs/uri/7.0/uri-template.md b/docs/uri/7.0/uri-template.md new file mode 100644 index 0000000..5d21072 --- /dev/null +++ b/docs/uri/7.0/uri-template.md @@ -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 + '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" +~~~ + +

expandToUri(), expandToUrl() and expandToPsr7Uri() are available since +version 7.6.0

+ +

Since version 7.6 the expand() method takes a second optional parameter +which serves as a base URI. The generated URI is resolved against the Base URI.

+ +~~~php + '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` +~~~ + +

expandToUriOrFail() and expandToUrlOrFail() are available since +version 7.6.0

+ +## Template Variables + +

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 true and false which will be converted +to 1 and 0 respectively.

+ +### 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. + +

a WHATWG URL must always be absolute if the URI template is not you +MUST provide a base URL to expandToUrl() otherwise an Uri\WhatWg\InvalidUrlException +exception will be thrown.

+ +~~~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 +~~~ + +

You may return a different Psr7\Http\Message\UriInterface object if you +provide to the expandToPsr7Uri your own Psr\Http\Message\UriFactoryInterface class +as the third optional argument.

+ +~~~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']) +~~~ + +

Following RFC6570 requirements means not support for +nested array like the one used with http_build_query

+ +~~~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 + +

Available since version 7.6

+ +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 +// '; rel="next"; me="you"' +~~~ + +The `Symfony\Component\WebLink\Link` package implements `PSR-13` interfaces. diff --git a/uri/UriTemplate/Template.php b/uri/UriTemplate/Template.php new file mode 100644 index 0000000..6e0d459 --- /dev/null +++ b/uri/UriTemplate/Template.php @@ -0,0 +1,148 @@ + + * + * 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 = '/(?\{[^}]*})/x'; + + /** @var array */ + private readonly array $expressions; + /** @var array */ + 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); + } +} diff --git a/uri/UriTemplate/TemplateTest.php b/uri/UriTemplate/TemplateTest.php new file mode 100644 index 0000000..85f7dd2 --- /dev/null +++ b/uri/UriTemplate/TemplateTest.php @@ -0,0 +1,189 @@ + + * + * 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 */ + 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|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'])); + } +}