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): specify detailed return type for parseBody #2771

Merged
merged 13 commits into from
May 24, 2024
10 changes: 7 additions & 3 deletions deno_dist/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,15 @@ export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
* ```
* @see https://hono.dev/api/request#parsebody
*/
async parseBody<T extends BodyData = BodyData>(options?: ParseBodyOptions): Promise<T> {
async parseBody<Options extends Partial<ParseBodyOptions>, T extends BodyData<Options>>(
options?: Options
): Promise<T>
async parseBody<T extends BodyData>(options?: Partial<ParseBodyOptions>): Promise<T>
async parseBody(options?: Partial<ParseBodyOptions>) {
if (this.bodyCache.parsedBody) {
return this.bodyCache.parsedBody as T
return this.bodyCache.parsedBody
}
const parsedBody = await parseBody<T>(this, options)
const parsedBody = await parseBody(this, options)
this.bodyCache.parsedBody = parsedBody
return parsedBody
}
Expand Down
74 changes: 62 additions & 12 deletions deno_dist/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,39 @@
import { HonoRequest } from '../request.ts'

type BodyDataValue = string | File | (string | File)[] | { [key: string]: BodyDataValue }
export type BodyData = Record<string, BodyDataValue>
type BodyDataValueDot = { [x: string]: string | File | BodyDataValueDot } & {}
type BodyDataValueDotAll = {
[x: string]: string | File | (string | File)[] | BodyDataValueDotAll
} & {}
type SimplifyBodyData<T> = {
[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<T> =
| 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<T> = { [key: string]: BodyDataValueComponent<T> | BodyDataValueObject<T> }
type BodyDataValue<T> =
| BodyDataValueComponent<T>
| (T extends { dot: false }
? never // explicitly set to false
: T extends { dot: true } | { dot: boolean }
? BodyDataValueObject<T> // use dot option
: never) // without options
export type BodyData<T extends Partial<ParseBodyOptions> = {}> = SimplifyBodyData<
Record<string, BodyDataValue<T>>
>

export type ParseBodyOptions = {
/**
* Determines whether all fields with multiple values should be parsed as arrays.
Expand Down Expand Up @@ -44,10 +76,20 @@ export type ParseBodyOptions = {
* @param {Partial<ParseBodyOptions>} [options] - Options for parsing the body.
* @returns {Promise<T>} The parsed body data.
*/
export const parseBody = async <T extends BodyData = BodyData>(
interface ParseBody {
<Options extends Partial<ParseBodyOptions>, T extends BodyData<Options>>(
request: HonoRequest | Request,
options?: Options
): Promise<T>
<T extends BodyData>(
request: HonoRequest | Request,
options?: Partial<ParseBodyOptions>
): Promise<T>
}
export const parseBody: ParseBody = async (
request: HonoRequest | Request,
options: Partial<ParseBodyOptions> = Object.create(null)
): Promise<T> => {
options = Object.create(null)
) => {
const { all = false, dot = false } = options

const headers = request instanceof HonoRequest ? request.raw.headers : request.headers
Expand All @@ -57,10 +99,10 @@ export const parseBody = async <T extends BodyData = BodyData>(
(contentType !== null && contentType.startsWith('multipart/form-data')) ||
(contentType !== null && contentType.startsWith('application/x-www-form-urlencoded'))
) {
return parseFormData<T>(request, { all, dot })
return parseFormData(request, { all, dot })
}

return {} as T
return {}
}

/**
Expand All @@ -71,7 +113,7 @@ export const parseBody = async <T extends BodyData = BodyData>(
* @param {ParseBodyOptions} options - Options for parsing the form data.
* @returns {Promise<T>} The parsed body data.
*/
async function parseFormData<T extends BodyData = BodyData>(
async function parseFormData<T extends BodyData>(
request: HonoRequest | Request,
options: ParseBodyOptions
): Promise<T> {
Expand All @@ -92,7 +134,7 @@ async function parseFormData<T extends BodyData = BodyData>(
* @param {ParseBodyOptions} options - Options for parsing the form data.
* @returns {T} The converted body data.
*/
function convertFormDataToBodyData<T extends BodyData = BodyData>(
function convertFormDataToBodyData<T extends BodyData>(
formData: FormData,
options: ParseBodyOptions
): T {
Expand Down Expand Up @@ -134,7 +176,11 @@ function convertFormDataToBodyData<T extends BodyData = BodyData>(
* @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)
Expand All @@ -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<Partial<ParseBodyOptions>>
): void => {
let nestedForm = form
const keys = key.split('.')

Expand All @@ -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
}
})
}
2 changes: 1 addition & 1 deletion deno_dist/validator/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions src/request.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { HonoRequest } from './request'
import type { RouterRoute } from './types'

type RecursiveRecord<K extends string, T> = {
[key in K]: T | RecursiveRecord<K, T>
}

describe('Query', () => {
test('req.query() and req.queries()', () => {
const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B')
Expand Down Expand Up @@ -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<string | File>()
})

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<string, string | File>
>()
})

it('{all: true, dot: true}', async () => {
expectTypeOf((await req.parseBody({ all: true, dot: true }))['key']).toEqualTypeOf<
| string
| File
| (string | File)[]
| RecursiveRecord<string, string | File | (string | File)[]>
>()
})

it('specify return type explicitly', async () => {
expectTypeOf(
await req.parseBody<{ key1: string; key2: string }>({ all: true, dot: true })
).toEqualTypeOf<{ key1: string; key2: string }>()
})
})
})
})
10 changes: 7 additions & 3 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,15 @@ export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
* ```
* @see https://hono.dev/api/request#parsebody
*/
async parseBody<T extends BodyData = BodyData>(options?: ParseBodyOptions): Promise<T> {
async parseBody<Options extends Partial<ParseBodyOptions>, T extends BodyData<Options>>(
options?: Options
): Promise<T>
async parseBody<T extends BodyData>(options?: Partial<ParseBodyOptions>): Promise<T>
async parseBody(options?: Partial<ParseBodyOptions>) {
if (this.bodyCache.parsedBody) {
return this.bodyCache.parsedBody as T
return this.bodyCache.parsedBody
}
const parsedBody = await parseBody<T>(this, options)
const parsedBody = await parseBody(this, options)
this.bodyCache.parsedBody = parsedBody
return parsedBody
}
Expand Down
112 changes: 111 additions & 1 deletion src/utils/body.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { parseBody } from './body'
import { parseBody, type BodyData } from './body'

type RecursiveRecord<K extends string, T> = {
[key in K]: T | RecursiveRecord<K, T>
}

describe('Parse Body Util', () => {
const FORM_URL = 'https://localhost/form'
Expand Down Expand Up @@ -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<string | File>()
})

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<string, string | File>
>()
})

