298 lines
9.4 KiB
PHP
298 lines
9.4 KiB
PHP
|
<?php
|
||
|
|
||
|
declare(strict_types=1);
|
||
|
|
||
|
namespace GuzzleHttp\UriTemplate;
|
||
|
|
||
|
/**
|
||
|
* Expands URI templates. Userland implementation of PECL uri_template.
|
||
|
*
|
||
|
* @link http://tools.ietf.org/html/rfc6570
|
||
|
*/
|
||
|
final class UriTemplate
|
||
|
{
|
||
|
/**
|
||
|
* @var array<string, array{prefix:string, joiner:string, query:bool}> Hash for quick operator lookups
|
||
|
*/
|
||
|
private static $operatorHash = [
|
||
|
'' => ['prefix' => '', 'joiner' => ',', 'query' => false],
|
||
|
'+' => ['prefix' => '', 'joiner' => ',', 'query' => false],
|
||
|
'#' => ['prefix' => '#', 'joiner' => ',', 'query' => false],
|
||
|
'.' => ['prefix' => '.', 'joiner' => '.', 'query' => false],
|
||
|
'/' => ['prefix' => '/', 'joiner' => '/', 'query' => false],
|
||
|
';' => ['prefix' => ';', 'joiner' => ';', 'query' => true],
|
||
|
'?' => ['prefix' => '?', 'joiner' => '&', 'query' => true],
|
||
|
'&' => ['prefix' => '&', 'joiner' => '&', 'query' => true],
|
||
|
];
|
||
|
|
||
|
/**
|
||
|
* @var string[] Delimiters
|
||
|
*/
|
||
|
private static $delims = [
|
||
|
':',
|
||
|
'/',
|
||
|
'?',
|
||
|
'#',
|
||
|
'[',
|
||
|
']',
|
||
|
'@',
|
||
|
'!',
|
||
|
'$',
|
||
|
'&',
|
||
|
'\'',
|
||
|
'(',
|
||
|
')',
|
||
|
'*',
|
||
|
'+',
|
||
|
',',
|
||
|
';',
|
||
|
'=',
|
||
|
];
|
||
|
|
||
|
/**
|
||
|
* @var string[] Percent encoded delimiters
|
||
|
*/
|
||
|
private static $delimsPct = [
|
||
|
'%3A',
|
||
|
'%2F',
|
||
|
'%3F',
|
||
|
'%23',
|
||
|
'%5B',
|
||
|
'%5D',
|
||
|
'%40',
|
||
|
'%21',
|
||
|
'%24',
|
||
|
'%26',
|
||
|
'%27',
|
||
|
'%28',
|
||
|
'%29',
|
||
|
'%2A',
|
||
|
'%2B',
|
||
|
'%2C',
|
||
|
'%3B',
|
||
|
'%3D',
|
||
|
];
|
||
|
|
||
|
/**
|
||
|
* @param array<string,mixed> $variables Variables to use in the template expansion
|
||
|
*
|
||
|
* @throws \RuntimeException
|
||
|
*/
|
||
|
public static function expand(string $template, array $variables): string
|
||
|
{
|
||
|
if (false === \strpos($template, '{')) {
|
||
|
return $template;
|
||
|
}
|
||
|
|
||
|
/** @var string|null */
|
||
|
$result = \preg_replace_callback(
|
||
|
'/\{([^\}]+)\}/',
|
||
|
self::expandMatchCallback($variables),
|
||
|
$template
|
||
|
);
|
||
|
|
||
|
if (null === $result) {
|
||
|
throw new \RuntimeException(\sprintf('Unable to process template: %s', \preg_last_error_msg()));
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param array<string,mixed> $variables Variables to use in the template expansion
|
||
|
*
|
||
|
* @return callable(string[]): string
|
||
|
*/
|
||
|
private static function expandMatchCallback(array $variables): callable
|
||
|
{
|
||
|
return static function (array $matches) use ($variables): string {
|
||
|
return self::expandMatch($matches, $variables);
|
||
|
};
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Process an expansion
|
||
|
*
|
||
|
* @param array<string,mixed> $variables Variables to use in the template expansion
|
||
|
* @param string[] $matches Matches met in the preg_replace_callback
|
||
|
*
|
||
|
* @return string Returns the replacement string
|
||
|
*/
|
||
|
private static function expandMatch(array $matches, array $variables): string
|
||
|
{
|
||
|
$replacements = [];
|
||
|
$parsed = self::parseExpression($matches[1]);
|
||
|
$prefix = self::$operatorHash[$parsed['operator']]['prefix'];
|
||
|
$joiner = self::$operatorHash[$parsed['operator']]['joiner'];
|
||
|
$useQuery = self::$operatorHash[$parsed['operator']]['query'];
|
||
|
$allUndefined = true;
|
||
|
|
||
|
foreach ($parsed['values'] as $value) {
|
||
|
if (!isset($variables[$value['value']])) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
/** @var mixed */
|
||
|
$variable = $variables[$value['value']];
|
||
|
$actuallyUseQuery = $useQuery;
|
||
|
$expanded = '';
|
||
|
|
||
|
if (\is_array($variable)) {
|
||
|
$isAssoc = self::isAssoc($variable);
|
||
|
$kvp = [];
|
||
|
/** @var mixed $var */
|
||
|
foreach ($variable as $key => $var) {
|
||
|
if ($isAssoc) {
|
||
|
$key = \rawurlencode((string) $key);
|
||
|
$isNestedArray = \is_array($var);
|
||
|
} else {
|
||
|
$isNestedArray = false;
|
||
|
}
|
||
|
|
||
|
if (!$isNestedArray) {
|
||
|
$var = \rawurlencode((string) $var);
|
||
|
if ($parsed['operator'] === '+' || $parsed['operator'] === '#') {
|
||
|
$var = self::decodeReserved($var);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($value['modifier'] === '*') {
|
||
|
if ($isAssoc) {
|
||
|
if ($isNestedArray) {
|
||
|
// Nested arrays must allow for deeply nested structures.
|
||
|
$var = \http_build_query([$key => $var], '', '&', \PHP_QUERY_RFC3986);
|
||
|
} else {
|
||
|
$var = \sprintf('%s=%s', (string) $key, (string) $var);
|
||
|
}
|
||
|
} elseif ($key > 0 && $actuallyUseQuery) {
|
||
|
$var = \sprintf('%s=%s', $value['value'], (string) $var);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** @var string $var */
|
||
|
|
||
|
$kvp[$key] = $var;
|
||
|
}
|
||
|
|
||
|
if (0 === \count($variable)) {
|
||
|
$actuallyUseQuery = false;
|
||
|
} elseif ($value['modifier'] === '*') {
|
||
|
$expanded = \implode($joiner, $kvp);
|
||
|
if ($isAssoc) {
|
||
|
// Don't prepend the value name when using the explode
|
||
|
// modifier with an associative array.
|
||
|
$actuallyUseQuery = false;
|
||
|
}
|
||
|
} else {
|
||
|
if ($isAssoc) {
|
||
|
// 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.
|
||
|
foreach ($kvp as $k => &$v) {
|
||
|
$v = \sprintf('%s,%s', $k, $v);
|
||
|
}
|
||
|
}
|
||
|
$expanded = \implode(',', $kvp);
|
||
|
}
|
||
|
} else {
|
||
|
$allUndefined = false;
|
||
|
if ($value['modifier'] === ':' && isset($value['position'])) {
|
||
|
$variable = \substr((string) $variable, 0, $value['position']);
|
||
|
}
|
||
|
$expanded = \rawurlencode((string) $variable);
|
||
|
if ($parsed['operator'] === '+' || $parsed['operator'] === '#') {
|
||
|
$expanded = self::decodeReserved($expanded);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($actuallyUseQuery) {
|
||
|
if (!$expanded && $joiner !== '&') {
|
||
|
$expanded = $value['value'];
|
||
|
} else {
|
||
|
$expanded = \sprintf('%s=%s', $value['value'], $expanded);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$replacements[] = $expanded;
|
||
|
}
|
||
|
|
||
|
$ret = \implode($joiner, $replacements);
|
||
|
|
||
|
if ('' === $ret) {
|
||
|
// Spec section 3.2.4 and 3.2.5
|
||
|
if (false === $allUndefined && ('#' === $prefix || '.' === $prefix)) {
|
||
|
return $prefix;
|
||
|
}
|
||
|
} else {
|
||
|
if ('' !== $prefix) {
|
||
|
return \sprintf('%s%s', $prefix, $ret);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $ret;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Parse an expression into parts
|
||
|
*
|
||
|
* @param string $expression Expression to parse
|
||
|
*
|
||
|
* @return array{operator:string, values:array<array{value:string, modifier:(''|'*'|':'), position?:int}>}
|
||
|
*/
|
||
|
private static function parseExpression(string $expression): array
|
||
|
{
|
||
|
$result = [];
|
||
|
|
||
|
if (isset(self::$operatorHash[$expression[0]])) {
|
||
|
$result['operator'] = $expression[0];
|
||
|
/** @var string */
|
||
|
$expression = \substr($expression, 1);
|
||
|
} else {
|
||
|
$result['operator'] = '';
|
||
|
}
|
||
|
|
||
|
$result['values'] = [];
|
||
|
foreach (\explode(',', $expression) as $value) {
|
||
|
$value = \trim($value);
|
||
|
$varspec = [];
|
||
|
if ($colonPos = \strpos($value, ':')) {
|
||
|
$varspec['value'] = (string) \substr($value, 0, $colonPos);
|
||
|
$varspec['modifier'] = ':';
|
||
|
$varspec['position'] = (int) \substr($value, $colonPos + 1);
|
||
|
} elseif (\substr($value, -1) === '*') {
|
||
|
$varspec['modifier'] = '*';
|
||
|
$varspec['value'] = (string) \substr($value, 0, -1);
|
||
|
} else {
|
||
|
$varspec['value'] = $value;
|
||
|
$varspec['modifier'] = '';
|
||
|
}
|
||
|
$result['values'][] = $varspec;
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines if an array is associative.
|
||
|
*
|
||
|
* This makes the assumption that input arrays are sequences or hashes.
|
||
|
* This assumption is a tradeoff for accuracy in favor of speed, but it
|
||
|
* should work in almost every case where input is supplied for a URI
|
||
|
* template.
|
||
|
*/
|
||
|
private static function isAssoc(array $array): bool
|
||
|
{
|
||
|
return $array && \array_keys($array)[0] !== 0;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Removes percent encoding on reserved characters (used with + and #
|
||
|
* modifiers).
|
||
|
*/
|
||
|
private static function decodeReserved(string $string): string
|
||
|
{
|
||
|
return \str_replace(self::$delimsPct, self::$delims, $string);
|
||
|
}
|
||
|
}
|