Skip to content

Commit f2ebad0

Browse files
committed
MDL-77293 libraries: fix array query parameter handling
1 parent 2b337b4 commit f2ebad0

File tree

2 files changed

+185
-60
lines changed

2 files changed

+185
-60
lines changed

lib/classes/url.php

+107-60
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@ public function __construct(
153153
}
154154
if (isset($parts['query'])) {
155155
// Note: the values may not be correctly decoded, url parameters should be always passed as array.
156-
parse_str(str_replace('&', '&', $parts['query']), $this->params);
156+
$out = [];
157+
parse_str(str_replace('&', '&', $parts['query']), $out);
158+
$this->params($out);
157159
}
158160
unset($parts['query']);
159161
foreach ($parts as $key => $value) {
@@ -179,28 +181,36 @@ public function __construct(
179181
*
180182
* The added params override existing ones if they have the same name.
181183
*
182-
* @param null|array $params Defaults to null. If null then returns all params.
184+
* @param null|array $params Array of parameters to add. Note all values that are not arrays are cast to strings.
183185
* @return array Array of Params for url.
184186
* @throws coding_exception
185187
*/
186188
public function params(?array $params = null) {
187189
$params = (array)$params;
190+
$params = $this->clean_url_params($params);
191+
$this->params = array_merge($this->params, $params);
192+
return $this->params;
193+
}
188194

189-
foreach ($params as $key => $value) {
190-
if (is_int($key)) {
191-
throw new coding_exception('Url parameters can not have numeric keys!');
192-
}
193-
if (!is_string($value)) {
194-
if (is_array($value)) {
195-
throw new coding_exception('Url parameters values can not be arrays!');
196-
}
197-
if (is_object($value) && !method_exists($value, '__toString')) {
198-
throw new coding_exception('Url parameters values can not be objects, unless __toString() is defined!');
199-
}
195+
/**
196+
* Converts given URL parameter values that are not arrays into strings.
197+
*
198+
* This is to maintain the same behaviour as the original params() function.
199+
*
200+
* @param array $params
201+
* @return array
202+
*/
203+
private function clean_url_params(array $params): array {
204+
// Convert all values to strings.
205+
// This was the original implementation of the params function,
206+
// which we have kept for backwards compatibility.
207+
array_walk_recursive($params, function(&$value) {
208+
if (is_object($value) && !method_exists($value, '__toString')) {
209+
throw new coding_exception('Url parameters values can not be objects, unless __toString() is defined!');
200210
}
201-
$this->params[$key] = (string)$value;
202-
}
203-
return $this->params;
211+
$value = (string) $value;
212+
});
213+
return $params;
204214
}
205215

206216
/**
@@ -246,7 +256,7 @@ public function remove_all_params($unused = null) {
246256
*
247257
* @param string $paramname name
248258
* @param string $newvalue Param value. If new value specified current value is overriden or parameter is added
249-
* @return mixed string parameter value, null if parameter does not exist
259+
* @return array|string|null parameter value, null if parameter does not exist.
250260
*/
251261
public function param($paramname, $newvalue = '') {
252262
if (func_num_args() > 1) {
@@ -261,28 +271,65 @@ public function param($paramname, $newvalue = '') {
261271
}
262272

263273
/**
264-
* Merges parameters and validates them
274+
* Merges parameters.
265275
*
266276
* @param null|array $overrideparams
267277
* @return array merged parameters
268-
* @throws coding_exception
269278
*/
270279
protected function merge_overrideparams(?array $overrideparams = null) {
271-
$overrideparams = (array)$overrideparams;
272-
$params = $this->params;
273-
foreach ($overrideparams as $key => $value) {
274-
if (is_int($key)) {
275-
throw new coding_exception('Overridden parameters can not have numeric keys!');
276-
}
277-
if (is_array($value)) {
278-
throw new coding_exception('Overridden parameters values can not be arrays!');
279-
}
280-
if (is_object($value) && !method_exists($value, '__toString')) {
281-
throw new coding_exception('Overridden parameters values can not be objects, unless __toString() is defined!');
280+
$overrideparams = (array) $overrideparams;
281+
$overrideparams = $this->clean_url_params($overrideparams);
282+
return array_merge($this->params, $overrideparams);
283+
}
284+
285+
/**
286+
* Recursively transforms the given array of values to query string parts.
287+
*
288+
* Example query string parts: a=2, a[0]=2
289+
*
290+
* @param array $data Data to encode into query string parts. Can be a multi level array. All end values must be strings.
291+
* @return array array of query string parts. All parts are rawurlencoded.
292+
*/
293+
private function recursively_transform_params_to_query_string_parts(array $data): array {
294+
$stringparams = [];
295+
296+
// Define a recursive function to encode the array into a set of string params.
297+
// We need to do this recursively, so that multi level array parameters are properly supported.
298+
$parsestringparams = function (array $data) use (&$stringparams, &$parsestringparams) {
299+
foreach ($data as $key => $value) {
300+
// If this is an array, rewrite the $value keys to track the position in the array.
301+
// and pass back to this function recursively until the values are no longer arrays.
302+
// E.g. if $key is 'a' and $value was [0 => true, 1 => false]
303+
// the new array becomes ['a[0]' => true, 'a[1]' => false].
304+
if (is_array($value)) {
305+
$newvalue = [];
306+
foreach ($value as $innerkey => $innervalue) {
307+
$newkey = $key . '[' . $innerkey . ']';
308+
$newvalue[$newkey] = $innervalue;
309+
}
310+
$parsestringparams($newvalue);
311+
} else {
312+
// Else no more arrays to traverse - build the final query string part.
313+
// We enforce that all end values are strings for consistency.
314+
// When params() is used, it will convert all params given to strings.
315+
// This will catch out anyone setting the params property directly.
316+
if (!is_string($value)) {
317+
throw new coding_exception('Unexpected query string value type.
318+
All values that are not arrays should be a string.');
319+
}
320+
321+
if (isset($value) && $value !== '') {
322+
$stringparams[] = rawurlencode($key) . '=' . rawurlencode($value);
323+
} else {
324+
$stringparams[] = rawurlencode($key);
325+
}
326+
}
282327
}
283-
$params[$key] = (string)$value;
284-
}
285-
return $params;
328+
};
329+
330+
$parsestringparams($data);
331+
332+
return $stringparams;
286333
}
287334

288335
/**
@@ -296,29 +343,18 @@ protected function merge_overrideparams(?array $overrideparams = null) {
296343
* @return string query string that can be added to a url.
297344
*/
298345
public function get_query_string($escaped = true, ?array $overrideparams = null) {
299-
$arr = [];
300346
if ($overrideparams !== null) {
301347
$params = $this->merge_overrideparams($overrideparams);
302348
} else {
303349
$params = $this->params;
304350
}
305-
foreach ($params as $key => $val) {
306-
if (is_array($val)) {
307-
foreach ($val as $index => $value) {
308-
$arr[] = rawurlencode($key . '[' . $index . ']') . "=" . rawurlencode($value);
309-
}
310-
} else {
311-
if (isset($val) && $val !== '') {
312-
$arr[] = rawurlencode($key) . "=" . rawurlencode($val);
313-
} else {
314-
$arr[] = rawurlencode($key);
315-
}
316-
}
317-
}
351+
352+
$stringparams = $this->recursively_transform_params_to_query_string_parts($params);
353+
318354
if ($escaped) {
319-
return implode('&', $arr);
355+
return implode('&', $stringparams);
320356
} else {
321-
return implode('&', $arr);
357+
return implode('&', $stringparams);
322358
}
323359
}
324360

@@ -330,17 +366,28 @@ public function get_query_string($escaped = true, ?array $overrideparams = null)
330366
* @return array params array for templates.
331367
*/
332368
public function export_params_for_template(): array {
333-
$data = [];
334-
foreach ($this->params as $key => $val) {
335-
if (is_array($val)) {
336-
foreach ($val as $index => $value) {
337-
$data[] = ['name' => $key . '[' . $index . ']', 'value' => $value];
338-
}
339-
} else {
340-
$data[] = ['name' => $key, 'value' => $val];
369+
$querystringparts = $this->recursively_transform_params_to_query_string_parts($this->params);
370+
371+
return array_map(function ($value) {
372+
// First urldecode it, they are encoded by default.
373+
$value = rawurldecode($value);
374+
375+
// Now seperate the parts into name and value.
376+
// splitting on the first '=' sign.
377+
$parts = explode('=', $value, 2);
378+
379+
// Parts must be of length 1 or 2, anything else is an invalid.
380+
if (count($parts) !== 1 && count($parts) !== 2) {
381+
throw new coding_exception('Invalid query string construction, unexpected number of parts');
341382
}
342-
}
343-
return $data;
383+
384+
// There might not always be a '=' e.g. when the value is an empty string.
385+
// in this case, just fallback to an empty string.
386+
$name = $parts[0];
387+
$value = $parts[1] ?? '';
388+
389+
return ['name' => $name, 'value' => $value];
390+
}, $querystringparts);
344391
}
345392

346393
/**
@@ -847,7 +894,7 @@ public function get_path($includeslashargument = true) {
847894
* Returns a given parameter value from the URL.
848895
*
849896
* @param string $name Name of parameter
850-
* @return string Value of parameter or null if not set
897+
* @return array|string|null Value of parameter or null if not set.
851898
*/
852899
public function get_param($name) {
853900
if (array_key_exists($name, $this->params)) {

lib/tests/url_test.php

+78
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,16 @@ public static function export_params_for_template_provider(): array {
289289
4 => ['name' => 'param3', 'value' => '3'],
290290
],
291291
],
292+
'multi level array embedded with other params' => [
293+
'url' => "@{$baseurl}/?param1=1&tags[0][0]=123&tags[0][1]=456&param2=2&param3=3",
294+
'expected' => [
295+
0 => ['name' => 'param1', 'value' => '1'],
296+
1 => ['name' => 'tags[0][0]', 'value' => '123'],
297+
2 => ['name' => 'tags[0][1]', 'value' => '456'],
298+
3 => ['name' => 'param2', 'value' => '2'],
299+
4 => ['name' => 'param3', 'value' => '3'],
300+
],
301+
],
292302
'params with array at the end' => [
293303
'url' => "@{$baseurl}/?param1=1&tags[]=123&tags[]=456",
294304
'expected' => [
@@ -686,4 +696,72 @@ public function test_routed_path(): void {
686696
$url = url::routed_path('/example');
687697
$this->assertSame('/example', $url->out_as_local_url(false));
688698
}
699+
700+
/**
701+
* Provides various urls with multi level array query parameters
702+
*
703+
* @return array
704+
*/
705+
public static function multi_level_query_params_provider(): array {
706+
return [
707+
'multi level with integer values' => [
708+
'url' => 'https://example.moodle.net?test[0][0]=1&test[0][1]=0',
709+
'extraparams' => [],
710+
'expectedparams' => ['test' => [0 => ['1', '0']]],
711+
'expectedurlout' => 'https://example.moodle.net?test%5B0%5D%5B0%5D=1&test%5B0%5D%5B1%5D=0',
712+
],
713+
'multi level with bool-looking string values' => [
714+
'url' => 'https://example.moodle.net?test[0][0]=true&test[0][1]=false',
715+
'extraparams' => [],
716+
// These are actually strings, and should be interpreted as such,
717+
// even if they look like booleans.
718+
'expectedparams' => ['test' => [0 => ['true', 'false']]],
719+
'expectedurlout' => 'https://example.moodle.net?test%5B0%5D%5B0%5D=true&test%5B0%5D%5B1%5D=false',
720+
],
721+
'multi level with bool params values' => [
722+
'url' => 'https://example.moodle.net',
723+
'extraparams' => ['test' => [0 => [true, false]]],
724+
'expectedparams' => ['test' => [0 => ['1', '']]],
725+
// Bool values get stringified. This means true = 1 and false = ''.
726+
'expectedurlout' => 'https://example.moodle.net?test%5B0%5D%5B0%5D=1&test%5B0%5D%5B1%5D',
727+
],
728+
'triple level array with string values' => [
729+
'url' => 'https://example.moodle.net?test[0][0][0]=abc&test[0][0][1]=xyz',
730+
'extraparams' => [],
731+
'expectedparams' => ['test' => [0 => [0 => ['abc', 'xyz']]]],
732+
'expectedurlout' => 'https://example.moodle.net?test%5B0%5D%5B0%5D%5B0%5D=abc&test%5B0%5D%5B0%5D%5B1%5D=xyz',
733+
],
734+
'multi level params with empty arrays as values' => [
735+
'url' => 'https://example.moodle.net',
736+
'extraparams' => ['test' => [[[]], [[], []]]],
737+
'expectedparams' => ['test' => [[[]], [[], []]]],
738+
// Empty arrays don't hold any data; they are just containers.
739+
// So this should not have the param present in the query params.
740+
'expectedurlout' => 'https://example.moodle.net',
741+
],
742+
'multi level params with non sequential arrays keys' => [
743+
'url' => 'https://example.moodle.net?test[2]=a&test[0]=b',
744+
'extraparams' => [],
745+
'expectedparams' => ['test' => [2 => 'a', 0 => 'b']],
746+
'expectedurlout' => 'https://example.moodle.net?test%5B2%5D=a&test%5B0%5D=b',
747+
],
748+
];
749+
}
750+
751+
/**
752+
* Tests url parameter handling where multi level arrays are involved.
753+
*
754+
* @param string $urlstring url string to parse.
755+
* @param array $extraparams extra parameters to pass directly to ->params() function.
756+
* @param array $expectedparams php array of expected parameters expected to be parsed.
757+
* @param string $expectedurlout unescaped url string that is expected when calling ->out() on the url object.
758+
* @dataProvider multi_level_query_params_provider
759+
*/
760+
public function test_multi_level_array_query_params(string $urlstring, array $extraparams, array $expectedparams,
761+
string $expectedurlout): void {
762+
$url = new url($urlstring);
763+
$url->params($extraparams);
764+
$this->assertSame($expectedparams, $url->params());
765+
$this->assertSame($expectedurlout, $url->out(false));
766+
}
689767
}

0 commit comments

Comments
 (0)