it('{dot: boolean}', async () => {
expectTypeOf((await parseBody(req, { dot: !!Math.random() }))['key']).toEqualTypeOf<
string | File | RecursiveRecord<string, string | File>
>()
})

it('{all: true, dot: true}', async () => {
expectTypeOf((await parseBody(req, { all: true, dot: true }))['key']).toEqualTypeOf<
| string
| File
| (string | File)[]
| RecursiveRecord<string, string | File | (string | File)[]>
>()
})

it('{all: boolean, dot: boolean}', async () => {
expectTypeOf(
(await parseBody(req, { all: !!Math.random(), dot: !!Math.random() }))['key']
).toEqualTypeOf<
| string
| File
| (string | File)[]
| RecursiveRecord<string, string | File | (string | File)[]>
>()
})

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<string | File>()
})

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<string, string | File>
>()
})

it('{dot: boolean}', async () => {
expectTypeOf(({} as BodyData<{ dot: boolean }>)['key']).toEqualTypeOf<
string | File | RecursiveRecord<string, string | File>
>()
})

it('{all: true, dot: true}', async () => {
expectTypeOf(({} as BodyData<{ all: true; dot: true }>)['key']).toEqualTypeOf<
string | File | (string | File)[] | RecursiveRecord<string, string | File | (string | File)[]>
>()
})

it('{all: boolean, dot: boolean}', async () => {
expectTypeOf(({} as BodyData<{ all: boolean; dot: boolean }>)['key']).toEqualTypeOf<
string | File | (string | File)[] | RecursiveRecord<string, string | File | (string | File)[]>
>()
})
})
Loading