UriTemplate.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. <?php
  2. declare(strict_types=1);
  3. namespace GuzzleHttp\UriTemplate;
  4. /**
  5. * Expands URI templates. Userland implementation of PECL uri_template.
  6. *
  7. * @link http://tools.ietf.org/html/rfc6570
  8. */
  9. final class UriTemplate
  10. {
  11. /**
  12. * @var array<string, array{prefix:string, joiner:string, query:bool}> Hash for quick operator lookups
  13. */
  14. private static $operatorHash = [
  15. '' => ['prefix' => '', 'joiner' => ',', 'query' => false],
  16. '+' => ['prefix' => '', 'joiner' => ',', 'query' => false],
  17. '#' => ['prefix' => '#', 'joiner' => ',', 'query' => false],
  18. '.' => ['prefix' => '.', 'joiner' => '.', 'query' => false],
  19. '/' => ['prefix' => '/', 'joiner' => '/', 'query' => false],
  20. ';' => ['prefix' => ';', 'joiner' => ';', 'query' => true],
  21. '?' => ['prefix' => '?', 'joiner' => '&', 'query' => true],
  22. '&' => ['prefix' => '&', 'joiner' => '&', 'query' => true],
  23. ];
  24. /**
  25. * @var string[] Delimiters
  26. */
  27. private static $delims = [
  28. ':',
  29. '/',
  30. '?',
  31. '#',
  32. '[',
  33. ']',
  34. '@',
  35. '!',
  36. '$',
  37. '&',
  38. '\'',
  39. '(',
  40. ')',
  41. '*',
  42. '+',
  43. ',',
  44. ';',
  45. '=',
  46. ];
  47. /**
  48. * @var string[] Percent encoded delimiters
  49. */
  50. private static $delimsPct = [
  51. '%3A',
  52. '%2F',
  53. '%3F',
  54. '%23',
  55. '%5B',
  56. '%5D',
  57. '%40',
  58. '%21',
  59. '%24',
  60. '%26',
  61. '%27',
  62. '%28',
  63. '%29',
  64. '%2A',
  65. '%2B',
  66. '%2C',
  67. '%3B',
  68. '%3D',
  69. ];
  70. /**
  71. * @param array<string,mixed> $variables Variables to use in the template expansion
  72. *
  73. * @throws \RuntimeException
  74. */
  75. public static function expand(string $template, array $variables): string
  76. {
  77. if (false === \strpos($template, '{')) {
  78. return $template;
  79. }
  80. /** @var string|null */
  81. $result = \preg_replace_callback(
  82. '/\{([^\}]+)\}/',
  83. self::expandMatchCallback($variables),
  84. $template
  85. );
  86. if (null === $result) {
  87. throw new \RuntimeException(\sprintf('Unable to process template: %s', \preg_last_error_msg()));
  88. }
  89. return $result;
  90. }
  91. /**
  92. * @param array<string,mixed> $variables Variables to use in the template expansion
  93. *
  94. * @return callable(string[]): string
  95. */
  96. private static function expandMatchCallback(array $variables): callable
  97. {
  98. return static function (array $matches) use ($variables): string {
  99. return self::expandMatch($matches, $variables);
  100. };
  101. }
  102. /**
  103. * Process an expansion
  104. *
  105. * @param array<string,mixed> $variables Variables to use in the template expansion
  106. * @param string[] $matches Matches met in the preg_replace_callback
  107. *
  108. * @return string Returns the replacement string
  109. */
  110. private static function expandMatch(array $matches, array $variables): string
  111. {
  112. $replacements = [];
  113. $parsed = self::parseExpression($matches[1]);
  114. $prefix = self::$operatorHash[$parsed['operator']]['prefix'];
  115. $joiner = self::$operatorHash[$parsed['operator']]['joiner'];
  116. $useQuery = self::$operatorHash[$parsed['operator']]['query'];
  117. $allUndefined = true;
  118. foreach ($parsed['values'] as $value) {
  119. if (!isset($variables[$value['value']])) {
  120. continue;
  121. }
  122. /** @var mixed */
  123. $variable = $variables[$value['value']];
  124. $actuallyUseQuery = $useQuery;
  125. $expanded = '';
  126. if (\is_array($variable)) {
  127. $isAssoc = self::isAssoc($variable);
  128. $kvp = [];
  129. /** @var mixed $var */
  130. foreach ($variable as $key => $var) {
  131. if ($isAssoc) {
  132. $key = \rawurlencode((string) $key);
  133. $isNestedArray = \is_array($var);
  134. } else {
  135. $isNestedArray = false;
  136. }
  137. if (!$isNestedArray) {
  138. $var = \rawurlencode((string) $var);
  139. if ($parsed['operator'] === '+' || $parsed['operator'] === '#') {
  140. $var = self::decodeReserved($var);
  141. }
  142. }
  143. if ($value['modifier'] === '*') {
  144. if ($isAssoc) {
  145. if ($isNestedArray) {
  146. // Nested arrays must allow for deeply nested structures.
  147. $var = \http_build_query([$key => $var], '', '&', \PHP_QUERY_RFC3986);
  148. } else {
  149. $var = \sprintf('%s=%s', (string) $key, (string) $var);
  150. }
  151. } elseif ($key > 0 && $actuallyUseQuery) {
  152. $var = \sprintf('%s=%s', $value['value'], (string) $var);
  153. }
  154. }
  155. /** @var string $var */
  156. $kvp[$key] = $var;
  157. }
  158. if (0 === \count($variable)) {
  159. $actuallyUseQuery = false;
  160. } elseif ($value['modifier'] === '*') {
  161. $expanded = \implode($joiner, $kvp);
  162. if ($isAssoc) {
  163. // Don't prepend the value name when using the explode
  164. // modifier with an associative array.
  165. $actuallyUseQuery = false;
  166. }
  167. } else {
  168. if ($isAssoc) {
  169. // When an associative array is encountered and the
  170. // explode modifier is not set, then the result must be
  171. // a comma separated list of keys followed by their
  172. // respective values.
  173. foreach ($kvp as $k => &$v) {
  174. $v = \sprintf('%s,%s', $k, $v);
  175. }
  176. }
  177. $expanded = \implode(',', $kvp);
  178. }
  179. } else {
  180. $allUndefined = false;
  181. if ($value['modifier'] === ':' && isset($value['position'])) {
  182. $variable = \substr((string) $variable, 0, $value['position']);
  183. }
  184. $expanded = \rawurlencode((string) $variable);
  185. if ($parsed['operator'] === '+' || $parsed['operator'] === '#') {
  186. $expanded = self::decodeReserved($expanded);
  187. }
  188. }
  189. if ($actuallyUseQuery) {
  190. if (!$expanded && $joiner !== '&') {
  191. $expanded = $value['value'];
  192. } else {
  193. $expanded = \sprintf('%s=%s', $value['value'], $expanded);
  194. }
  195. }
  196. $replacements[] = $expanded;
  197. }
  198. $ret = \implode($joiner, $replacements);
  199. if ('' === $ret) {
  200. // Spec section 3.2.4 and 3.2.5
  201. if (false === $allUndefined && ('#' === $prefix || '.' === $prefix)) {
  202. return $prefix;
  203. }
  204. } else {
  205. if ('' !== $prefix) {
  206. return \sprintf('%s%s', $prefix, $ret);
  207. }
  208. }
  209. return $ret;
  210. }
  211. /**
  212. * Parse an expression into parts
  213. *
  214. * @param string $expression Expression to parse
  215. *
  216. * @return array{operator:string, values:array<array{value:string, modifier:(''|'*'|':'), position?:int}>}
  217. */
  218. private static function parseExpression(string $expression): array
  219. {
  220. $result = [];
  221. if (isset(self::$operatorHash[$expression[0]])) {
  222. $result['operator'] = $expression[0];
  223. /** @var string */
  224. $expression = \substr($expression, 1);
  225. } else {
  226. $result['operator'] = '';
  227. }
  228. $result['values'] = [];
  229. foreach (\explode(',', $expression) as $value) {
  230. $value = \trim($value);
  231. $varspec = [];
  232. if ($colonPos = \strpos($value, ':')) {
  233. $varspec['value'] = (string) \substr($value, 0, $colonPos);
  234. $varspec['modifier'] = ':';
  235. $varspec['position'] = (int) \substr($value, $colonPos + 1);
  236. } elseif (\substr($value, -1) === '*') {
  237. $varspec['modifier'] = '*';
  238. $varspec['value'] = (string) \substr($value, 0, -1);
  239. } else {
  240. $varspec['value'] = $value;
  241. $varspec['modifier'] = '';
  242. }
  243. $result['values'][] = $varspec;
  244. }
  245. return $result;
  246. }
  247. /**
  248. * Determines if an array is associative.
  249. *
  250. * This makes the assumption that input arrays are sequences or hashes.
  251. * This assumption is a tradeoff for accuracy in favor of speed, but it
  252. * should work in almost every case where input is supplied for a URI
  253. * template.
  254. */
  255. private static function isAssoc(array $array): bool
  256. {
  257. return $array && \array_keys($array)[0] !== 0;
  258. }
  259. /**
  260. * Removes percent encoding on reserved characters (used with + and #
  261. * modifiers).
  262. */
  263. private static function decodeReserved(string $string): string
  264. {
  265. return \str_replace(self::$delimsPct, self::$delims, $string);
  266. }
  267. }