UriTemplate.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. <?php
  2. declare(strict_types=1);
  3. namespace GuzzleHttp\UriTemplate;
  4. /**
  5. * Expands URI templates. Userland implementation of PECL uri_template.
  6. *
  7. * @see https://datatracker.ietf.org/doc/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. * @param array<string,mixed> $variables Variables to use in the template expansion
  26. *
  27. * @throws \RuntimeException
  28. */
  29. public static function expand(string $template, array $variables): string
  30. {
  31. if (false === \strpos($template, '{')) {
  32. return $template;
  33. }
  34. /** @var string|null */
  35. $result = \preg_replace_callback(
  36. '/\{([^\}]+)\}/',
  37. self::expandMatchCallback($variables),
  38. $template
  39. );
  40. if (null === $result) {
  41. throw new \RuntimeException(\sprintf('Unable to process template: %s', \preg_last_error_msg()));
  42. }
  43. return $result;
  44. }
  45. /**
  46. * @param array<string,mixed> $variables Variables to use in the template expansion
  47. *
  48. * @return callable(string[]): string
  49. */
  50. private static function expandMatchCallback(array $variables): callable
  51. {
  52. return static function (array $matches) use ($variables): string {
  53. return self::expandMatch($matches, $variables);
  54. };
  55. }
  56. /**
  57. * Process an expansion
  58. *
  59. * @param array<string,mixed> $variables Variables to use in the template expansion
  60. * @param string[] $matches Matches met in the preg_replace_callback
  61. *
  62. * @return string Returns the replacement string
  63. */
  64. private static function expandMatch(array $matches, array $variables): string
  65. {
  66. $replacements = [];
  67. $parsed = self::parseExpression($matches[1]);
  68. $prefix = self::$operatorHash[$parsed['operator']]['prefix'];
  69. $joiner = self::$operatorHash[$parsed['operator']]['joiner'];
  70. $useQuery = self::$operatorHash[$parsed['operator']]['query'];
  71. $allowReserved = $parsed['operator'] === '+' || $parsed['operator'] === '#';
  72. $hasDefinedVariable = false;
  73. foreach ($parsed['values'] as $value) {
  74. if (!isset($variables[$value['value']])) {
  75. continue;
  76. }
  77. $variable = $variables[$value['value']];
  78. $actuallyUseQuery = $useQuery;
  79. $expanded = '';
  80. if (\is_array($variable)) {
  81. $isAssoc = self::isAssoc($variable);
  82. $kvp = [];
  83. /** @var mixed $var */
  84. foreach ($variable as $key => $var) {
  85. if ($isAssoc) {
  86. $rawKey = (string) $key;
  87. $key = \rawurlencode($rawKey);
  88. $isNestedArray = \is_array($var);
  89. } else {
  90. $isNestedArray = false;
  91. }
  92. if (!$isNestedArray) {
  93. $var = self::encodeValue(self::stringifyValue($var), $allowReserved);
  94. }
  95. if ($value['modifier'] === '*') {
  96. if ($isAssoc) {
  97. if ($isNestedArray) {
  98. // Nested arrays must allow for deeply nested structures.
  99. $var = \http_build_query([$rawKey => self::stringifyNonFiniteFloats($var)], '', '&', \PHP_QUERY_RFC3986);
  100. if ($var === '') {
  101. continue;
  102. }
  103. } else {
  104. $var = \sprintf('%s=%s', (string) $key, (string) $var);
  105. }
  106. } elseif ($key > 0 && $actuallyUseQuery) {
  107. $var = \sprintf('%s=%s', $value['value'], (string) $var);
  108. }
  109. }
  110. /** @var string $var */
  111. $kvp[$key] = $var;
  112. }
  113. if ($kvp === []) {
  114. continue;
  115. } elseif ($value['modifier'] === '*') {
  116. $expanded = \implode($joiner, $kvp);
  117. if ($isAssoc) {
  118. // Don't prepend the value name when using the explode
  119. // modifier with an associative array.
  120. $actuallyUseQuery = false;
  121. }
  122. } else {
  123. if ($isAssoc) {
  124. // When an associative array is encountered and the
  125. // explode modifier is not set, then the result must be
  126. // a comma separated list of keys followed by their
  127. // respective values.
  128. foreach ($kvp as $k => &$v) {
  129. $v = \sprintf('%s,%s', $k, $v);
  130. }
  131. }
  132. $expanded = \implode(',', $kvp);
  133. }
  134. } else {
  135. $variable = self::stringifyValue($variable);
  136. if ($value['modifier'] === ':' && isset($value['position'])) {
  137. $variable = self::prefixValue($variable, $value['position']);
  138. }
  139. $expanded = self::encodeValue($variable, $allowReserved);
  140. }
  141. if ($actuallyUseQuery) {
  142. if ($expanded === '' && $joiner !== '&') {
  143. $expanded = $value['value'];
  144. } else {
  145. $expanded = \sprintf('%s=%s', $value['value'], $expanded);
  146. }
  147. }
  148. $hasDefinedVariable = true;
  149. $replacements[] = $expanded;
  150. }
  151. $ret = \implode($joiner, $replacements);
  152. // Spec section 3.2.1 and appendix A: the operator's first string is
  153. // appended once any variable in the expression is defined, even when
  154. // every defined value expands to an empty string.
  155. if ('' !== $prefix && $hasDefinedVariable) {
  156. return \sprintf('%s%s', $prefix, $ret);
  157. }
  158. return $ret;
  159. }
  160. /**
  161. * Parse an expression into parts
  162. *
  163. * @param string $expression Expression to parse
  164. *
  165. * @return array{operator:string, values:array<array{value:string, modifier:(''|'*'|':'), position?:int}>}
  166. */
  167. private static function parseExpression(string $expression): array
  168. {
  169. $result = [];
  170. if (isset(self::$operatorHash[$expression[0]])) {
  171. $result['operator'] = $expression[0];
  172. /** @var string */
  173. $expression = \substr($expression, 1);
  174. } else {
  175. $result['operator'] = '';
  176. }
  177. $result['values'] = [];
  178. foreach (\explode(',', $expression) as $value) {
  179. $value = \trim($value, " \n\r\t\0\x0B");
  180. $varspec = [];
  181. if ($colonPos = \strpos($value, ':')) {
  182. $varspec['value'] = (string) \substr($value, 0, $colonPos);
  183. $varspec['modifier'] = ':';
  184. $varspec['position'] = (int) \substr($value, $colonPos + 1);
  185. } elseif (\substr($value, -1) === '*') {
  186. $varspec['modifier'] = '*';
  187. $varspec['value'] = (string) \substr($value, 0, -1);
  188. } else {
  189. $varspec['value'] = $value;
  190. $varspec['modifier'] = '';
  191. }
  192. $result['values'][] = $varspec;
  193. }
  194. return $result;
  195. }
  196. /**
  197. * Determines if an array is associative.
  198. *
  199. * This makes the assumption that input arrays are sequences or hashes.
  200. * This assumption is a tradeoff for accuracy in favor of speed, but it
  201. * should work in almost every case where input is supplied for a URI
  202. * template.
  203. */
  204. private static function isAssoc(array $array): bool
  205. {
  206. return $array && \array_keys($array)[0] !== 0;
  207. }
  208. /**
  209. * Cast a variable value to its expansion string.
  210. *
  211. * Non-finite floats are converted explicitly because coercing them to
  212. * string triggers a warning on PHP 8.5.
  213. *
  214. * @param mixed $value
  215. */
  216. private static function stringifyValue($value): string
  217. {
  218. if (\is_float($value) && !\is_finite($value)) {
  219. return \is_nan($value) ? 'NAN' : ($value > 0 ? 'INF' : '-INF');
  220. }
  221. return (string) $value;
  222. }
  223. /**
  224. * Stringify non-finite float members of a nested array so that
  225. * http_build_query does not trigger coercion warnings on PHP 8.5.
  226. *
  227. * @param array<array-key,mixed> $value
  228. *
  229. * @return array<array-key,mixed>
  230. */
  231. private static function stringifyNonFiniteFloats(array $value): array
  232. {
  233. /** @var mixed $member */
  234. foreach ($value as $key => $member) {
  235. if (\is_float($member) && !\is_finite($member)) {
  236. $value[$key] = self::stringifyValue($member);
  237. } elseif (\is_array($member)) {
  238. $value[$key] = self::stringifyNonFiniteFloats($member);
  239. }
  240. }
  241. return $value;
  242. }
  243. /**
  244. * Select a prefix by Unicode code points and pct-encoded characters.
  245. *
  246. * Malformed bytes continue to count individually.
  247. */
  248. private static function prefixValue(string $value, int $length): string
  249. {
  250. if ($length < 1) {
  251. return \substr($value, 0, $length);
  252. }
  253. $valueLength = \strlen($value);
  254. if ($valueLength <= $length) {
  255. return $value;
  256. }
  257. $offset = 0;
  258. for ($taken = 0; $taken < $length && $offset < $valueLength; ++$taken) {
  259. $offset += self::prefixCharacterByteLength($value, $offset, $valueLength);
  260. }
  261. return \substr($value, 0, $offset);
  262. }
  263. private static function prefixCharacterByteLength(string $value, int $offset, int $valueLength): int
  264. {
  265. if ($value[$offset] === '%' && $offset + 2 < $valueLength && \strspn($value, '0123456789ABCDEFabcdef', $offset + 1, 2) === 2) {
  266. $lead = (int) \hexdec(\substr($value, $offset + 1, 2));
  267. $octets = self::utf8SequenceByteLength($lead);
  268. if ($octets === 1) {
  269. return 3;
  270. }
  271. $candidate = \chr($lead);
  272. for ($index = 1; $index < $octets; ++$index) {
  273. $tripletOffset = $offset + 3 * $index;
  274. if ($tripletOffset + 2 >= $valueLength || $value[$tripletOffset] !== '%' || \strspn($value, '0123456789ABCDEFabcdef', $tripletOffset + 1, 2) !== 2) {
  275. return 3;
  276. }
  277. $candidate .= \chr((int) \hexdec(\substr($value, $tripletOffset + 1, 2)));
  278. }
  279. return self::isSingleUtf8CodePoint($candidate) ? 3 * $octets : 3;
  280. }
  281. $octets = self::utf8SequenceByteLength(\ord($value[$offset]));
  282. if ($octets === 1 || $offset + $octets > $valueLength) {
  283. return 1;
  284. }
  285. return self::isSingleUtf8CodePoint(\substr($value, $offset, $octets)) ? $octets : 1;
  286. }
  287. private static function utf8SequenceByteLength(int $lead): int
  288. {
  289. if ($lead >= 0xC2 && $lead <= 0xDF) {
  290. return 2;
  291. }
  292. if ($lead >= 0xE0 && $lead <= 0xEF) {
  293. return 3;
  294. }
  295. return $lead >= 0xF0 && $lead <= 0xF4 ? 4 : 1;
  296. }
  297. private static function isSingleUtf8CodePoint(string $candidate): bool
  298. {
  299. $result = \preg_match('/\A.\z/us', $candidate);
  300. if ($result !== false) {
  301. return $result === 1;
  302. }
  303. if (\preg_last_error() === \PREG_BAD_UTF8_ERROR) {
  304. return false;
  305. }
  306. throw new \RuntimeException(\sprintf('Unable to process template: %s', \preg_last_error_msg()));
  307. }
  308. private static function encodeValue(string $value, bool $allowReserved): string
  309. {
  310. if ($value === '') {
  311. return '';
  312. }
  313. $matches = [];
  314. if (\preg_match_all('/%[0-9A-Fa-f]{2}|./s', $value, $matches) === false) {
  315. throw new \RuntimeException(\sprintf('Unable to encode URI template value: %s', \preg_last_error_msg()));
  316. }
  317. $encoded = '';
  318. foreach ($matches[0] as $token) {
  319. if ($allowReserved && \preg_match('/\A%[0-9A-Fa-f]{2}\z/', $token) === 1) {
  320. $encoded .= $token;
  321. continue;
  322. }
  323. if (\preg_match('/\A[A-Za-z0-9._~-]\z/', $token) === 1) {
  324. $encoded .= $token;
  325. continue;
  326. }
  327. if ($allowReserved && \strlen($token) === 1 && \strpos(":/?#[]@!$&'()*+,;=", $token) !== false) {
  328. $encoded .= $token;
  329. continue;
  330. }
  331. $encoded .= \rawurlencode($token);
  332. }
  333. return $encoded;
  334. }
  335. }