query-string
Version:
Parse and stringify URL query strings
557 lines (444 loc) • 13.3 kB
JavaScript
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 (type === 'boolean' && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
return value.toLowerCase() === 'true';
}
if (type === 'boolean' && value !== null && (value.toLowerCase() === '1' || value.toLowerCase() === '0')) {
return value.toLowerCase() === '1';
}
if (type === 'string[]' && options.arrayFormat !== 'none' && typeof value === 'string') {
return [value];
}
if (type === 'number[]' && options.arrayFormat !== 'none' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
return [Number(value)];
}
if (type === 'number' && !Number.isNaN(Number(value)) && (typeof value === 'string' && value.trim() !== '')) {
return Number(value);
}
if (options.parseBooleans && value !== null && (value.toLowerCase() === 'true' || value.toLowerCase() === 'false')) {
return value.toLowerCase() === 'true';
}
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, ...options}),
...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);
}