diff --git a/.github/funding.yml b/.github/funding.yml deleted file mode 100644 index 07c8db1c..00000000 --- a/.github/funding.yml +++ /dev/null @@ -1,4 +0,0 @@ -github: sindresorhus -open_collective: sindresorhus -tidelift: npm/query-string -custom: https://sindresorhus.com/donate diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b85fc2a9..346585cf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,14 +10,11 @@ jobs: fail-fast: false matrix: node-version: - - 14 - - 12 - - 10 - - 8 - - 6 + - 20 + - 18 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm install diff --git a/base.d.ts b/base.d.ts new file mode 100644 index 00000000..6bedc6d6 --- /dev/null +++ b/base.d.ts @@ -0,0 +1,671 @@ +export type ParseOptions = { + /** + Decode the keys and values. URI components are decoded with [`decode-uri-component`](https://github.com/SamVerschueren/decode-uri-component). + + @default true + */ + readonly decode?: boolean; + + /** + @default 'none' + + - `bracket`: Parse arrays with bracket representation: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo[]=1&foo[]=2&foo[]=3', {arrayFormat: 'bracket'}); + //=> {foo: ['1', '2', '3']} + ``` + + - `index`: Parse arrays with index representation: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo[0]=1&foo[1]=2&foo[3]=3', {arrayFormat: 'index'}); + //=> {foo: ['1', '2', '3']} + ``` + + - `comma`: Parse arrays with elements separated by comma: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo=1,2,3', {arrayFormat: 'comma'}); + //=> {foo: ['1', '2', '3']} + ``` + + - `separator`: Parse arrays with elements separated by a custom character: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo=1|2|3', {arrayFormat: 'separator', arrayFormatSeparator: '|'}); + //=> {foo: ['1', '2', '3']} + ``` + + - `bracket-separator`: Parse arrays (that are explicitly marked with brackets) with elements separated by a custom character: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo[]', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> {foo: []} + + queryString.parse('foo[]=', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> {foo: ['']} + + queryString.parse('foo[]=1', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> {foo: ['1']} + + queryString.parse('foo[]=1|2|3', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> {foo: ['1', '2', '3']} + + queryString.parse('foo[]=1||3|||6', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> {foo: ['1', '', 3, '', '', '6']} + + queryString.parse('foo[]=1|2|3&bar=fluffy&baz[]=4', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> {foo: ['1', '2', '3'], bar: 'fluffy', baz:['4']} + ``` + + - `colon-list-separator`: Parse arrays with parameter names that are explicitly marked with `:list`: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo:list=one&foo:list=two', {arrayFormat: 'colon-list-separator'}); + //=> {foo: ['one', 'two']} + ``` + + - `none`: Parse arrays with elements using duplicate keys: + + ``` + import queryString from 'query-string'; + + queryString.parse('foo=1&foo=2&foo=3'); + //=> {foo: ['1', '2', '3']} + ``` + */ + readonly arrayFormat?: + | 'bracket' + | 'index' + | 'comma' + | 'separator' + | 'bracket-separator' + | 'colon-list-separator' + | 'none'; + + /** + The character used to separate array elements when using `{arrayFormat: 'separator'}`. + + @default , + */ + readonly arrayFormatSeparator?: string; + + /** + Supports both `Function` as a custom sorting function or `false` to disable sorting. + + If omitted, keys are sorted using `Array#sort`, which means, converting them to strings and comparing strings in Unicode code point order. + + @default true + + @example + ``` + import queryString from 'query-string'; + + const order = ['c', 'a', 'b']; + + queryString.parse('?a=one&b=two&c=three', { + sort: (itemLeft, itemRight) => order.indexOf(itemLeft) - order.indexOf(itemRight) + }); + //=> {c: 'three', a: 'one', b: 'two'} + ``` + + @example + ``` + import queryString from 'query-string'; + + queryString.parse('?a=one&c=three&b=two', {sort: false}); + //=> {a: 'one', c: 'three', b: 'two'} + ``` + */ + readonly sort?: ((itemLeft: string, itemRight: string) => number) | false; + + /** + Parse the value as a number type instead of string type if it's a number. + + @default false + + @example + ``` + import queryString from 'query-string'; + + queryString.parse('foo=1', {parseNumbers: true}); + //=> {foo: 1} + ``` + */ + readonly parseNumbers?: boolean; + + /** + Parse the value as a boolean type instead of string type if it's a boolean. + + @default false + + @example + ``` + import queryString from 'query-string'; + + queryString.parse('foo=true', {parseBooleans: true}); + //=> {foo: true} + ``` + */ + readonly parseBooleans?: boolean; + + /** + Parse the fragment identifier from the URL and add it to result object. + + @default false + + @example + ``` + import queryString from 'query-string'; + + queryString.parseUrl('https://foo.bar?foo=bar#xyz', {parseFragmentIdentifier: true}); + //=> {url: 'https://foo.bar', query: {foo: 'bar'}, fragmentIdentifier: 'xyz'} + ``` + */ + 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 +export type ParsedQuery = Record>; + +/** +Parse a query string into an object. Leading `?` or `#` are ignored, so you can pass `location.search` or `location.hash` directly. + +The returned object is created with [`Object.create(null)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create) and thus does not have a `prototype`. + +@param query - The query string to parse. +*/ +export function parse(query: string, options: {parseBooleans: true; parseNumbers: true} & ParseOptions): ParsedQuery; +export function parse(query: string, options: {parseBooleans: true} & ParseOptions): ParsedQuery; +export function parse(query: string, options: {parseNumbers: true} & ParseOptions): ParsedQuery; +export function parse(query: string, options?: ParseOptions): ParsedQuery; + +export type ParsedUrl = { + readonly url: string; + readonly query: ParsedQuery; + + /** + The fragment identifier of the URL. + + Present when the `parseFragmentIdentifier` option is `true`. + */ + readonly fragmentIdentifier?: string; +}; + +/** +Extract the URL and the query string as an object. + +If the `parseFragmentIdentifier` option is `true`, the object will also contain a `fragmentIdentifier` property. + +@param url - The URL to parse. + +@example +``` +import queryString from 'query-string'; + +queryString.parseUrl('https://foo.bar?foo=bar'); +//=> {url: 'https://foo.bar', query: {foo: 'bar'}} + +queryString.parseUrl('https://foo.bar?foo=bar#xyz', {parseFragmentIdentifier: true}); +//=> {url: 'https://foo.bar', query: {foo: 'bar'}, fragmentIdentifier: 'xyz'} +``` +*/ +export function parseUrl(url: string, options?: ParseOptions): ParsedUrl; + +export type StringifyOptions = { + /** + Strictly encode URI components. It uses [`encodeURIComponent`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) if set to `false`. You probably [don't care](https://github.com/sindresorhus/query-string/issues/42) about this option. + + @default true + */ + readonly strict?: boolean; + + /** + [URL encode](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) the keys and values. + + @default true + */ + readonly encode?: boolean; + + /** + @default 'none' + + - `bracket`: Serialize arrays using bracket representation: + + ``` + import queryString from 'query-string'; + + queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket'}); + //=> 'foo[]=1&foo[]=2&foo[]=3' + ``` + + - `index`: Serialize arrays using index representation: + + ``` + import queryString from 'query-string'; + + queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'index'}); + //=> 'foo[0]=1&foo[1]=2&foo[2]=3' + ``` + + - `comma`: Serialize arrays by separating elements with comma: + + ``` + import queryString from 'query-string'; + + queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'comma'}); + //=> 'foo=1,2,3' + + queryString.stringify({foo: [1, null, '']}, {arrayFormat: 'comma'}); + //=> 'foo=1,,' + // Note that typing information for null values is lost + // and `.parse('foo=1,,')` would return `{foo: [1, '', '']}`. + ``` + + - `separator`: Serialize arrays by separating elements with character: + + ``` + import queryString from 'query-string'; + + queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'separator', arrayFormatSeparator: '|'}); + //=> 'foo=1|2|3' + ``` + + - `bracket-separator`: Serialize arrays by explicitly post-fixing array names with brackets and separating elements with a custom character: + + ``` + import queryString from 'query-string'; + + queryString.stringify({foo: []}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> 'foo[]' + + queryString.stringify({foo: ['']}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> 'foo[]=' + + queryString.stringify({foo: [1]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> 'foo[]=1' + + queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> 'foo[]=1|2|3' + + queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> 'foo[]=1||3|||6' + + queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|', skipNull: true}); + //=> 'foo[]=1||3|6' + + queryString.stringify({foo: [1, 2, 3], bar: 'fluffy', baz: [4]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); + //=> 'foo[]=1|2|3&bar=fluffy&baz[]=4' + ``` + + - `colon-list-separator`: Serialize arrays with parameter names that are explicitly marked with `:list`: + + ```js + import queryString from 'query-string'; + + queryString.stringify({foo: ['one', 'two']}, {arrayFormat: 'colon-list-separator'}); + //=> 'foo:list=one&foo:list=two' + ``` + + - `none`: Serialize arrays by using duplicate keys: + + ``` + import queryString from 'query-string'; + + queryString.stringify({foo: [1, 2, 3]}); + //=> 'foo=1&foo=2&foo=3' + ``` + */ + readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'bracket-separator' | 'colon-list-separator' | 'none'; + + /** + The character used to separate array elements when using `{arrayFormat: 'separator'}`. + + @default , + */ + readonly arrayFormatSeparator?: string; + + /** + Supports both `Function` as a custom sorting function or `false` to disable sorting. + + If omitted, keys are sorted using `Array#sort`, which means, converting them to strings and comparing strings in Unicode code point order. + + @default true + + @example + ``` + import queryString from 'query-string'; + + const order = ['c', 'a', 'b']; + + queryString.stringify({a: 1, b: 2, c: 3}, { + sort: (itemLeft, itemRight) => order.indexOf(itemLeft) - order.indexOf(itemRight) + }); + //=> 'c=3&a=1&b=2' + ``` + + @example + ``` + import queryString from 'query-string'; + + queryString.stringify({b: 1, c: 2, a: 3}, {sort: false}); + //=> 'b=1&c=2&a=3' + ``` + */ + readonly sort?: ((itemLeft: string, itemRight: string) => number) | false; + + /** + Skip keys with `null` as the value. + + Note that keys with `undefined` as the value are always skipped. + + @default false + + @example + ``` + import queryString from 'query-string'; + + queryString.stringify({a: 1, b: undefined, c: null, d: 4}, { + skipNull: true + }); + //=> 'a=1&d=4' + + queryString.stringify({a: undefined, b: null}, { + skipNull: true + }); + //=> '' + ``` + */ + readonly skipNull?: boolean; + + /** + Skip keys with an empty string as the value. + + @default false + + @example + ``` + import queryString from 'query-string'; + + queryString.stringify({a: 1, b: '', c: '', d: 4}, { + skipEmptyString: true + }); + //=> 'a=1&d=4' + ``` + + @example + ``` + import queryString from 'query-string'; + + queryString.stringify({a: '', b: ''}, { + skipEmptyString: true + }); + //=> '' + ``` + */ + readonly skipEmptyString?: boolean; +}; + +export type Stringifiable = string | boolean | number | bigint | null | undefined; // eslint-disable-line @typescript-eslint/ban-types + +export type StringifiableRecord = Record< +string, +Stringifiable | readonly Stringifiable[] +>; + +/** +Stringify an object into a query string and sort the keys. +*/ +export function stringify( + // TODO: Use the below instead when the following TS issues are fixed: + // - https://github.com/microsoft/TypeScript/issues/15300 + // - https://github.com/microsoft/TypeScript/issues/42021 + // Context: https://github.com/sindresorhus/query-string/issues/298 + // object: StringifiableRecord, + object: Record, + options?: StringifyOptions +): string; + +/** +Extract a query string from a URL that can be passed into `.parse()`. + +Note: This behaviour can be changed with the `skipNull` option. +*/ +export function extract(url: string): string; + +export type UrlObject = { + readonly url: string; + + /** + Overrides queries in the `url` property. + */ + readonly query?: StringifiableRecord; + + /** + Overrides the fragment identifier in the `url` property. + */ + readonly fragmentIdentifier?: string; +}; + +/** +Stringify an object into a URL with a query string and sorting the keys. The inverse of [`.parseUrl()`](https://github.com/sindresorhus/query-string#parseurlstring-options) + +Query items in the `query` property overrides queries in the `url` property. + +The `fragmentIdentifier` property overrides the fragment identifier in the `url` property. + +@example +``` +queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar'}}); +//=> 'https://foo.bar?foo=bar' + +queryString.stringifyUrl({url: 'https://foo.bar?foo=baz', query: {foo: 'bar'}}); +//=> 'https://foo.bar?foo=bar' + +queryString.stringifyUrl({ + url: 'https://foo.bar', + query: { + top: 'foo' + }, + fragmentIdentifier: 'bar' +}); +//=> 'https://foo.bar?top=foo#bar' +``` +*/ +export function stringifyUrl( + object: UrlObject, + options?: StringifyOptions +): string; + +/** +Pick query parameters from a URL. + +@param url - The URL containing the query parameters to pick. +@param keys - The names of the query parameters to keep. All other query parameters will be removed from the URL. +@param filter - A filter predicate that will be provided the name of each query parameter and its value. The `parseNumbers` and `parseBooleans` options also affect `value`. + +@returns The URL with the picked query parameters. + +@example +``` +queryString.pick('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?foo=1#hello' + +queryString.pick('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?bar=2#hello' +``` +*/ +export function pick( + url: string, + keys: readonly string[], + options?: ParseOptions & StringifyOptions +): string; +export function pick( + url: string, + filter: (key: string, value: string | boolean | number) => boolean, + options?: {parseBooleans: true; parseNumbers: true} & ParseOptions & StringifyOptions +): string; +export function pick( + url: string, + filter: (key: string, value: string | boolean) => boolean, + options?: {parseBooleans: true} & ParseOptions & StringifyOptions +): string; +export function pick( + url: string, + filter: (key: string, value: string | number) => boolean, + options?: {parseNumbers: true} & ParseOptions & StringifyOptions +): string; + +/** +Exclude query parameters from a URL. Like `.pick()` but reversed. + +@param url - The URL containing the query parameters to exclude. +@param keys - The names of the query parameters to remove. All other query parameters will remain in the URL. +@param filter - A filter predicate that will be provided the name of each query parameter and its value. The `parseNumbers` and `parseBooleans` options also affect `value`. + +@returns The URL without the excluded the query parameters. + +@example +``` +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?bar=2#hello' + +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?foo=1#hello' +``` +*/ +export function exclude( + url: string, + keys: readonly string[], + options?: ParseOptions & StringifyOptions +): string; +export function exclude( + url: string, + filter: (key: string, value: string | boolean | number) => boolean, + options?: {parseBooleans: true; parseNumbers: true} & ParseOptions & StringifyOptions +): string; +export function exclude( + url: string, + filter: (key: string, value: string | boolean) => boolean, + options?: {parseBooleans: true} & ParseOptions & StringifyOptions +): string; +export function exclude( + url: string, + filter: (key: string, value: string | number) => boolean, + options?: {parseNumbers: true} & ParseOptions & StringifyOptions +): string; diff --git a/base.js b/base.js new file mode 100644 index 00000000..ad9e7b1a --- /dev/null +++ b/base.js @@ -0,0 +1,540 @@ +import decodeComponent from 'decode-uri-component'; +import {includeKeys} from 'filter-obj'; +import splitOnFirst from 'split-on-first'; + +const isNullOrUndefined = value => value === null || value === undefined; + +// eslint-disable-next-line unicorn/prefer-code-point +const strictUriEncode = string => encodeURIComponent(string).replaceAll(/[!'()*]/g, x => `%${x.charCodeAt(0).toString(16).toUpperCase()}`); + +const encodeFragmentIdentifier = Symbol('encodeFragmentIdentifier'); + +function encoderForArrayFormat(options) { + switch (options.arrayFormat) { + case 'index': { + return key => (result, value) => { + const index = result.length; + + if ( + value === undefined + || (options.skipNull && value === null) + || (options.skipEmptyString && value === '') + ) { + return result; + } + + if (value === null) { + return [ + ...result, [encode(key, options), '[', index, ']'].join(''), + ]; + } + + return [ + ...result, + [encode(key, options), '[', encode(index, options), ']=', encode(value, options)].join(''), + ]; + }; + } + + case 'bracket': { + return key => (result, value) => { + if ( + value === undefined + || (options.skipNull && value === null) + || (options.skipEmptyString && value === '') + ) { + return result; + } + + if (value === null) { + return [ + ...result, + [encode(key, options), '[]'].join(''), + ]; + } + + return [ + ...result, + [encode(key, options), '[]=', encode(value, options)].join(''), + ]; + }; + } + + case 'colon-list-separator': { + return key => (result, value) => { + if ( + value === undefined + || (options.skipNull && value === null) + || (options.skipEmptyString && value === '') + ) { + return result; + } + + if (value === null) { + return [ + ...result, + [encode(key, options), ':list='].join(''), + ]; + } + + return [ + ...result, + [encode(key, options), ':list=', encode(value, options)].join(''), + ]; + }; + } + + case 'comma': + case 'separator': + case 'bracket-separator': { + const keyValueSeparator = options.arrayFormat === 'bracket-separator' + ? '[]=' + : '='; + + return key => (result, value) => { + if ( + value === undefined + || (options.skipNull && value === null) + || (options.skipEmptyString && value === '') + ) { + return result; + } + + // Translate null to an empty string so that it doesn't serialize as 'null' + value = value === null ? '' : value; + + if (result.length === 0) { + return [[encode(key, options), keyValueSeparator, encode(value, options)].join('')]; + } + + return [[result, encode(value, options)].join(options.arrayFormatSeparator)]; + }; + } + + default: { + return key => (result, value) => { + if ( + value === undefined + || (options.skipNull && value === null) + || (options.skipEmptyString && value === '') + ) { + return result; + } + + if (value === null) { + return [ + ...result, + encode(key, options), + ]; + } + + return [ + ...result, + [encode(key, options), '=', encode(value, options)].join(''), + ]; + }; + } + } +} + +function parserForArrayFormat(options) { + let result; + + switch (options.arrayFormat) { + case 'index': { + return (key, value, accumulator) => { + result = /\[(\d*)]$/.exec(key); + + key = key.replace(/\[\d*]$/, ''); + + if (!result) { + accumulator[key] = value; + return; + } + + if (accumulator[key] === undefined) { + accumulator[key] = {}; + } + + accumulator[key][result[1]] = value; + }; + } + + case 'bracket': { + return (key, value, accumulator) => { + result = /(\[])$/.exec(key); + key = key.replace(/\[]$/, ''); + + if (!result) { + accumulator[key] = value; + return; + } + + if (accumulator[key] === undefined) { + accumulator[key] = [value]; + return; + } + + accumulator[key] = [...accumulator[key], value]; + }; + } + + case 'colon-list-separator': { + return (key, value, accumulator) => { + result = /(:list)$/.exec(key); + key = key.replace(/:list$/, ''); + + if (!result) { + accumulator[key] = value; + return; + } + + if (accumulator[key] === undefined) { + accumulator[key] = [value]; + return; + } + + accumulator[key] = [...accumulator[key], value]; + }; + } + + case 'comma': + case 'separator': { + return (key, value, accumulator) => { + const isArray = typeof value === 'string' && value.includes(options.arrayFormatSeparator); + const isEncodedArray = (typeof value === 'string' && !isArray && decode(value, options).includes(options.arrayFormatSeparator)); + value = isEncodedArray ? decode(value, options) : value; + const newValue = isArray || isEncodedArray ? value.split(options.arrayFormatSeparator).map(item => decode(item, options)) : (value === null ? value : decode(value, options)); + accumulator[key] = newValue; + }; + } + + case 'bracket-separator': { + return (key, value, accumulator) => { + const isArray = /(\[])$/.test(key); + key = key.replace(/\[]$/, ''); + + if (!isArray) { + accumulator[key] = value ? decode(value, options) : value; + return; + } + + const arrayValue = value === null + ? [] + : decode(value, options).split(options.arrayFormatSeparator); + + if (accumulator[key] === undefined) { + accumulator[key] = arrayValue; + return; + } + + accumulator[key] = [...accumulator[key], ...arrayValue]; + }; + } + + default: { + return (key, value, accumulator) => { + if (accumulator[key] === undefined) { + accumulator[key] = value; + return; + } + + accumulator[key] = [...[accumulator[key]].flat(), value]; + }; + } + } +} + +function validateArrayFormatSeparator(value) { + if (typeof value !== 'string' || value.length !== 1) { + throw new TypeError('arrayFormatSeparator must be single character string'); + } +} + +function encode(value, options) { + if (options.encode) { + return options.strict ? strictUriEncode(value) : encodeURIComponent(value); + } + + return value; +} + +function decode(value, options) { + if (options.decode) { + return decodeComponent(value); + } + + return value; +} + +function keysSorter(input) { + if (Array.isArray(input)) { + return input.sort(); + } + + if (typeof input === 'object') { + return keysSorter(Object.keys(input)) + .sort((a, b) => Number(a) - Number(b)) + .map(key => input[key]); + } + + return input; +} + +function removeHash(input) { + const hashStart = input.indexOf('#'); + if (hashStart !== -1) { + input = input.slice(0, hashStart); + } + + return input; +} + +function getHash(url) { + let hash = ''; + const hashStart = url.indexOf('#'); + if (hashStart !== -1) { + hash = url.slice(hashStart); + } + + return hash; +} + +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() !== '')) { + return Number(value); + } + + return value; +} + +export function extract(input) { + input = removeHash(input); + const queryStart = input.indexOf('?'); + if (queryStart === -1) { + return ''; + } + + return input.slice(queryStart + 1); +} + +export function parse(query, options) { + options = { + decode: true, + sort: true, + arrayFormat: 'none', + arrayFormatSeparator: ',', + parseNumbers: false, + parseBooleans: false, + types: Object.create(null), + ...options, + }; + + validateArrayFormatSeparator(options.arrayFormatSeparator); + + const formatter = parserForArrayFormat(options); + + // Create an object with no prototype + const returnValue = Object.create(null); + + if (typeof query !== 'string') { + return returnValue; + } + + query = query.trim().replace(/^[?#&]/, ''); + + if (!query) { + return returnValue; + } + + for (const parameter of query.split('&')) { + if (parameter === '') { + continue; + } + + const parameter_ = options.decode ? parameter.replaceAll('+', ' ') : parameter; + + let [key, value] = splitOnFirst(parameter_, '='); + + if (key === undefined) { + key = parameter_; + } + + // Missing `=` should be `null`: + // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters + value = value === undefined ? null : (['comma', 'separator', 'bracket-separator'].includes(options.arrayFormat) ? value : decode(value, options)); + formatter(decode(key, options), value, returnValue); + } + + for (const [key, value] of Object.entries(returnValue)) { + if (typeof value === 'object' && value !== null && options.types[key] !== 'string') { + for (const [key2, value2] of Object.entries(value)) { + 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, options.types[key]); + } + } + + if (options.sort === false) { + return returnValue; + } + + // TODO: Remove the use of `reduce`. + // eslint-disable-next-line unicorn/no-array-reduce + return (options.sort === true ? Object.keys(returnValue).sort() : Object.keys(returnValue).sort(options.sort)).reduce((result, key) => { + const value = returnValue[key]; + result[key] = Boolean(value) && typeof value === 'object' && !Array.isArray(value) ? keysSorter(value) : value; + return result; + }, Object.create(null)); +} + +export function stringify(object, options) { + if (!object) { + return ''; + } + + options = { + encode: true, + strict: true, + arrayFormat: 'none', + arrayFormatSeparator: ',', + ...options, + }; + + validateArrayFormatSeparator(options.arrayFormatSeparator); + + const shouldFilter = key => ( + (options.skipNull && isNullOrUndefined(object[key])) + || (options.skipEmptyString && object[key] === '') + ); + + const formatter = encoderForArrayFormat(options); + + const objectCopy = {}; + + for (const [key, value] of Object.entries(object)) { + if (!shouldFilter(key)) { + objectCopy[key] = value; + } + } + + const keys = Object.keys(objectCopy); + + if (options.sort !== false) { + keys.sort(options.sort); + } + + return keys.map(key => { + const value = object[key]; + + if (value === undefined) { + return ''; + } + + if (value === null) { + return encode(key, options); + } + + if (Array.isArray(value)) { + if (value.length === 0 && options.arrayFormat === 'bracket-separator') { + return encode(key, options) + '[]'; + } + + return value + .reduce(formatter(key), []) + .join('&'); + } + + return encode(key, options) + '=' + encode(value, options); + }).filter(x => x.length > 0).join('&'); +} + +export function parseUrl(url, options) { + options = { + decode: true, + ...options, + }; + + let [url_, hash] = splitOnFirst(url, '#'); + + if (url_ === undefined) { + url_ = url; + } + + return { + url: url_?.split('?')?.[0] ?? '', + query: parse(extract(url), options), + ...(options && options.parseFragmentIdentifier && hash ? {fragmentIdentifier: decode(hash, options)} : {}), + }; +} + +export function stringifyUrl(object, options) { + options = { + encode: true, + strict: true, + [encodeFragmentIdentifier]: true, + ...options, + }; + + const url = removeHash(object.url).split('?')[0] || ''; + const queryFromUrl = extract(object.url); + + const query = { + ...parse(queryFromUrl, {sort: false}), + ...object.query, + }; + + let queryString = stringify(query, options); + queryString &&= `?${queryString}`; + + let hash = getHash(object.url); + if (typeof object.fragmentIdentifier === 'string') { + const urlObjectForFragmentEncode = new URL(url); + urlObjectForFragmentEncode.hash = object.fragmentIdentifier; + hash = options[encodeFragmentIdentifier] ? urlObjectForFragmentEncode.hash : `#${object.fragmentIdentifier}`; + } + + return `${url}${queryString}${hash}`; +} + +export function pick(input, filter, options) { + options = { + parseFragmentIdentifier: true, + [encodeFragmentIdentifier]: false, + ...options, + }; + + const {url, query, fragmentIdentifier} = parseUrl(input, options); + + return stringifyUrl({ + url, + query: includeKeys(query, filter), + fragmentIdentifier, + }, options); +} + +export function exclude(input, filter, options) { + const exclusionFilter = Array.isArray(filter) ? key => !filter.includes(key) : (key, value) => !filter(key, value); + + return pick(input, exclusionFilter, options); +} diff --git a/benchmark.js b/benchmark.js index af120ea7..674e6ef4 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,6 +1,5 @@ -'use strict'; -const Benchmark = require('benchmark'); -const queryString = require('.'); +import Benchmark from 'benchmark'; +import queryString from './index.js'; const {stringify, stringifyUrl} = queryString; const suite = new Benchmark.Suite(); @@ -13,21 +12,22 @@ const TEST_OBJECT = { published: true, symbols: 'πµ', chapters: [1, 2, 3], - none: null + none: null, }; const TEST_HOST = 'https://foo.bar/'; const TEST_STRING = stringify(TEST_OBJECT); const TEST_BRACKETS_STRING = stringify(TEST_OBJECT, {arrayFormat: 'bracket'}); const TEST_INDEX_STRING = stringify(TEST_OBJECT, {arrayFormat: 'index'}); const TEST_COMMA_STRING = stringify(TEST_OBJECT, {arrayFormat: 'comma'}); +const TEST_BRACKET_SEPARATOR_STRING = stringify(TEST_OBJECT, {arrayFormat: 'bracket-separator'}); const TEST_URL = stringifyUrl({url: TEST_HOST, query: TEST_OBJECT}); // Creates a test case and adds it to the suite const defineTestCase = (methodName, input, options) => { - const fn = queryString[methodName]; + const function_ = queryString[methodName]; const label = options ? ` (${stringify(options)})` : ''; - suite.add(methodName + label, () => fn(input, options || {})); + suite.add(methodName + label, () => function_(input, options || {})); }; // Define all test cases @@ -41,6 +41,7 @@ defineTestCase('parse', TEST_STRING, {decode: false}); defineTestCase('parse', TEST_BRACKETS_STRING, {arrayFormat: 'bracket'}); defineTestCase('parse', TEST_INDEX_STRING, {arrayFormat: 'index'}); defineTestCase('parse', TEST_COMMA_STRING, {arrayFormat: 'comma'}); +defineTestCase('parse', TEST_BRACKET_SEPARATOR_STRING, {arrayFormat: 'bracket-separator'}); // Stringify defineTestCase('stringify', TEST_OBJECT); @@ -51,6 +52,7 @@ defineTestCase('stringify', TEST_OBJECT, {skipEmptyString: true}); defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'bracket'}); defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'index'}); defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'comma'}); +defineTestCase('stringify', TEST_OBJECT, {arrayFormat: 'bracket-separator'}); // Extract defineTestCase('extract', TEST_URL); @@ -66,7 +68,7 @@ suite.on('cycle', event => { const {name, hz} = event.target; const opsPerSec = Math.round(hz).toLocaleString(); - console.log(name.padEnd(36, '_') + opsPerSec.padStart(12, '_') + ' ops/s'); + console.log(name.padEnd(46, '_') + opsPerSec.padStart(3, '_') + ' ops/s'); }); suite.run(); diff --git a/index.d.ts b/index.d.ts index 5db5cf2f..5385c476 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,416 +1,16 @@ -export interface ParseOptions { - /** - Decode the keys and values. URI components are decoded with [`decode-uri-component`](https://github.com/SamVerschueren/decode-uri-component). - - @default true - */ - readonly decode?: boolean; - - /** - @default 'none' - - - `bracket`: Parse arrays with bracket representation: - - ``` - import queryString = require('query-string'); - - queryString.parse('foo[]=1&foo[]=2&foo[]=3', {arrayFormat: 'bracket'}); - //=> {foo: ['1', '2', '3']} - ``` - - - `index`: Parse arrays with index representation: - - ``` - import queryString = require('query-string'); - - queryString.parse('foo[0]=1&foo[1]=2&foo[3]=3', {arrayFormat: 'index'}); - //=> {foo: ['1', '2', '3']} - ``` - - - `comma`: Parse arrays with elements separated by comma: - - ``` - import queryString = require('query-string'); - - queryString.parse('foo=1,2,3', {arrayFormat: 'comma'}); - //=> {foo: ['1', '2', '3']} - ``` - - - `separator`: Parse arrays with elements separated by a custom character: - - ``` - import queryString = require('query-string'); - - queryString.parse('foo=1|2|3', {arrayFormat: 'separator', arrayFormatSeparator: '|'}); - //=> {foo: ['1', '2', '3']} - ``` - - - `none`: Parse arrays with elements using duplicate keys: - - ``` - import queryString = require('query-string'); - - queryString.parse('foo=1&foo=2&foo=3'); - //=> {foo: ['1', '2', '3']} - ``` - */ - readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'none'; - - /** - The character used to separate array elements when using `{arrayFormat: 'separator'}`. - - @default , - */ - readonly arrayFormatSeparator?: string; - - /** - Supports both `Function` as a custom sorting function or `false` to disable sorting. - - If omitted, keys are sorted using `Array#sort`, which means, converting them to strings and comparing strings in Unicode code point order. - - @default true - - @example - ``` - import queryString = require('query-string'); - - const order = ['c', 'a', 'b']; - - queryString.parse('?a=one&b=two&c=three', { - sort: (itemLeft, itemRight) => order.indexOf(itemLeft) - order.indexOf(itemRight) - }); - //=> {c: 'three', a: 'one', b: 'two'} - ``` - - @example - ``` - import queryString = require('query-string'); - - queryString.parse('?a=one&c=three&b=two', {sort: false}); - //=> {a: 'one', c: 'three', b: 'two'} - ``` - */ - readonly sort?: ((itemLeft: string, itemRight: string) => number) | false; - - /** - Parse the value as a number type instead of string type if it's a number. - - @default false - - @example - ``` - import queryString = require('query-string'); - - queryString.parse('foo=1', {parseNumbers: true}); - //=> {foo: 1} - ``` - */ - readonly parseNumbers?: boolean; - - /** - Parse the value as a boolean type instead of string type if it's a boolean. - - @default false - - @example - ``` - import queryString = require('query-string'); - - queryString.parse('foo=true', {parseBooleans: true}); - //=> {foo: true} - ``` - */ - readonly parseBooleans?: boolean; - - /** - Parse the fragment identifier from the URL and add it to result object. - - @default false - - @example - ``` - import queryString = require('query-string'); - - queryString.parseUrl('https://foo.bar?foo=bar#xyz', {parseFragmentIdentifier: true}); - //=> {url: 'https://foo.bar', query: {foo: 'bar'}, fragmentIdentifier: 'xyz'} - ``` - */ - readonly parseFragmentIdentifier?: boolean; -} - -export interface ParsedQuery { - [key: string]: T | T[] | null; -} - -/** -Parse a query string into an object. Leading `?` or `#` are ignored, so you can pass `location.search` or `location.hash` directly. - -The returned object is created with [`Object.create(null)`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create) and thus does not have a `prototype`. - -@param query - The query string to parse. -*/ -export function parse(query: string, options: {parseBooleans: true, parseNumbers: true} & ParseOptions): ParsedQuery; -export function parse(query: string, options: {parseBooleans: true} & ParseOptions): ParsedQuery; -export function parse(query: string, options: {parseNumbers: true} & ParseOptions): ParsedQuery; -export function parse(query: string, options?: ParseOptions): ParsedQuery; - -export interface ParsedUrl { - readonly url: string; - readonly query: ParsedQuery; - - /** - The fragment identifier of the URL. - - Present when the `parseFragmentIdentifier` option is `true`. - */ - readonly fragmentIdentifier?: string; -} - -/** -Extract the URL and the query string as an object. - -If the `parseFragmentIdentifier` option is `true`, the object will also contain a `fragmentIdentifier` property. - -@param url - The URL to parse. - -@example -``` -import queryString = require('query-string'); - -queryString.parseUrl('https://foo.bar?foo=bar'); -//=> {url: 'https://foo.bar', query: {foo: 'bar'}} - -queryString.parseUrl('https://foo.bar?foo=bar#xyz', {parseFragmentIdentifier: true}); -//=> {url: 'https://foo.bar', query: {foo: 'bar'}, fragmentIdentifier: 'xyz'} -``` -*/ -export function parseUrl(url: string, options?: ParseOptions): ParsedUrl; - -export interface StringifyOptions { - /** - Strictly encode URI components with [`strict-uri-encode`](https://github.com/kevva/strict-uri-encode). It uses [`encodeURIComponent`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) if set to `false`. You probably [don't care](https://github.com/sindresorhus/query-string/issues/42) about this option. - - @default true - */ - readonly strict?: boolean; - - /** - [URL encode](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) the keys and values. - - @default true - */ - readonly encode?: boolean; - - /** - @default 'none' - - - `bracket`: Serialize arrays using bracket representation: - - ``` - import queryString = require('query-string'); - - queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket'}); - //=> 'foo[]=1&foo[]=2&foo[]=3' - ``` - - - `index`: Serialize arrays using index representation: - - ``` - import queryString = require('query-string'); - - queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'index'}); - //=> 'foo[0]=1&foo[1]=2&foo[2]=3' - ``` - - - `comma`: Serialize arrays by separating elements with comma: - - ``` - import queryString = require('query-string'); - - queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'comma'}); - //=> 'foo=1,2,3' - - queryString.stringify({foo: [1, null, '']}, {arrayFormat: 'comma'}); - //=> 'foo=1,,' - // Note that typing information for null values is lost - // and `.parse('foo=1,,')` would return `{foo: [1, '', '']}`. - ``` - - - `separator`: Serialize arrays by separating elements with character: - - ``` - import queryString = require('query-string'); - - queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'separator', arrayFormatSeparator: '|'}); - //=> 'foo=1|2|3' - ``` - - - `none`: Serialize arrays by using duplicate keys: - - ``` - import queryString = require('query-string'); - - queryString.stringify({foo: [1, 2, 3]}); - //=> 'foo=1&foo=2&foo=3' - ``` - */ - readonly arrayFormat?: 'bracket' | 'index' | 'comma' | 'separator' | 'none'; - - /** - The character used to separate array elements when using `{arrayFormat: 'separator'}`. - - @default , - */ - readonly arrayFormatSeparator?: string; - - /** - Supports both `Function` as a custom sorting function or `false` to disable sorting. - - If omitted, keys are sorted using `Array#sort`, which means, converting them to strings and comparing strings in Unicode code point order. - - @default true - - @example - ``` - import queryString = require('query-string'); - - const order = ['c', 'a', 'b']; - - queryString.stringify({a: 1, b: 2, c: 3}, { - sort: (itemLeft, itemRight) => order.indexOf(itemLeft) - order.indexOf(itemRight) - }); - //=> 'c=3&a=1&b=2' - ``` - - @example - ``` - import queryString = require('query-string'); - - queryString.stringify({b: 1, c: 2, a: 3}, {sort: false}); - //=> 'b=1&c=2&a=3' - ``` - */ - readonly sort?: ((itemLeft: string, itemRight: string) => number) | false; - - /** - Skip keys with `null` as the value. - - Note that keys with `undefined` as the value are always skipped. - - @default false - - @example - ``` - import queryString = require('query-string'); - - queryString.stringify({a: 1, b: undefined, c: null, d: 4}, { - skipNull: true - }); - //=> 'a=1&d=4' - - queryString.stringify({a: undefined, b: null}, { - skipNull: true - }); - //=> '' - ``` - */ - readonly skipNull?: boolean; - - /** - Skip keys with an empty string as the value. - - @default false - - @example - ``` - import queryString = require('query-string'); - - queryString.stringify({a: 1, b: '', c: '', d: 4}, { - skipEmptyString: true - }); - //=> 'a=1&d=4' - ``` - - @example - ``` - import queryString = require('query-string'); - - queryString.stringify({a: '', b: ''}, { - skipEmptyString: true - }); - //=> '' - ``` - */ - readonly skipEmptyString?: boolean; -} - -export type Stringifiable = string | boolean | number | null | undefined; - -export type StringifiableRecord = Record< - string, - Stringifiable | readonly Stringifiable[] ->; - -/** -Stringify an object into a query string and sort the keys. -*/ -export function stringify( - // TODO: Use the below instead when the following TS issues are fixed: - // - https://github.com/microsoft/TypeScript/issues/15300 - // - https://github.com/microsoft/TypeScript/issues/42021 - // Context: https://github.com/sindresorhus/query-string/issues/298 - // object: StringifiableRecord, - object: Record, - options?: StringifyOptions -): string; - -/** -Extract a query string from a URL that can be passed into `.parse()`. - -Note: This behaviour can be changed with the `skipNull` option. -*/ -export function extract(url: string): string; - -export interface UrlObject { - readonly url: string; - - /** - Overrides queries in the `url` property. - */ - readonly query: StringifiableRecord; - - /** - Overrides the fragment identifier in the `url` property. - */ - readonly fragmentIdentifier?: string; -} - -/** -Stringify an object into a URL with a query string and sorting the keys. The inverse of [`.parseUrl()`](https://github.com/sindresorhus/query-string#parseurlstring-options) - -Query items in the `query` property overrides queries in the `url` property. - -The `fragmentIdentifier` property overrides the fragment identifier in the `url` property. - -@example -``` -queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar'}}); -//=> 'https://foo.bar?foo=bar' - -queryString.stringifyUrl({url: 'https://foo.bar?foo=baz', query: {foo: 'bar'}}); -//=> 'https://foo.bar?foo=bar' - -queryString.stringifyUrl({ - url: 'https://foo.bar', - query: { - top: 'foo' - }, - fragmentIdentifier: 'bar' -}); -//=> 'https://foo.bar?top=foo#bar' -``` -*/ -export function stringifyUrl( - object: UrlObject, - options?: StringifyOptions -): string; +/// export * as default from './base.js'; + +// Workaround for TS missing feature. +import * as queryString from './base.js'; + +export default queryString; + +export { + type ParseOptions, + type ParsedQuery, + type ParsedUrl, + type StringifyOptions, + type Stringifiable, + type StringifiableRecord, + type UrlObject, +} from './base.js'; diff --git a/index.js b/index.js index ed89dd1e..02fe2b67 100644 --- a/index.js +++ b/index.js @@ -1,388 +1,3 @@ -'use strict'; -const strictUriEncode = require('strict-uri-encode'); -const decodeComponent = require('decode-uri-component'); -const splitOnFirst = require('split-on-first'); +import * as queryString from './base.js'; +export default queryString; -const isNullOrUndefined = value => value === null || value === undefined; - -function encoderForArrayFormat(options) { - switch (options.arrayFormat) { - case 'index': - return key => (result, value) => { - const index = result.length; - - if ( - value === undefined || - (options.skipNull && value === null) || - (options.skipEmptyString && value === '') - ) { - return result; - } - - if (value === null) { - return [...result, [encode(key, options), '[', index, ']'].join('')]; - } - - return [ - ...result, - [encode(key, options), '[', encode(index, options), ']=', encode(value, options)].join('') - ]; - }; - - case 'bracket': - return key => (result, value) => { - if ( - value === undefined || - (options.skipNull && value === null) || - (options.skipEmptyString && value === '') - ) { - return result; - } - - if (value === null) { - return [...result, [encode(key, options), '[]'].join('')]; - } - - return [...result, [encode(key, options), '[]=', encode(value, options)].join('')]; - }; - - case 'comma': - case 'separator': - return key => (result, value) => { - if ( - value === undefined || - (options.skipNull && value === null) || - (options.skipEmptyString && value === '') - ) { - return result; - } - - if (result.length === 0) { - return [[encode(key, options), '=', encode(value === null ? '' : value, options)].join('')]; - } - - if (value === null || value === '') { - return [[result, ''].join(options.arrayFormatSeparator)]; - } - - return [[result, encode(value, options)].join(options.arrayFormatSeparator)]; - }; - - default: - return key => (result, value) => { - if ( - value === undefined || - (options.skipNull && value === null) || - (options.skipEmptyString && value === '') - ) { - return result; - } - - if (value === null) { - return [...result, encode(key, options)]; - } - - return [...result, [encode(key, options), '=', encode(value, options)].join('')]; - }; - } -} - -function parserForArrayFormat(options) { - let result; - - switch (options.arrayFormat) { - case 'index': - return (key, value, accumulator) => { - result = /\[(\d*)\]$/.exec(key); - - key = key.replace(/\[\d*\]$/, ''); - - if (!result) { - accumulator[key] = value; - return; - } - - if (accumulator[key] === undefined) { - accumulator[key] = {}; - } - - accumulator[key][result[1]] = value; - }; - - case 'bracket': - return (key, value, accumulator) => { - result = /(\[\])$/.exec(key); - key = key.replace(/\[\]$/, ''); - - if (!result) { - accumulator[key] = value; - return; - } - - if (accumulator[key] === undefined) { - accumulator[key] = [value]; - return; - } - - accumulator[key] = [].concat(accumulator[key], value); - }; - - case 'comma': - case 'separator': - return (key, value, accumulator) => { - const isArray = typeof value === 'string' && value.includes(options.arrayFormatSeparator); - const isEncodedArray = (typeof value === 'string' && !isArray && decode(value, options).includes(options.arrayFormatSeparator)); - value = isEncodedArray ? decode(value, options) : value; - const newValue = isArray || isEncodedArray ? value.split(options.arrayFormatSeparator).map(item => decode(item, options)) : value === null ? value : decode(value, options); - accumulator[key] = newValue; - }; - - default: - return (key, value, accumulator) => { - if (accumulator[key] === undefined) { - accumulator[key] = value; - return; - } - - accumulator[key] = [].concat(accumulator[key], value); - }; - } -} - -function validateArrayFormatSeparator(value) { - if (typeof value !== 'string' || value.length !== 1) { - throw new TypeError('arrayFormatSeparator must be single character string'); - } -} - -function encode(value, options) { - if (options.encode) { - return options.strict ? strictUriEncode(value) : encodeURIComponent(value); - } - - return value; -} - -function decode(value, options) { - if (options.decode) { - return decodeComponent(value); - } - - return value; -} - -function keysSorter(input) { - if (Array.isArray(input)) { - return input.sort(); - } - - if (typeof input === 'object') { - return keysSorter(Object.keys(input)) - .sort((a, b) => Number(a) - Number(b)) - .map(key => input[key]); - } - - return input; -} - -function removeHash(input) { - const hashStart = input.indexOf('#'); - if (hashStart !== -1) { - input = input.slice(0, hashStart); - } - - return input; -} - -function getHash(url) { - let hash = ''; - const hashStart = url.indexOf('#'); - if (hashStart !== -1) { - hash = url.slice(hashStart); - } - - return hash; -} - -function extract(input) { - input = removeHash(input); - const queryStart = input.indexOf('?'); - if (queryStart === -1) { - return ''; - } - - return input.slice(queryStart + 1); -} - -function parseValue(value, options) { - 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 value; -} - -function parse(query, options) { - options = Object.assign({ - decode: true, - sort: true, - arrayFormat: 'none', - arrayFormatSeparator: ',', - parseNumbers: false, - parseBooleans: false - }, options); - - validateArrayFormatSeparator(options.arrayFormatSeparator); - - const formatter = parserForArrayFormat(options); - - // Create an object with no prototype - const ret = Object.create(null); - - if (typeof query !== 'string') { - return ret; - } - - query = query.trim().replace(/^[?#&]/, ''); - - if (!query) { - return ret; - } - - for (const param of query.split('&')) { - let [key, value] = splitOnFirst(options.decode ? param.replace(/\+/g, ' ') : param, '='); - - // Missing `=` should be `null`: - // http://w3.org/TR/2012/WD-url-20120524/#collect-url-parameters - value = value === undefined ? null : ['comma', 'separator'].includes(options.arrayFormat) ? value : decode(value, options); - formatter(decode(key, options), value, ret); - } - - for (const key of Object.keys(ret)) { - const value = ret[key]; - if (typeof value === 'object' && value !== null) { - for (const k of Object.keys(value)) { - value[k] = parseValue(value[k], options); - } - } else { - ret[key] = parseValue(value, options); - } - } - - if (options.sort === false) { - return ret; - } - - return (options.sort === true ? Object.keys(ret).sort() : Object.keys(ret).sort(options.sort)).reduce((result, key) => { - const value = ret[key]; - if (Boolean(value) && typeof value === 'object' && !Array.isArray(value)) { - // Sort object keys, not values - result[key] = keysSorter(value); - } else { - result[key] = value; - } - - return result; - }, Object.create(null)); -} - -exports.extract = extract; -exports.parse = parse; - -exports.stringify = (object, options) => { - if (!object) { - return ''; - } - - options = Object.assign({ - encode: true, - strict: true, - arrayFormat: 'none', - arrayFormatSeparator: ',' - }, options); - - validateArrayFormatSeparator(options.arrayFormatSeparator); - - const shouldFilter = key => ( - (options.skipNull && isNullOrUndefined(object[key])) || - (options.skipEmptyString && object[key] === '') - ); - - const formatter = encoderForArrayFormat(options); - - const objectCopy = {}; - - for (const key of Object.keys(object)) { - if (!shouldFilter(key)) { - objectCopy[key] = object[key]; - } - } - - const keys = Object.keys(objectCopy); - - if (options.sort !== false) { - keys.sort(options.sort); - } - - return keys.map(key => { - const value = object[key]; - - if (value === undefined) { - return ''; - } - - if (value === null) { - return encode(key, options); - } - - if (Array.isArray(value)) { - return value - .reduce(formatter(key), []) - .join('&'); - } - - return encode(key, options) + '=' + encode(value, options); - }).filter(x => x.length > 0).join('&'); -}; - -exports.parseUrl = (url, options) => { - options = Object.assign({ - decode: true - }, options); - - const [url_, hash] = splitOnFirst(url, '#'); - - return Object.assign( - { - url: url_.split('?')[0] || '', - query: parse(extract(url), options) - }, - options && options.parseFragmentIdentifier && hash ? {fragmentIdentifier: decode(hash, options)} : {} - ); -}; - -exports.stringifyUrl = (object, options) => { - options = Object.assign({ - encode: true, - strict: true - }, options); - - const url = removeHash(object.url).split('?')[0] || ''; - const queryFromUrl = exports.extract(object.url); - const parsedQueryFromUrl = exports.parse(queryFromUrl, {sort: false}); - - const query = Object.assign(parsedQueryFromUrl, object.query); - let queryString = exports.stringify(query, options); - if (queryString) { - queryString = `?${queryString}`; - } - - let hash = getHash(object.url); - if (object.fragmentIdentifier) { - hash = `#${encode(object.fragmentIdentifier, options)}`; - } - - return `${url}${queryString}${hash}`; -}; diff --git a/index.test-d.ts b/index.test-d.ts index 2aab3fe1..bcb97025 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,5 +1,5 @@ import {expectType} from 'tsd'; -import * as queryString from '.'; +import queryString from './index.js'; // Stringify expectType( @@ -9,14 +9,14 @@ expectType( num: 123, numArray: [456], bool: true, - boolArray: [false] - }) + boolArray: [false], + }), ); expectType(queryString.stringify({foo: 'bar'}, {strict: false})); expectType(queryString.stringify({foo: 'bar'}, {encode: false})); expectType( - queryString.stringify({foo: 'bar'}, {arrayFormat: 'bracket'}) + queryString.stringify({foo: 'bar'}, {arrayFormat: 'bracket'}), ); expectType(queryString.stringify({foo: 'bar'}, {arrayFormat: 'index'})); expectType(queryString.stringify({foo: 'bar'}, {arrayFormat: 'none'})); @@ -30,18 +30,18 @@ expectType( {foo: 'bar'}, { sort: (itemLeft, itemRight) => - order.indexOf(itemLeft) - order.indexOf(itemRight) - } - ) + order.indexOf(itemLeft) - order.indexOf(itemRight), + }, + ), ); // Ensure it accepts an `interface`. -interface Query { +type Query = { foo: string; -} +}; const query: Query = { - foo: 'bar' + foo: 'bar', }; queryString.stringify(query); @@ -50,56 +50,56 @@ queryString.stringify(query); expectType(queryString.parse('?foo=bar')); expectType( - queryString.parse('?foo=bar', {decode: false}) + queryString.parse('?foo=bar', {decode: false}), ); expectType( - queryString.parse('?foo=bar', {arrayFormat: 'bracket'}) + queryString.parse('?foo=bar', {arrayFormat: 'bracket'}), ); expectType( - queryString.parse('?foo=bar', {arrayFormat: 'index'}) + queryString.parse('?foo=bar', {arrayFormat: 'index'}), ); expectType( - queryString.parse('?foo=bar', {arrayFormat: 'none'}) + queryString.parse('?foo=bar', {arrayFormat: 'none'}), ); expectType( - queryString.parse('?foo=bar', {arrayFormat: 'comma'}) + queryString.parse('?foo=bar', {arrayFormat: 'comma'}), ); expectType>( - queryString.parse('?foo=1', {parseNumbers: true}) + queryString.parse('?foo=1', {parseNumbers: true}), ); expectType>( - queryString.parse('?foo=true', {parseBooleans: true}) + queryString.parse('?foo=true', {parseBooleans: true}), ); expectType>( - queryString.parse('?foo=true', {parseBooleans: true, parseNumbers: true}) + queryString.parse('?foo=true', {parseBooleans: true, parseNumbers: true}), ); // Parse URL expectType(queryString.parseUrl('?foo=bar')); expectType( - queryString.parseUrl('?foo=bar', {decode: false}) + queryString.parseUrl('?foo=bar', {decode: false}), ); expectType( - queryString.parseUrl('?foo=bar', {arrayFormat: 'bracket'}) + queryString.parseUrl('?foo=bar', {arrayFormat: 'bracket'}), ); expectType( - queryString.parseUrl('?foo=bar', {arrayFormat: 'index'}) + queryString.parseUrl('?foo=bar', {arrayFormat: 'index'}), ); expectType( - queryString.parseUrl('?foo=bar', {arrayFormat: 'none'}) + queryString.parseUrl('?foo=bar', {arrayFormat: 'none'}), ); expectType( - queryString.parseUrl('?foo=bar', {arrayFormat: 'comma'}) + queryString.parseUrl('?foo=bar', {arrayFormat: 'comma'}), ); expectType( - queryString.parseUrl('?foo=1', {parseNumbers: true}) + queryString.parseUrl('?foo=1', {parseNumbers: true}), ); expectType( - queryString.parseUrl('?foo=true', {parseBooleans: true}) + queryString.parseUrl('?foo=true', {parseBooleans: true}), ); expectType( - queryString.parseUrl('?foo=true#bar', {parseFragmentIdentifier: true}) + queryString.parseUrl('?foo=true#bar', {parseFragmentIdentifier: true}), ); // Extract @@ -114,13 +114,19 @@ expectType( 1, true, null, - undefined + undefined, ], fooNumber: 1, fooBoolean: true, fooNull: null, fooUndefined: undefined, - fooString: 'hi' + fooString: 'hi', }, - }) + }), ); + +// Pick +expectType(queryString.pick('http://foo.bar/?abc=def&hij=klm', ['abc'])); + +// Exclude +expectType(queryString.exclude('http://foo.bar/?abc=def&hij=klm', ['abc'])); diff --git a/license b/license index e464bf78..fa7ceba3 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ MIT License -Copyright (c) Sindre Sorhus (http://sindresorhus.com) +Copyright (c) Sindre Sorhus (https://sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/package.json b/package.json index df8a1f25..4023082d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "query-string", - "version": "6.13.8", + "version": "9.1.1", "description": "Parse and stringify URL query strings", "license": "MIT", "repository": "sindresorhus/query-string", @@ -10,8 +10,14 @@ "email": "sindresorhus@gmail.com", "url": "https://sindresorhus.com" }, + "type": "module", + "exports": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "sideEffects": false, "engines": { - "node": ">=6" + "node": ">=18" }, "scripts": { "benchmark": "node benchmark.js", @@ -19,7 +25,9 @@ }, "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "base.js", + "base.d.ts" ], "keywords": [ "browser", @@ -34,19 +42,25 @@ "stringify", "encode", "decode", - "searchparams" + "searchparams", + "filter" ], "dependencies": { - "decode-uri-component": "^0.2.0", - "split-on-first": "^1.0.0", - "strict-uri-encode": "^2.0.0" + "decode-uri-component": "^0.4.1", + "filter-obj": "^5.1.0", + "split-on-first": "^3.0.0" }, "devDependencies": { - "ava": "^1.4.1", + "ava": "^6.1.1", "benchmark": "^2.1.4", - "deep-equal": "^1.0.1", - "fast-check": "^1.5.0", - "tsd": "^0.7.3", - "xo": "^0.24.0" + "deep-equal": "^2.2.3", + "fast-check": "^3.15.1", + "tsd": "^0.30.7", + "xo": "^0.57.0" + }, + "tsd": { + "compilerOptions": { + "module": "node16" + } } } diff --git a/readme.md b/readme.md index 7997b5dd..a53dfdec 100644 --- a/readme.md +++ b/readme.md @@ -16,8 +16,8 @@ Special thanks to:

