diff --git a/deno_dist/request.ts b/deno_dist/request.ts index 6fd80d42b..c74d1d2d3 100644 --- a/deno_dist/request.ts +++ b/deno_dist/request.ts @@ -183,11 +183,15 @@ export class HonoRequest

{ * ``` * @see https://hono.dev/api/request#parsebody */ - async parseBody(options?: ParseBodyOptions): Promise { + async parseBody, T extends BodyData>( + options?: Options + ): Promise + async parseBody(options?: Partial): Promise + async parseBody(options?: Partial) { if (this.bodyCache.parsedBody) { - return this.bodyCache.parsedBody as T + return this.bodyCache.parsedBody } - const parsedBody = await parseBody(this, options) + const parsedBody = await parseBody(this, options) this.bodyCache.parsedBody = parsedBody return parsedBody } diff --git a/deno_dist/utils/body.ts b/deno_dist/utils/body.ts index db47b8c23..c4b7a7287 100644 --- a/deno_dist/utils/body.ts +++ b/deno_dist/utils/body.ts @@ -1,7 +1,39 @@ import { HonoRequest } from '../request.ts' -type BodyDataValue = string | File | (string | File)[] | { [key: string]: BodyDataValue } -export type BodyData = Record +type BodyDataValueDot = { [x: string]: string | File | BodyDataValueDot } & {} +type BodyDataValueDotAll = { + [x: string]: string | File | (string | File)[] | BodyDataValueDotAll +} & {} +type SimplifyBodyData = { + [K in keyof T]: string | File | (string | File)[] | BodyDataValueDotAll extends T[K] + ? string | File | (string | File)[] | BodyDataValueDotAll + : string | File | BodyDataValueDot extends T[K] + ? string | File | BodyDataValueDot + : string | File | (string | File)[] extends T[K] + ? string | File | (string | File)[] + : string | File +} & {} + +type BodyDataValueComponent = + | string + | File + | (T extends { all: false } + ? never // explicitly set to false + : T extends { all: true } | { all: boolean } + ? (string | File)[] // use all option + : never) // without options +type BodyDataValueObject = { [key: string]: BodyDataValueComponent | BodyDataValueObject } +type BodyDataValue = + | BodyDataValueComponent + | (T extends { dot: false } + ? never // explicitly set to false + : T extends { dot: true } | { dot: boolean } + ? BodyDataValueObject // use dot option + : never) // without options +export type BodyData = {}> = SimplifyBodyData< + Record> +> + export type ParseBodyOptions = { /** * Determines whether all fields with multiple values should be parsed as arrays. @@ -44,10 +76,20 @@ export type ParseBodyOptions = { * @param {Partial} [options] - Options for parsing the body. * @returns {Promise} The parsed body data. */ -export const parseBody = async ( +interface ParseBody { + , T extends BodyData>( + request: HonoRequest | Request, + options?: Options + ): Promise + ( + request: HonoRequest | Request, + options?: Partial + ): Promise +} +export const parseBody: ParseBody = async ( request: HonoRequest | Request, - options: Partial = Object.create(null) -): Promise => { + options = Object.create(null) +) => { const { all = false, dot = false } = options const headers = request instanceof HonoRequest ? request.raw.headers : request.headers @@ -57,10 +99,10 @@ export const parseBody = async ( (contentType !== null && contentType.startsWith('multipart/form-data')) || (contentType !== null && contentType.startsWith('application/x-www-form-urlencoded')) ) { - return parseFormData(request, { all, dot }) + return parseFormData(request, { all, dot }) } - return {} as T + return {} } /** @@ -71,7 +113,7 @@ export const parseBody = async ( * @param {ParseBodyOptions} options - Options for parsing the form data. * @returns {Promise} The parsed body data. */ -async function parseFormData( +async function parseFormData( request: HonoRequest | Request, options: ParseBodyOptions ): Promise { @@ -92,7 +134,7 @@ async function parseFormData( * @param {ParseBodyOptions} options - Options for parsing the form data. * @returns {T} The converted body data. */ -function convertFormDataToBodyData( +function convertFormDataToBodyData( formData: FormData, options: ParseBodyOptions ): T { @@ -134,7 +176,11 @@ function convertFormDataToBodyData( * @param {string} key - The key to parse. * @param {FormDataEntryValue} value - The value to assign. */ -const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntryValue): void => { +const handleParsingAllValues = ( + form: BodyData<{ all: true }>, + key: string, + value: FormDataEntryValue +): void => { if (form[key] !== undefined) { if (Array.isArray(form[key])) { ;(form[key] as (string | File)[]).push(value) @@ -153,7 +199,11 @@ const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntr * @param {string} key - The dot notation key. * @param {BodyDataValue} value - The value to assign. */ -const handleParsingNestedValues = (form: BodyData, key: string, value: BodyDataValue): void => { +const handleParsingNestedValues = ( + form: BodyData, + key: string, + value: BodyDataValue> +): void => { let nestedForm = form const keys = key.split('.') @@ -169,7 +219,7 @@ const handleParsingNestedValues = (form: BodyData, key: string, value: BodyDataV ) { nestedForm[key] = Object.create(null) } - nestedForm = nestedForm[key] as BodyData + nestedForm = nestedForm[key] as unknown as BodyData } }) } diff --git a/deno_dist/validator/validator.ts b/deno_dist/validator/validator.ts index 20571fd95..564375c2f 100644 --- a/deno_dist/validator/validator.ts +++ b/deno_dist/validator/validator.ts @@ -91,7 +91,7 @@ export const validator = < try { const arrayBuffer = await c.req.arrayBuffer() const formData = await bufferToFormData(arrayBuffer, contentType) - const form: BodyData = {} + const form: BodyData<{ all: true }> = {} formData.forEach((value, key) => { if (key.endsWith('[]')) { if (form[key] === undefined) { diff --git a/src/request.test.ts b/src/request.test.ts index 9163f2a38..d73d38aae 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -1,6 +1,10 @@ import { HonoRequest } from './request' import type { RouterRoute } from './types' +type RecursiveRecord = { + [key in K]: T | RecursiveRecord +} + describe('Query', () => { test('req.query() and req.queries()', () => { const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') @@ -251,4 +255,67 @@ describe('Body methods with caching', () => { expect(async () => await req.arrayBuffer()).not.toThrow() expect(async () => await req.blob()).not.toThrow() }) + + describe('req.parseBody()', async () => { + it('should parse form data', async () => { + const data = new FormData() + data.append('foo', 'bar') + const req = new HonoRequest( + new Request('http://localhost', { + method: 'POST', + body: data, + }) + ) + expect((await req.parseBody())['foo']).toBe('bar') + expect((await req.parseBody())['foo']).toBe('bar') + expect(async () => await req.text()).not.toThrow() + expect(async () => await req.arrayBuffer()).not.toThrow() + expect(async () => await req.blob()).not.toThrow() + }) + + describe('Return type', () => { + let req: HonoRequest + beforeEach(() => { + const data = new FormData() + data.append('foo', 'bar') + req = new HonoRequest( + new Request('http://localhost', { + method: 'POST', + body: data, + }) + ) + }) + + it('without options', async () => { + expectTypeOf((await req.parseBody())['key']).toEqualTypeOf() + }) + + it('{all: true}', async () => { + expectTypeOf((await req.parseBody({ all: true }))['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{dot: true}', async () => { + expectTypeOf((await req.parseBody({ dot: true }))['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{all: true, dot: true}', async () => { + expectTypeOf((await req.parseBody({ all: true, dot: true }))['key']).toEqualTypeOf< + | string + | File + | (string | File)[] + | RecursiveRecord + >() + }) + + it('specify return type explicitly', async () => { + expectTypeOf( + await req.parseBody<{ key1: string; key2: string }>({ all: true, dot: true }) + ).toEqualTypeOf<{ key1: string; key2: string }>() + }) + }) + }) }) diff --git a/src/request.ts b/src/request.ts index 7c028aee5..291e7e233 100644 --- a/src/request.ts +++ b/src/request.ts @@ -183,11 +183,15 @@ export class HonoRequest

{ * ``` * @see https://hono.dev/api/request#parsebody */ - async parseBody(options?: ParseBodyOptions): Promise { + async parseBody, T extends BodyData>( + options?: Options + ): Promise + async parseBody(options?: Partial): Promise + async parseBody(options?: Partial) { if (this.bodyCache.parsedBody) { - return this.bodyCache.parsedBody as T + return this.bodyCache.parsedBody } - const parsedBody = await parseBody(this, options) + const parsedBody = await parseBody(this, options) this.bodyCache.parsedBody = parsedBody return parsedBody } diff --git a/src/utils/body.test.ts b/src/utils/body.test.ts index d04ed0801..6e049802d 100644 --- a/src/utils/body.test.ts +++ b/src/utils/body.test.ts @@ -1,4 +1,8 @@ -import { parseBody } from './body' +import { parseBody, type BodyData } from './body' + +type RecursiveRecord = { + [key in K]: T | RecursiveRecord +} describe('Parse Body Util', () => { const FORM_URL = 'https://localhost/form' @@ -240,4 +244,110 @@ describe('Parse Body Util', () => { expect(await parseBody(req)).toEqual({}) }) + + describe('Return type', () => { + let req: Request + beforeEach(() => { + req = createRequest(FORM_URL, 'POST', new FormData()) + }) + + it('without options', async () => { + expectTypeOf((await parseBody(req))['key']).toEqualTypeOf() + }) + + it('{all: true}', async () => { + expectTypeOf((await parseBody(req, { all: true }))['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{all: boolean}', async () => { + expectTypeOf((await parseBody(req, { all: !!Math.random() }))['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{dot: true}', async () => { + expectTypeOf((await parseBody(req, { dot: true }))['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{dot: boolean}', async () => { + expectTypeOf((await parseBody(req, { dot: !!Math.random() }))['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{all: true, dot: true}', async () => { + expectTypeOf((await parseBody(req, { all: true, dot: true }))['key']).toEqualTypeOf< + | string + | File + | (string | File)[] + | RecursiveRecord + >() + }) + + it('{all: boolean, dot: boolean}', async () => { + expectTypeOf( + (await parseBody(req, { all: !!Math.random(), dot: !!Math.random() }))['key'] + ).toEqualTypeOf< + | string + | File + | (string | File)[] + | RecursiveRecord + >() + }) + + it('specify return type explicitly', async () => { + expectTypeOf( + await parseBody<{ key1: string; key2: string }>(req, { + all: !!Math.random(), + dot: !!Math.random(), + }) + ).toEqualTypeOf<{ key1: string; key2: string }>() + }) + }) +}) + +describe('BodyData', () => { + it('without options', async () => { + expectTypeOf(({} as BodyData)['key']).toEqualTypeOf() + }) + + it('{all: true}', async () => { + expectTypeOf(({} as BodyData<{ all: true }>)['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{all: boolean}', async () => { + expectTypeOf(({} as BodyData<{ all: boolean }>)['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{dot: true}', async () => { + expectTypeOf(({} as BodyData<{ dot: true }>)['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{dot: boolean}', async () => { + expectTypeOf(({} as BodyData<{ dot: boolean }>)['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{all: true, dot: true}', async () => { + expectTypeOf(({} as BodyData<{ all: true; dot: true }>)['key']).toEqualTypeOf< + string | File | (string | File)[] | RecursiveRecord + >() + }) + + it('{all: boolean, dot: boolean}', async () => { + expectTypeOf(({} as BodyData<{ all: boolean; dot: boolean }>)['key']).toEqualTypeOf< + string | File | (string | File)[] | RecursiveRecord + >() + }) }) diff --git a/src/utils/body.ts b/src/utils/body.ts index 8013dd212..4f08ccdef 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,7 +1,39 @@ import { HonoRequest } from '../request' -type BodyDataValue = string | File | (string | File)[] | { [key: string]: BodyDataValue } -export type BodyData = Record +type BodyDataValueDot = { [x: string]: string | File | BodyDataValueDot } & {} +type BodyDataValueDotAll = { + [x: string]: string | File | (string | File)[] | BodyDataValueDotAll +} & {} +type SimplifyBodyData = { + [K in keyof T]: string | File | (string | File)[] | BodyDataValueDotAll extends T[K] + ? string | File | (string | File)[] | BodyDataValueDotAll + : string | File | BodyDataValueDot extends T[K] + ? string | File | BodyDataValueDot + : string | File | (string | File)[] extends T[K] + ? string | File | (string | File)[] + : string | File +} & {} + +type BodyDataValueComponent = + | string + | File + | (T extends { all: false } + ? never // explicitly set to false + : T extends { all: true } | { all: boolean } + ? (string | File)[] // use all option + : never) // without options +type BodyDataValueObject = { [key: string]: BodyDataValueComponent | BodyDataValueObject } +type BodyDataValue = + | BodyDataValueComponent + | (T extends { dot: false } + ? never // explicitly set to false + : T extends { dot: true } | { dot: boolean } + ? BodyDataValueObject // use dot option + : never) // without options +export type BodyData = {}> = SimplifyBodyData< + Record> +> + export type ParseBodyOptions = { /** * Determines whether all fields with multiple values should be parsed as arrays. @@ -44,10 +76,20 @@ export type ParseBodyOptions = { * @param {Partial} [options] - Options for parsing the body. * @returns {Promise} The parsed body data. */ -export const parseBody = async ( +interface ParseBody { + , T extends BodyData>( + request: HonoRequest | Request, + options?: Options + ): Promise + ( + request: HonoRequest | Request, + options?: Partial + ): Promise +} +export const parseBody: ParseBody = async ( request: HonoRequest | Request, - options: Partial = Object.create(null) -): Promise => { + options = Object.create(null) +) => { const { all = false, dot = false } = options const headers = request instanceof HonoRequest ? request.raw.headers : request.headers @@ -57,10 +99,10 @@ export const parseBody = async ( (contentType !== null && contentType.startsWith('multipart/form-data')) || (contentType !== null && contentType.startsWith('application/x-www-form-urlencoded')) ) { - return parseFormData(request, { all, dot }) + return parseFormData(request, { all, dot }) } - return {} as T + return {} } /** @@ -71,7 +113,7 @@ export const parseBody = async ( * @param {ParseBodyOptions} options - Options for parsing the form data. * @returns {Promise} The parsed body data. */ -async function parseFormData( +async function parseFormData( request: HonoRequest | Request, options: ParseBodyOptions ): Promise { @@ -92,7 +134,7 @@ async function parseFormData( * @param {ParseBodyOptions} options - Options for parsing the form data. * @returns {T} The converted body data. */ -function convertFormDataToBodyData( +function convertFormDataToBodyData( formData: FormData, options: ParseBodyOptions ): T { @@ -134,7 +176,11 @@ function convertFormDataToBodyData( * @param {string} key - The key to parse. * @param {FormDataEntryValue} value - The value to assign. */ -const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntryValue): void => { +const handleParsingAllValues = ( + form: BodyData<{ all: true }>, + key: string, + value: FormDataEntryValue +): void => { if (form[key] !== undefined) { if (Array.isArray(form[key])) { ;(form[key] as (string | File)[]).push(value) @@ -153,7 +199,11 @@ const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntr * @param {string} key - The dot notation key. * @param {BodyDataValue} value - The value to assign. */ -const handleParsingNestedValues = (form: BodyData, key: string, value: BodyDataValue): void => { +const handleParsingNestedValues = ( + form: BodyData, + key: string, + value: BodyDataValue> +): void => { let nestedForm = form const keys = key.split('.') @@ -169,7 +219,7 @@ const handleParsingNestedValues = (form: BodyData, key: string, value: BodyDataV ) { nestedForm[key] = Object.create(null) } - nestedForm = nestedForm[key] as BodyData + nestedForm = nestedForm[key] as unknown as BodyData } }) } diff --git a/src/validator/validator.ts b/src/validator/validator.ts index 4b4f1b871..cf66d0e35 100644 --- a/src/validator/validator.ts +++ b/src/validator/validator.ts @@ -91,7 +91,7 @@ export const validator = < try { const arrayBuffer = await c.req.arrayBuffer() const formData = await bufferToFormData(arrayBuffer, contentType) - const form: BodyData = {} + const form: BodyData<{ all: true }> = {} formData.forEach((value, key) => { if (key.endsWith('[]')) { if (form[key] === undefined) {