QueryString.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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 BackedEnum;
  13. use League\Uri\Exceptions\SyntaxError;
  14. use League\Uri\KeyValuePair\Converter;
  15. use ReflectionEnum;
  16. use ReflectionException;
  17. use SplObjectStorage;
  18. use Stringable;
  19. use TypeError;
  20. use UnitEnum;
  21. use ValueError;
  22. use function array_is_list;
  23. use function array_key_exists;
  24. use function array_keys;
  25. use function get_debug_type;
  26. use function get_object_vars;
  27. use function http_build_query;
  28. use function implode;
  29. use function is_array;
  30. use function is_object;
  31. use function is_resource;
  32. use function is_scalar;
  33. use function rawurldecode;
  34. use function str_replace;
  35. use function strpos;
  36. use function substr;
  37. use const PHP_QUERY_RFC1738;
  38. use const PHP_QUERY_RFC3986;
  39. /**
  40. * A class to parse the URI query string.
  41. *
  42. * @see https://tools.ietf.org/html/rfc3986#section-3.4
  43. */
  44. final class QueryString
  45. {
  46. private const PAIR_VALUE_DECODED = 1;
  47. private const PAIR_VALUE_PRESERVED = 2;
  48. private const RECURSION_MARKER = "\0__RECURSION_INTERNAL_MARKER__\0";
  49. /**
  50. * @codeCoverageIgnore
  51. */
  52. private function __construct()
  53. {
  54. }
  55. /**
  56. * Build a query string from a list of pairs.
  57. *
  58. * @see QueryString::buildFromPairs()
  59. * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
  60. *
  61. * @param iterable<array{0:string, 1:mixed}> $pairs
  62. * @param non-empty-string $separator
  63. *
  64. * @throws SyntaxError If the encoding type is invalid
  65. * @throws SyntaxError If a pair is invalid
  66. */
  67. public static function build(iterable $pairs, string $separator = '&', int $encType = PHP_QUERY_RFC3986, StringCoercionMode $coercionMode = StringCoercionMode::Native): ?string
  68. {
  69. return self::buildFromPairs($pairs, Converter::fromEncodingType($encType)->withSeparator($separator), $coercionMode);
  70. }
  71. /**
  72. * Build a query string from a list of pairs.
  73. *
  74. * The method expects the return value from Query::parse to build
  75. * a valid query string. This method differs from PHP http_build_query as
  76. * it does not modify parameters keys.
  77. *
  78. * If a reserved character is found in a URI component and
  79. * no delimiting role is known for that character, then it must be
  80. * interpreted as representing the data octet corresponding to that
  81. * character's encoding in US-ASCII.
  82. *
  83. * @see https://datatracker.ietf.org/doc/html/rfc3986#section-2.2
  84. *
  85. * @param iterable<array{0:string, 1:mixed}> $pairs
  86. *
  87. * @throws SyntaxError If the encoding type is invalid
  88. * @throws SyntaxError If a pair is invalid
  89. */
  90. public static function buildFromPairs(iterable $pairs, ?Converter $converter = null, StringCoercionMode $coercionMode = StringCoercionMode::Native): ?string
  91. {
  92. $keyValuePairs = [];
  93. foreach ($pairs as $pair) {
  94. if (!is_array($pair) || [0, 1] !== array_keys($pair)) {
  95. throw new SyntaxError('A pair must be a sequential array starting at `0` and containing two elements.');
  96. }
  97. [$key, $value] = $pair;
  98. $coercionMode->isCoercible($value) || throw new SyntaxError('Converting a type `'.get_debug_type($value).'` into a string is not supported by the '.(StringCoercionMode::Native === $coercionMode ? 'PHP Native' : 'Ecmascript').' coercion mode.');
  99. try {
  100. $key = $coercionMode->coerce($key);
  101. $value = $coercionMode->coerce($value);
  102. } catch (TypeError $typeError) {
  103. throw new SyntaxError('The pair can not be converted to build a query string.', previous: $typeError);
  104. }
  105. $keyValuePairs[] = [(string) Encoder::encodeQueryKeyValue($key), null === $value ? null : Encoder::encodeQueryKeyValue($value)];
  106. }
  107. return ($converter ?? Converter::fromRFC3986())->toValue($keyValuePairs);
  108. }
  109. /**
  110. * Build a query string from an object or an array like http_build_query without discarding values.
  111. * The method differs from http_build_query for the following behavior:
  112. *
  113. * - if a resource is used, a TypeError is thrown.
  114. * - if a recursion is detected a ValueError is thrown
  115. * - the method preserves value with `null` value (http_build_query) skip the key.
  116. * - the method does not handle prefix usage
  117. *
  118. * @param array<array-key, mixed> $data
  119. * @param non-empty-string $separator
  120. *
  121. * @throws TypeError if a resource is found it the input array
  122. * @throws ValueError if a recursion is detected
  123. */
  124. public static function compose(
  125. array|object $data,
  126. string $separator = '&',
  127. int $encType = PHP_QUERY_RFC1738,
  128. QueryComposeMode $composeMode = QueryComposeMode::Native
  129. ): ?string {
  130. if (QueryComposeMode::Native === $composeMode) {
  131. return http_build_query(data: $data, arg_separator: $separator, encoding_type: $encType);
  132. }
  133. $query = self::composeFromValue($data, Converter::fromEncodingType($encType)->withSeparator($separator), $composeMode);
  134. return QueryComposeMode::Safe !== $composeMode ? (string) $query : $query;
  135. }
  136. public static function composeFromValue(
  137. array|object $data,
  138. ?Converter $converter = null,
  139. QueryComposeMode $composeMode = QueryComposeMode::Native,
  140. ): ?string {
  141. if (QueryComposeMode::EnumLenient === $composeMode && $data instanceof UnitEnum && !$data instanceof BackedEnum) {
  142. return '';
  143. }
  144. QueryComposeMode::Safe !== $composeMode || is_array($data) || throw new TypeError('In safe mode only arrays are supported.');
  145. $converter ??= Converter::fromRFC3986();
  146. $pairs = QueryComposeMode::Native !== $composeMode
  147. ? self::composeRecursive($composeMode, $data)
  148. : self::parseFromValue(http_build_query(data: $data, arg_separator: '&'), Converter::fromRFC1738());
  149. return self::buildFromPairs($pairs, $converter);
  150. }
  151. /**
  152. * @param array<array-key, mixed>|object $data
  153. * @param SplObjectStorage<object, null> $seenObjects
  154. *
  155. * @throws TypeError if a resource is found it the input array
  156. * @throws ValueError if a recursion is detected
  157. * @throws ReflectionException if reflection is not possible on the Enum
  158. *
  159. * @return iterable<array{0: array-key, 1: string|int|float|bool|null}>
  160. */
  161. private static function composeRecursive(
  162. QueryComposeMode $composeMode,
  163. array|object $data,
  164. string|int $prefix = '',
  165. SplObjectStorage $seenObjects = new SplObjectStorage(),
  166. ): iterable {
  167. QueryComposeMode::Safe !== $composeMode || is_array($data) || throw new TypeError('In safe mode only arrays are supported.');
  168. in_array($composeMode, [QueryComposeMode::EnumCompatible, QueryComposeMode::EnumLenient], true) || !$data instanceof UnitEnum || throw new TypeError('Argument #1 ($data) must not be an enum, '.((new ReflectionEnum($data::class))->isBacked() ? 'Backed' : 'Pure').' given') ;
  169. if (is_object($data)) {
  170. if ($seenObjects->contains($data)) {
  171. QueryComposeMode::Safe !== $composeMode || throw new ValueError('composition failed; circular reference detected.');
  172. return;
  173. }
  174. $seenObjects->attach($data);
  175. $data = get_object_vars($data);
  176. }
  177. if (self::hasCircularReference($data)) {
  178. QueryComposeMode::Safe !== $composeMode || throw new ValueError('composition failed; circular reference detected.');
  179. return;
  180. }
  181. $stripIndices = QueryComposeMode::Safe === $composeMode && array_is_list($data);
  182. foreach ($data as $name => $value) {
  183. $name = $stripIndices ? '' : $name;
  184. if ('' !== $prefix) {
  185. $name = $prefix.'['.$name.']';
  186. }
  187. if (is_resource($value)) {
  188. QueryComposeMode::Safe !== $composeMode || throw new TypeError('composition failed; a resource has been detected and can not be converted.');
  189. continue;
  190. }
  191. if (is_scalar($value)) {
  192. yield [$name, $value];
  193. continue;
  194. }
  195. if (null === $value) {
  196. if (QueryComposeMode::Safe === $composeMode) {
  197. yield [$name, $value];
  198. }
  199. continue;
  200. }
  201. if ($value instanceof BackedEnum) {
  202. if (QueryComposeMode::Compatible !== $composeMode) {
  203. yield [$name, $value->value];
  204. continue;
  205. }
  206. $value = get_object_vars($value);
  207. }
  208. if ($value instanceof UnitEnum) {
  209. if (QueryComposeMode::EnumLenient === $composeMode) {
  210. continue;
  211. }
  212. QueryComposeMode::Compatible === $composeMode || throw new TypeError('Unbacked enum '.$value::class.' cannot be converted to a string');
  213. $value = get_object_vars($value);
  214. }
  215. if (QueryComposeMode::Safe === $composeMode && is_object($value)) {
  216. throw new ValueError('In conservative mode only arrays, scalar value or null are supported.');
  217. }
  218. yield from self::composeRecursive($composeMode, $value, $name, $seenObjects);
  219. }
  220. }
  221. /**
  222. * Array recursion detection.
  223. * @see https://stackoverflow.com/questions/9042142/detecting-infinite-array-recursion-in-php
  224. */
  225. private static function hasCircularReference(array &$arr): bool
  226. {
  227. if (isset($arr[self::RECURSION_MARKER])) {
  228. return true;
  229. }
  230. try {
  231. $arr[self::RECURSION_MARKER] = true;
  232. foreach ($arr as $key => &$value) {
  233. if (self::RECURSION_MARKER !== $key && is_array($value) && self::hasCircularReference($value)) {
  234. return true;
  235. }
  236. }
  237. return false;
  238. } finally {
  239. unset($arr[self::RECURSION_MARKER]);
  240. }
  241. }
  242. /**
  243. * Parses the query string.
  244. *
  245. * The result depends on the query parsing mode
  246. *
  247. * @see QueryString::extractFromValue()
  248. *
  249. * @param non-empty-string $separator
  250. *
  251. * @throws SyntaxError
  252. */
  253. public static function extract(
  254. BackedEnum|Stringable|string|bool|null $query,
  255. string $separator = '&',
  256. int $encType = PHP_QUERY_RFC3986,
  257. QueryExtractMode $extractMode = QueryExtractMode::Unmangled,
  258. ): array {
  259. return self::extractFromValue(
  260. $query,
  261. Converter::fromEncodingType($encType)->withSeparator($separator),
  262. $extractMode,
  263. );
  264. }
  265. /**
  266. * Parses the query string.
  267. *
  268. * The result depends on the query parsing mode
  269. *
  270. * @throws SyntaxError
  271. */
  272. public static function extractFromValue(
  273. BackedEnum|Stringable|string|bool|null $query,
  274. ?Converter $converter = null,
  275. QueryExtractMode $extractMode = QueryExtractMode::Unmangled,
  276. ): array {
  277. $pairs = ($converter ?? Converter::fromRFC3986())->toPairs($query);
  278. if (QueryExtractMode::Native === $extractMode) {
  279. if ([] === $pairs) {
  280. return [];
  281. }
  282. $data = [];
  283. foreach ($pairs as [$key, $value]) {
  284. $key = str_replace('&', '%26', (string) $key);
  285. $data[] = null === $value ? $key : $key.'='.str_replace('&', '%26', $value);
  286. }
  287. parse_str(implode('&', $data), $result);
  288. return $result;
  289. }
  290. return self::convert(
  291. self::decodePairs($pairs, self::PAIR_VALUE_PRESERVED),
  292. $extractMode
  293. );
  294. }
  295. /**
  296. * Parses a query string into a collection of key/value pairs.
  297. *
  298. * @param non-empty-string $separator
  299. *
  300. * @throws SyntaxError
  301. *
  302. * @return array<int, array{0:string, 1:string|null}>
  303. */
  304. public static function parse(BackedEnum|Stringable|string|bool|null $query, string $separator = '&', int $encType = PHP_QUERY_RFC3986): array
  305. {
  306. return self::parseFromValue($query, Converter::fromEncodingType($encType)->withSeparator($separator));
  307. }
  308. /**
  309. * Parses a query string into a collection of key/value pairs.
  310. *
  311. * @throws SyntaxError
  312. *
  313. * @return array<int, array{0:string, 1:string|null}>
  314. */
  315. public static function parseFromValue(BackedEnum|Stringable|string|bool|null $query, ?Converter $converter = null): array
  316. {
  317. return self::decodePairs(
  318. ($converter ?? Converter::fromRFC3986())->toPairs($query),
  319. self::PAIR_VALUE_DECODED
  320. );
  321. }
  322. /**
  323. * @param array<non-empty-list<string|null>> $pairs
  324. *
  325. * @return array<int, array{0:string, 1:string|null}>
  326. */
  327. private static function decodePairs(array $pairs, int $pairValueState): array
  328. {
  329. $decodePair = static function (array $pair, int $pairValueState): array {
  330. [$key, $value] = $pair;
  331. return match ($pairValueState) {
  332. self::PAIR_VALUE_PRESERVED => [(string) Encoder::decodeAll($key), $value],
  333. default => [(string) Encoder::decodeAll($key), Encoder::decodeAll($value)],
  334. };
  335. };
  336. return array_reduce(
  337. $pairs,
  338. fn (array $carry, array $pair) => [...$carry, $decodePair($pair, $pairValueState)],
  339. []
  340. );
  341. }
  342. /**
  343. * Converts a collection of key/value pairs and returns
  344. * the store PHP variables as elements of an array.
  345. */
  346. public static function convert(iterable $pairs, QueryExtractMode $extractMode = QueryExtractMode::Unmangled): array
  347. {
  348. $returnedValue = [];
  349. foreach ($pairs as $pair) {
  350. $returnedValue = self::extractPhpVariable($returnedValue, $pair, extractMode: $extractMode);
  351. }
  352. return $returnedValue;
  353. }
  354. /**
  355. * Parses a query pair like parse_str without mangling the results array keys.
  356. *
  357. * <ul>
  358. * <li>empty name are not saved</li>
  359. * <li>If the value from name is duplicated its corresponding value will be overwritten</li>
  360. * <li>if no "[" is detected the value is added to the return array with the name as index</li>
  361. * <li>if no "]" is detected after detecting a "[" the value is added to the return array with the name as index</li>
  362. * <li>if there's a mismatch in bracket usage the remaining part is dropped</li>
  363. * <li>“.” and “ ” are not converted to “_”</li>
  364. * <li>If there is no “]”, then the first “[” is not converted to becomes an “_”</li>
  365. * <li>no whitespace trimming is done on the key value</li>
  366. * </ul>
  367. *
  368. * @see https://php.net/parse_str
  369. * @see https://wiki.php.net/rfc/on_demand_name_mangling
  370. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic1.phpt
  371. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic2.phpt
  372. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic3.phpt
  373. * @see https://github.com/php/php-src/blob/master/ext/standard/tests/strings/parse_str_basic4.phpt
  374. *
  375. * @param array $data the submitted array
  376. * @param array|string $name the pair key
  377. * @param string $value the pair value
  378. */
  379. private static function extractPhpVariable(
  380. array $data,
  381. array|string $name,
  382. ?string $value = '',
  383. QueryExtractMode $extractMode = QueryExtractMode::Unmangled
  384. ): array {
  385. if (is_array($name)) {
  386. [$name, $value] = $name;
  387. if (null !== $value || QueryExtractMode::LossLess !== $extractMode) {
  388. $value = rawurldecode((string) $value);
  389. }
  390. }
  391. if ('' === $name) {
  392. return $data;
  393. }
  394. $leftBracketPosition = strpos($name, '[');
  395. if (false === $leftBracketPosition) {
  396. $data[$name] = $value;
  397. return $data;
  398. }
  399. $rightBracketPosition = strpos($name, ']', $leftBracketPosition);
  400. if (false === $rightBracketPosition) {
  401. $data[$name] = $value;
  402. return $data;
  403. }
  404. $key = substr($name, 0, $leftBracketPosition);
  405. if ('' === $key) {
  406. $key = '0';
  407. }
  408. if (!array_key_exists($key, $data) || !is_array($data[$key])) {
  409. $data[$key] = [];
  410. }
  411. $remaining = substr($name, $rightBracketPosition + 1);
  412. if (!str_starts_with($remaining, '[') || !str_contains($remaining, ']')) {
  413. $remaining = '';
  414. }
  415. $name = substr($name, $leftBracketPosition + 1, $rightBracketPosition - $leftBracketPosition - 1).$remaining;
  416. if ('' === $name) {
  417. $data[$key][] = $value;
  418. return $data;
  419. }
  420. $data[$key] = self::extractPhpVariable($data[$key], $name, $value, $extractMode);
  421. return $data;
  422. }
  423. }