diff --git a/package-lock.json b/package-lock.json index d68148a2a075fc..715088d2457a69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18333,7 +18333,6 @@ "requires": { "@babel/runtime": "^7.11.2", "lodash": "^4.17.19", - "qs": "^6.5.2", "react-native-url-polyfill": "^1.1.2" } }, @@ -52241,7 +52240,8 @@ "qs": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", - "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true }, "query-string": { "version": "4.3.4", diff --git a/packages/url/README.md b/packages/url/README.md index be964361ac1def..c0df7acf89e227 100644 --- a/packages/url/README.md +++ b/packages/url/README.md @@ -37,6 +37,36 @@ _Returns_ - `string`: URL with arguments applied. +# **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`: Data to encode. + +_Returns_ + +- `string`: Query string. + # **cleanForSlug** Performs some basic cleanup of a string for use as a post slug. @@ -188,7 +218,27 @@ _Parameters_ _Returns_ -- `(QueryArgParsed|undefined)`: Query arg value. +- `(QueryArgParsed|void)`: Query arg value. + +# **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. # **getQueryString** diff --git a/packages/url/package.json b/packages/url/package.json index 01d14070995d0c..b57a37eba4af01 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -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": { diff --git a/packages/url/src/add-query-args.js b/packages/url/src/add-query-args.js index 2d48c0be5a06b8..859785d44544a7 100644 --- a/packages/url/src/add-query-args.js +++ b/packages/url/src/add-query-args.js @@ -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 @@ -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 ); } diff --git a/packages/url/src/build-query-string.js b/packages/url/src/build-query-string.js new file mode 100644 index 00000000000000..51216a9f791d99 --- /dev/null +++ b/packages/url/src/build-query-string.js @@ -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} 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 ); +} diff --git a/packages/url/src/get-query-arg.js b/packages/url/src/get-query-arg.js index f6e184f0798ce3..d81a6249a1c0ae 100644 --- a/packages/url/src/get-query-arg.js +++ b/packages/url/src/get-query-arg.js @@ -1,7 +1,7 @@ /** - * External dependencies + * Internal dependencies */ -import { parse } from 'qs'; +import { getQueryArgs } from './get-query-args'; /* eslint-disable jsdoc/valid-types */ /** @@ -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 ]; } diff --git a/packages/url/src/get-query-args.js b/packages/url/src/get-query-args.js new file mode 100644 index 00000000000000..273f7f380c8ef1 --- /dev/null +++ b/packages/url/src/get-query-args.js @@ -0,0 +1,94 @@ +/** + * Internal dependencies + */ +import { getQueryString } from './get-query-string'; + +/** @typedef {import('./get-query-arg').QueryArgParsed} QueryArgParsed */ + +/** + * @typedef {Record} QueryArgs + */ + +/** + * Sets a value in object deeply by a given array of path segments. Mutates the + * object reference. + * + * @param {Record} 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; + }, {} ) + ); +} diff --git a/packages/url/src/get-query-string.js b/packages/url/src/get-query-string.js index e1437f4e78a131..8624ce5c6c8b9d 100644 --- a/packages/url/src/get-query-string.js +++ b/packages/url/src/get-query-string.js @@ -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 ) { diff --git a/packages/url/src/index.js b/packages/url/src/index.js index 5175803dfb1e9e..f060ae8152897d 100644 --- a/packages/url/src/index.js +++ b/packages/url/src/index.js @@ -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'; diff --git a/packages/url/src/remove-query-args.js b/packages/url/src/remove-query-args.js index 796e46e0ab980c..8d2ac9757ae305 100644 --- a/packages/url/src/remove-query-args.js +++ b/packages/url/src/remove-query-args.js @@ -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 @@ -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 ); } diff --git a/packages/url/src/test/index.test.js b/packages/url/src/test/index.test.js index 9699e24ec0a067..ff256fd5784ea2 100644 --- a/packages/url/src/test/index.test.js +++ b/packages/url/src/test/index.test.js @@ -16,6 +16,7 @@ import { getPath, isValidPath, getQueryString, + buildQueryString, isValidQueryString, getFragment, isValidFragment, @@ -27,6 +28,7 @@ import { safeDecodeURI, filterURLForDisplay, cleanForSlug, + getQueryArgs, } from '../'; import wptData from './fixtures/wpt-data'; @@ -288,6 +290,14 @@ describe( 'getQueryString', () => { ).toBe( 'foo=bar&foo=baz?test' ); } ); + it( 'returns the query string of a path', () => { + expect( getQueryString( '/wp-json/wp/v2/posts?type=page' ) ).toBe( + 'type=page' + ); + + expect( getQueryString( '/wp-json/wp/v2/posts' ) ).toBeUndefined(); + } ); + it( 'returns undefined when the provided does not contain a url query string', () => { expect( getQueryString( '' ) ).toBeUndefined(); expect( @@ -313,6 +323,56 @@ describe( 'getQueryString', () => { } ); } ); +describe( 'buildQueryString', () => { + it( 'builds simple strings', () => { + const data = { + foo: 'bar', + baz: 'boom', + cow: 'milk', + php: 'hypertext processor', + }; + + expect( buildQueryString( data ) ).toBe( + 'foo=bar&baz=boom&cow=milk&php=hypertext%20processor' + ); + } ); + + it( 'builds complex data', () => { + const data = { + user: { + name: 'Bob Smith', + age: 47, + sex: 'M', + dob: '5/12/1956', + }, + pastimes: [ 'golf', 'opera', 'poker', 'rap' ], + children: { + bobby: { age: 12, sex: 'M' }, + sally: { age: 8, sex: 'F' }, + }, + }; + + expect( buildQueryString( data ) ).toBe( + 'user%5Bname%5D=Bob%20Smith&user%5Bage%5D=47&user%5Bsex%5D=M&user%5Bdob%5D=5%2F12%2F1956&pastimes%5B0%5D=golf&pastimes%5B1%5D=opera&pastimes%5B2%5D=poker&pastimes%5B3%5D=rap&children%5Bbobby%5D%5Bage%5D=12&children%5Bbobby%5D%5Bsex%5D=M&children%5Bsally%5D%5Bage%5D=8&children%5Bsally%5D%5Bsex%5D=F' + ); + } ); + + it( 'builds falsey values', () => { + const data = { + empty: '', + null: null, + undefined, + zero: 0, + }; + + expect( buildQueryString( data ) ).toBe( 'empty=&null=&zero=0' ); + } ); + + it( 'builds an empty object as an empty string', () => { + expect( buildQueryString( {} ) ).toBe( '' ); + } ); +} ); + describe( 'isValidQueryString', () => { it( 'returns true if the query string is valid', () => { expect( isValidQueryString( 'test' ) ).toBe( true ); @@ -520,6 +580,116 @@ describe( 'addQueryArgs', () => { } ); } ); +describe( 'getQueryArgs', () => { + it( 'should parse simple query arguments', () => { + const url = 'https://andalouses.example/beach?foo=bar&baz=quux'; + + expect( getQueryArgs( url ) ).toEqual( { + foo: 'bar', + baz: 'quux', + } ); + } ); + + it( 'should accumulate array of values', () => { + const url = + 'https://andalouses.example/beach?foo[]=zero&foo[]=one&foo[]=two'; + + expect( getQueryArgs( url ) ).toEqual( { + foo: [ 'zero', 'one', 'two' ], + } ); + } ); + + it( 'should accumulate keyed array of values', () => { + const url = + 'https://andalouses.example/beach?foo[1]=one&foo[0]=zero&foo[]=two'; + + expect( getQueryArgs( url ) ).toEqual( { + foo: [ 'zero', 'one', 'two' ], + } ); + } ); + + it( 'should accumulate object of values', () => { + const url = + 'https://andalouses.example/beach?foo[zero]=0&foo[one]=1&foo[]=empty'; + + expect( getQueryArgs( url ) ).toEqual( { + foo: { + '': 'empty', + zero: '0', + one: '1', + }, + } ); + } ); + + it( 'normalizes mixed numeric and named keys', () => { + const url = 'https://andalouses.example/beach?foo[0]=0&foo[one]=1'; + + expect( getQueryArgs( url ) ).toEqual( { + foo: { + '0': '0', + one: '1', + }, + } ); + } ); + + it( 'should return empty object for URL without querystring', () => { + const urlWithoutQuerystring = 'https://andalouses.example/beach'; + const urlWithEmptyQuerystring = 'https://andalouses.example/beach?'; + const invalidURL = 'example'; + + expect( getQueryArgs( invalidURL ) ).toEqual( {} ); + expect( getQueryArgs( urlWithoutQuerystring ) ).toEqual( {} ); + expect( getQueryArgs( urlWithEmptyQuerystring ) ).toEqual( {} ); + } ); + + it( 'should gracefully handle empty keys and values', () => { + const url = 'https://andalouses.example/beach?&foo'; + + expect( getQueryArgs( url ) ).toEqual( { + foo: '', + } ); + } ); + + describe( 'reverses buildQueryString', () => { + it( 'unbuilds simple strings', () => { + const data = { + foo: 'bar', + baz: 'boom', + cow: 'milk', + php: 'hypertext processor', + }; + + expect( + getQueryArgs( + 'https://example.com/?foo=bar&baz=boom&cow=milk&php=hypertext%20processor' + ) + ).toEqual( data ); + } ); + + it( 'unbuilds complex data, with stringified values', () => { + const data = { + user: { + name: 'Bob Smith', + age: '47', + sex: 'M', + dob: '5/12/1956', + }, + pastimes: [ 'golf', 'opera', 'poker', 'rap' ], + children: { + bobby: { age: '12', sex: 'M' }, + sally: { age: '8', sex: 'F' }, + }, + }; + + expect( + getQueryArgs( + 'https://example.com/?user%5Bname%5D=Bob%20Smith&user%5Bage%5D=47&user%5Bsex%5D=M&user%5Bdob%5D=5%2F12%2F1956&pastimes%5B0%5D=golf&pastimes%5B1%5D=opera&pastimes%5B2%5D=poker&pastimes%5B3%5D=rap&children%5Bbobby%5D%5Bage%5D=12&children%5Bbobby%5D%5Bsex%5D=M&children%5Bsally%5D%5Bage%5D=8&children%5Bsally%5D%5Bsex%5D=F' + ) + ).toEqual( data ); + } ); + } ); +} ); + describe( 'getQueryArg', () => { it( 'should get the value of an existing query arg', () => { const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; @@ -543,6 +713,7 @@ describe( 'getQueryArg', () => { const url = 'https://andalouses.example/beach?foo=bar&bar=baz#foo'; expect( getQueryArg( url, 'foo' ) ).toEqual( 'bar' ); + expect( getQueryArg( url, 'bar' ) ).toEqual( 'baz' ); } ); } ); @@ -567,6 +738,12 @@ describe( 'hasQueryArg', () => { } ); describe( 'removeQueryArgs', () => { + it( 'should not change URL without a querystring', () => { + const url = 'https://andalouses.example/beach'; + + expect( removeQueryArgs( url, 'baz', 'test' ) ).toEqual( url ); + } ); + it( 'should not change URL not containing query args', () => { const url = 'https://andalouses.example/beach?foo=bar&bar=baz'; diff --git a/tsconfig.base.json b/tsconfig.base.json index fbaf60c3fdd529..495796402bdf85 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -37,8 +37,8 @@ "**/*.ios.js", "**/*.native.js", "**/benchmark", - "**/build-*/**", - "**/build/**", + "packages/*/build-*/**", + "packages/*/build/**", "**/test/**", "packages/**/react-native-*/**" ]