├── CHANGELOG.md ├── LICENSE ├── composer.json └── src ├── Exception ├── InvalidQueryPair.php ├── InvalidUriComponent.php ├── MalformedUriComponent.php └── UnknownEncoding.php └── Parser └── QueryString.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All Notable changes to League Uri Query Parser will be documented in this file 4 | 5 | ## 1.0.1 - 2019-04-16 6 | 7 | ### Added 8 | 9 | - None 10 | 11 | ### Fixed 12 | 13 | - Fix double encoding [#3](https://github.com/thephpleague/uri-query-parser/issues/3) 14 | - now requires PHP 7.1 15 | 16 | ### Deprecated 17 | 18 | - None 19 | 20 | ### Removed 21 | 22 | - None 23 | 24 | ## 1.0.0 - 2019-01-05 25 | 26 | First stable release -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 ignace nyamagana butera 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/uri-query-parser", 3 | "type": "library", 4 | "description" : "parse and build a query string the right way in PHP", 5 | "keywords": [ 6 | "url", 7 | "uri", 8 | "components", 9 | "query", 10 | "parser", 11 | "builder" 12 | ], 13 | "license": "MIT", 14 | "homepage": "https://uri.thephpleague.com", 15 | "authors": [ 16 | { 17 | "name" : "Ignace Nyamagana Butera", 18 | "email" : "nyamsprod@gmail.com", 19 | "homepage" : "https://nyamsprod.com" 20 | } 21 | ] , 22 | "require": { 23 | "php": "^7.1.3" 24 | }, 25 | "require-dev": { 26 | "friendsofphp/php-cs-fixer": "^2.3", 27 | "phpunit/phpunit": "^7.0 | ^8.0", 28 | "phpstan/phpstan": "^0.11.1", 29 | "phpstan/phpstan-strict-rules": "^0.11.0", 30 | "phpstan/phpstan-phpunit": "^0.11.0" 31 | }, 32 | "suggest": { 33 | "league/uri-components": "Manipulate URI components using modern API", 34 | "league/uri-parser": "RFC3986 compliant URI parser" 35 | }, 36 | "autoload": { 37 | "psr-4": { 38 | "League\\Uri\\": "src" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "LeagueTest\\Uri\\": "tests" 44 | } 45 | }, 46 | "scripts": { 47 | "phpcs": "php-cs-fixer fix -v --diff --dry-run --allow-risky=yes --ansi", 48 | "phpstan-src": "phpstan analyse -l max -c phpstan.src.neon src --ansi", 49 | "phpstan-tests": "phpstan analyse -l max -c phpstan.tests.neon tests --ansi", 50 | "phpstan": [ 51 | "@phpstan-src", 52 | "@phpstan-tests" 53 | ], 54 | "phpunit": "phpunit --coverage-text", 55 | "test": [ 56 | "@phpcs", 57 | "@phpstan", 58 | "@phpunit" 59 | ] 60 | }, 61 | "scripts-descriptions": { 62 | "phpcs": "Runs coding style test suite", 63 | "phpstan": "Runs complete codebase static analysis", 64 | "phpstan-src": "Runs source code static analysis", 65 | "phpstan-test": "Runs test suite static analysis", 66 | "phpunit": "Runs unit and functional testing", 67 | "test": "Runs full test suite" 68 | }, 69 | "extra": { 70 | "branch-alias": { 71 | "dev-master": "1.x-dev" 72 | } 73 | }, 74 | "config": { 75 | "sort-packages": true 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Exception/InvalidQueryPair.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\Exception; 15 | 16 | class InvalidQueryPair extends InvalidUriComponent 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/InvalidUriComponent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\Exception; 15 | 16 | use InvalidArgumentException; 17 | 18 | class InvalidUriComponent extends InvalidArgumentException 19 | { 20 | } 21 | -------------------------------------------------------------------------------- /src/Exception/MalformedUriComponent.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\Exception; 15 | 16 | class MalformedUriComponent extends InvalidUriComponent 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Exception/UnknownEncoding.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\Exception; 15 | 16 | class UnknownEncoding extends InvalidUriComponent 17 | { 18 | } 19 | -------------------------------------------------------------------------------- /src/Parser/QueryString.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | declare(strict_types=1); 13 | 14 | namespace League\Uri\Parser; 15 | 16 | use League\Uri\Exception\InvalidQueryPair; 17 | use League\Uri\Exception\MalformedUriComponent; 18 | use League\Uri\Exception\UnknownEncoding; 19 | use TypeError; 20 | use function array_key_exists; 21 | use function array_keys; 22 | use function explode; 23 | use function html_entity_decode; 24 | use function implode; 25 | use function is_array; 26 | use function is_bool; 27 | use function is_numeric; 28 | use function is_scalar; 29 | use function is_string; 30 | use function method_exists; 31 | use function preg_match; 32 | use function preg_quote; 33 | use function preg_replace_callback; 34 | use function rawurldecode; 35 | use function rawurlencode; 36 | use function sprintf; 37 | use function str_replace; 38 | use function str_split; 39 | use function strpos; 40 | use function substr; 41 | use const PHP_QUERY_RFC1738; 42 | use const PHP_QUERY_RFC3986; 43 | 44 | /** 45 | * A class to parse the URI query string. 46 | * 47 | * @see https://tools.ietf.org/html/rfc3986#section-3.4 48 | */ 49 | final class QueryString 50 | { 51 | private const REGEXP_INVALID_CHARS = '/[\x00-\x1f\x7f]/'; 52 | 53 | private const REGEXP_ENCODED_PATTERN = ',%[A-Fa-f0-9]{2},'; 54 | 55 | private const REGEXP_DECODED_PATTERN = ',%2[D|E]|3[0-9]|4[1-9|A-F]|5[0-9|A|F]|6[1-9|A-F]|7[0-9|E],i'; 56 | 57 | private const REGEXP_UNRESERVED_CHAR = '/[^A-Za-z0-9_\-\.~]/'; 58 | 59 | private const ENCODING_LIST = [ 60 | PHP_QUERY_RFC1738 => [ 61 | 'suffixKey' => '*', 62 | 'suffixValue' => '*=&', 63 | ], 64 | PHP_QUERY_RFC3986 => [ 65 | 'suffixKey' => "!$'()*+,;:@?/%", 66 | 'suffixValue' => "!$'()*+,;=:@?/&%", 67 | ], 68 | ]; 69 | 70 | private const DECODE_PAIR_VALUE = 1; 71 | private const PRESERVE_PAIR_VALUE = 2; 72 | 73 | /** 74 | * @var string 75 | */ 76 | private static $regexpKey; 77 | 78 | /** 79 | * @var string 80 | */ 81 | private static $regexpValue; 82 | 83 | /** 84 | * @codeCoverageIgnore 85 | */ 86 | private function __construct() 87 | { 88 | } 89 | 90 | /** 91 | * Parses a query string into a collection of key/value pairs. 92 | * 93 | * @param null|mixed $query 94 | */ 95 | public static function parse($query, string $separator = '&', int $enc_type = PHP_QUERY_RFC3986): array 96 | { 97 | $query = self::prepareQuery($query, $enc_type); 98 | if (null === $query) { 99 | return []; 100 | } 101 | 102 | if ('' === $query) { 103 | return [['', null]]; 104 | } 105 | 106 | $retval = []; 107 | foreach (self::getPairs($query, $separator) as $pair) { 108 | $retval[] = self::parsePair((string) $pair, self::DECODE_PAIR_VALUE); 109 | } 110 | 111 | return $retval; 112 | } 113 | 114 | /** 115 | * Prepare and normalize query before processing. 116 | * 117 | * @param null|mixed $query 118 | * 119 | * @throws MalformedUriComponent If the query string is invalid 120 | * @throws TypeError If the query is not stringable or the null value 121 | * @throws UnknownEncoding If the encoding type is invalid 122 | */ 123 | private static function prepareQuery($query, int $enc_type): ?string 124 | { 125 | if (!isset(self::ENCODING_LIST[$enc_type])) { 126 | throw new UnknownEncoding(sprintf('Unknown Encoding: %s', $enc_type)); 127 | } 128 | 129 | if (null === $query) { 130 | return $query; 131 | } 132 | 133 | if (is_bool($query)) { 134 | return true === $query ? '1' : '0'; 135 | } 136 | 137 | if (!is_scalar($query) && !method_exists($query, '__toString')) { 138 | throw new TypeError(sprintf('The query must be a scalar, a stringable object or the `null` value, `%s` given', gettype($query))); 139 | } 140 | 141 | $query = (string) $query; 142 | if ('' === $query) { 143 | return $query; 144 | } 145 | 146 | if (1 === preg_match(self::REGEXP_INVALID_CHARS, $query)) { 147 | throw new MalformedUriComponent(sprintf('Invalid query string: %s', $query)); 148 | } 149 | 150 | if (PHP_QUERY_RFC1738 === $enc_type) { 151 | return str_replace('+', ' ', $query); 152 | } 153 | 154 | return $query; 155 | } 156 | 157 | private static function getPairs(string $query, string $separator): array 158 | { 159 | if ('' === $separator) { 160 | return str_split($query); 161 | } 162 | 163 | if (false === strpos($query, $separator)) { 164 | return [$query]; 165 | } 166 | 167 | return (array) explode($separator, $query); 168 | } 169 | 170 | /** 171 | * Returns the key/value pair from a query string pair. 172 | */ 173 | private static function parsePair(string $pair, int $parseValue): array 174 | { 175 | [$key, $value] = explode('=', $pair, 2) + [1 => null]; 176 | $key = (string) $key; 177 | 178 | if (1 === preg_match(self::REGEXP_ENCODED_PATTERN, $key)) { 179 | $key = preg_replace_callback(self::REGEXP_ENCODED_PATTERN, [self::class, 'decodeMatch'], $key); 180 | } 181 | 182 | if (null === $value) { 183 | return [$key, $value]; 184 | } 185 | 186 | if ($parseValue === self::DECODE_PAIR_VALUE && 1 === preg_match(self::REGEXP_ENCODED_PATTERN, $value)) { 187 | $value = preg_replace_callback(self::REGEXP_ENCODED_PATTERN, [self::class, 'decodeMatch'], $value); 188 | } 189 | 190 | return [$key, $value]; 191 | } 192 | 193 | /** 194 | * Decodes a match string. 195 | */ 196 | private static function decodeMatch(array $matches): string 197 | { 198 | return rawurldecode($matches[0]); 199 | } 200 | 201 | /** 202 | * Build a query string from an associative array. 203 | * 204 | * The method expects the return value from Query::parse to build 205 | * a valid query string. This method differs from PHP http_build_query as 206 | * it does not modify parameters keys. 207 | * 208 | * @throws UnknownEncoding If the encoding type is invalid 209 | * @throws InvalidQueryPair If a pair is invalid 210 | */ 211 | public static function build(iterable $pairs, string $separator = '&', int $enc_type = PHP_QUERY_RFC3986): ?string 212 | { 213 | if (null === (self::ENCODING_LIST[$enc_type] ?? null)) { 214 | throw new UnknownEncoding(sprintf('Unknown Encoding: %s', $enc_type)); 215 | } 216 | 217 | self::$regexpValue = '/(%[A-Fa-f0-9]{2})|[^A-Za-z0-9_\-\.~'.preg_quote( 218 | str_replace( 219 | html_entity_decode($separator, ENT_HTML5, 'UTF-8'), 220 | '', 221 | self::ENCODING_LIST[$enc_type]['suffixValue'] 222 | ), 223 | '/' 224 | ).']+/ux'; 225 | 226 | self::$regexpKey = '/(%[A-Fa-f0-9]{2})|[^A-Za-z0-9_\-\.~'.preg_quote( 227 | str_replace( 228 | html_entity_decode($separator, ENT_HTML5, 'UTF-8'), 229 | '', 230 | self::ENCODING_LIST[$enc_type]['suffixKey'] 231 | ), 232 | '/' 233 | ).']+/ux'; 234 | 235 | $res = []; 236 | foreach ($pairs as $pair) { 237 | $res[] = self::buildPair($pair); 238 | } 239 | 240 | if ([] === $res) { 241 | return null; 242 | } 243 | 244 | $query = implode($separator, $res); 245 | if (PHP_QUERY_RFC1738 === $enc_type) { 246 | return str_replace(['+', '%20'], ['%2B', '+'], $query); 247 | } 248 | 249 | return $query; 250 | } 251 | 252 | /** 253 | * Build a RFC3986 query key/value pair association. 254 | * 255 | * @throws InvalidQueryPair If the pair is invalid 256 | */ 257 | private static function buildPair(array $pair): string 258 | { 259 | if ([0, 1] !== array_keys($pair)) { 260 | throw new InvalidQueryPair('A pair must be a sequential array starting at `0` and containing two elements.'); 261 | } 262 | 263 | [$name, $value] = $pair; 264 | if (!is_scalar($name)) { 265 | throw new InvalidQueryPair(sprintf('A pair key must be a scalar value `%s` given.', gettype($name))); 266 | } 267 | 268 | if (is_bool($name)) { 269 | $name = (int) $name; 270 | } 271 | 272 | if (is_string($name) && 1 === preg_match(self::$regexpKey, $name)) { 273 | $name = preg_replace_callback(self::$regexpKey, [self::class, 'encodeMatches'], $name); 274 | } 275 | 276 | if (is_string($value)) { 277 | if (1 !== preg_match(self::$regexpValue, $value)) { 278 | return $name.'='.$value; 279 | } 280 | 281 | return $name.'='.preg_replace_callback(self::$regexpValue, [self::class, 'encodeMatches'], $value); 282 | } 283 | 284 | if (is_numeric($value)) { 285 | return $name.'='.$value; 286 | } 287 | 288 | if (is_bool($value)) { 289 | return $name.'='.(int) $value; 290 | } 291 | 292 | if (null === $value) { 293 | return (string) $name; 294 | } 295 | 296 | throw new InvalidQueryPair(sprintf('A pair value must be a scalar value or the null value, `%s` given.', gettype($value))); 297 | } 298 | 299 | /** 300 | * Encodes matched sequences. 301 | */ 302 | private static function encodeMatches(array $matches): string 303 | { 304 | if (1 === preg_match(self::REGEXP_UNRESERVED_CHAR, rawurldecode($matches[0]))) { 305 | return rawurlencode($matches[0]); 306 | } 307 | 308 | return $matches[0]; 309 | } 310 | 311 | /** 312 | * Parses the query string like parse_str without mangling the results. 313 | * 314 | * The result is similar as PHP parse_str when used with its 315 | * second argument with the difference that variable names are 316 | * not mangled. 317 | * 318 | * @see http://php.net/parse_str 319 | * @see https://wiki.php.net/rfc/on_demand_name_mangling 320 | * 321 | * @param null|mixed $query 322 | */ 323 | public static function extract($query, string $separator = '&', int $enc_type = PHP_QUERY_RFC3986): array 324 | { 325 | $query = self::prepareQuery($query, $enc_type); 326 | if (null === $query || '' === $query) { 327 | return []; 328 | } 329 | 330 | $retval = []; 331 | foreach (self::getPairs($query, $separator) as $pair) { 332 | $retval[] = self::parsePair((string) $pair, self::PRESERVE_PAIR_VALUE); 333 | } 334 | 335 | return self::convert($retval); 336 | } 337 | 338 | /** 339 | * Converts a collection of key/value pairs and returns 340 | * the store PHP variables as elements of an array. 341 | */ 342 | public static function convert(iterable $pairs): array 343 | { 344 | $retval = []; 345 | foreach ($pairs as $pair) { 346 | $retval = self::extractPhpVariable($retval, $pair); 347 | } 348 | 349 | return $retval; 350 | } 351 | 352 | /** 353 | * Parses a query pair like parse_str without mangling the results array keys. 354 | * 355 | *