Skip to content

Commit

Permalink
feat(utils/body): add dot notation support for parseBody (#2675)
Browse files Browse the repository at this point in the history
* feat: add dot notation support for parseBody

* denoify & lint

* refactor: default value and jsdoc

* denoify & lint

* test: more tests

* fix: destruct option key value to false if value is undefined

* types: remove object

* refactor: handle nested values inside a function

* fix: remove unusual import

* denoify & lint

* refactor: explicitly state just all and dot

* fix: issue raised by multiple append on the same dot notation key

* types: dont use {}

* chore: denoify & lint

* fix(types): Deno compatible types

* fix: should override value if  option is false

* chore: denoify & lint

* make option typings partials and should not update file object properties

* refactor: make code less complicated

* chore: add tsdoc

* chore: remove comment

* refactor(utils/body): reduce the complexity of O(mn)

* chore: remove parseAllValues and change value to BodyDataValue type
  • Loading branch information
fzn0x authored May 22, 2024
1 parent 778ac10 commit 568f872
Show file tree
Hide file tree
Showing 3 changed files with 349 additions and 28 deletions.
121 changes: 107 additions & 14 deletions deno_dist/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HonoRequest } from '../request.ts'

export type BodyData = Record<string, string | File | (string | File)[]>
type BodyDataValue = string | File | (string | File)[] | { [key: string]: BodyDataValue }
export type BodyData = Record<string, BodyDataValue>
export type ParseBodyOptions = {
/**
* Determines whether all fields with multiple values should be parsed as arrays.
Expand All @@ -17,26 +18,59 @@ export type ParseBodyOptions = {
* If all is true:
* parseBody should return { file: ['aaa', 'bbb'], message: 'hello' }
*/
all?: boolean
all: boolean
/**
* Determines whether all fields with dot notation should be parsed as nested objects.
* @default false
* @example
* const data = new FormData()
* data.append('obj.key1', 'value1')
* data.append('obj.key2', 'value2')
*
* If dot is false:
* parseBody should return { 'obj.key1': 'value1', 'obj.key2': 'value2' }
*
* If dot is true:
* parseBody should return { obj: { key1: 'value1', key2: 'value2' } }
*/
dot: boolean
}

/**
* Parses the body of a request based on the provided options.
*
* @template T - The type of the parsed body data.
* @param {HonoRequest | Request} request - The request object to parse.
* @param {Partial<ParseBodyOptions>} [options] - Options for parsing the body.
* @returns {Promise<T>} The parsed body data.
*/
export const parseBody = async <T extends BodyData = BodyData>(
request: HonoRequest | Request,
options: ParseBodyOptions = { all: false }
options: Partial<ParseBodyOptions> = Object.create(null)
): Promise<T> => {
const { all = false, dot = false } = options

const headers = request instanceof HonoRequest ? request.raw.headers : request.headers
const contentType = headers.get('Content-Type')

if (
(contentType !== null && contentType.startsWith('multipart/form-data')) ||
(contentType !== null && contentType.startsWith('application/x-www-form-urlencoded'))
) {
return parseFormData<T>(request, options)
return parseFormData<T>(request, { all, dot })
}

return {} as T
}

/**
* Parses form data from a request.
*
* @template T - The type of the parsed body data.
* @param {HonoRequest | Request} request - The request object containing form data.
* @param {ParseBodyOptions} options - Options for parsing the form data.
* @returns {Promise<T>} The parsed body data.
*/
async function parseFormData<T extends BodyData = BodyData>(
request: HonoRequest | Request,
options: ParseBodyOptions
Expand All @@ -50,33 +84,92 @@ async function parseFormData<T extends BodyData = BodyData>(
return {} as T
}

/**
* Converts form data to body data based on the provided options.
*
* @template T - The type of the parsed body data.
* @param {FormData} formData - The form data to convert.
* @param {ParseBodyOptions} options - Options for parsing the form data.
* @returns {T} The converted body data.
*/
function convertFormDataToBodyData<T extends BodyData = BodyData>(
formData: FormData,
options: ParseBodyOptions
): T {
const form: BodyData = {}
const form: BodyData = Object.create(null)

formData.forEach((value, key) => {
const shouldParseAllValues = options.all || key.endsWith('[]')

if (!shouldParseAllValues) {
form[key] = value
} else {
if (shouldParseAllValues) {
handleParsingAllValues(form, key, value)
} else {
form[key] = value
}
})

if (options.dot) {
const nestedForm: BodyData = Object.create(null)

Object.entries(form).forEach(([key, value]) => {
const shouldParseDotValues = key.includes('.')

if (shouldParseDotValues) {
handleParsingNestedValues(nestedForm, key, value)
} else {
nestedForm[key] = value
}
})

return nestedForm as T
}

return form as T
}

/**
* Handles parsing all values for a given key, supporting multiple values as arrays.
*
* @param {BodyData} form - The form data object.
* @param {string} key - The key to parse.
* @param {FormDataEntryValue} value - The value to assign.
*/
const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntryValue): void => {
const formKey = form[key]

if (formKey && Array.isArray(formKey)) {
;(form[key] as (string | File)[]).push(value)
} else if (formKey) {
form[key] = [formKey, value]
if (form[key] !== undefined) {
if (Array.isArray(form[key])) {
;(form[key] as (string | File)[]).push(value)
} else {
form[key] = [form[key] as string | File, value]
}
} else {
form[key] = value
}
}

/**
* Handles parsing nested values using dot notation keys.
*
* @param {BodyData} form - The form data object.
* @param {string} key - The dot notation key.
* @param {BodyDataValue} value - The value to assign.
*/
const handleParsingNestedValues = (form: BodyData, key: string, value: BodyDataValue): void => {
let nestedForm = form
const keys = key.split('.')

keys.forEach((key, index) => {
if (index === keys.length - 1) {
nestedForm[key] = value
} else {
if (
!nestedForm[key] ||
typeof nestedForm[key] !== 'object' ||
Array.isArray(nestedForm[key]) ||
nestedForm[key] instanceof File
) {
nestedForm[key] = Object.create(null)
}
nestedForm = nestedForm[key] as BodyData
}
})
}
135 changes: 135 additions & 0 deletions src/utils/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,46 @@ describe('Parse Body Util', () => {
})
})

