diff --git a/base.d.ts b/base.d.ts index 4a2a3b0..6bedc6d 100644 --- a/base.d.ts +++ b/base.d.ts @@ -87,7 +87,14 @@ export type ParseOptions = { //=> {foo: ['1', '2', '3']} ``` */ - readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'bracket-separator' | 'colon-list-separator' | 'none'; + readonly arrayFormat?: + | 'bracket' + | 'index' + | 'comma' + | 'separator' + | 'bracket-separator' + | 'colon-list-separator' + | 'none'; /** The character used to separate array elements when using `{arrayFormat: 'separator'}`. @@ -169,6 +176,108 @@ export type ParseOptions = { ``` */ readonly parseFragmentIdentifier?: boolean; + + /** + Specify a pre-defined schema to be used when parsing values. The types specified will take precedence over options such as: `parseNumber`, `parseBooleans`, and `arrayFormat`. + + Use this feature to override the type of a value. This can be useful when the type is ambiguous such as a phone number (see example 1 and 2). + + It is possible to provide a custom function as the parameter type. The parameter's value will equal the function's return value (see example 4). + + NOTE: Array types (`string[]` and `number[]`) will have no effect if `arrayFormat` is set to `none` (see example 5). + + @default {} + + @example + Parse `phoneNumber` as a string, overriding the `parseNumber` option: + ``` + import queryString from 'query-string'; + + queryString.parse('?phoneNumber=%2B380951234567&id=1', { + parseNumbers: true, + types: { + phoneNumber: 'string', + } + }); + //=> {phoneNumber: '+380951234567', id: 1} + ``` + + @example + Parse `items` as an array of strings, overriding the `parseNumber` option: + ``` + import queryString from 'query-string'; + + queryString.parse('?age=20&items=1%2C2%2C3', { + parseNumber: true, + types: { + items: 'string[]', + } + }); + //=> {age: 20, items: ['1', '2', '3']} + ``` + + @example + Parse `age` as a number, even when `parseNumber` is false: + ``` + import queryString from 'query-string'; + + queryString.parse('?age=20&id=01234&zipcode=90210', { + types: { + age: 'number', + } + }); + //=> {age: 20, id: '01234', zipcode: '90210 } + ``` + + @example + Parse `age` using a custom value parser: + ``` + import queryString from 'query-string'; + + queryString.parse('?age=20&id=01234&zipcode=90210', { + types: { + age: (value) => value * 2, + } + }); + //=> {age: 40, id: '01234', zipcode: '90210 } + ``` + + @example + Array types will have no effect when `arrayFormat` is set to `none` + ``` + queryString.parse('ids=001%2C002%2C003&foods=apple%2Corange%2Cmango', { + arrayFormat: 'none', + types: { + ids: 'number[]', + foods: 'string[]', + }, + } + //=> {ids:'001,002,003', foods:'apple,orange,mango'} + ``` + + @example + Parse a query utilizing all types: + ``` + import queryString from 'query-string'; + + queryString.parse('?ids=001%2C002%2C003&items=1%2C2%2C3&price=22%2E00&numbers=1%2C2%2C3&double=5&number=20', { + arrayFormat: 'comma', + types: { + ids: 'string', + items: 'string[]', + price: 'string', + numbers: 'number[]', + double: (value) => value * 2, + number: 'number', + }, + }); + //=> {ids: '001,002,003', items: ['1', '2', '3'], price: '22.00', numbers: [1, 2, 3], double: 10, number: 20} + ``` + */ + readonly types?: Record< + string, + 'number' | 'string' | 'string[]' | 'number[]' | ((value: string) => unknown) + >; }; // eslint-disable-next-line @typescript-eslint/ban-types diff --git a/base.js b/base.js index dc3f742..ccbdb6e 100644 --- a/base.js +++ b/base.js @@ -1,6 +1,6 @@ import decodeComponent from 'decode-uri-component'; -import splitOnFirst from 'split-on-first'; import {includeKeys} from 'filter-obj'; +import splitOnFirst from 'split-on-first'; const isNullOrUndefined = value => value === null || value === undefined; @@ -300,11 +300,25 @@ function getHash(url) { return hash; } -function parseValue(value, options) { +function parseValue(value, options, type) { + if (type === 'string' && typeof value === 'string') { + return value; + } + + if (typeof type === 'function' && typeof value === 'string') { + return type(value); + } + + if (options.parseBooleans && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) { + return value.toLowerCase() === 'true'; + } + + if (type === 'number' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) { + return Number(value); + } + if (options.parseNumbers && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) { - value = Number(value); - } else if (options.parseBooleans && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) { - value = value.toLowerCase() === 'true'; + return Number(value); } return value; @@ -328,6 +342,7 @@ export function parse(query, options) { arrayFormatSeparator: ',', parseNumbers: false, parseBooleans: false, + types: Object.create(null), ...options, }; @@ -368,12 +383,15 @@ export function parse(query, options) { } for (const [key, value] of Object.entries(returnValue)) { - if (typeof value === 'object' && value !== null) { + if (typeof value === 'object' && value !== null && options.types[key] !== 'string') { for (const [key2, value2] of Object.entries(value)) { - value[key2] = parseValue(value2, options); + const type = options.types[key] ? options.types[key].replace('[]', '') : undefined; + value[key2] = parseValue(value2, options, type); } + } else if (typeof value === 'object' && value !== null && options.types[key] === 'string') { + returnValue[key] = Object.values(value).join(options.arrayFormatSeparator); } else { - returnValue[key] = parseValue(value, options); + returnValue[key] = parseValue(value, options, options.types[key]); } } diff --git a/readme.md b/readme.md index 2bf52a1..111f980 100644 --- a/readme.md +++ b/readme.md @@ -224,6 +224,114 @@ queryString.parse('foo=true', {parseBooleans: true}); Parse the value as a boolean type instead of string type if it's a boolean. +##### types + +Type: `object`\ +Default: `{}` + +Specify a pre-defined schema to be used when parsing values. The types specified will take precedence over options such as: `parseNumber`, `parseBooleans`, and `arrayFormat`. + +Use this feature to override the type of a value. This can be useful when the type is ambiguous such as a phone number. + +It is possible to provide a custom function as the parameter type. The parameter's value will equal the function's return value. + +Supported Types: + +- `'string'`: Parse `phoneNumber` as a string (overriding the `parseNumber` option): + +```js +import queryString from 'query-string'; + +queryString.parse('?phoneNumber=%2B380951234567&id=1', { + parseNumbers: true, + types: { + phoneNumber: 'string', + } +}); +//=> {phoneNumber: '+380951234567', id: 1} +``` + +- `'number'`: Parse `age` as a number (even when `parseNumber` is false): + +```js +import queryString from 'query-string'; + +queryString.parse('?age=20&id=01234&zipcode=90210', { + types: { + age: 'number', + } +}); +//=> {age: 20, id: '01234', zipcode: '90210 } +``` + +- `'string[]'`: Parse `items` as an array of strings (overriding the `parseNumber` option): + +```js +import queryString from 'query-string'; + +queryString.parse('?age=20&items=1%2C2%2C3', { + parseNumber: true, + types: { + items: 'string[]', + } +}); +//=> {age: 20, items: ['1', '2', '3']} +``` + +- `'number[]'`: Parse `items` as an array of numbers (even when `parseNumber` is false): + +```js +import queryString from 'query-string'; + +queryString.parse('?age=20&items=1%2C2%2C3', { + types: { + items: 'number[]', + } +}); +//=> {age: '20', items: [1, 2, 3]} +``` + +- `'Function'`: Provide a custom function as the parameter type. The parameter's value will equal the function's return value. + +```js +import queryString from 'query-string'; + +queryString.parse('?age=20&id=01234&zipcode=90210', { + types: { + age: (value) => value * 2, + } +}); +//=> {age: 40, id: '01234', zipcode: '90210 } +``` + +NOTE: Array types (`string[]` and `number[]`) will have no effect if `arrayFormat` is set to `none`. + +```js +queryString.parse('ids=001%2C002%2C003&foods=apple%2Corange%2Cmango', { + arrayFormat: 'none', + types: { + ids: 'number[]', + foods: 'string[]', + }, +} +//=> {ids:'001,002,003', foods:'apple,orange,mango'} +``` + +###### Function + +```js +import queryString from 'query-string'; + +queryString.parse('?age=20&id=01234&zipcode=90210', { + types: { + age: (value) => value * 2, + } +}); +//=> {age: 40, id: '01234', zipcode: '90210 } +``` + +Parse the value as a boolean type instead of string type if it's a boolean. + ### .stringify(object, options?) Stringify an object into a query string and sorting the keys. diff --git a/test/parse.js b/test/parse.js index 17de629..3409255 100644 --- a/test/parse.js +++ b/test/parse.js @@ -404,3 +404,130 @@ test('query strings having (:list) colon-list-separator arrays', t => { test('query strings having (:list) colon-list-separator arrays including null values', t => { t.deepEqual(queryString.parse('bar:list=one&bar:list=two&foo', {arrayFormat: 'colon-list-separator'}), {bar: ['one', 'two'], foo: null}); }); + +test('types option: can override a parsed number to be a string ', t => { + t.deepEqual(queryString.parse('phoneNumber=%2B380951234567', { + parseNumbers: true, + types: { + phoneNumber: 'string', + }, + }), {phoneNumber: '+380951234567'}); +}); + +test('types option: can override a parsed boolean value to be a string', t => { + t.deepEqual(queryString.parse('question=true', { + parseBooleans: true, + types: { + question: 'string', + }, + }), { + question: 'true', + }); +}); + +test('types option: can override parsed numbers arrays to be string[]', t => { + t.deepEqual(queryString.parse('ids=999%2C998%2C997&items=1%2C2%2C3', { + arrayFormat: 'comma', + parseNumbers: true, + types: { + ids: 'string[]', + }, + }), { + ids: ['999', '998', '997'], + items: [1, 2, 3], + }); +}); + +test('types option: can override string arrays to be number[]', t => { + t.deepEqual(queryString.parse('ids=001%2C002%2C003&items=1%2C2%2C3', { + arrayFormat: 'comma', + types: { + ids: 'number[]', + }, + }), { + ids: [1, 2, 3], + items: ['1', '2', '3'], + }); +}); + +test('types option: can override an array to be string', t => { + t.deepEqual(queryString.parse('ids=001%2C002%2C003&items=1%2C2%2C3', { + arrayFormat: 'comma', + parseNumbers: true, + types: { + ids: 'string', + }, + }), { + ids: '001,002,003', + items: [1, 2, 3], + }); +}); + +test('types option: can override a separator array to be string ', t => { + t.deepEqual(queryString.parse('ids=001|002|003&items=1|2|3', { + arrayFormat: 'separator', + arrayFormatSeparator: '|', + parseNumbers: true, + types: { + ids: 'string', + }, + }), { + ids: '001|002|003', + items: [1, 2, 3], + }); +}); + +test('types option: when value is not of specified type, it will safely parse the value as string', t => { + t.deepEqual(queryString.parse('id=example', { + types: { + id: 'number', + }, + }), { + id: 'example', + }); +}); + +test('types option: array types will have no effect if arrayFormat is set to "none"', t => { + t.deepEqual(queryString.parse('ids=001%2C002%2C003&foods=apple%2Corange%2Cmango', { + arrayFormat: 'none', + types: { + ids: 'number[]', + foods: 'string[]', + }, + }), { + ids: '001,002,003', + foods: 'apple,orange,mango', + }); +}); + +test('types option: will parse the value as number if specified in type but parseNumbers is false', t => { + t.deepEqual(queryString.parse('id=123', { + arrayFormat: 'comma', + types: { + id: 'number', + }, + }), { + id: 123, + }); +}); + +test('types option: all supported types work in conjunction with one another', t => { + t.deepEqual(queryString.parse('ids=001%2C002%2C003&items=1%2C2%2C3&price=22%2E00&numbers=1%2C2%2C3&double=5&number=20', { + arrayFormat: 'comma', + types: { + ids: 'string', + items: 'string[]', + price: 'string', + numbers: 'number[]', + double: value => value * 2, + number: 'number', + }, + }), { + ids: '001,002,003', + items: ['1', '2', '3'], + price: '22.00', + numbers: [1, 2, 3], + double: 10, + number: 20, + }); +});