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 10 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
65 changes: 59 additions & 6 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 = {}
): Promise<T> => {
const { all = options?.all ?? false, dot = options?.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, { ...options, all, dot })
}

return {} as T
Expand Down Expand Up @@ -60,22 +78,57 @@ function convertFormDataToBodyData<T extends BodyData = BodyData>(
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 handleParsingAllValues = (
form: BodyData,
key: string,
value: FormDataEntryValue,
dot?: boolean
): void => {
const formKey = form[key]

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

const handleNestedValues = (
form: BodyData,
key: string,
value: FormDataEntryValue,
dot?: boolean
): void => {
if (dot && key.includes('.')) {
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] = nestedForm[key] || {}
}
nestedForm = nestedForm[key] as BodyData
}
})
} else {
form[key] = value
}
Expand Down
81 changes: 81 additions & 0 deletions src/utils/body.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,87 @@ 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 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
65 changes: 59 additions & 6 deletions src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HonoRequest } from '../request'

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
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
}

export const parseBody = async <T extends BodyData = BodyData>(
request: HonoRequest | Request,
options: ParseBodyOptions = { all: false }
options: ParseBodyOptions = {}
): Promise<T> => {
const { all = options?.all ?? false, dot = options?.dot ?? false } = options
fzn0x marked this conversation as resolved.
Show resolved Hide resolved

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, { ...options, all, dot })
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
}

return {} as T
Expand Down Expand Up @@ -60,22 +78,57 @@ function convertFormDataToBodyData<T extends BodyData = BodyData>(
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 handleParsingAllValues = (
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
form: BodyData,
key: string,
value: FormDataEntryValue,
dot?: boolean
): void => {
const formKey = form[key]

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

const handleNestedValues = (
fzn0x marked this conversation as resolved.
Show resolved Hide resolved
form: BodyData,
key: string,
value: FormDataEntryValue,
dot?: boolean
): void => {
if (dot && key.includes('.')) {
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] = nestedForm[key] || {}
}
nestedForm = nestedForm[key] as BodyData
}
})
} else {
form[key] = value
}
Expand Down