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}