123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- <?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);
- }
- }
|