parse-nested-form-data
Version:
A tiny node module for parsing FormData by name into objects and arrays
388 lines (379 loc) • 11.4 kB
JavaScript
import { File } from '@remix-run/web-file';
/**
* @name parse-nested-form-data
* @license MIT license.
* @copyright (c) 2022 Christian Schurr
* @author Christian Schurr <chris@schurr.dev>
*/
/**
* Thrown when a path is used multiple times or has missmatching path parts.
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[0]', 'b')
* formData.append('a[0]', 'c')
* parseFormData(formData)
* // throws DuplicateKeyError('a[0]')
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a', 'b')
* formData.append('a', 'c')
* parseFormData(formData)
* // throws DuplicateKeyError('a')
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a', 'b')
* formData.append('a[]', 'c')
* parseFormData(formData)
* // throws DuplicateKeyError('a[]')
* ```
*
*/
class DuplicateKeyError extends Error {
constructor(key) {
super(`Duplicate key at path part ${key}`);
this.key = void 0;
this.key = key;
}
}
/**
* Thrown when an array is used at the same path with an order parameter and
* without an order parameter.
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[0]', 'a')
* formData.append('a[]', 'b')
* parseFormData(formData)
* // => throws `MixedArrayError(a[])`
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[]', 'a')
* formData.append('a[0]', 'b')
* parseFormData(formData)
* // => throws `MixedArrayError(a[0])`
* ```
*/
class MixedArrayError extends Error {
constructor(key) {
super(`Mixed array at path part ${key}`);
this.key = void 0;
this.key = key;
}
}
function isJsonObject(val) {
return typeof val === 'object' && !Array.isArray(val) && val !== null && !(val instanceof File);
}
/**
* Default Transformer for `parseFormData`.
*
* Transforms a FormData Entry into a path and a `JsonLeafValue`.
*
* - `path` starts with `+` -> transform value to `number`
* - `path` starts with `&` -> transform value to `boolean`
* - `path` starts with `-` -> transform value to `null`
*
* @example
* ```ts
* const entry = ['a[0]', 'b']
* const result = defaultTransform(entry)
* // => {path: 'a[0]', value: 'b'}
* ```
*
* @example
* ```ts
* const entry = ['+a[0]', '1']
* const result = defaultTransform(entry)
* // => {path: 'a[0]', value: 1}
* ```
*
* @example
* ```ts
* const entry = ['&a[0]', 'true']
* const result = defaultTransform(entry)
* // => {path: 'a[0]', value: true}
* ```
*
* @example
* ```ts
* const entry = ['-a[0]', 'null']
* const result = defaultTransform(entry)
* // => {path: 'a[0]', value: null}
* ```
*
* @example
* ```ts
* const entry = ['a[0]', new File([''], 'file.txt')]
* const result = defaultTransform(entry)
* // => {path: 'a[0]', value: File}
* ```
*
*
* @param entry [path, value]: the FormData entry
* @returns the path and the transformed value
*/
function defaultTransform(entry) {
let path = entry[0];
let value = entry[1];
if (path.startsWith('+')) {
path = path.slice(1);
value = Number(value);
} else if (path.startsWith('&')) {
path = path.slice(1);
value = value === 'on' || value === 'true' || Boolean(Number(value));
} else if (path.startsWith('-')) {
path = path.slice(1);
value = null;
}
return {
path,
value
};
}
/**
*
* Transforms a FormData path into an array of `PathPart`s.
*
* @param path - the path to extract the path parts from
* @returns {Array<PathPart>} the extracted path parts
*
* @example
* ```ts
* const path = 'a[0].b'
* const result = extractPathParts(path)
* // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
* // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'},
* // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}]
* ```
*
* @example
* ```ts
* const path = 'a.b'
* const result = extractPathParts(path)
* // => [{path: 'a', type: 'object, default: {}, pathToPart: 'a'},
* // {path: 'b', type: 'object', default: {}, pathToPart: 'a.b'}]
* ```
*
* @example
* ```ts
* const path = 'a[][0]'
* const result = extractPathParts(path)
* // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
* // {path: '', type: 'array', default: [], pathToPart: 'a[]'},
* // {path: '0', type: 'array', default: {}, pathToPart: 'a[][0]'}]
* ```
*
*
*/
function extractPathParts(path) {
const re = /((?<array>\d*)\]|(?<pathPart>[^.[]+))(?<nextType>\[|\.|$)/g;
return Array.from(path.matchAll(re)).map(match => {
// self casted RegexExpMatchArray to custom
const typedMatch = match;
const {
array,
pathPart,
nextType
} = typedMatch.groups;
const type = array === undefined ? 'object' : 'array';
const nextDefault = nextType === '[' ? [] : {};
return {
path: array ?? pathPart,
type,
default: nextDefault,
pathToPart: path.slice(0, typedMatch.index + typedMatch[1].length)
};
});
}
/**
*
* Returns the value accessed via `pathPart` in the `currentPathObject`
* and a setter function to set the value in the `currentPathObject` via the
* provided `pathPart`.
*
* @param pathPart - the path part to get the setter and getter for
* @param currentPathObject - the object at the current path (before the path part)
* @param arraysWithOrder - a set of arrays that have an order
* @returns the setter and getter for the path part
*
* @example
* ```ts
* const pathPart = {path: 'a', type: 'object', default: {}, pathToPart: 'a'}
* const currentPathObject = {}
* const arraysWithOrder = new Set()
* const [value, setValue] = getSetterAndGetter(pathPart, currentPathObject, arraysWithOrder)
* setValue('b')
* // => currentPathObject = {a: 'b'}
* ```
*
* @example
* ```ts
* const pathPart = {path: '0', type: 'array', default: [], pathToPart: 'a[0]'}
* const currentPathObject = {a: []}
* const arraysWithOrder = new Set()
* const [value, setValue] = getSetterAndGetter(pathPart, currentPathObject, arraysWithOrder)
* setValue('b')
* // => currentPathObject = {a: ['b']}
* ```
*
*/
function handlePathPart(pathPart, currentPathObject, arraysWithOrder) {
if (pathPart.type === 'object') {
if (Array.isArray(currentPathObject)) {
throw new DuplicateKeyError(pathPart.pathToPart);
}
const currentObject = currentPathObject;
return [currentObject[pathPart.path], val => currentObject[pathPart.path] = val];
}
if (!Array.isArray(currentPathObject)) {
throw new DuplicateKeyError(pathPart.pathToPart);
}
const currentArray = currentPathObject;
const isOrdered = pathPart.path !== '';
const isOrderedArray = arraysWithOrder.has(currentArray);
if (isOrdered) {
arraysWithOrder.add(currentArray);
}
if (!isOrdered && isOrderedArray || isOrdered && !isOrderedArray && currentArray.length > 0) {
throw new MixedArrayError(pathPart.pathToPart);
}
const order = isOrdered ? Number(pathPart.path) : currentArray.length;
return [currentArray[order], val => currentArray[order] = val];
}
/**
*
* Parses a FormData object to a JSON object. This is done by parsing the `name`
* attribute of each `FormDataEntryValue` and then inserting the value at the
* path. Also by default the start of the path is used to transform the value.
*
*
* In front of the whole `key`:
* - `+` => parse to `Number`
* - `-` => set value to `null`
* - `&` => parse to `Boolean`
*
* - `.` between path parts => nest into `objects`
* - `[\d*]` after path part => push to array in order `\d` or push to end if `[]`
*
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('+a', '1')
* formData.append('&b', 'true')
* formData.append('-c', 'null')
* formData.append('d', 'foo')
* parseFormData(formData, defaultTransform)
* // => {a: 1, b: true, c: null, d: 'foo'}
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a.b', 'foo')
* parseFormData(formData)
* // => {a: {b: 'foo'}}
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[0]', 'foo')
* formData.append('a[1]', 'bar')
* parseFormData(formData)
* // => {a: ['foo', 'bar']}
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[]', 'foo')
* formData.append('a[]', 'bar')
* parseFormData(formData)
* // => {a: ['foo', 'bar']}
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[0]', 'foo')
* parseFormData(formData, {transformEntry: (path, value) => {path, value: value + 'bar'}})
* // => {a: ['foobar']}
* ```
*
* @example
* ```ts
* const formData = new FormData()
* formData.append('a[0]', 'foo')
* formData.append('a[1]', '')
* parseFormData(formData, {removeEmptyString: true})
* // => {a: ['foo']}
* ```
*
* @param {Iterable<[string, string | File]>} formData - an iterator of an [`path`, `value`] tuple
* - `path` := `^(\+|\-|\&)?([^\.]+?(\[\d*\])*)(\.[^\.]+?(\[\d*\])*)*$` (e.g. `+a[][1].b`)
* - `value` := `string` or `File`
* @param {ParseFormDataOptions} options - options for parsing the form data
* - `transformEntry` - a function to transform the path and the value before
* inserting the value at the path in the resulting object (default: `defaultTransform`)
* - `removeEmptyString` - if `true` removes all entries where the value is an empty string
* @returns {JsonObject} the parsed JSON object
* @throws `DuplicateKeyError` if
* - a path part is an object and the path part is already defined as an object
* - a path part is an array and the path part is already defined as an array
* @throws `MixedArrayError` if at a specific path part an unordered array is
* defined and at a later path part an ordered array is defined or vice versa
* - e.g. `a[0]` and `a[]`
* - e.g. `a[]` and `a[0]`
*/
function parseFormData(formData, _temp) {
let {
removeEmptyString = false,
transformEntry = defaultTransform
} = _temp === void 0 ? {} : _temp;
const result = {};
// all arrays we need to squash (in place) later
const arraysWithOrder = new Set();
for (const entry of Array.from(formData)) {
if (removeEmptyString && entry[1] === '') continue;
const {
path,
value
} = transformEntry(entry, defaultTransform);
const pathParts = extractPathParts(path);
let currentPathObject = result;
pathParts.forEach((pathPart, idx) => {
const [nextPathValue, setNextPathValue] = handlePathPart(pathPart, currentPathObject, arraysWithOrder);
if (pathParts.length - 1 === idx) {
if (nextPathValue !== undefined) {
throw new DuplicateKeyError(pathPart.pathToPart);
}
setNextPathValue(value);
} else {
if (nextPathValue !== undefined && !isJsonObject(nextPathValue) && !Array.isArray(nextPathValue)) {
throw new DuplicateKeyError(pathPart.pathToPart);
}
const nextPathObject = nextPathValue ?? pathPart.default;
currentPathObject = nextPathObject;
setNextPathValue(nextPathObject);
}
});
}
for (const orderedArray of Array.from(arraysWithOrder)) {
// replace array with a squashed array
// array.flat(0) will remove all empty slots (e.g. [0, , 1] => [0, 1])
orderedArray.splice(0, orderedArray.length, ...orderedArray.flat(0));
}
return result;
}
export { DuplicateKeyError, MixedArrayError, parseFormData };