friendship ended with social-app. php is my new best friend
1<?php
2/**
3 * Class QueryUtil
4 *
5 * @created 27.03.2021
6 * @author smiley <smiley@chillerlan.net>
7 * @copyright 2021 smiley
8 * @license MIT
9 */
10declare(strict_types=1);
11
12namespace chillerlan\HTTP\Utils;
13
14use InvalidArgumentException;
15use function array_map, array_merge, call_user_func_array, explode, implode, is_array, is_bool,
16 is_iterable, is_numeric, is_scalar, is_string, rawurldecode, rawurlencode, sort, strcmp,
17 str_replace, trim, uksort, urlencode;
18use const PHP_QUERY_RFC1738, PHP_QUERY_RFC3986, SORT_STRING;
19
20final class QueryUtil{
21
22 public const BOOLEANS_AS_BOOL = 0;
23 public const BOOLEANS_AS_INT = 1;
24 public const BOOLEANS_AS_STRING = 2;
25 public const BOOLEANS_AS_INT_STRING = 3;
26
27 public const NO_ENCODING = -1;
28
29 /**
30 * Cleans/normalizes an array of query parameters
31 *
32 * By default, booleans will be left as-is (`BOOLEANS_AS_BOOL`) and may result in empty values.
33 * If `$remove_empty` is set to `true` (default), empty and `null` values will be removed from the array.
34 *
35 * `$bool_cast` converts booleans to a type determined like following:
36 *
37 * - `BOOLEANS_AS_BOOL` : unchanged boolean value (default)
38 * - `BOOLEANS_AS_INT` : integer values 0 or 1
39 * - `BOOLEANS_AS_STRING` : "true"/"false" strings
40 * - `BOOLEANS_AS_INT_STRING`: "0"/"1"
41 *
42 * @param array<int|string, scalar|bool|null> $params
43 *
44 * @return array<int|string, scalar|bool|null>
45 */
46 public static function cleanParams(
47 iterable $params,
48 int|null $bool_cast = null,
49 bool|null $remove_empty = null,
50 ):array{
51 $bool_cast ??= self::BOOLEANS_AS_BOOL;
52 $remove_empty ??= true;
53
54 $cleaned = [];
55
56 foreach($params as $key => $value){
57
58 if(is_iterable($value)){
59 // recursion
60 $cleaned[$key] = call_user_func_array(__METHOD__, [$value, $bool_cast, $remove_empty]);
61 }
62 elseif(is_bool($value)){
63
64 $cleaned[$key] = match($bool_cast){
65 self::BOOLEANS_AS_BOOL => $value,
66 self::BOOLEANS_AS_INT => (int)$value,
67 self::BOOLEANS_AS_STRING => ($value) ? 'true' : 'false',
68 self::BOOLEANS_AS_INT_STRING => (string)(int)$value,
69 default => throw new InvalidArgumentException('invalid $bool_cast parameter value'),
70 };
71
72 }
73 elseif(is_string($value)){
74 $value = trim($value);
75
76 if($remove_empty && empty($value)){
77 continue;
78 }
79
80 $cleaned[$key] = $value;
81 }
82 else{
83
84 if($remove_empty && (!is_numeric($value) && empty($value))){
85 continue;
86 }
87
88 $cleaned[$key] = $value;
89 }
90 }
91
92 return $cleaned;
93 }
94
95 /**
96 * Builds a query string from an array of key value pairs.
97 *
98 * Valid values for $encoding are PHP_QUERY_RFC3986 (default) and PHP_QUERY_RFC1738,
99 * any other integer value will be interpreted as "no encoding".
100 *
101 * Boolean values will be cast to int(0,1), null values will be removed, leaving only their keys.
102 *
103 * @link https://github.com/abraham/twitteroauth/blob/57108b31f208d0066ab90a23257cdd7bb974c67d/src/Util.php#L84-L122
104 * @link https://github.com/guzzle/psr7/blob/c0dcda9f54d145bd4d062a6d15f54931a67732f9/src/Query.php#L59-L113
105 *
106 * @param array<string, scalar|bool|array<int, scalar|bool|null>|null> $params
107 */
108 public static function build(
109 array $params,
110 int|null $encoding = null,
111 string|null $delimiter = null,
112 string|null $enclosure = null,
113 ):string{
114
115 if(empty($params)){
116 return '';
117 }
118
119 $encoding ??= PHP_QUERY_RFC3986;
120 $enclosure ??= '';
121 $delimiter ??= '&';
122
123 $encode = match($encoding){
124 PHP_QUERY_RFC3986 => rawurlencode(...),
125 PHP_QUERY_RFC1738 => urlencode(...),
126 default => fn(string $str):string => $str,
127 };
128
129 $pair = function(string $key, $value) use ($encode, $enclosure):string{
130
131 if($value === null){
132 return $key;
133 }
134
135 if(is_bool($value)){
136 $value = (int)$value;
137 }
138
139 // For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61)
140 return $key.'='.$enclosure.$encode((string)$value).$enclosure;
141 };
142
143 // Parameters are sorted by name, using lexicographical byte value ordering.
144 uksort($params, strcmp(...));
145
146 $pairs = [];
147
148 foreach($params as $parameter => $value){
149 $parameter = $encode((string)$parameter);
150
151 if(is_array($value)){
152 // If two or more parameters share the same name, they are sorted by their value
153 sort($value, SORT_STRING);
154
155 foreach($value as $duplicateValue){
156 $pairs[] = $pair($parameter, $duplicateValue);
157 }
158
159 }
160 else{
161 $pairs[] = $pair($parameter, $value);
162 }
163
164 }
165
166 // Each name-value pair is separated by an '&' character (ASCII code 38)
167 return implode($delimiter, $pairs);
168 }
169
170 /**
171 * Merges additional query parameters into an existing query string
172 *
173 * @param array<string, scalar|bool|null> $query
174 */
175 public static function merge(string $uri, array $query):string{
176 $querypart = (UriUtil::parseUrl($uri)['query'] ?? '');
177 $params = array_merge(self::parse($querypart), $query);
178 $requestURI = explode('?', $uri)[0];
179
180 if(!empty($params)){
181 $requestURI .= '?'.self::build($params);
182 }
183
184 return $requestURI;
185 }
186
187 /**
188 * Parses a query string into an associative array.
189 *
190 * @link https://github.com/guzzle/psr7/blob/c0dcda9f54d145bd4d062a6d15f54931a67732f9/src/Query.php#L9-L57
191 *
192 * @return array<string, string|string[]>
193 */
194 public static function parse(string $querystring, int|null $urlEncoding = null):array{
195 $querystring = trim($querystring, '?'); // handle leftover question marks (e.g. Twitter API "next_results")
196
197 if($querystring === ''){
198 return [];
199 }
200
201 $decode = match($urlEncoding){
202 self::NO_ENCODING => fn(string $str):string => $str,
203 PHP_QUERY_RFC3986 => rawurldecode(...),
204 PHP_QUERY_RFC1738 => urldecode(...),
205 default => fn(string $value):string => rawurldecode(str_replace('+', ' ', $value)),
206 };
207
208 $result = [];
209
210 foreach(explode('&', $querystring) as $pair){
211 $parts = explode('=', $pair, 2);
212 $key = $decode($parts[0]);
213 $value = isset($parts[1]) ? $decode($parts[1]) : null;
214
215 if(!isset($result[$key])){
216 $result[$key] = $value;
217 }
218 else{
219
220 if(!is_array($result[$key])){
221 $result[$key] = [$result[$key]];
222 }
223
224 $result[$key][] = $value;
225 }
226 }
227
228 return $result;
229 }
230
231 /**
232 * Recursive rawurlencode
233 *
234 * @param string|array<int, scalar|null> $data
235 * @return string|string[]
236 * @throws \InvalidArgumentException
237 */
238 public static function recursiveRawurlencode(mixed $data):array|string{
239
240 if(is_array($data)){
241 return array_map(__METHOD__, $data);
242 }
243
244 if(!is_scalar($data) && $data !== null){
245 throw new InvalidArgumentException('$data is neither scalar nor null');
246 }
247
248 return rawurlencode((string)$data);
249 }
250
251}