commit 2c92ab421ca1a5bd59c5ceb3f2ba1acd2948604b Author: upstream Date: Fri Sep 4 08:23:31 2026 +0200 Upstream thephpleague/uri-src state of the touched files before 11370c055f diff --git a/uri/UriTemplate/Operator.php b/uri/UriTemplate/Operator.php new file mode 100644 index 0000000..0316b80 --- /dev/null +++ b/uri/UriTemplate/Operator.php @@ -0,0 +1,225 @@ + + * + * 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 League\Uri\Encoder; +use League\Uri\Exceptions\SyntaxError; +use Stringable; + +use function implode; +use function is_array; +use function preg_match; +use function rawurlencode; +use function str_contains; +use function substr; + +/** + * Processing behavior according to the expression type operator. + * + * @internal The class exposes the internal representation of an Operator and its usage + * + * @link https://www.rfc-editor.org/rfc/rfc6570#section-2.2 + * @link https://tools.ietf.org/html/rfc6570#appendix-A + */ +enum Operator: string +{ + /** + * Expression regular expression pattern. + * + * @link https://tools.ietf.org/html/rfc6570#section-2.2 + */ + private const REGEXP_EXPRESSION = '/^\{(?:(?[\.\/;\?&\=,\!@\|\+#])?(?[^\}]*))\}$/'; + + /** + * Reserved Operator characters. + * + * @link https://tools.ietf.org/html/rfc6570#section-2.2 + */ + private const RESERVED_OPERATOR = '=,!@|'; + + case None = ''; + case ReservedChars = '+'; + case Label = '.'; + case Path = '/'; + case PathParam = ';'; + case Query = '?'; + case QueryPair = '&'; + case Fragment = '#'; + + public function first(): string + { + return match ($this) { + self::None, self::ReservedChars => '', + default => $this->value, + }; + } + + public function separator(): string + { + return match ($this) { + self::None, self::ReservedChars, self::Fragment => ',', + self::Query, self::QueryPair => '&', + default => $this->value, + }; + } + + public function isNamed(): bool + { + return match ($this) { + self::Query, self::PathParam, self::QueryPair => true, + default => false, + }; + } + + /** + * Removes percent encoding on reserved characters (used with + and # modifiers). + */ + public function decode(string $var): string + { + return match ($this) { + Operator::ReservedChars, Operator::Fragment => (string) Encoder::encodeQueryOrFragment($var), + default => rawurlencode($var), + }; + } + + /** + * @throws SyntaxError if the expression is invalid + * @throws SyntaxError if the operator used in the expression is invalid + * @throws SyntaxError if the contained variable specifiers are invalid + * + * @return array{operator:Operator, variables:string} + */ + public static function parseExpression(Stringable|string $expression): array + { + $expression = (string) $expression; + if (1 !== preg_match(self::REGEXP_EXPRESSION, $expression, $parts)) { + throw new SyntaxError('The expression "'.$expression.'" is invalid.'); + } + + /** @var array{operator:string, variables:string} $parts */ + $parts = $parts + ['operator' => '']; + if ('' !== $parts['operator'] && str_contains(self::RESERVED_OPERATOR, $parts['operator'])) { + throw new SyntaxError('The operator used in the expression "'.$expression.'" is reserved.'); + } + + return [ + 'operator' => self::from($parts['operator']), + 'variables' => $parts['variables'], + ]; + } + + /** + * Replaces an expression with the given variables. + * + * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied + * @throws TemplateCanNotBeExpanded if the variables contains nested array values + */ + public function expand(VarSpecifier $varSpecifier, VariableBag $variables): string + { + $value = $variables->fetch($varSpecifier->name); + if (null === $value) { + return ''; + } + + [$expanded, $actualQuery] = $this->inject($value, $varSpecifier); + if (!$actualQuery) { + return $expanded; + } + + if ('&' !== $this->separator() && '' === $expanded) { + return $varSpecifier->name; + } + + return $varSpecifier->name.'='.$expanded; + } + + /** + * @param string|array $value + * + * @return array{0:string, 1:bool} + */ + private function inject(array|string $value, VarSpecifier $varSpec): array + { + if (is_array($value)) { + return $this->replaceList($value, $varSpec); + } + + if (':' === $varSpec->modifier) { + $value = substr($value, 0, $varSpec->position); + } + + return [$this->decode($value), $this->isNamed()]; + } + + /** + * Expands an expression using a list of values. + * + * @param array $value + * + * @throws TemplateCanNotBeExpanded if the variables is an array and a ":" modifier needs to be applied + * + * @return array{0:string, 1:bool} + */ + private function replaceList(array $value, VarSpecifier $varSpec): array + { + if (':' === $varSpec->modifier) { + throw TemplateCanNotBeExpanded::dueToUnableToProcessValueListWithPrefix($varSpec->name); + } + + if ([] === $value) { + return ['', false]; + } + + $pairs = []; + $isList = array_is_list($value); + $useQuery = $this->isNamed(); + foreach ($value as $key => $var) { + if (!$isList) { + $key = rawurlencode((string) $key); + } + + $var = $this->decode($var); + if ('*' === $varSpec->modifier) { + if (!$isList) { + $var = $key.'='.$var; + } elseif ($key > 0 && $useQuery) { + $var = $varSpec->name.'='.$var; + } + } + + $pairs[$key] = $var; + } + + if ('*' === $varSpec->modifier) { + if (!$isList) { + // Don't prepend the value name when using the `explode` modifier with an associative array. + $useQuery = false; + } + + return [implode($this->separator(), $pairs), $useQuery]; + } + + if (!$isList) { + // When an associative array is encountered and the `explode` modifier is not set, then + // the result must be a comma separated list of keys followed by their respective values. + $retVal = []; + foreach ($pairs as $offset => $data) { + $retVal[$offset] = $offset.','.$data; + } + $pairs = $retVal; + } + + return [implode(',', $pairs), $useQuery]; + } +} diff --git a/uri/UriTemplateTest.php b/uri/UriTemplateTest.php new file mode 100644 index 0000000..281f2fc --- /dev/null +++ b/uri/UriTemplateTest.php @@ -0,0 +1,367 @@ + + * + * 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; + +use League\Uri\Exceptions\SyntaxError; +use League\Uri\UriTemplate\Template; +use League\Uri\UriTemplate\TemplateCanNotBeExpanded; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use PHPUnit\Framework\TestCase; +use Uri\WhatWg\InvalidUrlException; + +#[CoversClass(UriTemplate::class)] +#[CoversClass(Template::class)] +final class UriTemplateTest extends TestCase +{ + public function testGetTemplate(): void + { + $template = 'https://example.com{+path}{/segments}{?query,more*,foo[]*}'; + $variables = [ + 'path' => '/foo/bar', + 'segments' => ['one', 'two'], + 'query' => 'test', + 'more' => ['fun', 'ice cream'], + 'foo[]' => ['fizz', 'buzz'], + ]; + + self::assertSame($template, (string) new UriTemplate($template, $variables)); + } + + public function testGetDefaultVariables(): void + { + $template = 'https://example.com{+path}{/segments}{?query,more*,foo[]*}'; + $variables = [ + 'path' => '/foo/bar', + 'segments' => ['one', 'two', 3, true, 'false', false, null], + 'query' => 'test', + 'more' => ['fun', 'ice cream'], + 'foo[]' => ['fizz', 'buzz'], + 'nonexistent' => ['random'], + ]; + + $expectedVariables = [ + 'path' => '/foo/bar', + 'segments' => ['one', 'two', '3', '1', 'false', '0', ''], + 'query' => 'test', + 'more' => ['fun', 'ice cream'], + 'foo[]' => ['fizz', 'buzz'], + ]; + + $uriTemplate = new UriTemplate($template, $variables); + self::assertEquals($expectedVariables, [...$uriTemplate->getDefaultVariables()]); + self::assertCount(5, $uriTemplate->getDefaultVariables()); + + $uriTemplateEmpty = new UriTemplate($template, []); + self::assertSame([], [...$uriTemplateEmpty->getDefaultVariables()]); + self::assertCount(0, $uriTemplateEmpty->getDefaultVariables()); + } + + public function testWithDefaultVariables(): void + { + $template = '{foo}{bar}'; + $variables = ['foo' => 'foo', 'bar' => 'bar']; + $newVariables = ['foo' => 'bar', 'bar' => 'foo']; + $newAltVariables = ['foo' => 'foo', 'bar' => 'bar', 'filteredVariable' => 'random']; + + $uriTemplate = new UriTemplate($template, $variables); + $newTemplate = $uriTemplate->withDefaultVariables($newVariables); + $altTemplate = $uriTemplate->withDefaultVariables($variables); + $newAltTemplate = $uriTemplate->withDefaultVariables($newAltVariables); + + self::assertEquals($altTemplate->getDefaultVariables(), $uriTemplate->getDefaultVariables()); + self::assertEquals($newAltTemplate->getDefaultVariables(), $uriTemplate->getDefaultVariables()); + self::assertNotEquals($newTemplate->getDefaultVariables(), $uriTemplate->getDefaultVariables()); + } + + #[DataProvider('expectedVariableNames')] + public function testGetVariableNames(string $template, array $expected): void + { + self::assertSame($expected, (new UriTemplate($template))->getVariableNames()); + } + + public static function expectedVariableNames(): iterable + { + return [ + [ + 'template' => '', + 'expected' => [], + ], + [ + 'template' => '{foo}{bar}', + 'expected' => ['foo', 'bar'], + ], + [ + 'template' => '{foo}{foo:2}{+foo}', + 'expected' => ['foo'], + ], + [ + 'template' => '{bar}{foo}', + 'expected' => ['bar', 'foo'], + ], + ]; + } + + #[DataProvider('templateExpansionProvider')] + public function testExpandsUriTemplates(string $template, string $expectedUriString, array $variables): void + { + self::assertSame($expectedUriString, (new UriTemplate($template))->expand($variables)->toString()); + self::assertSame($expectedUriString, (new UriTemplate($template))->expandToUri($variables)->toRawString()); + } + + public static function templateExpansionProvider(): iterable + { + $variables = [ + 'var' => 'value', + 'hello' => 'Hello World!', + 'empty' => '', + 'path' => '/foo/bar', + 'x' => '1024', + 'y' => '768', + 'null' => null, + 'list' => ['red', 'green', 'blue'], + 'keys' => [ + 'semi' => ';', + 'dot' => '.', + 'comma' => ',', + ], + 'empty_keys' => [], + 'bool' => true, + ]; + + $templateAndExpansionData = [ + 'level 1' => [ + ['foo', 'foo'], + ['{var}', 'value'], + ['{hello}', 'Hello%20World%21'], + ['{bool}', '1'], + ], + 'level 2' => [ + ['{+var}', 'value'], + ['{+hello}', 'Hello%20World!'], + ['{+path}/here', '/foo/bar/here'], + ['here?ref={+path}', 'here?ref=/foo/bar'], + ], + 'level 3' => [ + ['X{#var}', 'X#value'], + ['X{#hello}', 'X#Hello%20World!'], + ['map?{x,y}', 'map?1024,768'], + ['{x,hello,y}', '1024,Hello%20World%21,768'], + ['{+x,hello,y}', '1024,Hello%20World!,768'], + ['{+path,x}/here', '/foo/bar,1024/here'], + ['{#x,hello,y}', '#1024,Hello%20World!,768'], + ['{#path,x}/here', '#/foo/bar,1024/here'], + ['X{.var}', 'X.value'], + ['X{.x,y}', 'X.1024.768'], + ['{/var}', '/value'], + ['{/var,x}/here', '/value/1024/here'], + ['{;x,y}', ';x=1024;y=768'], + ['{;x,y,empty}', ';x=1024;y=768;empty'], + ['{?x,y}', '?x=1024&y=768'], + ['{?x,y,empty}', '?x=1024&y=768&empty='], + ['{?x,y,undef}', '?x=1024&y=768'], + ['?fixed=yes{&x}', '?fixed=yes&x=1024'], + ['{&x,y,empty}', '&x=1024&y=768&empty='], + ], + 'level 4' => [ + ['{var:3}', 'val'], + ['{var:30}', 'value'], + ['{list}', 'red,green,blue'], + ['{list*}', 'red,green,blue'], + ['{keys}', 'semi,%3B,dot,.,comma,%2C'], + ['{keys*}', 'semi=%3B,dot=.,comma=%2C'], + ['{+path:6}/here', '/foo/b/here'], + ['{+list}', 'red,green,blue'], + ['{+list*}', 'red,green,blue'], + ['{+keys}', 'semi,;,dot,.,comma,,'], + ['{+keys*}', 'semi=;,dot=.,comma=,'], + ['{#path:6}/here', '#/foo/b/here'], + ['{#list}', '#red,green,blue'], + ['{#list*}', '#red,green,blue'], + ['{#keys}', '#semi,;,dot,.,comma,,'], + ['{#keys*}', '#semi=;,dot=.,comma=,'], + ['X{.var:3}', 'X.val'], + ['X{.list}', 'X.red,green,blue'], + ['X{.list*}', 'X.red.green.blue'], + ['X{.keys}', 'X.semi,%3B,dot,.,comma,%2C'], + ['X{.keys*}', 'X.semi=%3B.dot=..comma=%2C'], + ['{/var:1,var}', '/v/value'], + ['{/list}', '/red,green,blue'], + ['{/list*}', '/red/green/blue'], + ['{/list*,path:4}', '/red/green/blue/%2Ffoo'], + ['{/keys}', '/semi,%3B,dot,.,comma,%2C'], + ['{/keys*}', '/semi=%3B/dot=./comma=%2C'], + ['{;hello:5}', ';hello=Hello'], + ['{;list}', ';list=red,green,blue'], + ['{;list*}', ';list=red;list=green;list=blue'], + ['{;keys}', ';keys=semi,%3B,dot,.,comma,%2C'], + ['{;keys*}', ';semi=%3B;dot=.;comma=%2C'], + ['{?var:3}', '?var=val'], + ['{?list}', '?list=red,green,blue'], + ['{?list*}', '?list=red&list=green&list=blue'], + ['{?keys}', '?keys=semi,%3B,dot,.,comma,%2C'], + ['{?keys*}', '?semi=%3B&dot=.&comma=%2C'], + ['{&var:3}', '&var=val'], + ['{&list}', '&list=red,green,blue'], + ['{&list*}', '&list=red&list=green&list=blue'], + ['{&keys}', '&keys=semi,%3B,dot,.,comma,%2C'], + ['{&keys*}', '&semi=%3B&dot=.&comma=%2C'], + ['{.null}', ''], + ['{.null,var}', '.value'], + ['X{.empty_keys*}', 'X'], + ['X{.empty_keys}', 'X'], + ], + 'extra' => [ + // Test that missing expansions are skipped + ['test{&missing*}', 'test'], + // Test that multiple expansions can be set + ['http://{var}/{var:2}{?keys*}', 'http://value/va?semi=%3B&dot=.&comma=%2C'], + // Test more complex query string stuff + ['http://www.test.com{+path}{?var,keys*}', 'http://www.test.com/foo/bar?var=value&semi=%3B&dot=.&comma=%2C'], + ], + ]; + + foreach ($templateAndExpansionData as $specification => $tests) { + foreach ($tests as $offset => $test) { + yield $specification.' test '.$offset => [ + 'template' => $test[0], + 'expectedUriString' => $test[1], + 'variables' => $variables, + ]; + } + } + } + + public function testAllowsQueryValuePairsArrayExpansion(): void + { + $template = 'http://example.com{+path}{/segments}{?query,more*,foo[]*}'; + $variables = [ + 'path' => '/foo/bar', + 'segments' => ['one', 'two'], + 'query' => 'test', + 'more' => ['fun', 'ice cream'], + 'foo[]' => ['fizz', 'buzz'], + ]; + $expectedUri = 'http://example.com/foo/bar/one,two?query=test&more=fun&more=ice%20cream&foo[]=fizz&foo[]=buzz'; + + $uriTemplate = new UriTemplate($template); + self::assertSame($expectedUri, $uriTemplate->expandToUrl($variables)->toAsciiString()); + self::assertSame($expectedUri, $uriTemplate->expandToUrlOrFail($variables)->toAsciiString()); + } + + public function testDisallowNestedArrayExpansion(): void + { + $template = 'http://example.com{?query,data*,foo*}'; + $variables = [ + 'query' => 'test', + 'data' => [ + 'more' => ['fun', 'ice cream'], + ], + 'foo' => [ + 'baz' => [ + 'bar' => 'fizz', + 'test' => 'buzz', + ], + 'bam' => 'boo', + ], + ]; + + self::expectException(TemplateCanNotBeExpanded::class); + + (new UriTemplate($template))->expand($variables); + } + + public function testExpandWithDefaultVariables(): void + { + $template = 'http://example.com{+path}{/segments}{?query,more*,foo[]*}'; + $defaultVariables = [ + 'path' => '/foo/bar', + 'segments' => ['one', 'two'], + ]; + $variables = [ + 'query' => 'test', + 'more' => ['fun', 'ice cream'], + 'foo[]' => ['fizz', 'buzz'], + ]; + $expectedUri = 'http://example.com/foo/bar/one,two?query=test&more=fun&more=ice%20cream&foo%5B%5D=fizz&foo%5B%5D=buzz'; + + $uriTemplate = new UriTemplate($template, $defaultVariables); + self::assertSame($expectedUri, $uriTemplate->expand($variables)->toString()); + self::assertSame($expectedUri, $uriTemplate->expandOrFail($variables)->toString()); + } + + public function testExpandWithDefaultVariablesWithOverride(): void + { + $template = 'http://example.com{+path}{/segments}{?query,more*,foo[]*}'; + + $defaultVariables = [ + 'path' => '/foo/bar', + 'segments' => ['one', 'two'], + ]; + + $variables = [ + 'path' => '/bar/baz', + 'query' => 'test', + 'more' => ['fun', 'ice cream'], + 'foo[]' => ['fizz', 'buzz'], + ]; + + self::assertSame( + 'http://example.com/bar/baz/one,two?query=test&more=fun&more=ice%20cream&foo%5B%5D=fizz&foo%5B%5D=buzz', + (new UriTemplate($template, $defaultVariables))->expand($variables)->toString() + ); + } + + /** + * @see https://github.com/uri-templates/uritemplate-test/blob/master/negative-tests.json + */ + #[TestWith(['http://example.com/}/{+foo}'])] + #[TestWith(['http://example.com/{/{+foo}'])] + public function testInvalidUriTemplate(string $template): void + { + self::expectException(SyntaxError::class); + + new UriTemplate($template); + } + + public function testExpansionWithMultipleSameExpression(): void + { + $template = '{foo}/{foo}'; + $data = ['foo' => 'foo']; + + self::assertSame('foo/foo', (new UriTemplate($template, $data))->expand()->toString()); + } + + public function testExpandOrFailIfVariablesAreMissing(): void + { + $this->expectException(TemplateCanNotBeExpanded::class); + + (new UriTemplate('{var}'))->expandOrFail(); + } + + public function testExpandOrFailIfAtLeastOneVariableIsMissing(): void + { + $this->expectException(TemplateCanNotBeExpanded::class); + + (new UriTemplate('{var}{baz}'))->expandOrFail(['var' => 'bar']); + } + + public function testExpandToUrlFailsWithRelativeUrl(): void + { + $this->expectException(InvalidUrlException::class); + + (new UriTemplate('/foobar'))->expandToUrl(); + } +}