mw.Uri: Add support for array parameters with explicit indexes
authorBartosz Dziewoński <matma.rex@gmail.com>
Tue, 27 Aug 2019 20:14:01 +0000 (22:14 +0200)
committerBartosz Dziewoński <matma.rex@gmail.com>
Tue, 27 Aug 2019 21:46:33 +0000 (21:46 +0000)
When the new 'arrayParams' option is set, query strings like
`&foo[0]=a&foo[1]=b` will be parsed as a single parameter `foo`
containing an array, rather than two separate parameters.

The new option also affects the behavior of array parameters like
`&foo[]=a&foo[]=b`, which will be parsed as a parameter named `foo`
rather than `foo[]`, and disables array handling for parameters that
don't contain an array index at the end.

Unlike in PHP, this does not handle associative or multi-dimensional
arrays, but that may be improved in the future.

Bug: T231382
Change-Id: I48d4bb3fdf0ea7f5eb133c59bf63651ba356fc42

resources/src/mediawiki.Uri/Uri.js
tests/qunit/suites/resources/mediawiki/mediawiki.Uri.test.js

index 4343ecc..a91e57a 100644 (file)
                 * @param {boolean} [options.strictMode=false] Trigger strict mode parsing of the url.
                 * @param {boolean} [options.overrideKeys=false] Whether to let duplicate query parameters
                 *  override each other (`true`) or automagically convert them to an array (`false`).
