Skip to content

Commit

Permalink
URL: Implement custom getQueryArgs, buildQueryString (#20693)
Browse files Browse the repository at this point in the history
* URL: Tolerate query string retrieval from path

* URL: Implement custom getQueryArgs, buildQueryString

* URL: Always return object from getQueryArgs

* URL: Avoid modifying input URL without any querystring

* Fix the issue with file resolution
Props to @sirreal!

Co-authored-by: Grzegorz Ziolkowski <[email protected]>
  • Loading branch information
aduth and gziolo authored Nov 30, 2020
1 parent 77be77c commit 02bc517
Show file tree
Hide file tree
Showing 12 changed files with 408 additions and 34 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion packages/url/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,36 @@ _Returns_

- `string`: URL with arguments applied.

<a name="buildQueryString" href="#buildQueryString">#</a> **buildQueryString**

Generates URL-encoded query string using input query data.

It is intended to behave equivalent as PHP's `http_build_query`, configured
with encoding type PHP_QUERY_RFC3986 (spaces as `%20`).

_Usage_

```js
const queryString = buildQueryString( {
simple: 'is ok',
arrays: [ 'are', 'fine', 'too' ],
objects: {
evenNested: {
ok: 'yes',
},
},
} );
// "simple=is%20ok&arrays%5B0%5D=are&arrays%5B1%5D=fine&arrays%5B2%5D=too&objects%5BevenNested%5D%5Bok%5D=yes"
```

_Parameters_

- _data_ `Record<string,*>`: Data to encode.

_Returns_

- `string`: Query string.

<a name="cleanForSlug" href="#cleanForSlug">#</a> **cleanForSlug**

Performs some basic cleanup of a string for use as a post slug.
Expand Down Expand Up @@ -188,7 +218,27 @@ _Parameters_

_Returns_

- `(QueryArgParsed|undefined)`: Query arg value.
- `(QueryArgParsed|void)`: Query arg value.

<a name="getQueryArgs" href="#getQueryArgs">#</a> **getQueryArgs**

Returns an object of query arguments of the given URL. If the given URL is
invalid or has no querystring, an empty object is returned.

_Usage_

```js
const foo = getQueryArgs( 'https://wordpress.org?foo=bar&bar=baz' );
// { "foo": "bar", "bar": "baz" }
```

_Parameters_

- _url_ `string`: URL.

_Returns_

- `QueryArgs`: Query args object.

<a name="getQueryString" href="#getQueryString">#</a> **getQueryString**

Expand Down
1 change: 0 additions & 1 deletion packages/url/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"dependencies": {
"@babel/runtime": "^7.11.2",
"lodash": "^4.17.19",
"qs": "^6.5.2",
"react-native-url-polyfill": "^1.1.2"
},
"publishConfig": {
Expand Down
12 changes: 5 additions & 7 deletions packages/url/src/add-query-args.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* External dependencies
* Internal dependencies
*/
import { parse, stringify } from 'qs';
import { getQueryArgs } from './get-query-args';
import { buildQueryString } from './build-query-string';

/**
* Appends arguments as querystring to the provided URL. If the URL already
Expand Down Expand Up @@ -31,14 +32,11 @@ export function addQueryArgs( url = '', args ) {
const queryStringIndex = url.indexOf( '?' );
if ( queryStringIndex !== -1 ) {
// Merge into existing query arguments.
args = Object.assign(
parse( url.substr( queryStringIndex + 1 ) ),
args
);
args = Object.assign( getQueryArgs( url ), args );

// Change working base URL to omit previous query arguments.
baseUrl = baseUrl.substr( 0, queryStringIndex );
}

return baseUrl + '?' + stringify( args );
return baseUrl + '?' + buildQueryString( args );
}
61 changes: 61 additions & 0 deletions packages/url/src/build-query-string.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Generates URL-encoded query string using input query data.
*
* It is intended to behave equivalent as PHP's `http_build_query`, configured
* with encoding type PHP_QUERY_RFC3986 (spaces as `%20`).
*
* @example
* ```js
* const queryString = buildQueryString( {
* simple: 'is ok',
* arrays: [ 'are', 'fine', 'too' ],
* objects: {
* evenNested: {
* ok: 'yes',
* },
* },
* } );
* // "simple=is%20ok&arrays%5B0%5D=are&arrays%5B1%5D=fine&arrays%5B2%5D=too&objects%5BevenNested%5D%5Bok%5D=yes"
* ```
*
* @param {Record<string,*>} data Data to encode.
*
* @return {string} Query string.
*/
export function buildQueryString( data ) {
let string = '';

const stack = Array.from( Object.entries( data ) );

let pair;
while ( ( pair = stack.shift() ) ) {
let [ key, value ] = pair;

// Support building deeply nested data, from array or object values.
const hasNestedData =
Array.isArray( value ) || ( value && value.constructor === Object );

if ( hasNestedData ) {
// Push array or object values onto the stack as composed of their
// original key and nested index or key, retaining order by a
// combination of Array#reverse and Array#unshift onto the stack.
const valuePairs = Object.entries( value ).reverse();
for ( const [ member, memberValue ] of valuePairs ) {
stack.unshift( [ `${ key }[${ member }]`, memberValue ] );
}
} else if ( value !== undefined ) {
// Null is treated as special case, equivalent to empty string.
if ( value === null ) {
value = '';
}

string +=
'&' + [ key, value ].map( encodeURIComponent ).join( '=' );
}
}

// Loop will concatenate with leading `&`, but it's only expected for all
// but the first query parameter. This strips the leading `&`, while still
// accounting for the case that the string may in-fact be empty.
return string.substr( 1 );
}
14 changes: 4 additions & 10 deletions packages/url/src/get-query-arg.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
* Internal dependencies
*/
import { parse } from 'qs';
import { getQueryArgs } from './get-query-args';

/* eslint-disable jsdoc/valid-types */
/**
Expand All @@ -24,14 +24,8 @@ import { parse } from 'qs';
* const foo = getQueryArg( 'https://wordpress.org?foo=bar&bar=baz', 'foo' ); // bar
* ```
*
* @return {QueryArgParsed|undefined} Query arg value.
* @return {QueryArgParsed|void} Query arg value.
*/
export function getQueryArg( url, arg ) {
const queryStringIndex = url.indexOf( '?' );
const query =
queryStringIndex !== -1
? parse( url.substr( queryStringIndex + 1 ) )
: {};

return query[ arg ];
return getQueryArgs( url )[ arg ];
}
94 changes: 94 additions & 0 deletions packages/url/src/get-query-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Internal dependencies
*/
import { getQueryString } from './get-query-string';

/** @typedef {import('./get-query-arg').QueryArgParsed} QueryArgParsed */

/**
* @typedef {Record<string,QueryArgParsed>} QueryArgs
*/

/**
* Sets a value in object deeply by a given array of path segments. Mutates the
* object reference.
*
* @param {Record<string,*>} object Object in which to assign.
* @param {string[]} path Path segment at which to set value.
* @param {*} value Value to set.
*/
function setPath( object, path, value ) {
const length = path.length;
const lastIndex = length - 1;
for ( let i = 0; i < length; i++ ) {
let key = path[ i ];

if ( ! key && Array.isArray( object ) ) {
// If key is empty string and next value is array, derive key from
// the current length of the array.
key = object.length.toString();
}

// If the next key in the path is numeric (or empty string), it will be
// created as an array. Otherwise, it will be created as an object.
const isNextKeyArrayIndex = ! isNaN( Number( path[ i + 1 ] ) );

object[ key ] =
i === lastIndex
? // If at end of path, assign the intended value.
value
: // Otherwise, advance to the next object in the path, creating
// it if it does not yet exist.
object[ key ] || ( isNextKeyArrayIndex ? [] : {} );

if ( Array.isArray( object[ key ] ) && ! isNextKeyArrayIndex ) {
// If we current key is non-numeric, but the next value is an
// array, coerce the value to an object.
object[ key ] = { ...object[ key ] };
}

// Update working reference object to the next in the path.
object = object[ key ];
}
}

/**
* Returns an object of query arguments of the given URL. If the given URL is
* invalid or has no querystring, an empty object is returned.
*
* @param {string} url URL.
*
* @example
* ```js
* const foo = getQueryArgs( 'https://wordpress.org?foo=bar&bar=baz' );
* // { "foo": "bar", "bar": "baz" }
* ```
*
* @return {QueryArgs} Query args object.
*/
export function getQueryArgs( url ) {
return (
( getQueryString( url ) || '' )
// Normalize space encoding, accounting for PHP URL encoding
// corresponding to `application/x-www-form-urlencoded`.
//
// See: https://tools.ietf.org/html/rfc1866#section-8.2.1
.replace( /\+/g, '%20' )
.split( '&' )
.reduce( ( accumulator, keyValue ) => {
const [ key, value = '' ] = keyValue
.split( '=' )
// Filtering avoids decoding as `undefined` for value, where
// default is restored in destructuring assignment.
.filter( Boolean )
.map( decodeURIComponent );

if ( key ) {
const segments = key.replace( /\]/g, '' ).split( '[' );
setPath( accumulator, segments, value );
}

return accumulator;
}, {} )
);
}
2 changes: 1 addition & 1 deletion packages/url/src/get-query-string.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
export function getQueryString( url ) {
let query;
try {
query = new URL( url ).search.substring( 1 );
query = new URL( url, 'http://example.com' ).search.substring( 1 );
} catch ( error ) {}

if ( query ) {
Expand Down
2 changes: 2 additions & 0 deletions packages/url/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ export { isValidAuthority } from './is-valid-authority';
export { getPath } from './get-path';
export { isValidPath } from './is-valid-path';
export { getQueryString } from './get-query-string';
export { buildQueryString } from './build-query-string';
export { isValidQueryString } from './is-valid-query-string';
export { getPathAndQueryString } from './get-path-and-query-string';
export { getFragment } from './get-fragment';
export { isValidFragment } from './is-valid-fragment';
export { addQueryArgs } from './add-query-args';
export { getQueryArg } from './get-query-arg';
export { getQueryArgs } from './get-query-args';
export { hasQueryArg } from './has-query-arg';
export { removeQueryArgs } from './remove-query-args';
export { prependHTTP } from './prepend-http';
Expand Down
19 changes: 9 additions & 10 deletions packages/url/src/remove-query-args.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* External dependencies
* Internal dependencies
*/
import { parse, stringify } from 'qs';
import { getQueryArgs } from './get-query-args';
import { buildQueryString } from './build-query-string';

/**
* Removes arguments from the query string of the url
Expand All @@ -18,14 +19,12 @@ import { parse, stringify } from 'qs';
*/
export function removeQueryArgs( url, ...args ) {
const queryStringIndex = url.indexOf( '?' );
const query =
queryStringIndex !== -1
? parse( url.substr( queryStringIndex + 1 ) )
: {};
const baseUrl =
queryStringIndex !== -1 ? url.substr( 0, queryStringIndex ) : url;
if ( queryStringIndex === -1 ) {
return url;
}

const query = getQueryArgs( url );
const baseURL = url.substr( 0, queryStringIndex );
args.forEach( ( arg ) => delete query[ arg ] );

return baseUrl + '?' + stringify( query );
return baseURL + '?' + buildQueryString( query );
}
Loading

0 comments on commit 02bc517

Please sign in to comment.