QueryString.php 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. /**
  3. * League.Uri (https://uri.thephpleague.com)
  4. *
  5. * (c) Ignace Nyamagana Butera <nyamsprod@gmail.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. declare(strict_types=1);
  11. namespace League\Uri;
  12. use League\Uri\Exceptions\SyntaxError;
  13. use League\Uri\KeyValuePair\Converter;
  14. use Stringable;
  15. use function array_key_exists;
  16. use function array_keys;
  17. use function is_array;
  18. use function rawurldecode;
  19. use function strpos;
  20. use function substr;
  21. use const PHP_QUERY_RFC3986;
  22. /**
  23. * A class to parse the URI query string.
  24. *
  25. * @see https://tools.ietf.org/html/rfc3986#section-3.4
  26. */
  27. final class QueryString
  28. {
  29. private const PAIR_VALUE_DECODED = 1;
  30. private const PAIR_VALUE_PRESERVED = 2;
  31. /**
  32. * @codeCoverageIgnore
  33. */
  34. private function __construct()
  35. {
  36. }
  37. /**
  38. * Build a query string from a list of pairs.
  39. *
  40. * @see QueryString::buildFromPairs()
  41. * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
  42. *
  43. * @param iterable<array{0:string, 1:string|float|int|bool|null}> $pairs
  44. * @param non-empty-string $separator
  45. *
  46. * @throws SyntaxError If the encoding type is invalid
  47. * @throws SyntaxError If a pair is invalid
  48. */
  49. public static function build(iterable $pairs, string $separator = '&', int $encType = PHP_QUERY_RFC3986): ?string
  50. {
  51. return self::buildFromPairs($pairs, Converter::fromEncodingType($encType)->withSeparator($separator));
  52. }
  53. /**
  54. * Build a query string from a list of pairs.
  55. *
  56. * The method expects the return value from Query::parse to build
  57. * a valid query string. This method differs from PHP http_build_query as
  58. * it does not modify parameters keys.
  59. *
  60. * If a reserved character is found in a URI component and
  61. * no delimiting role is known for that character, then it must be
  62. * interpreted as representing the data octet corresponding to that
  63. * character's encoding in US-ASCII.
  64. *
  65. * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
  66. *
  67. * @param iterable<array{0:string, 1:string|float|int|bool|null}> $pairs
  68. *
  69. * @throws SyntaxError If the encoding type is invalid
  70. * @throws SyntaxError If a pair is invalid
  71. */
  72. public static function buildFromPairs(iterable $pairs, ?Converter $converter = null): ?string
  73. {
  74. $keyValuePairs = [];
  75. foreach ($pairs as $pair) {
  76. if (!is_array($pair) || [0, 1] !== array_keys($pair)) {
  77. throw new SyntaxError('A pair must be a sequential array starting at `0` and containing two elements.');
  78. }
  79. $keyValuePairs[] = [(string) Encoder::encodeQueryKeyValue($pair[0]), match(null) {
  80. $pair[1] => null,
  81. default => Encoder::encodeQueryKeyValue($pair[1]),
  82. }];
  83. }
  84. return ($converter ?? Converter::fromRFC3986())->toValue($keyValuePairs);
  85. }
  86. /**
  87. * Parses the query string like parse_str without mangling the results.
  88. *
  89. * @see QueryString::extractFromValue()
  90. * @see http://php.net/parse_str
  91. * @see https://wiki.php.net/rfc/on_demand_name_mangling
  92. *
  93. * @param non-empty-string $separator
  94. *
  95. * @throws SyntaxError
  96. */
  97. public static function extract(Stringable|string|bool|null $query, string $separator = '&', int $encType = PHP_QUERY_RFC3986): array
  98. {
  99. return self::extractFromValue($query, Converter::fromEncodingType($encType)->withSeparator($separator));
  100. }
  101. /**
  102. * Parses the query string like parse_str without mangling the results.
  103. *
  104. * The result is similar as PHP parse_str when used with its
  105. * second argument with the difference that variable names are
  106. * not mangled.
  107. *
  108. * @see http://php.net/parse_str
  109. * @see https://wiki.php.net/rfc/on_demand_name_mangling
  110. *
  111. * @throws SyntaxError
  112. */
  113. public static function extractFromValue(Stringable|string|bool|null $query, ?Converter $converter = null): array
  114. {
  115. return self::convert(self::decodePairs(
  116. ($converter ?? Converter::fromRFC3986())->toPairs($query),
  117. self::PAIR_VALUE_PRESERVED
  118. ));
  119. }
  120. /**
  121. * Parses a query string into a collection of key/value pairs.
  122. *
  123. * @param non-empty-string $separator
  124. *
  125. * @throws SyntaxError
  126. *
  127. * @return array<int, array{0:string, 1:string|null}>
  128. */
  129. public static function parse(Stringable|string|bool|null $query, string $separator = '&', int $encType = PHP_QUERY_RFC3986): array
  130. {
  131. return self::parseFromValue($query, Converter::fromEncodingType($encType)->withSeparator($separator));
  132. }
  133. /**
  134. * Parses a query string into a collection of key/value pairs.
  135. *
  136. * @throws SyntaxError
  137. *
  138. * @return array<int, array{0:string, 1:string|null}>
  139. */
  140. public static function parseFromValue(Stringable|string|bool|null $query, ?Converter $converter = null): array
  141. {
  142. return self::decodePairs(
  143. ($converter ?? Converter::fromRFC3986())->toPairs($query),
  144. self::PAIR_VALUE_DECODED
  145. );
  146. }
  147. /**
  148. * @param array<non-empty-list<string|null>> $pairs
  149. *
  150. * @return array<int, array{0:string, 1:string|null}>
  151. */
  152. private static function decodePairs(array $pairs, int $pairValueState): array
  153. {
  154. $decodePair = static function (array $pair, int $pairValueState): array {
  155. [$key, $value] = $pair;
  156. return match ($pairValueState) {
  157. self::PAIR_VALUE_PRESERVED => [(string) Encoder::decodeAll($key), $value],
  158. default => [(string) Encoder::decodeAll($key), Encoder::decodeAll($value)],
  159. };
  160. };
  161. return array_reduce(
  162. $pairs,
  163. fn (array $carry, array $pair) => [...$carry, $decodePair($pair, $pairValueState)],
  164. []
  165. );
  166. }
  167. /**
  168. * Converts a collection of key/value pairs and returns
  169. * the store PHP variables as elements of an array.
  170. */
  171. public static function convert(iterable $pairs): array
  172. {
  173. $returnedValue = [];
  174. foreach ($pairs as $pair) {
  175. $returnedValue = self::extractPhpVariable($returnedValue, $pair);
  176. }
  177. return $returnedValue;
  178. }
  179. /**
  180. * Parses a query pair like parse_str without mangling the results array keys.
  181. *
  182. * <ul>
  183. * <li>empty name are not saved</li>
  184. * <li>If the value from name is duplicated its corresponding value will be overwritten</li>
  185. * <li>if no "[" is detected the value is added to the return array with the name as index</li>
  186. * <li>if no "]" is detected after detecting a "[" the value is added to the return array with the name as index</li>
  187. * <li>if there's a mismatch in bracket usage the remaining part is dropped</li>
  188. * <li>“.” and “ ” are not converted to “_”</li>
  189. * <li>If there is no “]”, then the first “[” is not converted to becomes an “_”</li>
  190. * <li>no whitespace trimming is done on the key value</li>
  191. * </ul>
  192. *
  193. * @see https://php.net/parse_str
  194. * @see https://wiki.php.net/rfc/on_demand_name_mangling
  195. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic1.phpt
  196. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic2.phpt
  197. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic3.phpt
  198. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic4.phpt
  199. *
  200. * @param array $data the submitted array
  201. * @param array|string $name the pair key
  202. * @param string $value the pair value
  203. */
  204. private static function extractPhpVariable(array $data, array|string $name, string $value = ''): array
  205. {
  206. if (is_array($name)) {
  207. [$name, $value] = $name;
  208. $value = rawurldecode((string) $value);
  209. }
  210. if ('' === $name) {
  211. return $data;
  212. }
  213. $leftBracketPosition = strpos($name, '[');
  214. if (false === $leftBracketPosition) {
  215. $data[$name] = $value;
  216. return $data;
  217. }
  218. $rightBracketPosition = strpos($name, ']', $leftBracketPosition);
  219. if (false === $rightBracketPosition) {
  220. $data[$name] = $value;
  221. return $data;
  222. }
  223. $key = substr($name, 0, $leftBracketPosition);
  224. if (!array_key_exists($key, $data) || !is_array($data[$key])) {
  225. $data[$key] = [];
  226. }
  227. $index = substr($name, $leftBracketPosition + 1, $rightBracketPosition - $leftBracketPosition - 1);
  228. if ('' === $index) {
  229. $data[$key][] = $value;
  230. return $data;
  231. }
  232. $remaining = substr($name, $rightBracketPosition + 1);
  233. if (!str_starts_with($remaining, '[') || false === strpos($remaining, ']', 1)) {
  234. $remaining = '';
  235. }
  236. $data[$key] = self::extractPhpVariable($data[$key], $index.$remaining, $value);
  237. return $data;
  238. }
  239. }