Skip to content

Commit 54ee164

Browse files
committed
feat(Arktype): Adds ability to nest objects or declare specific keys in an array in FormData
1 parent b439139 commit 54ee164

File tree

3 files changed

+110
-23
lines changed

3 files changed

+110
-23
lines changed

.changeset/unlucky-camels-give.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@jhecht/arktype-utils': minor
3+
---
4+
5+
Updates formDataToObject to include nested objects
6+

packages/arktype-utils/src/formData.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { expect, describe, it } from 'vitest';
2-
import { formDataToObject, validateFormData } from './formData.js';
31
import { type } from 'arktype';
2+
import { describe, expect, it } from 'vitest';
3+
import { formDataToObject, validateFormData } from './formData.js';
44

55
describe('formDataToObject', () => {
66
it('Should parse empty formData object', () => {
@@ -79,10 +79,12 @@ describe('formDataToObject', () => {
7979
const fd = new FormData();
8080
fd.append('names[]', 'bob');
8181
fd.append('ages[]', '13');
82+
fd.append('title', 'Fantastic Voyage');
8283

8384
expect(formDataToObject(fd)).toStrictEqual({
8485
names: ['bob'],
8586
ages: [13],
87+
title: 'Fantastic Voyage',
8688
});
8789
});
8890

packages/arktype-utils/src/formData.ts

Lines changed: 100 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,40 +7,119 @@ type FilterFn = (a: EntriesTouple) => boolean;
77

88
type FormDataObjectEntry = FormDataEntryValue | number | boolean | bigint;
99

10+
const nameKeyExtractor = /(?<name>[a-zA-z]+[a-zA-Z-]+)\[(?<index>.*)\]/;
11+
const digitCheck = /^\d+$/;
12+
13+
type MagicObject = {
14+
readonly type: 'array' | 'object';
15+
add(key: string, value: string | File): void;
16+
toJS(): unknown[] | Record<string, unknown>;
17+
};
18+
19+
function makeMagicObject(init: EntriesTouple[] = []): MagicObject {
20+
const entries: EntriesTouple[] = ([] as EntriesTouple[]).concat(init);
21+
return {
22+
get type() {
23+
if (entries.every(([k]) => digitCheck.test(k) || k === ''))
24+
return 'array';
25+
return 'object';
26+
},
27+
add(key: string, value: string | File) {
28+
entries.push([key, value]);
29+
},
30+
toJS() {
31+
// TODO: Figure out how to clean this up
32+
if (this.type === 'array') {
33+
const arr: unknown[] = [];
34+
for (const [k, v] of entries) {
35+
if (k === '')
36+
arr.push(typeof v === 'string' ? stringToJSValue(v) : v);
37+
else arr[Number(k)] = typeof v === 'string' ? stringToJSValue(v) : v;
38+
}
39+
return arr;
40+
}
41+
const ret: Record<string, unknown> = {};
42+
for (const [k, v] of entries)
43+
ret[k] = typeof v === 'string' ? stringToJSValue(v) : v;
44+
return ret;
45+
},
46+
};
47+
}
48+
1049
/**
1150
*
1251
* @param fd the form data object
1352
* @param filterFn an optional filtering function to remove some values from the end object
14-
* @param pruneKeyNames if true, then the keynames matching the pattern of `key[]` will be pruned down to just
15-
* `key` in the resulting object
1653
* @returns an object mapped from the entries.
1754
*/
1855
export function formDataToObject(
1956
fd: FormData,
2057
filterFn: FilterFn = () => true,
21-
pruneKeyNames = true,
22-
): Record<string, FormDataObjectEntry | FormDataObjectEntry[]> {
23-
const ret: Record<string, FormDataObjectEntry | FormDataObjectEntry[]> = {};
24-
25-
for (let key of fd.keys()) {
26-
if (filterFn([key, ''])) {
27-
const all = fd.getAll(key);
28-
29-
if (all.length === 1 && !key.endsWith('[]')) {
30-
// regular stuff
31-
if (typeof all[0] === 'string') ret[key] = stringToJSValue(all[0]);
32-
else if (all[0] instanceof File) ret[key] = all[0];
33-
} else {
34-
if (pruneKeyNames && /\[.?\]/.test(key))
35-
key = key.replace(/\[.?\]/, '');
36-
37-
ret[key] = all.map(v =>
38-
typeof v === 'string' ? stringToJSValue(v) : v,
39-
);
58+
) {
59+
const ret: Record<string, unknown> = {};
60+
// Map of key name into magic type which converts its entries to either an object
61+
// or an array.
62+
const info = new Map<string, ReturnType<typeof makeMagicObject>>();
63+
// eslint-disable-next-line prefer-const
64+
for (let [iterator, value] of fd.entries()) {
65+
// If the key does not match this filter, continue the loop
66+
if (!filterFn([iterator, value])) continue;
67+
// run the iterator against the name key extractor
68+
const matches = iterator.match(nameKeyExtractor);
69+
// If we do not have matches, or the index match is empty, we go here.
70+
if (matches === null || matches[2] === '') {
71+
// Grab all of the values for the current iterator
72+
const all = fd.getAll(iterator);
73+
// If the length of all entries for this iterator is 1 AND the iterator name does not end
74+
// with [] (indicating the user wants this to be an array) we drop in here
75+
if (all.length === 1 && !iterator.endsWith('[]')) {
76+
// set the value on the return object
77+
ret[iterator] =
78+
typeof all[0] === 'string' ? stringToJSValue(all[0]) : all[0];
79+
// don't need the rest of the loop values here, so we forcibly continue
80+
continue;
4081
}
82+
// If the iterator includes an opening [], let's assume we don't want the `[]` to be included
83+
// so we trim it out here
84+
if (iterator.includes('['))
85+
iterator = iterator.slice(0, iterator.indexOf('['));
86+
87+
// Set the iterator value on returned object
88+
ret[iterator] = all.map(v =>
89+
typeof v === 'string' ? stringToJSValue(v) : v,
90+
);
91+
} else {
92+
// If we have matches, there's a bit more processing required
93+
const { groups } = matches;
94+
// Check to ensure our groups are there. It shouldn't be possible to have matches
95+
// without groups in modern JS, but c'est la vie
96+
if (groups === undefined) continue;
97+
// pull out the name and index. we add defaults so TS doesn't yell at us about possibly
98+
// being undefined
99+
const { name = '', index = '' } = groups;
100+
// Grab the magic item from the info map.
101+
let magic = info.get(name);
102+
// If we don't have a magic item, make one and set it
103+
if (!magic) {
104+
magic = makeMagicObject();
105+
info.set(name, magic);
106+
}
107+
108+
// If the index is '', it means we were given something like `name[]`, or `age[]`
109+
if (index === '') {
110+
// Get all the values for this iterator
111+
const all = fd.getAll(iterator);
112+
// Loop over
113+
for (const a of all) magic.add('', a as string);
114+
} else magic.add(index, value);
41115
}
42116
}
43117

118+
// Consolidation of items in the info values.
119+
for (const [key, magic] of info.entries()) {
120+
if (!ret[key]) ret[key] = magic.toJS();
121+
else console.error(`Key ${key} already exists in object.`);
122+
}
44123
return ret;
45124
}
46125

0 commit comments

Comments
 (0)