Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URL: Implement custom getQueryArgs, buildQueryString #20693

Merged
merged 5 commits into from
Nov 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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