UriTemplate.php 7.9 KB

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