Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/unlucky-camels-give.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@jhecht/arktype-utils': minor
---

Updates formDataToObject to include nested objects

6 changes: 4 additions & 2 deletions packages/arktype-utils/src/formData.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, describe, it } from 'vitest';
import { formDataToObject, validateFormData } from './formData.js';
import { type } from 'arktype';
import { describe, expect, it } from 'vitest';
import { formDataToObject, validateFormData } from './formData.js';

describe('formDataToObject', () => {
it('Should parse empty formData object', () => {
Expand Down Expand Up @@ -79,10 +79,12 @@ describe('formDataToObject', () => {
const fd = new FormData();
fd.append('names[]', 'bob');
fd.append('ages[]', '13');
fd.append('title', 'Fantastic Voyage');

expect(formDataToObject(fd)).toStrictEqual({
names: ['bob'],
ages: [13],
title: 'Fantastic Voyage',
});
});

Expand Down
121 changes: 100 additions & 21 deletions packages/arktype-utils/src/formData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,119 @@ type FilterFn = (a: EntriesTouple) => boolean;

type FormDataObjectEntry = FormDataEntryValue | number | boolean | bigint;

const nameKeyExtractor = /(?<name>[a-zA-z]+[a-zA-Z-]+)\[(?<index>.*)\]/;
const digitCheck = /^\d+$/;

type MagicObject = {
readonly type: 'array' | 'object';
add(key: string, value: string | File): void;
toJS(): unknown[] | Record<string, unknown>;
};

function makeMagicObject(init: EntriesTouple[] = []): MagicObject {
const entries: EntriesTouple[] = ([] as EntriesTouple[]).concat(init);
return {
get type() {
if (entries.every(([k]) => digitCheck.test(k) || k === ''))
return 'array';
return 'object';
},
add(key: string, value: string | File) {
entries.push([key, value]);
},
toJS() {
// TODO: Figure out how to clean this up
if (this.type === 'array') {
const arr: unknown[] = [];
for (const [k, v] of entries) {
if (k === '')
arr.push(typeof v === 'string' ? stringToJSValue(v) : v);
else arr[Number(k)] = typeof v === 'string' ? stringToJSValue(v) : v;
}
return arr;
}
const ret: Record<string, unknown> = {};
for (const [k, v] of entries)
ret[k] = typeof v === 'string' ? stringToJSValue(v) : v;
return ret;
},
};
}

/**
*
* @param fd the form data object
* @param filterFn an optional filtering function to remove some values from the end object
* @param pruneKeyNames if true, then the keynames matching the pattern of `key[]` will be pruned down to just
* `key` in the resulting object
* @returns an object mapped from the entries.
*/
export function formDataToObject(
fd: FormData,
filterFn: FilterFn = () => true,
pruneKeyNames = true,
): Record<string, FormDataObjectEntry | FormDataObjectEntry[]> {
const ret: Record<string, FormDataObjectEntry | FormDataObjectEntry[]> = {};

for (let key of fd.keys()) {
if (filterFn([key, ''])) {
const all = fd.getAll(key);

if (all.length === 1 && !key.endsWith('[]')) {
// regular stuff
if (typeof all[0] === 'string') ret[key] = stringToJSValue(all[0]);
else if (all[0] instanceof File) ret[key] = all[0];
} else {
if (pruneKeyNames && /\[.?\]/.test(key))
key = key.replace(/\[.?\]/, '');

ret[key] = all.map(v =>
typeof v === 'string' ? stringToJSValue(v) : v,
);
) {
const ret: Record<string, unknown> = {};
// Map of key name into magic type which converts its entries to either an object
// or an array.
const info = new Map<string, ReturnType<typeof makeMagicObject>>();
// eslint-disable-next-line prefer-const
for (let [iterator, value] of fd.entries()) {
// If the key does not match this filter, continue the loop
if (!filterFn([iterator, value])) continue;
// run the iterator against the name key extractor
const matches = iterator.match(nameKeyExtractor);
// If we do not have matches, or the index match is empty, we go here.
if (matches === null || matches[2] === '') {
// Grab all of the values for the current iterator
const all = fd.getAll(iterator);
// If the length of all entries for this iterator is 1 AND the iterator name does not end
// with [] (indicating the user wants this to be an array) we drop in here
if (all.length === 1 && !iterator.endsWith('[]')) {
// set the value on the return object
ret[iterator] =
typeof all[0] === 'string' ? stringToJSValue(all[0]) : all[0];
// don't need the rest of the loop values here, so we forcibly continue
continue;
}
// If the iterator includes an opening [], let's assume we don't want the `[]` to be included
// so we trim it out here
if (iterator.includes('['))
iterator = iterator.slice(0, iterator.indexOf('['));

// Set the iterator value on returned object
ret[iterator] = all.map(v =>
typeof v === 'string' ? stringToJSValue(v) : v,
);
} else {
// If we have matches, there's a bit more processing required
const { groups } = matches;
// Check to ensure our groups are there. It shouldn't be possible to have matches
// without groups in modern JS, but c'est la vie
if (groups === undefined) continue;
// pull out the name and index. we add defaults so TS doesn't yell at us about possibly
// being undefined
const { name = '', index = '' } = groups;
// Grab the magic item from the info map.
let magic = info.get(name);
// If we don't have a magic item, make one and set it
if (!magic) {
magic = makeMagicObject();
info.set(name, magic);
}

// If the index is '', it means we were given something like `name[]`, or `age[]`
if (index === '') {
// Get all the values for this iterator
const all = fd.getAll(iterator);
// Loop over
for (const a of all) magic.add('', a as string);
} else magic.add(index, value);
}
}

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

Expand Down