Skip to content

Commit fd687b3

Browse files
danielroepi0
andauthored
feat: add event handler generics for typed request body and query (#417)
Co-authored-by: Pooya Parsa <pooya@pi0.io>
1 parent 29445c4 commit fd687b3

File tree

10 files changed

+192
-29
lines changed

10 files changed

+192
-29
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ jobs:
2424
- run: pnpm install
2525
- run: pnpm lint
2626
- run: pnpm build
27+
- run: pnpm test:types
2728
- run: pnpm vitest --coverage && rm -rf coverage/tmp
2829
- uses: codecov/codecov-action@v3

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ coverage
66
.profile
77
.idea
88
.eslintcache
9+
tsconfig.vitest-temp.json

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
"play": "listhen ./playground/app.ts",
2828
"profile": "0x -o -D .profile -P 'autocannon -c 100 -p 10 -d 40 http://localhost:$PORT' ./playground/server.cjs",
2929
"release": "pnpm test && pnpm build && changelogen --release && pnpm publish && git push --follow-tags",
30-
"test": "pnpm lint && vitest run --coverage"
30+
"test": "pnpm lint && vitest --run typecheck && vitest --run --coverage",
31+
"test:types": "vitest typecheck"
3132
},
3233
"dependencies": {
3334
"cookie-es": "^1.0.0",

src/event/event.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { IncomingHttpHeaders } from "node:http";
2-
import type { H3EventContext, HTTPMethod } from "../types";
2+
import type { H3EventContext, HTTPMethod, EventHandlerRequest } from "../types";
33
import type { NodeIncomingMessage, NodeServerResponse } from "../node";
44
import {
55
MIMES,
@@ -25,7 +25,11 @@ export interface NodeEventContext {
2525
res: NodeServerResponse;
2626
}
2727

28-
export class H3Event implements Pick<FetchEvent, "respondWith"> {
28+
export class H3Event<
29+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
30+
_RequestT extends EventHandlerRequest = EventHandlerRequest
31+
> implements Pick<FetchEvent, "respondWith">
32+
{
2933
"__is_event__" = true;
3034

3135
// Context

src/event/utils.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
1-
import type { EventHandler, LazyEventHandler } from "../types";
1+
import type {
2+
EventHandler,
3+
LazyEventHandler,
4+
EventHandlerRequest,
5+
EventHandlerResponse,
6+
} from "../types";
27

3-
export function defineEventHandler<T = any>(
4-
handler: EventHandler<T>
5-
): EventHandler<T> {
8+
export function defineEventHandler<
9+
Request extends EventHandlerRequest = EventHandlerRequest,
10+
Response = any
11+
>(handler: EventHandler<Request, Response>): EventHandler<Request, Response>;
12+
// TODO: remove when appropriate
13+
// This signature provides backwards compatibility with previous signature where first generic was return type
14+
export function defineEventHandler<
15+
Request = EventHandlerRequest,
16+
Response = EventHandlerResponse
17+
>(
18+
handler: EventHandler<
19+
Request extends EventHandlerRequest ? Request : any,
20+
Request extends EventHandlerRequest ? Response : Request
21+
>
22+
): EventHandler<
23+
Request extends EventHandlerRequest ? Request : any,
24+
Request extends EventHandlerRequest ? Response : Request
25+
>;
26+
export function defineEventHandler<
27+
Request extends EventHandlerRequest = EventHandlerRequest,
28+
Response = EventHandlerResponse
29+
>(handler: EventHandler<Request, Response>): EventHandler<Request, Response> {
630
handler.__is_handler__ = true;
731
return handler;
832
}

src/types.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import type { QueryObject } from "ufo";
12
import type { H3Event } from "./event";
2-
import { Session } from "./utils/session";
3+
import type { Session } from "./utils/session";
34

45
export type {
56
ValidateFunction,
@@ -40,9 +41,25 @@ export interface H3EventContext extends Record<string, any> {
4041

4142
export type EventHandlerResponse<T = any> = T | Promise<T>;
4243

43-
export interface EventHandler<T = any> {
44+
export interface EventHandlerRequest {
45+
// TODO: Default to unknown in next major version
46+
body?: any;
47+
48+
query?: QueryObject;
49+
}
50+
51+
export type InferEventInput<
52+
Key extends keyof EventHandlerRequest,
53+
Event extends H3Event,
54+
T
55+
> = void extends T ? (Event extends H3Event<infer E> ? E[Key] : never) : T;
56+
57+
export interface EventHandler<
58+
Request extends EventHandlerRequest = EventHandlerRequest,
59+
Response extends EventHandlerResponse = EventHandlerResponse
60+
> {
4461
__is_handler__?: true;
45-
(event: H3Event): EventHandlerResponse<T>;
62+
(event: H3Event<Request>): Response;
4663
}
4764

4865
export type LazyEventHandler = () => EventHandler | Promise<EventHandler>;

src/utils/body.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { IncomingMessage } from "node:http";
22
import destr from "destr";
3-
import type { Encoding, HTTPMethod } from "../types";
3+
import type { Encoding, HTTPMethod, InferEventInput } from "../types";
44
import type { H3Event } from "../event";
55
import { createError } from "../error";
66
import { parse as parseMultipartData } from "./internal/multipart";
@@ -91,13 +91,15 @@ export function readRawBody<E extends Encoding = "utf8">(
9191
* const body = await readBody(event)
9292
* ```
9393
*/
94-
export async function readBody<T = any>(
95-
event: H3Event,
96-
options: { strict?: boolean } = {}
97-
): Promise<T | undefined | string> {
94+
95+
export async function readBody<
96+
T,
97+
Event extends H3Event = H3Event,
98+
_T = InferEventInput<"body", Event, T>
99+
>(event: Event, options: { strict?: boolean } = {}): Promise<_T> {
98100
const request = event.node.req as InternalRequest<T>;
99101
if (ParsedBodySymbol in request) {
100-
return request[ParsedBodySymbol];
102+
return request[ParsedBodySymbol] as _T;
101103
}
102104

103105
const contentType = request.headers["content-type"] || "";
@@ -116,13 +118,14 @@ export async function readBody<T = any>(
116118
}
117119

118120
request[ParsedBodySymbol] = parsed;
119-
return parsed;
121+
return parsed as unknown as _T;
120122
}
121123

122-
export async function readValidatedBody<T>(
123-
event: H3Event,
124-
validate: ValidateFunction<T>
125-
): Promise<T> {
124+
export async function readValidatedBody<
125+
T,
126+
Event extends H3Event = H3Event,
127+
_T = InferEventInput<"body", Event, T>
128+
>(event: Event, validate: ValidateFunction<_T>): Promise<_T> {
126129
const _body = await readBody(event, { strict: true });
127130
return validateData(_body, validate);
128131
}

src/utils/request.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { getQuery as _getQuery } from "ufo";
22
import { createError } from "../error";
3-
import type { HTTPMethod, RequestHeaders } from "../types";
3+
import type { HTTPMethod, InferEventInput, RequestHeaders } from "../types";
44
import type { H3Event } from "../event";
55
import { validateData, ValidateFunction } from "./internal/validate";
66

7-
export function getQuery(event: H3Event) {
8-
return _getQuery(event.path || "");
7+
export function getQuery<
8+
T,
9+
Event extends H3Event = H3Event,
10+
_T = Exclude<InferEventInput<"query", Event, T>, undefined>
11+
>(event: Event): _T {
12+
return _getQuery(event.path || "") as _T;
913
}
1014

11-
export function getValidatedQuery<T>(
12-
event: H3Event,
13-
validate: ValidateFunction<T>
14-
): Promise<T> {
15+
export function getValidatedQuery<
16+
T,
17+
Event extends H3Event = H3Event,
18+
_T = InferEventInput<"query", Event, T>
19+
>(event: Event, validate: ValidateFunction<_T>): Promise<_T> {
1520
const query = getQuery(event);
1621
return validateData(query, validate);
1722
}

test/types.test-d.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expectTypeOf } from "vitest";
2+
import type { QueryObject } from "ufo";
3+
import {
4+
eventHandler,
5+
H3Event,
6+
getQuery,
7+
readBody,
8+
readValidatedBody,
9+
getValidatedQuery,
10+
} from "../src";
11+
12+
describe("types", () => {
13+
describe("eventHandler", () => {
14+
it("return type (inferred)", () => {
15+
const handler = eventHandler(() => {
16+
return {
17+
foo: "bar",
18+
};
19+
});
20+
const response = handler({} as H3Event);
21+
expectTypeOf(response).toEqualTypeOf<{ foo: string }>();
22+
});
23+
24+
it("return type (simple generic)", () => {
25+
const handler = eventHandler<string>(() => {
26+
return "";
27+
});
28+
const response = handler({} as H3Event);
29+
expectTypeOf(response).toEqualTypeOf<string>();
30+
});
31+
});
32+
33+
describe("readBody", () => {
34+
it("untyped", () => {
35+
eventHandler(async (event) => {
36+
const body = await readBody(event);
37+
// TODO: Default to unknown in next major version
38+
expectTypeOf(body).toBeAny();
39+
});
40+
});
41+
42+
it("typed via generic", () => {
43+
eventHandler(async (event) => {
44+
const body = await readBody<string>(event);
45+
expectTypeOf(body).not.toBeAny();
46+
expectTypeOf(body).toBeString();
47+
});
48+
});
49+
50+
it("typed via validator", () => {
51+
eventHandler(async (event) => {
52+
// eslint-disable-next-line unicorn/consistent-function-scoping
53+
const validator = (body: unknown) => body as { id: string };
54+
const body = await readValidatedBody(event, validator);
55+
expectTypeOf(body).not.toBeAny();
56+
expectTypeOf(body).toEqualTypeOf<{ id: string }>();
57+
});
58+
});
59+
60+
it("typed via event handler", () => {
61+
eventHandler<{ body: { id: string } }>(async (event) => {
62+
const body = await readBody(event);
63+
expectTypeOf(body).not.toBeAny();
64+
expectTypeOf(body).toEqualTypeOf<{ id: string }>();
65+
});
66+
});
67+
});
68+
69+
describe("getQuery", () => {
70+
it("untyped", () => {
71+
eventHandler((event) => {
72+
const query = getQuery(event);
73+
expectTypeOf(query).not.toBeAny();
74+
expectTypeOf(query).toEqualTypeOf<QueryObject>();
75+
});
76+
});
77+
78+
it("typed via generic", () => {
79+
eventHandler((event) => {
80+
const query = getQuery<{ id: string }>(event);
81+
expectTypeOf(query).not.toBeAny();
82+
expectTypeOf(query).toEqualTypeOf<{ id: string }>();
83+
});
84+
});
85+
86+
it("typed via validator", () => {
87+
eventHandler(async (event) => {
88+
// eslint-disable-next-line unicorn/consistent-function-scoping
89+
const validator = (body: unknown) => body as { id: string };
90+
const body = await getValidatedQuery(event, validator);
91+
expectTypeOf(body).not.toBeAny();
92+
expectTypeOf(body).toEqualTypeOf<{ id: string }>();
93+
});
94+
});
95+
96+
it("typed via event handler", () => {
97+
eventHandler<{ query: { id: string } }>((event) => {
98+
const query = getQuery(event);
99+
expectTypeOf(query).not.toBeAny();
100+
expectTypeOf(query).toEqualTypeOf<{ id: string }>();
101+
});
102+
});
103+
});
104+
});

tsconfig.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"baseUrl": ".",
44
"target": "ESNext",
55
"module": "ESNext",
6+
"skipLibCheck": true,
7+
"allowSyntheticDefaultImports": true,
68
"moduleResolution": "Node",
79
"lib": [
810
"WebWorker",
@@ -16,6 +18,7 @@
1618
]
1719
},
1820
"include": [
19-
"src"
21+
"src",
22+
"test/types.test-d.ts"
2023
]
2124
}

0 commit comments

Comments
 (0)