- - + +

@@ -28,16 +28,19 @@ ## Install +```sh +npm install query-string ``` -$ npm install query-string -``` -This module targets Node.js 6 or later and the latest version of Chrome, Firefox, and Safari. If you want support for older browsers, or, if your project is using create-react-app v1, use version 5: `npm install query-string@5`. +> [!WARNING] +> Remember the hyphen! Do not install the deprecated [`querystring`](https://github.com/Gozala/querystring) package! + +For browser usage, this package targets the latest version of Chrome, Firefox, and Safari. ## Usage ```js -const queryString = require('query-string'); +import queryString from 'query-string'; console.log(location.search); //=> '?foo=bar' @@ -92,7 +95,7 @@ Default: `'none'` - `'bracket'`: Parse arrays with bracket representation: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo[]=1&foo[]=2&foo[]=3', {arrayFormat: 'bracket'}); //=> {foo: ['1', '2', '3']} @@ -101,7 +104,7 @@ queryString.parse('foo[]=1&foo[]=2&foo[]=3', {arrayFormat: 'bracket'}); - `'index'`: Parse arrays with index representation: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo[0]=1&foo[1]=2&foo[3]=3', {arrayFormat: 'index'}); //=> {foo: ['1', '2', '3']} @@ -110,7 +113,7 @@ queryString.parse('foo[0]=1&foo[1]=2&foo[3]=3', {arrayFormat: 'index'}); - `'comma'`: Parse arrays with elements separated by comma: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo=1,2,3', {arrayFormat: 'comma'}); //=> {foo: ['1', '2', '3']} @@ -119,16 +122,49 @@ queryString.parse('foo=1,2,3', {arrayFormat: 'comma'}); - `'separator'`: Parse arrays with elements separated by a custom character: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo=1|2|3', {arrayFormat: 'separator', arrayFormatSeparator: '|'}); //=> {foo: ['1', '2', '3']} ``` +- `'bracket-separator'`: Parse arrays (that are explicitly marked with brackets) with elements separated by a custom character: + +```js +import queryString from 'query-string'; + +queryString.parse('foo[]', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> {foo: []} + +queryString.parse('foo[]=', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> {foo: ['']} + +queryString.parse('foo[]=1', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> {foo: ['1']} + +queryString.parse('foo[]=1|2|3', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> {foo: ['1', '2', '3']} + +queryString.parse('foo[]=1||3|||6', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> {foo: ['1', '', 3, '', '', '6']} + +queryString.parse('foo[]=1|2|3&bar=fluffy&baz[]=4', {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> {foo: ['1', '2', '3'], bar: 'fluffy', baz:['4']} +``` + +- `'colon-list-separator'`: Parse arrays with parameter names that are explicitly marked with `:list`: + +```js +import queryString from 'query-string'; + +queryString.parse('foo:list=one&foo:list=two', {arrayFormat: 'colon-list-separator'}); +//=> {foo: ['one', 'two']} +``` + - `'none'`: Parse arrays with elements using duplicate keys: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo=1&foo=2&foo=3'); //=> {foo: ['1', '2', '3']} @@ -154,7 +190,7 @@ Type: `boolean`\ Default: `false` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo=1', {parseNumbers: true}); //=> {foo: 1} @@ -168,7 +204,7 @@ Type: `boolean`\ Default: `false` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('foo=true', {parseBooleans: true}); //=> {foo: true} @@ -176,6 +212,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. @@ -189,7 +333,7 @@ Type: `object` Type: `boolean`\ Default: `true` -Strictly encode URI components with [strict-uri-encode](https://github.com/kevva/strict-uri-encode). It uses [encodeURIComponent](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) if set to false. You probably [don't care](https://github.com/sindresorhus/query-string/issues/42) about this option. +Strictly encode URI components. It uses [encodeURIComponent](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) if set to false. You probably [don't care](https://github.com/sindresorhus/query-string/issues/42) about this option. ##### encode @@ -206,7 +350,7 @@ Default: `'none'` - `'bracket'`: Serialize arrays using bracket representation: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket'}); //=> 'foo[]=1&foo[]=2&foo[]=3' @@ -215,7 +359,7 @@ queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket'}); - `'index'`: Serialize arrays using index representation: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'index'}); //=> 'foo[0]=1&foo[1]=2&foo[2]=3' @@ -224,7 +368,7 @@ queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'index'}); - `'comma'`: Serialize arrays by separating elements with comma: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'comma'}); //=> 'foo=1,2,3' @@ -235,10 +379,55 @@ queryString.stringify({foo: [1, null, '']}, {arrayFormat: 'comma'}); // and `.parse('foo=1,,')` would return `{foo: [1, '', '']}`. ``` +- `'separator'`: Serialize arrays by separating elements with a custom character: + +```js +import queryString from 'query-string'; + +queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'separator', arrayFormatSeparator: '|'}); +//=> 'foo=1|2|3' +``` + +- `'bracket-separator'`: Serialize arrays by explicitly post-fixing array names with brackets and separating elements with a custom character: + +```js +import queryString from 'query-string'; + +queryString.stringify({foo: []}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> 'foo[]' + +queryString.stringify({foo: ['']}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> 'foo[]=' + +queryString.stringify({foo: [1]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> 'foo[]=1' + +queryString.stringify({foo: [1, 2, 3]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> 'foo[]=1|2|3' + +queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> 'foo[]=1||3|||6' + +queryString.stringify({foo: [1, '', 3, null, null, 6]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|', skipNull: true}); +//=> 'foo[]=1||3|6' + +queryString.stringify({foo: [1, 2, 3], bar: 'fluffy', baz: [4]}, {arrayFormat: 'bracket-separator', arrayFormatSeparator: '|'}); +//=> 'foo[]=1|2|3&bar=fluffy&baz[]=4' +``` + +- `'colon-list-separator'`: Serialize arrays with parameter names that are explicitly marked with `:list`: + +```js +import queryString from 'query-string'; + +queryString.stringify({foo: ['one', 'two']}, {arrayFormat: 'colon-list-separator'}); +//=> 'foo:list=one&foo:list=two' +``` + - `'none'`: Serialize arrays by using duplicate keys: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({foo: [1, 2, 3]}); //=> 'foo=1&foo=2&foo=3' @@ -258,7 +447,7 @@ Type: `Function | boolean` Supports both `Function` as a custom sorting function or `false` to disable sorting. ```js -const queryString = require('query-string'); +import queryString from 'query-string'; const order = ['c', 'a', 'b']; @@ -269,7 +458,7 @@ queryString.stringify({a: 1, b: 2, c: 3}, { ``` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({b: 1, c: 2, a: 3}, {sort: false}); //=> 'b=1&c=2&a=3' @@ -287,7 +476,7 @@ Type: `boolean`\ Default: `false` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({a: 1, b: undefined, c: null, d: 4}, { skipNull: true @@ -296,7 +485,7 @@ queryString.stringify({a: 1, b: undefined, c: null, d: 4}, { ``` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({a: undefined, b: null}, { skipNull: true @@ -312,7 +501,7 @@ Type: `boolean`\ Default: `false` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({a: 1, b: '', c: '', d: 4}, { skipEmptyString: true @@ -321,7 +510,7 @@ queryString.stringify({a: 1, b: '', c: '', d: 4}, { ``` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({a: '', b: ''}, { skipEmptyString: true @@ -344,7 +533,7 @@ Returns an object with a `url` and `query` property. If the `parseFragmentIdentifier` option is `true`, the object will also contain a `fragmentIdentifier` property. ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parseUrl('https://foo.bar?foo=bar'); //=> {url: 'https://foo.bar', query: {foo: 'bar'}} @@ -369,7 +558,7 @@ Type: `boolean`\ Default: `false` ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parseUrl('https://foo.bar?foo=bar#xyz', {parseFragmentIdentifier: true}); //=> {url: 'https://foo.bar', query: {foo: 'bar'}, fragmentIdentifier: 'xyz'} @@ -420,6 +609,64 @@ Type: `object` Query items to add to the URL. +### .pick(url, keys, options?) +### .pick(url, filter, options?) + +Pick query parameters from a URL. + +Returns a string with the new URL. + +```js +import queryString from 'query-string'; + +queryString.pick('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?foo=1#hello' + +queryString.pick('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?bar=2#hello' +``` + +### .exclude(url, keys, options?) +### .exclude(url, filter, options?) + +Exclude query parameters from a URL. + +Returns a string with the new URL. + +```js +import queryString from 'query-string'; + +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', ['foo']); +//=> 'https://foo.bar?bar=2#hello' + +queryString.exclude('https://foo.bar?foo=1&bar=2#hello', (name, value) => value === 2, {parseNumbers: true}); +//=> 'https://foo.bar?foo=1#hello' +``` + +#### url + +Type: `string` + +The URL containing the query parameters to filter. + +#### keys + +Type: `string[]` + +The names of the query parameters to filter based on the function used. + +#### filter + +Type: `(key, value) => boolean` + +A filter predicate that will be provided the name of each query parameter and its value. The `parseNumbers` and `parseBooleans` options also affect `value`. + +#### options + +Type: `object` + +[Parse options](#options) and [stringify options](#options-1). + ## Nesting This module intentionally doesn't support nesting as it's not spec'd and varies between implementations, which causes a lot of [edge cases](https://github.com/visionmedia/node-querystring/issues). @@ -427,7 +674,7 @@ This module intentionally doesn't support nesting as it's not spec'd and varies You're much better off just converting the object to a JSON string: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({ foo: 'bar', @@ -441,7 +688,7 @@ queryString.stringify({ However, there is support for multiple instances of the same key: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.parse('likes=cake&name=bob&likes=icecream'); //=> {likes: ['cake', 'icecream'], name: 'bob'} @@ -455,7 +702,7 @@ queryString.stringify({color: ['taupe', 'chartreuse'], id: '515'}); Sometimes you want to unset a key, or maybe just make it present without assigning a value to it. Here is how falsy values are stringified: ```js -const queryString = require('query-string'); +import queryString from 'query-string'; queryString.stringify({foo: false}); //=> 'foo=false' @@ -467,8 +714,8 @@ queryString.stringify({foo: undefined}); //=> '' ``` -## query-string for enterprise +## FAQ -Available as part of the Tidelift Subscription. +### Why is it parsing `+` as a space? -The maintainers of query-string and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/npm-query-string?utm_source=npm-query-string&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) +See [this answer](https://github.com/sindresorhus/query-string/issues/305). diff --git a/test/exclude.js b/test/exclude.js new file mode 100644 index 00000000..d38d8e4d --- /dev/null +++ b/test/exclude.js @@ -0,0 +1,21 @@ +import test from 'ava'; +import queryString from '../index.js'; + +test('excludes elements in a URL with a filter array', t => { + t.is(queryString.exclude('http://example.com/?a=1&b=2&c=3#a', ['c']), 'http://example.com/?a=1&b=2#a'); +}); + +test('excludes elements in a URL with a filter predicate', t => { + t.is(queryString.exclude('http://example.com/?a=1&b=2&c=3#a', (name, value) => { + t.is(typeof name, 'string'); + t.is(typeof value, 'number'); + + return name === 'a'; + }, { + parseNumbers: true, + }), 'http://example.com/?b=2&c=3#a'); +}); + +test('excludes elements in a URL without encoding fragment identifiers', t => { + t.is(queryString.exclude('https://example.com?a=b#/home', ['a']), 'https://example.com#/home'); +}); diff --git a/test/extract.js b/test/extract.js index b7cde183..eaf5e0be 100644 --- a/test/extract.js +++ b/test/extract.js @@ -1,5 +1,5 @@ import test from 'ava'; -import queryString from '..'; +import queryString from '../index.js'; test('extracts query string from url', t => { t.is(queryString.extract('https://foo.bar/?abc=def&hij=klm'), 'abc=def&hij=klm'); @@ -18,9 +18,13 @@ test('handles strings not containing query string', t => { test('throws for invalid values', t => { t.throws(() => { queryString.extract(null); - }, TypeError); + }, { + instanceOf: TypeError, + }); t.throws(() => { queryString.extract(undefined); - }, TypeError); + }, { + instanceOf: TypeError, + }); }); diff --git a/test/parse-url.js b/test/parse-url.js index 720333c4..35f8ae41 100644 --- a/test/parse-url.js +++ b/test/parse-url.js @@ -1,10 +1,11 @@ import test from 'ava'; -import queryString from '..'; +import queryString from '../index.js'; test('handles strings with query string', t => { t.deepEqual(queryString.parseUrl('https://foo.bar#top?foo=bar'), {url: 'https://foo.bar', query: {}}); t.deepEqual(queryString.parseUrl('https://foo.bar?foo=bar&foo=baz#top'), {url: 'https://foo.bar', query: {foo: ['bar', 'baz']}}); t.deepEqual(queryString.parseUrl('https://foo.bar?foo=bar&foo=baz'), {url: 'https://foo.bar', query: {foo: ['bar', 'baz']}}); + t.deepEqual(queryString.parseUrl('https://foo.bar?foo=null'), {url: 'https://foo.bar', query: {foo: 'null'}}); }); test('handles strings not containing query string', t => { @@ -28,9 +29,13 @@ test('handles strings with fragment identifier', t => { test('throws for invalid values', t => { t.throws(() => { queryString.parseUrl(null); - }, TypeError); + }, { + instanceOf: TypeError, + }); t.throws(() => { queryString.parseUrl(undefined); - }, TypeError); + }, { + instanceOf: TypeError, + }); }); diff --git a/test/parse.js b/test/parse.js index 3e8237e4..181f2dd3 100644 --- a/test/parse.js +++ b/test/parse.js @@ -1,5 +1,5 @@ import test from 'ava'; -import queryString from '..'; +import queryString from '../index.js'; test('query strings starting with a `?`', t => { t.deepEqual(queryString.parse('?foo=bar'), {foo: 'bar'}); @@ -13,31 +13,37 @@ test('query strings starting with a `&`', t => { t.deepEqual(queryString.parse('&foo=bar&foo=baz'), {foo: ['bar', 'baz']}); }); +test('query strings ending with a `&`', t => { + t.deepEqual(queryString.parse('foo=bar&'), {foo: 'bar'}); + t.deepEqual(queryString.parse('foo=bar&&&'), {foo: 'bar'}); +}); + test('parse a query string', t => { t.deepEqual(queryString.parse('foo=bar'), {foo: 'bar'}); + t.deepEqual(queryString.parse('foo=null'), {foo: 'null'}); }); test('parse multiple query string', t => { t.deepEqual(queryString.parse('foo=bar&key=val'), { foo: 'bar', - key: 'val' + key: 'val', }); }); test('parse multiple query string retain order when not sorted', t => { const expectedKeys = ['b', 'a', 'c']; const parsed = queryString.parse('b=foo&a=bar&c=yay', {sort: false}); - Object.keys(parsed).forEach((key, index) => { + for (const [index, key] of Object.keys(parsed).entries()) { t.is(key, expectedKeys[index]); - }); + } }); test('parse multiple query string sorted keys', t => { const fixture = ['a', 'b', 'c']; const parsed = queryString.parse('a=foo&c=bar&b=yay'); - Object.keys(parsed).forEach((key, index) => { + for (const [index, key] of Object.keys(parsed).entries()) { t.is(key, fixture[index]); - }); + } }); test('should sort parsed keys in given order', t => { @@ -45,20 +51,20 @@ test('should sort parsed keys in given order', t => { const sort = (key1, key2) => fixture.indexOf(key1) - fixture.indexOf(key2); const parsed = queryString.parse('a=foo&b=bar&c=yay', {sort}); - Object.keys(parsed).forEach((key, index) => { + for (const [index, key] of Object.keys(parsed).entries()) { t.is(key, fixture[index]); - }); + } }); test('parse query string without a value', t => { t.deepEqual(queryString.parse('foo'), {foo: null}); t.deepEqual(queryString.parse('foo&key'), { foo: null, - key: null + key: null, }); t.deepEqual(queryString.parse('foo=bar&key'), { foo: 'bar', - key: null + key: null, }); t.deepEqual(queryString.parse('a&a'), {a: [null, null]}); t.deepEqual(queryString.parse('a=&a'), {a: ['', null]}); @@ -104,7 +110,8 @@ test('handle multiple values and preserve appearance order with indexes', t => { }); test('query strings params including embedded `=`', t => { - t.deepEqual(queryString.parse('?param=https%3A%2F%2Fsomeurl%3Fid%3D2837'), {param: 'https://someurl?id=2837'}); + const value = 'https://someurl?id=2837'; + t.deepEqual(queryString.parse(`param=${encodeURIComponent(value)}`), {param: 'https://someurl?id=2837'}); }); test('object properties', t => { @@ -138,78 +145,118 @@ test('query string having a bracketed value and a single value and format option test('query strings having brackets arrays and format option as `bracket`', t => { t.deepEqual(queryString.parse('foo[]=bar&foo[]=baz', { - arrayFormat: 'bracket' + arrayFormat: 'bracket', }), {foo: ['bar', 'baz']}); }); test('query strings having comma separated arrays and format option as `comma`', t => { t.deepEqual(queryString.parse('foo=bar,baz', { - arrayFormat: 'comma' + arrayFormat: 'comma', }), {foo: ['bar', 'baz']}); }); test('query strings having pipe separated arrays and format option as `separator`', t => { t.deepEqual(queryString.parse('foo=bar|baz', { arrayFormat: 'separator', - arrayFormatSeparator: '|' + arrayFormatSeparator: '|', }), {foo: ['bar', 'baz']}); }); test('query strings having brackets arrays with null and format option as `bracket`', t => { t.deepEqual(queryString.parse('bar[]&foo[]=a&foo[]&foo[]=', { - arrayFormat: 'bracket' + arrayFormat: 'bracket', }), { foo: ['a', null, ''], - bar: [null] + bar: [null], }); }); test('query strings having comma separated arrays with null and format option as `comma`', t => { t.deepEqual(queryString.parse('bar&foo=a,', { - arrayFormat: 'comma' + arrayFormat: 'comma', }), { foo: ['a', ''], - bar: null + bar: null, }); }); test('query strings having indexed arrays and format option as `index`', t => { t.deepEqual(queryString.parse('foo[0]=bar&foo[1]=baz', { - arrayFormat: 'index' + arrayFormat: 'index', }), {foo: ['bar', 'baz']}); }); +test('query strings having brackets+separator arrays and format option as `bracket-separator` with 1 value', t => { + t.deepEqual(queryString.parse('foo[]=bar', { + arrayFormat: 'bracket-separator', + }), {foo: ['bar']}); +}); + +test('query strings having brackets+separator arrays and format option as `bracket-separator` with multiple values', t => { + t.deepEqual(queryString.parse('foo[]=bar,baz,,,biz', { + arrayFormat: 'bracket-separator', + }), {foo: ['bar', 'baz', '', '', 'biz']}); +}); + +test('query strings with multiple brackets+separator arrays and format option as `bracket-separator` using same key name', t => { + t.deepEqual(queryString.parse('foo[]=bar,baz&foo[]=biz,boz', { + arrayFormat: 'bracket-separator', + }), {foo: ['bar', 'baz', 'biz', 'boz']}); +}); + +test('query strings having an empty brackets+separator array and format option as `bracket-separator`', t => { + t.deepEqual(queryString.parse('foo[]', { + arrayFormat: 'bracket-separator', + }), {foo: []}); +}); + +test('query strings having a brackets+separator array and format option as `bracket-separator` with a single empty string', t => { + t.deepEqual(queryString.parse('foo[]=', { + arrayFormat: 'bracket-separator', + }), {foo: ['']}); +}); + +test('query strings having a brackets+separator array and format option as `bracket-separator` with a URL encoded value', t => { + const key = 'foo[]'; + const value = 'a,b,c,d,e,f'; + t.deepEqual(queryString.parse(`?${encodeURIComponent(key)}=${encodeURIComponent(value)}`, { + arrayFormat: 'bracket-separator', + }), { + foo: ['a', 'b', 'c', 'd', 'e', 'f'], + }); +}); + test('query strings having = within parameters (i.e. GraphQL IDs)', t => { t.deepEqual(queryString.parse('foo=bar=&foo=ba=z='), {foo: ['bar=', 'ba=z=']}); }); test('query strings having ordered index arrays and format option as `index`', t => { t.deepEqual(queryString.parse('foo[1]=bar&foo[0]=baz&foo[3]=one&foo[2]=two', { - arrayFormat: 'index' + arrayFormat: 'index', }), {foo: ['baz', 'bar', 'two', 'one']}); t.deepEqual(queryString.parse('foo[0]=bar&foo[1]=baz&foo[2]=one&foo[3]=two', { - arrayFormat: 'index' + arrayFormat: 'index', }), {foo: ['bar', 'baz', 'one', 'two']}); t.deepEqual(queryString.parse('foo[3]=three&foo[2]=two&foo[1]=one&foo[0]=zero', { - arrayFormat: 'index' + arrayFormat: 'index', }), {foo: ['zero', 'one', 'two', 'three']}); t.deepEqual(queryString.parse('foo[3]=three&foo[2]=two&foo[1]=one&foo[0]=zero&bat=buz', { - arrayFormat: 'index' + arrayFormat: 'index', }), {foo: ['zero', 'one', 'two', 'three'], bat: 'buz'}); t.deepEqual(queryString.parse('foo[1]=bar&foo[0]=baz', { - arrayFormat: 'index' + arrayFormat: 'index', }), {foo: ['baz', 'bar']}); t.deepEqual(queryString.parse('foo[102]=three&foo[2]=two&foo[1]=one&foo[0]=zero&bat=buz', { - arrayFormat: 'index' + arrayFormat: 'index', }), {bat: 'buz', foo: ['zero', 'one', 'two', 'three']}); t.deepEqual(queryString.parse('foo[102]=three&foo[2]=two&foo[100]=one&foo[0]=zero&bat=buz', { - arrayFormat: 'index' + arrayFormat: 'index', }), {bat: 'buz', foo: ['zero', 'two', 'one', 'three']}); }); @@ -218,7 +265,7 @@ test('circuit parse → stringify', t => { const sortedOriginal = 'bat=buz&foo[0]=&foo[1]=one&foo[2]&foo[3]=foo'; const expected = {bat: 'buz', foo: ['', 'one', null, 'foo']}; const options = { - arrayFormat: 'index' + arrayFormat: 'index', }; t.deepEqual(queryString.parse(original, options), expected); @@ -230,7 +277,34 @@ test('circuit original → parse → stringify → sorted original', t => { const original = 'foo[21474836471]=foo&foo[21474836470]&foo[1]=one&foo[0]=&bat=buz'; const sortedOriginal = 'bat=buz&foo[0]=&foo[1]=one&foo[2]&foo[3]=foo'; const options = { - arrayFormat: 'index' + arrayFormat: 'index', + }; + + t.deepEqual(queryString.stringify(queryString.parse(original, options), options), sortedOriginal); +}); + +test('circuit parse → stringify with array commas', t => { + const original = 'c=,a,,&b=&a='; + const sortedOriginal = 'a=&b=&c=,a,,'; + const expected = { + c: ['', 'a', '', ''], + b: '', + a: '', + }; + const options = { + arrayFormat: 'comma', + }; + + t.deepEqual(queryString.parse(original, options), expected); + + t.is(queryString.stringify(expected, options), sortedOriginal); +}); + +test('circuit original → parse → stringify with array commas → sorted original', t => { + const original = 'c=,a,,&b=&a='; + const sortedOriginal = 'a=&b=&c=,a,,'; + const options = { + arrayFormat: 'comma', }; t.deepEqual(queryString.stringify(queryString.parse(original, options), options), sortedOriginal); @@ -269,7 +343,8 @@ test('decode keys and values', t => { }); test('disable decoding of keys and values', t => { - t.deepEqual(queryString.parse('tags=postal%20office,burger%2C%20fries%20and%20coke', {decode: false}), {tags: 'postal%20office,burger%2C%20fries%20and%20coke'}); + const value = 'postal office,burger, fries and coke'; + t.deepEqual(queryString.parse(`tags=${encodeURIComponent(value)}`, {decode: false}), {tags: 'postal%20office%2Cburger%2C%20fries%20and%20coke'}); }); test('number value returns as string by default', t => { @@ -326,32 +401,175 @@ test('parseNumbers and parseBooleans can work with arrayFormat at the same time' }); test('parse throws TypeError for invalid arrayFormatSeparator', t => { - t.throws(_ => queryString.parse('', {arrayFormatSeparator: ',,'}), { - instanceOf: TypeError + t.throws(() => { + queryString.parse('', {arrayFormatSeparator: ',,'}); + }, { + instanceOf: TypeError, }); - t.throws(_ => queryString.parse('', {arrayFormatSeparator: []}), { - instanceOf: TypeError + + t.throws(() => { + queryString.parse('', {arrayFormatSeparator: []}); + }, { + instanceOf: TypeError, }); }); test('query strings having comma encoded and format option as `comma`', t => { - t.deepEqual(queryString.parse('foo=zero%2Cone,two%2Cthree', {arrayFormat: 'comma'}), { + const values = ['zero,one', 'two,three']; + t.deepEqual(queryString.parse(`foo=${encodeURIComponent(values[0])},${encodeURIComponent(values[1])}`, {arrayFormat: 'comma'}), { foo: [ 'zero,one', - 'two,three' - ] + 'two,three', + ], }); }); test('value should not be decoded twice with `arrayFormat` option set as `separator`', t => { t.deepEqual(queryString.parse('foo=2020-01-01T00:00:00%2B03:00', {arrayFormat: 'separator'}), { - foo: '2020-01-01T00:00:00+03:00' + foo: '2020-01-01T00:00:00+03:00', }); }); // See https://github.com/sindresorhus/query-string/issues/242 test('value separated by encoded comma will not be parsed as array with `arrayFormat` option set to `comma`', t => { - t.deepEqual(queryString.parse('id=1%2C2%2C3', {arrayFormat: 'comma', parseNumbers: true}), { - id: [1, 2, 3] + const value = '1,2,3'; + t.deepEqual(queryString.parse(`id=${encodeURIComponent(value)}`, {arrayFormat: 'comma', parseNumbers: true}), { + id: [1, 2, 3], + }); +}); + +test('query strings having (:list) colon-list-separator arrays', t => { + t.deepEqual(queryString.parse('bar:list=one&bar:list=two', {arrayFormat: 'colon-list-separator'}), {bar: ['one', 'two']}); +}); + +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 => { + const phoneNumber = '+380951234567'; + t.deepEqual(queryString.parse(`phoneNumber=${encodeURIComponent(phoneNumber)}`, { + 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,998,997&items=1,2,3', { + 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=1,2,3&items=1,2,3', { + 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,002,003&items=1,2,3', { + 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,002,003&foods=apple,orange,mango', { + 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,002,003&items=1,2,3&price=22.00&numbers=1,2,3&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, }); }); diff --git a/test/pick.js b/test/pick.js new file mode 100644 index 00000000..41dffa08 --- /dev/null +++ b/test/pick.js @@ -0,0 +1,21 @@ +import test from 'ava'; +import queryString from '../index.js'; + +test('picks elements in a URL with a filter array', t => { + t.is(queryString.pick('http://example.com/?a=1&b=2&c=3#a', ['a', 'b']), 'http://example.com/?a=1&b=2#a'); +}); + +test('picks elements in a URL with a filter predicate', t => { + t.is(queryString.pick('http://example.com/?a=1&b=2&c=3#a', (name, value) => { + t.is(typeof name, 'string'); + t.is(typeof value, 'number'); + + return name === 'a'; + }, { + parseNumbers: true, + }), 'http://example.com/?a=1#a'); +}); + +test('picks elements in a URL without encoding fragment identifiers', t => { + t.is(queryString.pick('https://example.com?a=b#/home', []), 'https://example.com#/home'); +}); diff --git a/test/properties.js b/test/properties.js index 6d4ace4d..af2b435a 100644 --- a/test/properties.js +++ b/test/properties.js @@ -1,7 +1,7 @@ import deepEqual from 'deep-equal'; -import * as fastCheck from 'fast-check'; +import fastCheck from 'fast-check'; import test from 'ava'; -import queryString from '..'; +import queryString from '../index.js'; // Valid query parameters must follow: // - key can be any unicode string (not empty) @@ -9,30 +9,33 @@ import queryString from '..'; // --> any unicode string // --> null // --> array containing values defined above (at least two items) -const queryParamsArbitrary = fastCheck.dictionary( +const queryParametersArbitrary = fastCheck.dictionary( fastCheck.fullUnicodeString(1, 10), fastCheck.oneof( fastCheck.fullUnicodeString(), fastCheck.constant(null), - fastCheck.array(fastCheck.oneof(fastCheck.fullUnicodeString(), fastCheck.constant(null)), 2, 10) - ) + fastCheck.array(fastCheck.oneof(fastCheck.fullUnicodeString(), fastCheck.constant(null)), 2, 10), + ), ); const optionsArbitrary = fastCheck.record({ arrayFormat: fastCheck.constantFrom('bracket', 'index', 'none'), strict: fastCheck.boolean(), encode: fastCheck.constant(true), - sort: fastCheck.constant(false) + sort: fastCheck.constant(false), }, {withDeletedKeys: true}); -test('should read correctly from stringified query params', t => { +test.failing('should read correctly from stringified query parameters', t => { t.notThrows(() => { fastCheck.assert( fastCheck.property( - queryParamsArbitrary, + queryParametersArbitrary, optionsArbitrary, - (object, options) => deepEqual(queryString.parse(queryString.stringify(object, options), options), object) - ) + (object, options) => deepEqual(queryString.parse(queryString.stringify(object, options), options), object), + ), + { + verbose: true, + }, ); }); }); diff --git a/test/stringify-url.js b/test/stringify-url.js index 2b3c3728..706a272a 100644 --- a/test/stringify-url.js +++ b/test/stringify-url.js @@ -1,38 +1,40 @@ import test from 'ava'; -import queryString from '..'; +import queryString from '../index.js'; test('stringify URL without a query string', t => { - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar/'}), 'https://foo.bar/'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar/', query: {}}), 'https://foo.bar/'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar/#top', query: {}}), 'https://foo.bar/#top'); - t.deepEqual(queryString.stringifyUrl({url: '', query: {}}), ''); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar?', query: {}}), 'https://foo.bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar?foo=bar', query: {}}), 'https://foo.bar?foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/'}), 'https://foo.bar/'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/', query: {}}), 'https://foo.bar/'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/#top', query: {}}), 'https://foo.bar/#top'); + t.is(queryString.stringifyUrl({url: '', query: {}}), ''); + t.is(queryString.stringifyUrl({url: 'https://foo.bar?', query: {}}), 'https://foo.bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar?foo=bar', query: {}}), 'https://foo.bar?foo=bar'); }); test('stringify URL with a query string', t => { - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar'}}), 'https://foo.bar?foo=bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar?', query: {foo: 'bar'}}), 'https://foo.bar?foo=bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar/#top', query: {foo: 'bar'}}), 'https://foo.bar/?foo=bar#top'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar', a: 'b'}}), 'https://foo.bar?a=b&foo=bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar?a=b', query: {foo: ['bar', 'baz']}}), 'https://foo.bar?a=b&foo=bar&foo=baz'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar?foo=baz', query: {foo: 'bar'}}), 'https://foo.bar?foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar'}}), 'https://foo.bar?foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar?', query: {foo: 'bar'}}), 'https://foo.bar?foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/#top', query: {foo: 'bar'}}), 'https://foo.bar/?foo=bar#top'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar', a: 'b'}}), 'https://foo.bar?a=b&foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar?a=b', query: {foo: ['bar', 'baz']}}), 'https://foo.bar?a=b&foo=bar&foo=baz'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar?foo=baz', query: {foo: 'bar'}}), 'https://foo.bar?foo=bar'); }); test('stringify URL with fragment identifier', t => { - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {top: 'foo'}, fragmentIdentifier: 'bar'}), 'https://foo.bar?top=foo#bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: ['bar', 'baz']}, fragmentIdentifier: 'top'}), 'https://foo.bar?foo=bar&foo=baz#top'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar/', query: {}, fragmentIdentifier: 'top'}), 'https://foo.bar/#top'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar/#abc', query: {}, fragmentIdentifier: 'top'}), 'https://foo.bar/#top'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {}}), 'https://foo.bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {}, fragmentIdentifier: 'foo bar'}), 'https://foo.bar#foo%20bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {top: 'foo'}, fragmentIdentifier: 'bar'}), 'https://foo.bar?top=foo#bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: ['bar', 'baz']}, fragmentIdentifier: 'top'}), 'https://foo.bar?foo=bar&foo=baz#top'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/', query: {}, fragmentIdentifier: 'top'}), 'https://foo.bar/#top'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/#abc', query: {}, fragmentIdentifier: 'top'}), 'https://foo.bar/#top'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {}}), 'https://foo.bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {}, fragmentIdentifier: 'foo bar'}), 'https://foo.bar#foo%20bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/', query: {}, fragmentIdentifier: '/foo/bar'}), 'https://foo.bar/#/foo/bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar/#foo', query: {}, fragmentIdentifier: ''}), 'https://foo.bar/'); }); test('skipEmptyString:: stringify URL with a query string', t => { const config = {skipEmptyString: true}; - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar', baz: ''}}, config), 'https://foo.bar?foo=bar'); - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar', baz: ['', 'qux']}}, config), 'https://foo.bar?baz=qux&foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar', baz: ''}}, config), 'https://foo.bar?foo=bar'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar', query: {foo: 'bar', baz: ['', 'qux']}}, config), 'https://foo.bar?baz=qux&foo=bar'); }); test('stringify URL from the result of `parseUrl` without query string', t => { @@ -54,5 +56,5 @@ test('stringify URL from the result of `parseUrl` with query string that contain }); test('stringify URL without sorting existing query params', t => { - t.deepEqual(queryString.stringifyUrl({url: 'https://foo.bar?C=3&A=1', query: {D: 4, B: 2}}, {sort: false}), 'https://foo.bar?C=3&A=1&D=4&B=2'); + t.is(queryString.stringifyUrl({url: 'https://foo.bar?C=3&A=1', query: {D: 4, B: 2}}, {sort: false}), 'https://foo.bar?C=3&A=1&D=4&B=2'); }); diff --git a/test/stringify.js b/test/stringify.js index 4ab748e8..cc6928a8 100644 --- a/test/stringify.js +++ b/test/stringify.js @@ -1,11 +1,11 @@ import test from 'ava'; -import queryString from '..'; +import queryString from '../index.js'; test('stringify', t => { t.is(queryString.stringify({foo: 'bar'}), 'foo=bar'); t.is(queryString.stringify({ foo: 'bar', - bar: 'baz' + bar: 'baz', }), 'bar=baz&foo=bar'); }); @@ -14,6 +14,13 @@ test('different types', t => { t.is(queryString.stringify(0), ''); }); +test('primitive types', t => { + t.is(queryString.stringify({a: 'string'}), 'a=string'); + t.is(queryString.stringify({a: true, b: false}), 'a=true&b=false'); + t.is(queryString.stringify({a: 0, b: 1n}), 'a=0&b=1'); + t.is(queryString.stringify({a: null, b: undefined}), 'a'); +}); + test('URI encode', t => { t.is(queryString.stringify({'foo bar': 'baz faz'}), 'foo%20bar=baz%20faz'); t.is(queryString.stringify({'foo bar': 'baz\'faz'}), 'foo%20bar=baz%27faz'); @@ -26,28 +33,28 @@ test('no encoding', t => { test('handle array value', t => { t.is(queryString.stringify({ abc: 'abc', - foo: ['bar', 'baz'] + foo: ['bar', 'baz'], }), 'abc=abc&foo=bar&foo=baz'); }); test('array order', t => { t.is(queryString.stringify({ abc: 'abc', - foo: ['baz', 'bar'] + foo: ['baz', 'bar'], }), 'abc=abc&foo=baz&foo=bar'); }); test('handle empty array value', t => { t.is(queryString.stringify({ abc: 'abc', - foo: [] + foo: [], }), 'abc=abc'); }); test('should not encode undefined values', t => { t.is(queryString.stringify({ abc: undefined, - foo: 'baz' + foo: 'baz', }), 'foo=baz'); }); @@ -55,28 +62,28 @@ test('should encode null values as just a key', t => { t.is(queryString.stringify({ 'x y z': null, abc: null, - foo: 'baz' + foo: 'baz', }), 'abc&foo=baz&x%20y%20z'); }); test('handle null values in array', t => { t.is(queryString.stringify({ foo: null, - bar: [null, 'baz'] + bar: [null, 'baz'], }), 'bar&bar=baz&foo'); }); test('handle undefined values in array', t => { t.is(queryString.stringify({ foo: null, - bar: [undefined, 'baz'] + bar: [undefined, 'baz'], }), 'bar=baz&foo'); }); test('handle undefined and null values in array', t => { t.is(queryString.stringify({ foo: null, - bar: [undefined, null, 'baz'] + bar: [undefined, null, 'baz'], }), 'bar&bar=baz&foo'); }); @@ -93,36 +100,36 @@ test('loose encoding', t => { test('array stringify representation with array indexes', t => { t.is(queryString.stringify({ foo: null, - bar: ['one', 'two'] + bar: ['one', 'two'], }, { - arrayFormat: 'index' + arrayFormat: 'index', }), 'bar[0]=one&bar[1]=two&foo'); }); test('array stringify representation with array brackets', t => { t.is(queryString.stringify({ foo: null, - bar: ['one', 'two'] + bar: ['one', 'two'], }, { - arrayFormat: 'bracket' + arrayFormat: 'bracket', }), 'bar[]=one&bar[]=two&foo'); }); test('array stringify representation with array brackets and null value', t => { t.is(queryString.stringify({ foo: ['a', null, ''], - bar: [null] + bar: [null], }, { - arrayFormat: 'bracket' + arrayFormat: 'bracket', }), 'bar[]&foo[]=a&foo[]&foo[]='); }); test('array stringify representation with array commas', t => { t.is(queryString.stringify({ foo: null, - bar: ['one', 'two'] + bar: ['one', 'two'], }, { - arrayFormat: 'comma' + arrayFormat: 'comma', }), 'bar=one,two&foo'); }); @@ -142,9 +149,9 @@ test('array stringify representation with array commas, null & empty string', t t.is(queryString.stringify({ c: [null, 'a', '', null], b: [null], - a: [''] + a: [''], }, { - arrayFormat: 'comma' + arrayFormat: 'comma', }), 'a=&b=&c=,a,,'); }); @@ -152,29 +159,29 @@ test('array stringify representation with array commas, null & empty string (ski t.is(queryString.stringify({ c: [null, 'a', '', null], b: [null], - a: [''] + a: [''], }, { skipNull: true, skipEmptyString: true, - arrayFormat: 'comma' + arrayFormat: 'comma', }), 'c=a'); }); test('array stringify representation with array commas and 0 value', t => { t.is(queryString.stringify({ foo: ['a', null, 0], - bar: [null] + bar: [null], }, { - arrayFormat: 'comma' + arrayFormat: 'comma', }), 'bar=&foo=a,,0'); }); test('array stringify representation with a bad array format', t => { t.is(queryString.stringify({ foo: null, - bar: ['one', 'two'] + bar: ['one', 'two'], }, { - arrayFormat: 'badinput' + arrayFormat: 'badinput', }), 'bar=one&bar=two&foo'); }); @@ -184,6 +191,71 @@ test('array stringify representation with array indexes and sparse array', t => t.is(queryString.stringify({bar: fixture}, {arrayFormat: 'index'}), 'bar[0]=one&bar[1]=two&bar[2]=three'); }); +test('array stringify representation with brackets and separators with empty array', t => { + t.is(queryString.stringify({ + foo: null, + bar: [], + }, { + arrayFormat: 'bracket-separator', + }), 'bar[]&foo'); +}); + +test('array stringify representation with brackets and separators with single value', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['one'], + }, { + arrayFormat: 'bracket-separator', + }), 'bar[]=one&foo'); +}); + +test('array stringify representation with brackets and separators with multiple values', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['one', 'two', 'three'], + }, { + arrayFormat: 'bracket-separator', + }), 'bar[]=one,two,three&foo'); +}); + +test('array stringify representation with brackets and separators with a single empty string', t => { + t.is(queryString.stringify({ + foo: null, + bar: [''], + }, { + arrayFormat: 'bracket-separator', + }), 'bar[]=&foo'); +}); + +test('array stringify representation with brackets and separators with a multiple empty string', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['', 'two', ''], + }, { + arrayFormat: 'bracket-separator', + }), 'bar[]=,two,&foo'); +}); + +test('array stringify representation with brackets and separators with dropped empty strings', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['', 'two', ''], + }, { + arrayFormat: 'bracket-separator', + skipEmptyString: true, + }), 'bar[]=two&foo'); +}); + +test('array stringify representation with brackets and separators with dropped null values', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['one', null, 'three', null, '', 'six'], + }, { + arrayFormat: 'bracket-separator', + skipNull: true, + }), 'bar[]=one,three,,six'); +}); + test('should sort keys in given order', t => { const fixture = ['c', 'a', 'b']; const sort = (key1, key2) => fixture.indexOf(key1) - fixture.indexOf(key2); @@ -204,7 +276,7 @@ test('should not sort when sort is false', t => { ln: 'g', nf: 'h', srs: 'i', - destination: 'g' + destination: 'g', }; t.is(queryString.stringify(fixture, {sort: false}), 'story=a&patch=b&deployment=c&lat=10&lng=20&sb=d&sc=e&mn=f&ln=g&nf=h&srs=i&destination=g'); }); @@ -213,9 +285,9 @@ test('should disable sorting', t => { t.is(queryString.stringify({ c: 'foo', b: 'bar', - a: 'baz' + a: 'baz', }, { - sort: false + sort: false, }), 'c=foo&b=bar&a=baz'); }); @@ -223,9 +295,9 @@ test('should ignore null when skipNull is set', t => { t.is(queryString.stringify({ a: 1, b: null, - c: 3 + c: 3, }, { - skipNull: true + skipNull: true, }), 'a=1&c=3'); }); @@ -233,9 +305,9 @@ test('should ignore emptyString when skipEmptyString is set', t => { t.is(queryString.stringify({ a: 1, b: '', - c: 3 + c: 3, }, { - skipEmptyString: true + skipEmptyString: true, }), 'a=1&c=3'); }); @@ -243,18 +315,18 @@ test('should ignore undefined when skipNull is set', t => { t.is(queryString.stringify({ a: 1, b: undefined, - c: 3 + c: 3, }, { - skipNull: true + skipNull: true, }), 'a=1&c=3'); }); test('should ignore both null and undefined when skipNull is set', t => { t.is(queryString.stringify({ a: undefined, - b: null + b: null, }, { - skipNull: true + skipNull: true, }), ''); }); @@ -262,36 +334,36 @@ test('should ignore both null and undefined when skipNull is set for arrayFormat t.is(queryString.stringify({ a: [undefined, null, 1, undefined, 2, null], b: null, - c: 1 + c: 1, }, { - skipNull: true + skipNull: true, }), 'a=1&a=2&c=1'); t.is(queryString.stringify({ a: [undefined, null, 1, undefined, 2, null], b: null, - c: 1 + c: 1, }, { skipNull: true, - arrayFormat: 'bracket' + arrayFormat: 'bracket', }), 'a[]=1&a[]=2&c=1'); t.is(queryString.stringify({ a: [undefined, null, 1, undefined, 2, null], b: null, - c: 1 + c: 1, }, { skipNull: true, - arrayFormat: 'comma' + arrayFormat: 'comma', }), 'a=1,2&c=1'); t.is(queryString.stringify({ a: [undefined, null, 1, undefined, 2, null], b: null, - c: 1 + c: 1, }, { skipNull: true, - arrayFormat: 'index' + arrayFormat: 'index', }), 'a[0]=1&a[1]=2&c=1'); }); @@ -299,51 +371,75 @@ test('should ignore empty string when skipEmptyString is set for arrayFormat', t t.is(queryString.stringify({ a: ['', 1, '', 2], b: '', - c: 1 + c: 1, }, { - skipEmptyString: true + skipEmptyString: true, }), 'a=1&a=2&c=1'); t.is(queryString.stringify({ a: ['', 1, '', 2], b: '', - c: 1 + c: 1, }, { skipEmptyString: true, - arrayFormat: 'bracket' + arrayFormat: 'bracket', }), 'a[]=1&a[]=2&c=1'); t.is(queryString.stringify({ a: ['', 1, '', 2], b: '', - c: 1 + c: 1, }, { skipEmptyString: true, - arrayFormat: 'comma' + arrayFormat: 'comma', }), 'a=1,2&c=1'); t.is(queryString.stringify({ a: ['', 1, '', 2], b: '', - c: 1 + c: 1, }, { skipEmptyString: true, - arrayFormat: 'index' + arrayFormat: 'index', }), 'a[0]=1&a[1]=2&c=1'); t.is(queryString.stringify({ a: ['', '', '', ''], - c: 1 + c: 1, }, { - skipEmptyString: true + skipEmptyString: true, }), 'c=1'); }); test('stringify throws TypeError for invalid arrayFormatSeparator', t => { t.throws(_ => queryString.stringify({}, {arrayFormatSeparator: ',,'}), { - instanceOf: TypeError + instanceOf: TypeError, }); t.throws(_ => queryString.stringify({}, {arrayFormatSeparator: []}), { - instanceOf: TypeError + instanceOf: TypeError, }); }); + +test('array stringify representation with (:list) colon-list-separator', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['one', 'two'], + }, { + arrayFormat: 'colon-list-separator', + }), 'bar:list=one&bar:list=two&foo'); +}); + +test('array stringify representation with (:list) colon-list-separator with null values', t => { + t.is(queryString.stringify({ + foo: null, + bar: ['one', ''], + }, { + arrayFormat: 'colon-list-separator', + }), 'bar:list=one&bar:list=&foo'); + t.is(queryString.stringify({ + foo: null, + bar: ['one', null], + }, { + arrayFormat: 'colon-list-separator', + }), 'bar:list=one&bar:list=&foo'); +});