From d514cc947ac609e78207b8b55fae0c91d243cb69 Mon Sep 17 00:00:00 2001 From: Ashok Suthar Date: Sun, 28 Jan 2024 11:34:16 +0100 Subject: [PATCH] [New] `parse`/`stringify`: add `decodeDotInKeys`/`encodeDotKeys` options --- .eslintrc | 2 +- README.md | 17 +++++++ lib/parse.js | 26 +++++++--- lib/stringify.js | 25 ++++++++-- test/parse.js | 86 ++++++++++++++++++++++++++++++++ test/stringify.js | 122 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 12 deletions(-) diff --git a/.eslintrc b/.eslintrc index 8a9467f1..b6927611 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,7 +14,7 @@ "id-length": [2, { "min": 1, "max": 25, "properties": "never" }], "indent": [2, 4], "max-lines-per-function": [2, { "max": 150 }], - "max-params": [2, 17], + "max-params": [2, 18], "max-statements": [2, 100], "multiline-comment-style": 0, "no-continue": 1, diff --git a/README.md b/README.md index d2589f62..e4b660b9 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,14 @@ var withDots = qs.parse('a.b=c', { allowDots: true }); assert.deepEqual(withDots, { a: { b: 'c' } }); ``` +Option `decodeDotInKeys` can be used to decode dot notations in keys +Note: it only works with `allowDots` so you have to provide both options to be `true`: + +```javascript +var withDots = qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: true, decodeDotInKeys: true }); +assert.deepEqual(withDots, { 'name.obj': { first: 'John', last: 'Doe' }}); +``` + Option `allowEmptyArrays` can be used to allowing empty array values in object ```javascript var withEmptyArrays = qs.parse('foo[]&bar=baz', { allowEmptyArrays: true }); @@ -426,6 +434,15 @@ qs.stringify({ a: { b: { c: 'd', e: 'f' } } }, { allowDots: true }); // 'a.b.c=d&a.b.e=f' ``` +You may encode the dot notation in the keys of object with option `encodeDotInKeys` by setting it to `true` +Notes: +1. it only works with `allowDots` being set to `true` +2. when `encodeValuesOnly` is provided with `encodeDotInKeys`, it would only encode dots in keys and nothing else. +```javascript +qs.stringify({ "name.obj": { "first": "John", "last": "Doe" } }, { allowDots: true, encodeDotInKeys: true }) +// 'name%252Eobj.first=John&name%252Eobj.last=Doe' +``` + You may allow empty array values by setting the `allowEmptyArrays` option to `true`: ```javascript qs.stringify({ foo: [], bar: 'baz' }, { allowEmptyArrays: true }); diff --git a/lib/parse.js b/lib/parse.js index dc488150..cea58ab7 100644 --- a/lib/parse.js +++ b/lib/parse.js @@ -14,6 +14,7 @@ var defaults = { charset: 'utf-8', charsetSentinel: false, comma: false, + decodeDotInKeys: true, decoder: utils.decode, delimiter: '&', depth: 5, @@ -128,20 +129,24 @@ var parseObject = function (chain, val, options, valuesParsed) { } else { obj = options.plainObjects ? Object.create(null) : {}; var cleanRoot = root.charAt(0) === '[' && root.charAt(root.length - 1) === ']' ? root.slice(1, -1) : root; - var index = parseInt(cleanRoot, 10); - if (!options.parseArrays && cleanRoot === '') { + var decodedRoot = cleanRoot; + if (options.decodeDotInKeys) { + decodedRoot = cleanRoot.replace(/%2E/g, '.'); + } + var index = parseInt(decodedRoot, 10); + if (!options.parseArrays && decodedRoot === '') { obj = { 0: leaf }; } else if ( !isNaN(index) - && root !== cleanRoot - && String(index) === cleanRoot + && root !== decodedRoot + && String(index) === decodedRoot && index >= 0 && (options.parseArrays && index <= options.arrayLimit) ) { obj = []; obj[index] = leaf; - } else if (cleanRoot !== '__proto__') { - obj[cleanRoot] = leaf; + } else if (decodedRoot !== '__proto__') { + obj[decodedRoot] = leaf; } } @@ -214,6 +219,10 @@ var normalizeParseOptions = function normalizeParseOptions(opts) { throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided'); } + if (typeof opts.decodeDotInKeys !== 'undefined' && typeof opts.decodeDotInKeys !== 'boolean') { + throw new TypeError('`decodeDotInKeys` option can only be `true` or `false`, when provided'); + } + if (opts.decoder !== null && typeof opts.decoder !== 'undefined' && typeof opts.decoder !== 'function') { throw new TypeError('Decoder has to be a function.'); } @@ -229,8 +238,10 @@ var normalizeParseOptions = function normalizeParseOptions(opts) { throw new TypeError('The duplicates option must be either combine, first, or last'); } + var allowDots = typeof opts.allowDots === 'undefined' ? opts.decodeDotInKeys === true ? true : defaults.allowDots : !!opts.allowDots; + return { - allowDots: typeof opts.allowDots === 'undefined' ? defaults.allowDots : !!opts.allowDots, + allowDots: allowDots, allowEmptyArrays: typeof opts.allowEmptyArrays === 'boolean' ? !!opts.allowEmptyArrays : defaults.allowEmptyArrays, allowPrototypes: typeof opts.allowPrototypes === 'boolean' ? opts.allowPrototypes : defaults.allowPrototypes, allowSparse: typeof opts.allowSparse === 'boolean' ? opts.allowSparse : defaults.allowSparse, @@ -238,6 +249,7 @@ var normalizeParseOptions = function normalizeParseOptions(opts) { charset: charset, charsetSentinel: typeof opts.charsetSentinel === 'boolean' ? opts.charsetSentinel : defaults.charsetSentinel, comma: typeof opts.comma === 'boolean' ? opts.comma : defaults.comma, + decodeDotInKeys: typeof opts.decodeDotInKeys === 'boolean' ? opts.decodeDotInKeys : defaults.decodeDotInKeys, decoder: typeof opts.decoder === 'function' ? opts.decoder : defaults.decoder, delimiter: typeof opts.delimiter === 'string' || utils.isRegExp(opts.delimiter) ? opts.delimiter : defaults.delimiter, // eslint-disable-next-line no-implicit-coercion, no-extra-parens diff --git a/lib/stringify.js b/lib/stringify.js index 393ef69a..584e9bd7 100644 --- a/lib/stringify.js +++ b/lib/stringify.js @@ -36,6 +36,7 @@ var defaults = { charsetSentinel: false, delimiter: '&', encode: true, + encodeDotInKeys: false, encoder: utils.encode, encodeValuesOnly: false, format: defaultFormat, @@ -67,6 +68,7 @@ var stringify = function stringify( allowEmptyArrays, strictNullHandling, skipNulls, + encodeDotInKeys, encoder, filter, sort, @@ -148,7 +150,12 @@ var stringify = function stringify( objKeys = sort ? keys.sort(sort) : keys; } - var adjustedPrefix = commaRoundTrip && isArray(obj) && obj.length === 1 ? prefix + '[]' : prefix; + var encodedPrefix = prefix; + if (encodeDotInKeys) { + encodedPrefix = prefix.replace(/\./g, '%2E'); + } + + var adjustedPrefix = commaRoundTrip && isArray(obj) && obj.length === 1 ? encodedPrefix + '[]' : encodedPrefix; if (allowEmptyArrays && isArray(obj) && obj.length === 0) { return adjustedPrefix + '[]'; @@ -162,9 +169,10 @@ var stringify = function stringify( continue; } + var encodedKey = allowDots && encodeDotInKeys ? key.replace(/\./g, '%2E') : key; var keyPrefix = isArray(obj) - ? typeof generateArrayPrefix === 'function' ? generateArrayPrefix(adjustedPrefix, key) : adjustedPrefix - : adjustedPrefix + (allowDots ? '.' + key : '[' + key + ']'); + ? typeof generateArrayPrefix === 'function' ? generateArrayPrefix(adjustedPrefix, encodedKey) : adjustedPrefix + : adjustedPrefix + (allowDots ? '.' + encodedKey : '[' + encodedKey + ']'); sideChannel.set(object, step); var valueSideChannel = getSideChannel(); @@ -177,6 +185,7 @@ var stringify = function stringify( allowEmptyArrays, strictNullHandling, skipNulls, + encodeDotInKeys, generateArrayPrefix === 'comma' && encodeValuesOnly && isArray(obj) ? null : encoder, filter, sort, @@ -202,6 +211,10 @@ var normalizeStringifyOptions = function normalizeStringifyOptions(opts) { throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided'); } + if (typeof opts.encodeDotInKeys !== 'undefined' && typeof opts.encodeDotInKeys !== 'boolean') { + throw new TypeError('`encodeDotInKeys` option can only be `true` or `false`, when provided'); + } + if (opts.encoder !== null && typeof opts.encoder !== 'undefined' && typeof opts.encoder !== 'function') { throw new TypeError('Encoder has to be a function.'); } @@ -238,9 +251,11 @@ var normalizeStringifyOptions = function normalizeStringifyOptions(opts) { throw new TypeError('`commaRoundTrip` must be a boolean, or absent'); } + var allowDots = typeof opts.allowDots === 'undefined' ? opts.encodeDotInKeys === true ? true : defaults.allowDots : !!opts.allowDots; + return { addQueryPrefix: typeof opts.addQueryPrefix === 'boolean' ? opts.addQueryPrefix : defaults.addQueryPrefix, - allowDots: typeof opts.allowDots === 'undefined' ? defaults.allowDots : !!opts.allowDots, + allowDots: allowDots, allowEmptyArrays: typeof opts.allowEmptyArrays === 'boolean' ? !!opts.allowEmptyArrays : defaults.allowEmptyArrays, arrayFormat: arrayFormat, charset: charset, @@ -248,6 +263,7 @@ var normalizeStringifyOptions = function normalizeStringifyOptions(opts) { commaRoundTrip: opts.commaRoundTrip, delimiter: typeof opts.delimiter === 'undefined' ? defaults.delimiter : opts.delimiter, encode: typeof opts.encode === 'boolean' ? opts.encode : defaults.encode, + encodeDotInKeys: typeof opts.encodeDotInKeys === 'boolean' ? opts.encodeDotInKeys : defaults.encodeDotInKeys, encoder: typeof opts.encoder === 'function' ? opts.encoder : defaults.encoder, encodeValuesOnly: typeof opts.encodeValuesOnly === 'boolean' ? opts.encodeValuesOnly : defaults.encodeValuesOnly, filter: filter, @@ -307,6 +323,7 @@ module.exports = function (object, opts) { options.allowEmptyArrays, options.strictNullHandling, options.skipNulls, + options.encodeDotInKeys, options.encode ? options.encoder : null, options.filter, options.sort, diff --git a/test/parse.js b/test/parse.js index 676e5786..61fd570a 100644 --- a/test/parse.js +++ b/test/parse.js @@ -78,6 +78,92 @@ test('parse()', function (t) { st.end(); }); + t.test('decode dot keys correctly', function (st) { + st.deepEqual( + qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: false, decodeDotInKeys: false }), + { 'name%2Eobj.first': 'John', 'name%2Eobj.last': 'Doe' }, + 'with allowDots false and decodeDotInKeys false' + ); + st.deepEqual( + qs.parse('name.obj.first=John&name.obj.last=Doe', { allowDots: true, decodeDotInKeys: false }), + { name: { obj: { first: 'John', last: 'Doe' } } }, + 'with allowDots false and decodeDotInKeys false' + ); + st.deepEqual( + qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: true, decodeDotInKeys: false }), + { 'name%2Eobj': { first: 'John', last: 'Doe' } }, + 'with allowDots true and decodeDotInKeys false' + ); + st.deepEqual( + qs.parse('name%252Eobj.first=John&name%252Eobj.last=Doe', { allowDots: true, decodeDotInKeys: true }), + { 'name.obj': { first: 'John', last: 'Doe' } }, + 'with allowDots true and decodeDotInKeys true' + ); + + st.deepEqual( + qs.parse( + 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + { allowDots: false, decodeDotInKeys: false } + ), + { 'name%2Eobj%2Esubobject.first%2Egodly%2Ename': 'John', 'name%2Eobj%2Esubobject.last': 'Doe' }, + 'with allowDots false and decodeDotInKeys false' + ); + st.deepEqual( + qs.parse( + 'name.obj.subobject.first.godly.name=John&name.obj.subobject.last=Doe', + { allowDots: true, decodeDotInKeys: false } + ), + { name: { obj: { subobject: { first: { godly: { name: "John" } }, last: "Doe" } } } }, + 'with allowDots true and decodeDotInKeys false' + ); + st.deepEqual( + qs.parse( + 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + { allowDots: true, decodeDotInKeys: true } + ), + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + 'with allowDots true and decodeDotInKeys true' + ); + + st.end(); + }); + + t.test('should decode dot in key of object, and allow enabling dot notation when decodeDotInKeys is set to true and allowDots is undefined', function (st) { + st.deepEqual( + qs.parse( + 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + { decodeDotInKeys: true } + ), + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + 'with allowDots undefined and decodeDotInKeys true' + ); + + st.end(); + }); + + t.test('should throw when decodeDotInKeys is not of type boolean', function (st) { + st['throws']( + function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: 'foobar' }); }, + TypeError + ); + + st['throws']( + function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: 0 }); }, + TypeError + ); + st['throws']( + function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: NaN }); }, + TypeError + ); + + st['throws']( + function () { qs.parse('foo[]&bar=baz', { decodeDotInKeys: null }); }, + TypeError + ); + + st.end(); + }); + t.test('allows empty arrays in obj values', function (st) { st.deepEqual(qs.parse('foo[]&bar=baz', { allowEmptyArrays: true }), { foo: [], bar: 'baz' }); st.deepEqual(qs.parse('foo[]&bar=baz', { allowEmptyArrays: false }), { foo: [''], bar: 'baz' }); diff --git a/test/stringify.js b/test/stringify.js index 3303974e..712cfda7 100644 --- a/test/stringify.js +++ b/test/stringify.js @@ -64,6 +64,128 @@ test('stringify()', function (t) { st.end(); }); + t.test('encodes dot in key of object when encodeDotInKeys and allowDots is provided', function (st) { + st.equal( + qs.stringify( + { 'name.obj': { first: 'John', last: 'Doe' } }, + { allowDots: false, encodeDotInKeys: false } + ), + 'name.obj%5Bfirst%5D=John&name.obj%5Blast%5D=Doe', + 'with allowDots false and encodeDotInKeys false' + ); + st.equal( + qs.stringify( + { 'name.obj': { first: 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: false } + ), + 'name.obj.first=John&name.obj.last=Doe', + 'with allowDots true and encodeDotInKeys false' + ); + st.equal( + qs.stringify( + { 'name.obj': { first: 'John', last: 'Doe' } }, + { allowDots: false, encodeDotInKeys: true } + ), + 'name%252Eobj%5Bfirst%5D=John&name%252Eobj%5Blast%5D=Doe', + 'with allowDots false and encodeDotInKeys true' + ); + st.equal( + qs.stringify( + { 'name.obj': { first: 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: true } + ), + 'name%252Eobj.first=John&name%252Eobj.last=Doe', + 'with allowDots true and encodeDotInKeys true' + ); + + st.equal( + qs.stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: false, encodeDotInKeys: false } + ), + 'name.obj.subobject%5Bfirst.godly.name%5D=John&name.obj.subobject%5Blast%5D=Doe', + 'with allowDots false and encodeDotInKeys false' + ); + st.equal( + qs.stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: false } + ), + 'name.obj.subobject.first.godly.name=John&name.obj.subobject.last=Doe', + 'with allowDots false and encodeDotInKeys false' + ); + st.equal( + qs.stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: false, encodeDotInKeys: true } + ), + 'name%252Eobj%252Esubobject%5Bfirst.godly.name%5D=John&name%252Eobj%252Esubobject%5Blast%5D=Doe', + 'with allowDots false and encodeDotInKeys true' + ); + st.equal( + qs.stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: true } + ), + 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + 'with allowDots true and encodeDotInKeys true' + ); + + st.end(); + }); + + t.test('should encode dot in key of object, and automatically set allowDots to `true` when encodeDotInKeys is true and allowDots in undefined', function (st) { + st.equal( + qs.stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { encodeDotInKeys: true } + ), + 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + 'with allowDots undefined and encodeDotInKeys true' + ); + st.end(); + }); + + t.test('should encode dot in key of object when encodeDotInKeys and allowDots is provided, and nothing else when encodeValuesOnly is provided', function (st) { + st.equal( + qs.stringify({ 'name.obj': { first: 'John', last: 'Doe' } }, { + encodeDotInKeys: true, allowDots: true, encodeValuesOnly: true + }), + 'name%2Eobj.first=John&name%2Eobj.last=Doe' + ); + + st.equal( + qs.stringify({ 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, { allowDots: true, encodeDotInKeys: true, encodeValuesOnly: true }), + 'name%2Eobj%2Esubobject.first%2Egodly%2Ename=John&name%2Eobj%2Esubobject.last=Doe' + ); + + st.end(); + }); + + t.test('should throw when encodeDotInKeys is not of type boolean', function (st) { + st['throws']( + function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 'foobar' }); }, + TypeError + ); + + st['throws']( + function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 0 }); }, + TypeError + ); + + st['throws']( + function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: NaN }); }, + TypeError + ); + + st['throws']( + function () { qs.stringify({ a: [], b: 'zz' }, { encodeDotInKeys: null }); }, + TypeError + ); + + st.end(); + }); + t.test('adds query prefix', function (st) { st.equal(qs.stringify({ a: 'b' }, { addQueryPrefix: true }), '?a=b'); st.end();