diff --git a/deno_dist/utils/body.ts b/deno_dist/utils/body.ts index cf9a2b62b..db47b8c23 100644 --- a/deno_dist/utils/body.ts +++ b/deno_dist/utils/body.ts @@ -1,6 +1,7 @@ import { HonoRequest } from '../request.ts' -export type BodyData = Record +type BodyDataValue = string | File | (string | File)[] | { [key: string]: BodyDataValue } +export type BodyData = Record export type ParseBodyOptions = { /** * Determines whether all fields with multiple values should be parsed as arrays. @@ -17,13 +18,38 @@ 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} [options] - Options for parsing the body. + * @returns {Promise} The parsed body data. + */ export const parseBody = async ( request: HonoRequest | Request, - options: ParseBodyOptions = { all: false } + options: Partial = Object.create(null) ): Promise => { + const { all = false, dot = false } = options + const headers = request instanceof HonoRequest ? request.raw.headers : request.headers const contentType = headers.get('Content-Type') @@ -31,12 +57,20 @@ export const parseBody = async ( (contentType !== null && contentType.startsWith('multipart/form-data')) || (contentType !== null && contentType.startsWith('application/x-www-form-urlencoded')) ) { - return parseFormData(request, options) + return parseFormData(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} The parsed body data. + */ async function parseFormData( request: HonoRequest | Request, options: ParseBodyOptions @@ -50,33 +84,92 @@ async function parseFormData( 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( 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 + } + }) +} diff --git a/src/utils/body.test.ts b/src/utils/body.test.ts index 80443466b..d04ed0801 100644 --- a/src/utils/body.test.ts +++ b/src/utils/body.test.ts @@ -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') @@ -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', diff --git a/src/utils/body.ts b/src/utils/body.ts index 295b807d4..8013dd212 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,6 +1,7 @@ import { HonoRequest } from '../request' -export type BodyData = Record +type BodyDataValue = string | File | (string | File)[] | { [key: string]: BodyDataValue } +export type BodyData = Record export type ParseBodyOptions = { /** * Determines whether all fields with multiple values should be parsed as arrays. @@ -17,13 +18,38 @@ 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} [options] - Options for parsing the body. + * @returns {Promise} The parsed body data. + */ export const parseBody = async ( request: HonoRequest | Request, - options: ParseBodyOptions = { all: false } + options: Partial = Object.create(null) ): Promise => { + const { all = false, dot = false } = options + const headers = request instanceof HonoRequest ? request.raw.headers : request.headers const contentType = headers.get('Content-Type') @@ -31,12 +57,20 @@ export const parseBody = async ( (contentType !== null && contentType.startsWith('multipart/form-data')) || (contentType !== null && contentType.startsWith('application/x-www-form-urlencoded')) ) { - return parseFormData(request, options) + return parseFormData(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} The parsed body data. + */ async function parseFormData( request: HonoRequest | Request, options: ParseBodyOptions @@ -50,33 +84,92 @@ async function parseFormData( 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( 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 + } + }) +}