parse-nested-form-data 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,387 @@
1
+ import { File } from '@remix-run/web-file';
2
+
3
+ /**
4
+ * @name parse-nested-form-data
5
+ * @license MIT license.
6
+ * @copyright (c) 2022 Christian Schurr
7
+ * @author Christian Schurr <chris@schurr.dev>
8
+ */
9
+
10
+ /**
11
+ * Thrown when a path is used multiple times or has missmatching path parts.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * const formData = new FormData()
16
+ * formData.append('a[0]', 'b')
17
+ * formData.append('a[0]', 'c')
18
+ * parseFormData(formData)
19
+ * // throws DuplicateKeyError('a[0]')
20
+ * ```
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * const formData = new FormData()
25
+ * formData.append('a', 'b')
26
+ * formData.append('a', 'c')
27
+ * parseFormData(formData)
28
+ * // throws DuplicateKeyError('a')
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const formData = new FormData()
33
+ * formData.append('a', 'b')
34
+ * formData.append('a[]', 'c')
35
+ * parseFormData(formData)
36
+ * // throws DuplicateKeyError('a[]')
37
+ * ```
38
+ *
39
+ */
40
+ class DuplicateKeyError extends Error {
41
+ constructor(key) {
42
+ super(`Duplicate key at path part ${key}`);
43
+ this.key = void 0;
44
+ this.key = key;
45
+ }
46
+ }
47
+ /**
48
+ * Thrown when an array is used at the same path with an order parameter and
49
+ * without an order parameter.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * const formData = new FormData()
54
+ * formData.append('a[0]', 'a')
55
+ * formData.append('a[]', 'b')
56
+ * parseFormData(formData)
57
+ * // => throws `MixedArrayError(a[])`
58
+ * ```
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * const formData = new FormData()
63
+ * formData.append('a[]', 'a')
64
+ * formData.append('a[0]', 'b')
65
+ * parseFormData(formData)
66
+ * // => throws `MixedArrayError(a[0])`
67
+ * ```
68
+ */
69
+ class MixedArrayError extends Error {
70
+ constructor(key) {
71
+ super(`Mixed array at path part ${key}`);
72
+ this.key = void 0;
73
+ this.key = key;
74
+ }
75
+ }
76
+ function isJsonObject(val) {
77
+ return typeof val === 'object' && !Array.isArray(val) && val !== null && !(val instanceof File);
78
+ }
79
+
80
+ /**
81
+ * Default Transformer for `parseFormData`.
82
+ *
83
+ * Transforms a FormData Entry into a path and a `JsonLeafValue`.
84
+ *
85
+ * - `path` starts with `+` -> transform value to `number`
86
+ * - `path` starts with `&` -> transform value to `boolean`
87
+ * - `path` starts with `-` -> transform value to `null`
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const entry = ['a[0]', 'b']
92
+ * const result = defaultTransform(entry)
93
+ * // => {path: 'a[0]', value: 'b'}
94
+ * ```
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const entry = ['+a[0]', '1']
99
+ * const result = defaultTransform(entry)
100
+ * // => {path: 'a[0]', value: 1}
101
+ * ```
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const entry = ['&a[0]', 'true']
106
+ * const result = defaultTransform(entry)
107
+ * // => {path: 'a[0]', value: true}
108
+ * ```
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * const entry = ['-a[0]', 'null']
113
+ * const result = defaultTransform(entry)
114
+ * // => {path: 'a[0]', value: null}
115
+ * ```
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const entry = ['a[0]', new File([''], 'file.txt')]
120
+ * const result = defaultTransform(entry)
121
+ * // => {path: 'a[0]', value: File}
122
+ * ```
123
+ *
124
+ *
125
+ * @param entry [path, value]: the FormData entry
126
+ * @returns the path and the transformed value
127
+ */
128
+ function defaultTransform(entry) {
129
+ let path = entry[0];
130
+ let value = entry[1];
131
+ if (path.startsWith('+')) {
132
+ path = path.slice(1);
133
+ value = Number(value);
134
+ } else if (path.startsWith('&')) {
135
+ path = path.slice(1);
136
+ value = value === 'on' || value === 'true' || Boolean(Number(value));
137
+ } else if (path.startsWith('-')) {
138
+ path = path.slice(1);
139
+ value = null;
140
+ }
141
+ return {
142
+ path,
143
+ value
144
+ };
145
+ }
146
+ /**
147
+ *
148
+ * Transforms a FormData path into an array of `PathPart`s.
149
+ *
150
+ * @param path - the path to extract the path parts from
151
+ * @returns {Array<PathPart>} the extracted path parts
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * const path = 'a[0].b'
156
+ * const result = extractPathParts(path)
157
+ * // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
158
+ * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'},
159
+ * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}]
160
+ * ```
161
+ *
162
+ * @example
163
+ * ```ts
164
+ * const path = 'a.b'
165
+ * const result = extractPathParts(path)
166
+ * // => [{path: 'a', type: 'object, default: {}, pathToPart: 'a'},
167
+ * // {path: 'b', type: 'object', default: {}, pathToPart: 'a.b'}]
168
+ * ```
169
+ *
170
+ * @example
171
+ * ```ts
172
+ * const path = 'a[][0]'
173
+ * const result = extractPathParts(path)
174
+ * // => [{path: 'a', type: 'object, default: [], pathToPart: 'a'},
175
+ * // {path: '', type: 'array', default: [], pathToPart: 'a[]'},
176
+ * // {path: '0', type: 'array', default: {}, pathToPart: 'a[][0]'}]
177
+ * ```
178
+ *
179
+ *
180
+ */
181
+ function extractPathParts(path) {
182
+ const re = /((?<array>\d*)\]|(?<pathPart>[^.[]+))(?<nextType>\[|\.|$)/g;
183
+ return Array.from(path.matchAll(re)).map(match => {
184
+ // self casted RegexExpMatchArray to custom
185
+ const typedMatch = match;
186
+ const {
187
+ array,
188
+ pathPart,
189
+ nextType
190
+ } = typedMatch.groups;
191
+ const type = array === undefined ? 'object' : 'array';
192
+ const nextDefault = nextType === '[' ? [] : {};
193
+ return {
194
+ path: array ?? pathPart,
195
+ type,
196
+ default: nextDefault,
197
+ pathToPart: path.slice(0, typedMatch.index + typedMatch[1].length)
198
+ };
199
+ });
200
+ }
201
+
202
+ /**
203
+ *
204
+ * Returns the value accessed via `pathPart` in the `currentPathObject`
205
+ * and a setter function to set the value in the `currentPathObject` via the
206
+ * provided `pathPart`.
207
+ *
208
+ * @param pathPart - the path part to get the setter and getter for
209
+ * @param currentPathObject - the object at the current path (before the path part)
210
+ * @param arraysWithOrder - a set of arrays that have an order
211
+ * @returns the setter and getter for the path part
212
+ *
213
+ * @example
214
+ * ```ts
215
+ * const pathPart = {path: 'a', type: 'object', default: {}, pathToPart: 'a'}
216
+ * const currentPathObject = {}
217
+ * const arraysWithOrder = new Set()
218
+ * const [value, setValue] = getSetterAndGetter(pathPart, currentPathObject, arraysWithOrder)
219
+ * setValue('b')
220
+ * // => currentPathObject = {a: 'b'}
221
+ * ```
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * const pathPart = {path: '0', type: 'array', default: [], pathToPart: 'a[0]'}
226
+ * const currentPathObject = {a: []}
227
+ * const arraysWithOrder = new Set()
228
+ * const [value, setValue] = getSetterAndGetter(pathPart, currentPathObject, arraysWithOrder)
229
+ * setValue('b')
230
+ * // => currentPathObject = {a: ['b']}
231
+ * ```
232
+ *
233
+ */
234
+ function handlePathPart(pathPart, currentPathObject, arraysWithOrder) {
235
+ if (pathPart.type === 'object') {
236
+ if (Array.isArray(currentPathObject)) {
237
+ throw new DuplicateKeyError(pathPart.pathToPart);
238
+ }
239
+ const currentObject = currentPathObject;
240
+ return [currentObject[pathPart.path], val => currentObject[pathPart.path] = val];
241
+ }
242
+ if (!Array.isArray(currentPathObject)) {
243
+ throw new DuplicateKeyError(pathPart.pathToPart);
244
+ }
245
+ const currentArray = currentPathObject;
246
+ const isOrdered = pathPart.path !== '';
247
+ const isOrderedArray = arraysWithOrder.has(currentArray);
248
+ if (isOrdered) {
249
+ arraysWithOrder.add(currentArray);
250
+ }
251
+ if (!isOrdered && isOrderedArray || isOrdered && !isOrderedArray && currentArray.length > 0) {
252
+ throw new MixedArrayError(pathPart.pathToPart);
253
+ }
254
+ const order = isOrdered ? Number(pathPart.path) : currentArray.length;
255
+ return [currentArray[order], val => currentArray[order] = val];
256
+ }
257
+
258
+ /**
259
+ *
260
+ * Parses a FormData object to a JSON object. This is done by parsing the `name`
261
+ * attribute of each `FormDataEntryValue` and then inserting the value at the
262
+ * path. Also by default the start of the path is used to transform the value.
263
+ *
264
+ *
265
+ * In front of the whole `key`:
266
+ * - `+` => parse to `Number`
267
+ * - `-` => set value to `null`
268
+ * - `&` => parse to `Boolean`
269
+ *
270
+ * - `.` between path parts => nest into `objects`
271
+ * - `[\d*]` after path part => push to array in order `\d` or push to end if `[]`
272
+ *
273
+
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * const formData = new FormData()
278
+ * formData.append('+a', '1')
279
+ * formData.append('&b', 'true')
280
+ * formData.append('-c', 'null')
281
+ * formData.append('d', 'foo')
282
+ * parseFormData(formData, defaultTransform)
283
+ * // => {a: 1, b: true, c: null, d: 'foo'}
284
+ * ```
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * const formData = new FormData()
289
+ * formData.append('a.b', 'foo')
290
+ * parseFormData(formData)
291
+ * // => {a: {b: 'foo'}}
292
+ * ```
293
+ *
294
+ * @example
295
+ * ```ts
296
+ * const formData = new FormData()
297
+ * formData.append('a[0]', 'foo')
298
+ * formData.append('a[1]', 'bar')
299
+ * parseFormData(formData)
300
+ * // => {a: ['foo', 'bar']}
301
+ * ```
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * const formData = new FormData()
306
+ * formData.append('a[]', 'foo')
307
+ * formData.append('a[]', 'bar')
308
+ * parseFormData(formData)
309
+ * // => {a: ['foo', 'bar']}
310
+ * ```
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * const formData = new FormData()
315
+ * formData.append('a[0]', 'foo')
316
+ * parseFormData(formData, {transformEntry: (path, value) => {path, value: value + 'bar'}})
317
+ * // => {a: ['foobar']}
318
+ * ```
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * const formData = new FormData()
323
+ * formData.append('a[0]', 'foo')
324
+ * formData.append('a[1]', '')
325
+ * parseFormData(formData, {removeEmptyString: true})
326
+ * // => {a: ['foo']}
327
+ * ```
328
+ *
329
+ * @param {Iterable<[string, string | File]>} formData - an iterator of an [`path`, `value`] tuple
330
+ * - `path` := `^(\+|\-|\&)?([^\.]+?(\[\d*\])*)(\.[^\.]+?(\[\d*\])*)*$` (e.g. `+a[][1].b`)
331
+ * - `value` := `string` or `File`
332
+ * @param {ParseFormDataOptions} options - options for parsing the form data
333
+ * - `transformEntry` - a function to transform the path and the value before
334
+ * inserting the value at the path in the resulting object (default: `defaultTransform`)
335
+ * - `removeEmptyString` - if `true` removes all entries where the value is an empty string
336
+ * @returns {JsonObject} the parsed JSON object
337
+ * @throws `DuplicateKeyError` if
338
+ * - a path part is an object and the path part is already defined as an object
339
+ * - a path part is an array and the path part is already defined as an array
340
+ * @throws `MixedArrayError` if at a specific path part an unordered array is
341
+ * defined and at a later path part an ordered array is defined or vice versa
342
+ * - e.g. `a[0]` and `a[]`
343
+ * - e.g. `a[]` and `a[0]`
344
+ */
345
+ function parseFormData(formData, _temp) {
346
+ let {
347
+ removeEmptyString = false,
348
+ transformEntry = defaultTransform
349
+ } = _temp === void 0 ? {} : _temp;
350
+ const result = {};
351
+
352
+ // all arrays we need to squash (in place) later
353
+ const arraysWithOrder = new Set();
354
+ for (const entry of Array.from(formData)) {
355
+ if (removeEmptyString && entry[1] === '') continue;
356
+ const {
357
+ path,
358
+ value
359
+ } = transformEntry(entry, defaultTransform);
360
+ const pathParts = extractPathParts(path);
361
+ let currentPathObject = result;
362
+ pathParts.forEach((pathPart, idx) => {
363
+ const [nextPathValue, setNextPathValue] = handlePathPart(pathPart, currentPathObject, arraysWithOrder);
364
+ if (pathParts.length - 1 === idx) {
365
+ if (nextPathValue !== undefined) {
366
+ throw new DuplicateKeyError(pathPart.pathToPart);
367
+ }
368
+ setNextPathValue(value);
369
+ } else {
370
+ if (nextPathValue !== undefined && !isJsonObject(nextPathValue) && !Array.isArray(nextPathValue)) {
371
+ throw new DuplicateKeyError(pathPart.pathToPart);
372
+ }
373
+ const nextPathObject = nextPathValue ?? pathPart.default;
374
+ currentPathObject = nextPathObject;
375
+ setNextPathValue(nextPathObject);
376
+ }
377
+ });
378
+ }
379
+ for (const orderedArray of Array.from(arraysWithOrder)) {
380
+ // replace array with a squashed array
381
+ // array.flat(0) will remove all empty slots (e.g. [0, , 1] => [0, 1])
382
+ orderedArray.splice(0, orderedArray.length, ...orderedArray.flat(0));
383
+ }
384
+ return result;
385
+ }
386
+
387
+ export { DuplicateKeyError, MixedArrayError, parseFormData };