+                * @param {boolean} [options.arrayParams=false] Whether to parse array query parameters (e.g.
+                *  `&foo[0]=a&foo[1]=b` or `&foo[]=a&foo[]=b`) or leave them alone. Currently this does not
+                *  handle associative or multi-dimensional arrays, but that may be improved in the future.
+                *  Implies `overrideKeys: true` (query parameters without `[...]` are not parsed as arrays).
                 * @throws {Error} when the query string or fragment contains an unknown % sequence
                 */
                function Uri( uri, options ) {
                        options = typeof options === 'object' ? options : { strictMode: !!options };
                        options = $.extend( {
                                strictMode: false,
-                               overrideKeys: false
+                               overrideKeys: false,
+                               arrayParams: false
                        }, options );
 
+                       this.arrayParams = options.arrayParams;
+
                        if ( uri !== undefined && uri !== null && uri !== '' ) {
                                if ( typeof uri === 'string' ) {
                                        this.parse( uri, options );
                                // using replace to iterate over a string
                                if ( uri.query ) {
                                        uri.query.replace( /(?:^|&)([^&=]*)(?:(=)([^&]*))?/g, function ( match, k, eq, v ) {
+                                               var arrayKeyMatch, i;
                                                if ( k ) {
                                                        k = Uri.decode( k );
                                                        v = ( eq === '' || eq === undefined ) ? null : Uri.decode( v );
+                                                       arrayKeyMatch = k.match( /^([^[]+)\[(\d*)\]$/ );
+
+                                                       // If arrayParams and this parameter name contains an array index...
+                                                       if ( options.arrayParams && arrayKeyMatch ) {
+                                                               // Remove the index from parameter name
+                                                               k = arrayKeyMatch[ 1 ];
+
+                                                               // Turn the parameter value into an array (throw away anything else)
+                                                               if ( !Array.isArray( q[ k ] ) ) {
+                                                                       q[ k ] = [];
+                                                               }
+
+                                                               i = arrayKeyMatch[ 2 ];
+                                                               if ( i === '' ) {
+                                                                       // If no explicit index, append at the end
+                                                                       i = q[ k ].length;
+                                                               }
+
+                                                               q[ k ][ i ] = v;
 
                                                        // If overrideKeys, always (re)set top level value.
                                                        // If not overrideKeys but this key wasn't set before, then we set it as well.
-                                                       if ( options.overrideKeys || !hasOwn.call( q, k ) ) {
+                                                       // arrayParams implies overrideKeys (no array handling for non-array params).
+                                                       } else if ( options.arrayParams || options.overrideKeys || !hasOwn.call( q, k ) ) {
                                                                q[ k ] = v;
 
                                                        // Use arrays if overrideKeys is false and key was already seen before
                         * @return {string}
                         */
                        getQueryString: function () {
-                               var args = [];
+                               var args = [],
+                                       arrayParams = this.arrayParams;
                                // eslint-disable-next-line no-jquery/no-each-util
                                $.each( this.query, function ( key, val ) {
                                        var k = Uri.encode( key ),
-                                               vals = Array.isArray( val ) ? val : [ val ];
-                                       vals.forEach( function ( v ) {
+                                               isArrayParam = Array.isArray( val ),
+                                               vals = isArrayParam ? val : [ val ];
+                                       vals.forEach( function ( v, i ) {
+                                               var ki = k;
+                                               if ( arrayParams && isArrayParam ) {
+                                                       ki += Uri.encode( '[' + i + ']' );
+                                               }
                                                if ( v === null ) {
-                                                       args.push( k );
+                                                       args.push( ki );
                                                } else if ( k === 'title' ) {
-                                                       args.push( k + '=' + mw.util.wikiUrlencode( v ) );
+                                                       args.push( ki + '=' + mw.util.wikiUrlencode( v ) );
                                                } else {
-                                                       args.push( k + '=' + Uri.encode( v ) );
+                                                       args.push( ki + '=' + Uri.encode( v ) );
                                                }
                                        } );
                                } );
index 5eb5e05..013fb0d 100644 (file)
 
        } );
 
+       QUnit.test( 'arrayParams', function ( assert ) {
+               var uri1, uri2, uri3, expectedQ, expectedS,
+                       uriMissing, expectedMissingQ, expectedMissingS,
+                       uriWeird, expectedWeirdQ, expectedWeirdS;
+
+               uri1 = new mw.Uri( 'http://example.com/?foo[]=a&foo[]=b&foo[]=c', { arrayParams: true } );
+               uri2 = new mw.Uri( 'http://example.com/?foo[0]=a&foo[1]=b&foo[2]=c', { arrayParams: true } );
+               uri3 = new mw.Uri( 'http://example.com/?foo[1]=b&foo[0]=a&foo[]=c', { arrayParams: true } );
+               expectedQ = { foo: [ 'a', 'b', 'c' ] };
+               expectedS = 'foo%5B0%5D=a&foo%5B1%5D=b&foo%5B2%5D=c';
+
+               assert.deepEqual( uri1.query, expectedQ,
+                       'array query parameters are parsed (implicit indexes)' );
+               assert.deepEqual( uri1.getQueryString(), expectedS,
+                       'array query parameters are encoded (always with explicit indexes)' );
+               assert.deepEqual( uri2.query, expectedQ,
+                       'array query parameters are parsed (explicit indexes)' );
+               assert.deepEqual( uri2.getQueryString(), expectedS,
+                       'array query parameters are encoded (always with explicit indexes)' );
+               assert.deepEqual( uri3.query, expectedQ,
+                       'array query parameters are parsed (mixed indexes, out of order)' );
+               assert.deepEqual( uri3.getQueryString(), expectedS,
+                       'array query parameters are encoded (always with explicit indexes)' );
+
+               uriMissing = new mw.Uri( 'http://example.com/?foo[0]=a&foo[2]=c', { arrayParams: true } );
+               // eslint-disable-next-line no-sparse-arrays
+               expectedMissingQ = { foo: [ 'a', , 'c' ] };
+               expectedMissingS = 'foo%5B0%5D=a&foo%5B2%5D=c';
+
+               assert.deepEqual( uriMissing.query, expectedMissingQ,
+                       'array query parameters are parsed (missing array item)' );
+               assert.deepEqual( uriMissing.getQueryString(), expectedMissingS,
+                       'array query parameters are encoded (missing array item)' );
+
+               uriWeird = new mw.Uri( 'http://example.com/?foo[0]=a&foo[1][1]=b&foo[x]=c', { arrayParams: true } );
+               expectedWeirdQ = { foo: [ 'a' ], 'foo[1][1]': 'b', 'foo[x]': 'c' };
+               expectedWeirdS = 'foo%5B0%5D=a&foo%5B1%5D%5B1%5D=b&foo%5Bx%5D=c';
+
+               assert.deepEqual( uriWeird.query, expectedWeirdQ,
+                       'array query parameters are parsed (multi-dimensional or associative arrays are ignored)' );
+               assert.deepEqual( uriWeird.getQueryString(), expectedWeirdS,
+                       'array query parameters are encoded (multi-dimensional or associative arrays are ignored)' );
+       } );
+
        QUnit.test( '.clone()', function ( assert ) {
                var original, clone;