Skip to content

Commit f9fd033

Browse files
committed
🛀 query build/parse rework
1 parent 3b68902 commit f9fd033

File tree

2 files changed

+205
-67
lines changed

2 files changed

+205
-67
lines changed

src/Psr7/Query.php

Lines changed: 115 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,21 @@
1010

1111
namespace chillerlan\HTTP\Psr7;
1212

13-
use function array_combine;
14-
use function array_keys;
15-
use function array_merge;
16-
use function array_values;
17-
use function explode;
18-
use function implode;
19-
use function is_array;
20-
use function parse_str;
21-
use function parse_url;
22-
use function sort;
23-
use function uksort;
24-
use const PHP_URL_QUERY;
25-
use const SORT_STRING;
13+
use function array_merge, explode, implode, is_array, is_bool, is_string, parse_url, rawurldecode, sort, str_replace, uksort;
14+
use const PHP_QUERY_RFC1738, PHP_QUERY_RFC3986, PHP_URL_QUERY, SORT_STRING;
2615

2716
/**
2817
*
2918
*/
30-
class Query{
19+
final class Query{
3120

3221
public const BOOLEANS_AS_BOOL = 0;
3322
public const BOOLEANS_AS_INT = 1;
3423
public const BOOLEANS_AS_STRING = 2;
3524
public const BOOLEANS_AS_INT_STRING = 3;
3625

26+
public const NO_ENCODING = -1;
27+
3728
/**
3829
* @param iterable $params
3930
* @param int|null $bool_cast converts booleans to a type determined like following:
@@ -42,93 +33,128 @@ class Query{
4233
* BOOLEANS_AS_STRING : "true"/"false" strings
4334
* BOOLEANS_AS_INT_STRING: "0"/"1"
4435
*
45-
* @param bool|null $remove_empty remove empty and NULL values
36+
* @param bool|null $remove_empty remove empty and NULL values (default: true)
4637
*
4738
* @return array
4839
*/
4940
public static function cleanParams(iterable $params, int $bool_cast = null, bool $remove_empty = null):array{
50-
$p = [];
51-
$bool_cast = $bool_cast ?? self::BOOLEANS_AS_BOOL;
52-
$remove_empty = $remove_empty ?? true;
41+
$bool_cast ??= self::BOOLEANS_AS_BOOL;
42+
$remove_empty ??= true;
43+
44+
$cleaned = [];
5345

5446
foreach($params as $key => $value){
5547

56-
if(is_bool($value)){
48+
if(is_iterable($value)){
49+
// recursion
50+
$cleaned[$key] = call_user_func_array(__METHOD__, [$value, $bool_cast, $remove_empty]);
51+
}
52+
elseif(is_bool($value)){
5753

5854
if($bool_cast === self::BOOLEANS_AS_BOOL){
59-
$p[$key] = $value;
55+
$cleaned[$key] = $value;
6056
}
6157
elseif($bool_cast === self::BOOLEANS_AS_INT){
62-
$p[$key] = (int)$value;
58+
$cleaned[$key] = (int)$value;
6359
}
6460
elseif($bool_cast === self::BOOLEANS_AS_STRING){
65-
$p[$key] = $value ? 'true' : 'false';
61+
$cleaned[$key] = $value ? 'true' : 'false';
6662
}
6763
elseif($bool_cast === self::BOOLEANS_AS_INT_STRING){
68-
$p[$key] = (string)(int)$value;
64+
$cleaned[$key] = (string)(int)$value;
6965
}
7066

7167
}
72-
elseif(is_iterable($value)){
73-
$p[$key] = call_user_func_array(__METHOD__, [$value, $bool_cast, $remove_empty]);
74-
}
75-
elseif($remove_empty === true && ($value === null || (!is_numeric($value) && empty($value)))){
76-
continue;
68+
elseif(is_string($value)){
69+
$value = trim($value);
70+
71+
if($remove_empty && empty($value)){
72+
continue;
73+
}
74+
75+
$cleaned[$key] = $value;
7776
}
7877
else{
79-
$p[$key] = $value;
78+
79+
if($remove_empty && ($value === null || (!is_numeric($value) && empty($value)))){
80+
continue;
81+
}
82+
83+
$cleaned[$key] = $value;
8084
}
8185
}
8286

83-
return $p;
87+
return $cleaned;
8488
}
8589

8690
/**
87-
* from https://github.com/abraham/twitteroauth/blob/master/src/Util.php
91+
* Build a query string from an array of key value pairs.
92+
*
93+
* Valid values for $encoding are PHP_QUERY_RFC3986 (default) and PHP_QUERY_RFC1738,
94+
* any other integer value will be interpreted as "no encoding".
95+
*
96+
* @link https://github.com/abraham/twitteroauth/blob/57108b31f208d0066ab90a23257cdd7bb974c67d/src/Util.php#L84-L122
97+
* @link https://github.com/guzzle/psr7/blob/c0dcda9f54d145bd4d062a6d15f54931a67732f9/src/Query.php#L59-L113
8898
*/
89-
public static function build(array $params, bool $urlencode = null, string $delimiter = null, string $enclosure = null):string{
99+
public static function build(array $params, int $encoding = null, string $delimiter = null, string $enclosure = null):string{
90100

91101
if(empty($params)){
92102
return '';
93103
}
94104

95-
// urlencode both keys and values
96-
if($urlencode ?? true){
97-
$params = array_combine(
98-
r_rawurlencode(array_keys($params)),
99-
r_rawurlencode(array_values($params))
100-
);
105+
$encoding ??= PHP_QUERY_RFC3986;
106+
$enclosure ??= '';
107+
$delimiter ??= '&';
108+
109+
if($encoding === PHP_QUERY_RFC3986){
110+
$encode = 'rawurlencode';
111+
}
112+
elseif($encoding === PHP_QUERY_RFC1738){
113+
$encode = 'urlencode';
114+
}
115+
else{
116+
$encode = fn(string $str):string => $str;
101117
}
102118

119+
$pair = function(string $key, $value) use ($encode, $enclosure):string{
120+
121+
if($value === null){
122+
return $key;
123+
}
124+
125+
if(is_bool($value)){
126+
$value = (int)$value;
127+
}
128+
129+
// For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61)
130+
return $key.'='.$enclosure.$encode((string)$value).$enclosure;
131+
};
132+
103133
// Parameters are sorted by name, using lexicographical byte value ordering.
104-
// Ref: Spec: 9.1.1 (1)
105134
uksort($params, 'strcmp');
106135

107-
$pairs = [];
108-
$enclosure = $enclosure ?? '';
136+
$pairs = [];
109137

110138
foreach($params as $parameter => $value){
139+
$parameter = $encode((string)$parameter);
111140

112141
if(is_array($value)){
113142
// If two or more parameters share the same name, they are sorted by their value
114-
// Ref: Spec: 9.1.1 (1)
115-
// June 12th, 2010 - changed to sort because of issue 164 by hidetaka
116143
sort($value, SORT_STRING);
117144

118145
foreach($value as $duplicateValue){
119-
$pairs[] = $parameter.'='.$enclosure.$duplicateValue.$enclosure;
146+
$pairs[] = $pair($parameter, $duplicateValue);
120147
}
121148

122149
}
123150
else{
124-
$pairs[] = $parameter.'='.$enclosure.$value.$enclosure;
151+
$pairs[] = $pair($parameter, $value);
125152
}
126153

127154
}
128155

129-
// For each parameter, the name is separated from the corresponding value by an '=' character (ASCII code 61)
130156
// Each name-value pair is separated by an '&' character (ASCII code 38)
131-
return implode($delimiter ?? '&', $pairs);
157+
return implode($delimiter, $pairs);
132158
}
133159

134160
/**
@@ -152,12 +178,50 @@ public static function merge(string $uri, array $query):string{
152178
}
153179

154180
/**
155-
* @todo placeholder/WIP
181+
* Parse a query string into an associative array.
182+
*
183+
* @link https://github.com/guzzle/psr7/blob/c0dcda9f54d145bd4d062a6d15f54931a67732f9/src/Query.php#L9-L57
156184
*/
157-
public static function parse(string $querystring):array{
158-
parse_str($querystring, $parsedquery);
185+
public static function parse(string $querystring, int $urlEncoding = null):array{
186+
187+
if($querystring === ''){
188+
return [];
189+
}
190+
191+
if($urlEncoding === self::NO_ENCODING){
192+
$decode = fn(string $str):string => $str;
193+
}
194+
elseif($urlEncoding === PHP_QUERY_RFC3986){
195+
$decode = 'rawurldecode';
196+
}
197+
elseif($urlEncoding === PHP_QUERY_RFC1738){
198+
$decode = 'urldecode';
199+
}
200+
else{
201+
$decode = fn(string $value):string => rawurldecode(str_replace('+', ' ', $value));
202+
}
203+
204+
$result = [];
205+
206+
foreach(explode('&', $querystring) as $pair){
207+
$parts = explode('=', $pair, 2);
208+
$key = $decode($parts[0]);
209+
$value = isset($parts[1]) ? $decode($parts[1]) : null;
210+
211+
if(!isset($result[$key])){
212+
$result[$key] = $value;
213+
}
214+
else{
215+
216+
if(!is_array($result[$key])){
217+
$result[$key] = [$result[$key]];
218+
}
219+
220+
$result[$key][] = $value;
221+
}
222+
}
159223

160-
return $parsedquery;
224+
return $result;
161225
}
162226

163227
}

tests/Psr7/QueryTest.php

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
/**
33
* Class QueryTest
44
*
5+
* @link https://github.com/guzzle/psr7/blob/c0dcda9f54d145bd4d062a6d15f54931a67732f9/tests/QueryTest.php
6+
*
57
* @created 27.03.2021
68
* @author smiley <smiley@chillerlan.net>
79
* @copyright 2021 smiley
@@ -12,6 +14,7 @@
1214

1315
use chillerlan\HTTP\Psr7\Query;
1416
use PHPUnit\Framework\TestCase;
17+
use const PHP_QUERY_RFC1738, PHP_QUERY_RFC3986;
1518

1619
/**
1720
*
@@ -58,26 +61,11 @@ public function queryParamDataProvider():array{
5861
* @param bool $remove_empty
5962
*/
6063
public function testCleanQueryParams(int $bool_cast, bool $remove_empty, array $expected):void{
61-
$data = ['whatever' => null, 'nope' => '', 'true' => true, 'false' => false, 'array' => ['value' => false]];
64+
$data = ['whatever' => null, 'nope' => ' ', 'true' => true, 'false' => false, 'array' => ['value' => false]];
6265

6366
$this::assertSame($expected, Query::cleanParams($data, $bool_cast, $remove_empty));
6467
}
6568

66-
public function testBuildHttpQuery():void{
67-
68-
$data = ['foo' => 'bar', 'whatever?' => 'nope!'];
69-
70-
$this::assertSame('', Query::build([]));
71-
$this::assertSame('foo=bar&whatever%3F=nope%21', Query::build($data));
72-
$this::assertSame('foo=bar&whatever?=nope!', Query::build($data, false));
73-
$this::assertSame('foo=bar, whatever?=nope!', Query::build($data, false, ', '));
74-
$this::assertSame('foo="bar", whatever?="nope!"', Query::build($data, false, ', ', '"'));
75-
76-
$data['florps'] = ['nope', 'nope', 'nah'];
77-
$this::assertSame('florps="nah", florps="nope", florps="nope", foo="bar", whatever?="nope!"', Query::build($data, false, ', ', '"'));
78-
}
79-
80-
8169
public function mergeQueryDataProvider():array{
8270
$uri = 'http://localhost/whatever/';
8371
$params = ['foo' => 'bar'];
@@ -102,5 +90,91 @@ public function testMergeQuery(string $uri, array $params, string $expected):voi
10290
$this::assertSame($expected, $merged);
10391
}
10492

93+
public function testBuildQuery():void{
94+
$data = ['foo' => 'bar', 'whatever?' => 'nope!'];
95+
96+
$this::assertSame('foo=bar&whatever%3F=nope%21', Query::build($data));
97+
$this::assertSame('foo=bar&whatever?=nope!', Query::build($data, Query::NO_ENCODING));
98+
$this::assertSame('foo=bar, whatever?=nope!', Query::build($data, Query::NO_ENCODING, ', '));
99+
$this::assertSame('foo="bar", whatever?="nope!"', Query::build($data, Query::NO_ENCODING, ', ', '"'));
100+
101+
$data['florps'] = ['nope', 'nope', 'nah'];
102+
$this::assertSame(
103+
'florps="nah", florps="nope", florps="nope", foo="bar", whatever?="nope!"',
104+
Query::build($data, Query::NO_ENCODING, ', ', '"')
105+
);
106+
}
107+
108+
public function testBuildQuerySort():void{
109+
$this::assertSame('a=2&b=1&b=2&b=3&c=1&d=4', Query::build(['c' => 1, 'a' => 2, 'b' => [3, 1, 2], 'd' => 4]));
110+
}
111+
112+
public function parseQueryProvider():array{
113+
return [
114+
'Does not need to parse when the string is empty' => ['', []],
115+
'Can parse mult-values items' => ['q=a&q=b', ['q' => ['a', 'b']]],
116+
'Can parse multi-valued items that use numeric indices' => ['q[0]=a&q[1]=b', ['q[0]' => 'a', 'q[1]' => 'b']],
117+
'Can parse duplicates and does not include numeric indices' => ['q[]=a&q[]=b', ['q[]' => ['a', 'b']]],
118+
'Ensures that the value of "q" is an array' => ['q[]=a', ['q[]' => 'a']],
119+
'Does not modify "." to "_" like parse_str()' => ['q.a=a&q.b=b', ['q.a' => 'a', 'q.b' => 'b']],
120+
'Can decode %20 to " "' => ['q%20a=a%20b', ['q a' => 'a b']],
121+
'Can parse strings with no values by assigning each to null' => ['a&q', ['a' => null, 'q' => null]],
122+
'Does not strip trailing equal signs' => ['data=abc=', ['data' => 'abc=']],
123+
'Can store duplicates without affecting other values' => ['?µ=c&foo=a&foo=b', ['' => 'c', 'foo' => ['a', 'b']]],
124+
'Sets value to null when no "=" is present' => ['foo', ['foo' => null]],
125+
'Preserves "0" keys' => ['0', ['0' => null]],
126+
'Sets the value to an empty string when "=" is present' => ['0=', ['0' => '']],
127+
'Preserves falsey keys 1' => ['var=0', ['var' => '0']],
128+
'Preserves falsey keys 2' => ['a[b][c]=1&a[b][c]=2', ['a[b][c]' => ['1', '2']]],
129+
'Preserves falsey keys 3' => ['a[b]=c&a[d]=e', ['a[b]' => 'c', 'a[d]' => 'e']],
130+
'Can parse multi-values items' => ['q=a&q=b&q=c', ['q' => ['a', 'b', 'c']]],
131+
];
132+
}
133+
134+
/**
135+
* @dataProvider parseQueryProvider
136+
*/
137+
public function testParsesQueries(string $input, array $output):void{
138+
$this::assertSame($output, Query::parse($input));
139+
}
140+
141+
/**
142+
* @dataProvider parseQueryProvider
143+
*/
144+
public function testParsesAndBuildsQueries(string $input): void{
145+
$result = Query::parse($input, Query::NO_ENCODING);
146+
147+
$this::assertSame($input, Query::build($result, Query::NO_ENCODING));
148+
}
149+
150+
public function testDoesNotDecode():void{
151+
$this::assertSame(['foo%20' => 'bar'], Query::parse('foo%20=bar', Query::NO_ENCODING));
152+
}
153+
154+
public function testEncodesWithRfc1738():void{
155+
$this::assertSame('foo+bar=baz%2B', Query::build(['foo bar' => 'baz+'], PHP_QUERY_RFC1738));
156+
}
157+
158+
public function testEncodesWithRfc3986():void{
159+
$this::assertSame('foo%20bar=baz%2B', Query::build(['foo bar' => 'baz+'], PHP_QUERY_RFC3986));
160+
}
161+
162+
public function testDoesNotEncode():void{
163+
$this::assertSame('foo bar=baz+', Query::build(['foo bar' => 'baz+'], Query::NO_ENCODING));
164+
}
165+
166+
public function testCanControlDecodingType():void{
167+
$this::assertSame('foo+bar', Query::parse('var=foo+bar', PHP_QUERY_RFC3986)['var']);
168+
$this::assertSame('foo bar', Query::parse('var=foo+bar', PHP_QUERY_RFC1738)['var']);
169+
}
170+
171+
public function testBuildBooleans():void{
172+
$this::assertSame('false=0&true=1', Query::build(['true' => true, 'false' => false]));
173+
174+
$this::assertSame(
175+
'bar=0&bar=false&foo=1&foo=true',
176+
Query::build(['foo' => [true, 'true'], 'bar' => [false, 'false']], PHP_QUERY_RFC1738)
177+
);
178+
}
105179

106180
}

0 commit comments

Comments
 (0)