it('should not update file object properties', async () => {
const file = new File(['foo'], 'file1', {
type: 'application/octet-stream',
})
const data = new FormData()

const req = createRequest(FORM_URL, 'POST', data)
vi.spyOn(req, 'formData').mockImplementation(
async () =>
({
forEach: (cb) => {
cb(file, 'file', data)
cb('hoo', 'file.hoo', data)
},
} as FormData)
)

const parsedData = await parseBody(req, { dot: true })
expect(parsedData.file).not.instanceOf(File)
expect(parsedData).toEqual({
file: {
hoo: 'hoo',
},
})
})

it('should override value if `all` option is false', async () => {
const data = new FormData()
data.append('file', 'aaa')
data.append('file', 'bbb')
data.append('message', 'hello')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req)).toEqual({
file: 'bbb',
message: 'hello',
})
})

it('should parse multiple values if `all` option is true', async () => {
const data = new FormData()
data.append('file', 'aaa')
Expand All @@ -64,6 +104,101 @@ describe('Parse Body Util', () => {
})
})

it('should not parse nested values in default', async () => {
const data = new FormData()
data.append('obj.key1', 'value1')
data.append('obj.key2', 'value2')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { dot: false })).toEqual({
'obj.key1': 'value1',
'obj.key2': 'value2',
})
})

it('should not parse nested values in default for non-nested keys', async () => {
const data = new FormData()
data.append('key1', 'value1')
data.append('key2', 'value2')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { dot: false })).toEqual({
key1: 'value1',
key2: 'value2',
})
})

it('should handle nested values and non-nested values together with dot option true', async () => {
const data = new FormData()
data.append('obj.key1', 'value1')
data.append('obj.key2', 'value2')
data.append('key3', 'value3')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { dot: true })).toEqual({
obj: { key1: 'value1', key2: 'value2' },
key3: 'value3',
})
})

it('should handle deeply nested objects with dot option true', async () => {
const data = new FormData()
data.append('a.b.c.d', 'value')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { dot: true })).toEqual({
a: { b: { c: { d: 'value' } } },
})
})

it('should parse nested values if `dot` option is true', async () => {
const data = new FormData()
data.append('obj.key1', 'value1')
data.append('obj.key2', 'value2')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { dot: true })).toEqual({
obj: { key1: 'value1', key2: 'value2' },
})
})

it('should parse data if both `all` and `dot` are set', async () => {
const data = new FormData()
data.append('obj.sub.foo', 'value1')
data.append('obj.sub.foo', 'value2')
data.append('key', 'value3')

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { dot: true, all: true })).toEqual({
obj: { sub: { foo: ['value1', 'value2'] } },
key: 'value3',
})
})

it('should parse nested values if values are `File`', async () => {
const file1 = new File(['foo'], 'file1', {
type: 'application/octet-stream',
})
const file2 = new File(['bar'], 'file2', {
type: 'application/octet-stream',
})
const data = new FormData()
data.append('file.file1', file1)
data.append('file.file2', file2)

const req = createRequest(FORM_URL, 'POST', data)

expect(await parseBody(req, { all: true, dot: true })).toEqual({
file: { file1, file2 },
})
})

it('should parse multiple values if values are `File`', async () => {
const file1 = new File(['foo'], 'file1', {
type: 'application/octet-stream',
Expand Down
Loading

0 comments on commit 568f872

Please sign in to comment.