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 20 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
107 changes: 94 additions & 13 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 @@ -18,20 +19,37 @@ export type ParseBodyOptions = {
* parseBody should return { file: ['aaa', 'bbb'], message: 'hello' }
*/
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
}

export const parseBody = async <T extends BodyData = BodyData>(
request: HonoRequest | Request,
options: ParseBodyOptions = { all: false }
options: 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
Expand All @@ -54,29 +72,92 @@ 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
handleNestedValues(form, key, value, options.dot)
} else {
handleParsingAllValues(form, key, value)
handleParsingAllValues(form, key, value, options.dot)
}
})

return form as T
}

const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntryValue): void => {
const formKey = form[key]
const handleParsingAllValues = (
form: BodyData,
key: string,
value: FormDataEntryValue,
dot?: boolean
): void => {
if (dot && key.includes('.')) {
handleNestedValues(form, key, value, dot, true)
} else {
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
}
}
}

if (formKey && Array.isArray(formKey)) {
;(form[key] as (string | File)[]).push(value)
} else if (formKey) {
form[key] = [formKey, value]
const handleNestedValues = (
form: BodyData,
key: string,
value: FormDataEntryValue,
dot?: boolean,
parseAllValues?: boolean
): void => {
if (dot && key.includes('.')) {
let nestedForm = form
const keys = key.split('.')

keys.forEach((key, index) => {
if (index === keys.length - 1) {
if (parseAllValues) {
if (nestedForm[key] !== undefined) {
if (Array.isArray(nestedForm[key])) {
;(nestedForm[key] as (string | File)[]).push(value)
} else {
nestedForm[key] = [nestedForm[key] as string | File, value as string | File]
}
} else {
nestedForm[key] = value
}
} else {
nestedForm[key] = value
}
} else {
if (
!nestedForm[key] ||
typeof nestedForm[key] !== 'object' ||
Array.isArray(nestedForm[key])
) {
nestedForm[key] = Object.create(null)
}
nestedForm = nestedForm[key] as BodyData
}
})
} else {
form[key] = value
if (parseAllValues) {
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
}
} else {
form[key] = value
}
}
}
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'] } }, // Is tis expected??
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
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