Skip to content

Commit

Permalink
fix(utils): Support arrays & nested objects in query params (#148)
Browse files Browse the repository at this point in the history
  • Loading branch information
offirgolan authored Dec 13, 2018
1 parent 955da6b commit 7e846b0
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 3 deletions.
3 changes: 2 additions & 1 deletion packages/@pollyjs/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
],
"license": "Apache-2.0",
"dependencies": {
"url-parse": "^1.4.4"
"url-parse": "^1.4.4",
"qs": "^6.6.0"
},
"devDependencies": {
"npm-run-all": "^4.1.3",
Expand Down
2 changes: 1 addition & 1 deletion packages/@pollyjs/utils/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ export { default as buildUrl } from './utils/build-url';

export { default as Serializers } from './utils/serializers';

export { default as URL } from 'url-parse';
export { default as URL } from './utils/url';
2 changes: 1 addition & 1 deletion packages/@pollyjs/utils/src/utils/build-url.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import URL from 'url-parse';
import URL from './url';

export default function buildUrl(...paths) {
const url = new URL(
Expand Down
100 changes: 100 additions & 0 deletions packages/@pollyjs/utils/src/utils/url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import URLParse from 'url-parse';
import qs from 'qs';

const ARRAY_FORMAT = Symbol();
const INDICES_REGEX = /\[\d+\]$/;
const BRACKETS_REGEX = /\[\]$/;

function parseQuery(query, options) {
return qs.parse(query, {
plainObjects: true,
ignoreQueryPrefix: true,
...options
});
}

function stringifyQuery(obj, options = {}) {
return qs.stringify(obj, { addQueryPrefix: true, ...options });
}

/**
* Given a query string, determine the array format used. Returns `undefined`
* if one cannot be determined.
*
* @param {String} query
* @returns {String | undefined}
*/
function arrayFormat(query) {
const keys = (query || '')
.replace('?', '')
.split('&')
.map(str => decodeURIComponent(str.split('=')[0]));

for (const key of keys) {
if (INDICES_REGEX.test(key)) {
// a[0]=b&a[1]=c
return 'indices';
} else if (BRACKETS_REGEX.test(key)) {
// a[]=b&a[]=c
return 'brackets';
}
}

// Look to see if any key has a duplicate
const hasDuplicate = keys.some((key, index) => keys.indexOf(key) !== index);

if (hasDuplicate) {
// 'a=b&a=c'
return 'repeat';
}
}

/**
* An extended url-parse class that uses `qs` instead of the default
* `querystringify` to support array and nested object query param strings.
*/
export default class URL extends URLParse {
constructor(url, parse) {
// Construct the url with an un-parsed querystring
super(url);

if (parse) {
// If we want the querystring to be parsed, use this.set('query', query)
// as it will always parse the string. If there is no initial querystring
// pass an object which will act as the parsed query.
this.set('query', this.query || {});
}
}

/**
* Override set for `query` so we can pass it our custom parser.
* https://github.com/unshiftio/url-parse/blob/1.4.4/index.js#L314-L316
*
* @override
*/
set(part, value, fn) {
if (part === 'query') {
if (value && typeof value === 'string') {
// Save the array format used so when we stringify it,
// we can use the correct format.
this[ARRAY_FORMAT] = arrayFormat(value) || this[ARRAY_FORMAT];
}

return super.set(part, value, parseQuery);
}

return super.set(part, value, fn);
}

/**
* Override toString so we can pass it our custom query stringify method.
* https://github.com/unshiftio/url-parse/blob/1.4.4/index.js#L414
*
* @override
*/
toString() {
return super.toString(obj =>
stringifyQuery(obj, { arrayFormat: this[ARRAY_FORMAT] })
);
}
}
73 changes: 73 additions & 0 deletions packages/@pollyjs/utils/tests/unit/utils/url-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import URL from '../../../src/utils/url';

const encode = encodeURIComponent;
const decode = decodeURIComponent;

describe('Unit | Utils | URL', function() {
it('should exist', function() {
expect(URL).to.be.a('function');
});

it('should work', function() {
expect(new URL('http://netflix.com').href).to.equal('http://netflix.com');
});

it('should should not parse the query string by default', function() {
expect(new URL('http://netflix.com?foo=bar').query).to.equal('?foo=bar');
});

it('should correctly parse query params', function() {
[
['', {}],
['foo=bar', { foo: 'bar' }],
['a[]=1&a[]=2', { a: ['1', '2'] }],
['a[1]=1&a[0]=2', { a: ['2', '1'] }],
['a=1&a=2', { a: ['1', '2'] }],
['foo[bar][baz]=1', { foo: { bar: { baz: '1' } } }]
].forEach(([query, obj]) => {
expect(new URL(`http://foo.bar?${query}`, true).query).to.deep.equal(obj);
});
});

it('should correctly stringify query params', function() {
[
// Query string will be undefined but we decode it in the assertion
[{}, decode(undefined)],
[{ foo: 'bar' }, 'foo=bar'],
[{ a: ['1', '2'] }, 'a[0]=1&a[1]=2'],
[{ foo: { bar: { baz: '1' } } }, 'foo[bar][baz]=1']
].forEach(([obj, query]) => {
const url = new URL('http://foo.bar', true);

url.set('query', obj);
expect(decode(url.href.split('?')[1])).to.equal(query);
expect(decode(url.toString().split('?')[1])).to.equal(query);
});
});

it('should correctly detect original array formats', function() {
[
'a[0]=1&a[1]=2',
`${encode('a[0]')}=1&${encode('a[1]')}=2`,
'a[]=1&a[]=2',
`${encode('a[]')}=1&${encode('a[]')}=2`,
'a=1&a=2'
].forEach(query => {
const url = new URL(`http://foo.bar?${query}`, true);

expect(decode(url.href.split('?')[1])).to.equal(decode(query));
expect(decode(url.toString().split('?')[1])).to.equal(decode(query));
});
});

it('should correctly handle changes in array formats', function() {
const url = new URL(`http://foo.bar`, true);

['a[0]=1&a[1]=2', 'a[]=1&a[]=2', 'a=1&a=2'].forEach(query => {
url.set('query', query);

expect(decode(url.href.split('?')[1])).to.equal(query);
expect(decode(url.toString().split('?')[1])).to.equal(query);
});
});
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -11133,6 +11133,11 @@ [email protected], qs@^6.4.0, qs@~6.5.1, qs@~6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"

qs@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.6.0.tgz#a99c0f69a8d26bf7ef012f871cdabb0aee4424c2"
integrity sha512-KIJqT9jQJDQx5h5uAVPimw6yVg2SekOKu959OCtktD3FjzbpvaPr8i4zzg07DOMz+igA4W/aNM7OV8H37pFYfA==

qs@~6.4.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233"
Expand Down

0 comments on commit 7e846b0

Please sign in to comment.