Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(utils/body): add dot notation support for parseBody #2675

Merged
merged 23 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
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