Skip to content

Commit

Permalink
feat: add event handler generics for typed request body and query (#417)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <pooya@pi0.io>
  • Loading branch information
danielroe and pi0 authored Jul 27, 2023
1 parent 29445c4 commit fd687b3
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 29 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ jobs:
- run: pnpm install
- run: pnpm lint
- run: pnpm build
- run: pnpm test:types
- run: pnpm vitest --coverage && rm -rf coverage/tmp
- uses: codecov/codecov-action@v3
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ coverage
.profile
.idea
.eslintcache
tsconfig.vitest-temp.json
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"play": "listhen ./playground/app.ts",
"profile": "0x -o -D .profile -P 'autocannon -c 100 -p 10 -d 40 http://localhost:$PORT' ./playground/server.cjs",
"release": "pnpm test && pnpm build && changelogen --release && pnpm publish && git push --follow-tags",
"test": "pnpm lint && vitest run --coverage"
"test": "pnpm lint && vitest --run typecheck && vitest --run --coverage",
"test:types": "vitest typecheck"
},
"dependencies": {
"cookie-es": "^1.0.0",
Expand Down
8 changes: 6 additions & 2 deletions src/event/event.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { IncomingHttpHeaders } from "node:http";
import type { H3EventContext, HTTPMethod } from "../types";
import type { H3EventContext, HTTPMethod, EventHandlerRequest } from "../types";
import type { NodeIncomingMessage, NodeServerResponse } from "../node";
import {
MIMES,
Expand All @@ -25,7 +25,11 @@ export interface NodeEventContext {
res: NodeServerResponse;
}

export class H3Event implements Pick<FetchEvent, "respondWith"> {
export class H3Event<
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_RequestT extends EventHandlerRequest = EventHandlerRequest
> implements Pick<FetchEvent, "respondWith">
{
"__is_event__" = true;

// Context
Expand Down
32 changes: 28 additions & 4 deletions src/event/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import type { EventHandler, LazyEventHandler } from "../types";
import type {
EventHandler,
LazyEventHandler,
EventHandlerRequest,
EventHandlerResponse,
} from "../types";

export function defineEventHandler<T = any>(
handler: EventHandler<T>
): EventHandler<T> {
export function defineEventHandler<
Request extends EventHandlerRequest = EventHandlerRequest,
Response = any
>(handler: EventHandler<Request, Response>): EventHandler<Request, Response>;
// TODO: remove when appropriate
// This signature provides backwards compatibility with previous signature where first generic was return type
export function defineEventHandler<
Request = EventHandlerRequest,
Response = EventHandlerResponse
>(
handler: EventHandler<
Request extends EventHandlerRequest ? Request : any,
Request extends EventHandlerRequest ? Response : Request
>
): EventHandler<
Request extends EventHandlerRequest ? Request : any,
Request extends EventHandlerRequest ? Response : Request
>;
export function defineEventHandler<
Request extends EventHandlerRequest = EventHandlerRequest,
Response = EventHandlerResponse
>(handler: EventHandler<Request, Response>): EventHandler<Request, Response> {
handler.__is_handler__ = true;
return handler;
}
Expand Down
23 changes: 20 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { QueryObject } from "ufo";
import type { H3Event } from "./event";
import { Session } from "./utils/session";
import type { Session } from "./utils/session";

export type {
ValidateFunction,
Expand Down Expand Up @@ -40,9 +41,25 @@ export interface H3EventContext extends Record<string, any> {

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

export interface EventHandler<T = any> {
export interface EventHandlerRequest {
// TODO: Default to unknown in next major version
body?: any;

query?: QueryObject;
}

export type InferEventInput<
Key extends keyof EventHandlerRequest,
Event extends H3Event,
T
> = void extends T ? (Event extends H3Event<infer E> ? E[Key] : never) : T;

export interface EventHandler<
Request extends EventHandlerRequest = EventHandlerRequest,
Response extends EventHandlerResponse = EventHandlerResponse
> {
__is_handler__?: true;
(event: H3Event): EventHandlerResponse<T>;
(event: H3Event<Request>): Response;
}

export type LazyEventHandler = () => EventHandler | Promise<EventHandler>;
Expand Down
25 changes: 14 additions & 11 deletions src/utils/body.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IncomingMessage } from "node:http";
import destr from "destr";
import type { Encoding, HTTPMethod } from "../types";
import type { Encoding, HTTPMethod, InferEventInput } from "../types";
import type { H3Event } from "../event";
import { createError } from "../error";
import { parse as parseMultipartData } from "./internal/multipart";
Expand Down Expand Up @@ -91,13 +91,15 @@ export function readRawBody<E extends Encoding = "utf8">(
* const body = await readBody(event)
* ```
*/
export async function readBody<T = any>(
event: H3Event,
options: { strict?: boolean } = {}
): Promise<T | undefined | string> {

export async function readBody<
T,
Event extends H3Event = H3Event,
_T = InferEventInput<"body", Event, T>
>(event: Event, options: { strict?: boolean } = {}): Promise<_T> {
const request = event.node.req as InternalRequest<T>;
if (ParsedBodySymbol in request) {
return request[ParsedBodySymbol];
return request[ParsedBodySymbol] as _T;
}

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

request[ParsedBodySymbol] = parsed;
return parsed;
return parsed as unknown as _T;
}

export async function readValidatedBody<T>(
event: H3Event,
validate: ValidateFunction<T>
): Promise<T> {
export async function readValidatedBody<
T,
Event extends H3Event = H3Event,
_T = InferEventInput<"body", Event, T>
>(event: Event, validate: ValidateFunction<_T>): Promise<_T> {
const _body = await readBody(event, { strict: true });
return validateData(_body, validate);
}
Expand Down
19 changes: 12 additions & 7 deletions src/utils/request.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { getQuery as _getQuery } from "ufo";
import { createError } from "../error";
import type { HTTPMethod, RequestHeaders } from "../types";
import type { HTTPMethod, InferEventInput, RequestHeaders } from "../types";
import type { H3Event } from "../event";
import { validateData, ValidateFunction } from "./internal/validate";

export function getQuery(event: H3Event) {
return _getQuery(event.path || "");
export function getQuery<
T,
Event extends H3Event = H3Event,
_T = Exclude<InferEventInput<"query", Event, T>, undefined>
>(event: Event): _T {
return _getQuery(event.path || "") as _T;
}

export function getValidatedQuery<T>(
event: H3Event,
validate: ValidateFunction<T>
): Promise<T> {
export function getValidatedQuery<
T,
Event extends H3Event = H3Event,
_T = InferEventInput<"query", Event, T>
>(event: Event, validate: ValidateFunction<_T>): Promise<_T> {
const query = getQuery(event);
return validateData(query, validate);
}
Expand Down
104 changes: 104 additions & 0 deletions test/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, it, expectTypeOf } from "vitest";
import type { QueryObject } from "ufo";
import {
eventHandler,
H3Event,
getQuery,
readBody,
readValidatedBody,
getValidatedQuery,
} from "../src";

describe("types", () => {
describe("eventHandler", () => {
it("return type (inferred)", () => {
const handler = eventHandler(() => {
return {
foo: "bar",
};
});
const response = handler({} as H3Event);
expectTypeOf(response).toEqualTypeOf<{ foo: string }>();
});

it("return type (simple generic)", () => {
const handler = eventHandler<string>(() => {
return "";
});
const response = handler({} as H3Event);
expectTypeOf(response).toEqualTypeOf<string>();
});
});

describe("readBody", () => {
it("untyped", () => {
eventHandler(async (event) => {
const body = await readBody(event);
// TODO: Default to unknown in next major version
expectTypeOf(body).toBeAny();
});
});

it("typed via generic", () => {
eventHandler(async (event) => {
const body = await readBody<string>(event);
expectTypeOf(body).not.toBeAny();
expectTypeOf(body).toBeString();
});
});

it("typed via validator", () => {
eventHandler(async (event) => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const validator = (body: unknown) => body as { id: string };
const body = await readValidatedBody(event, validator);
expectTypeOf(body).not.toBeAny();
expectTypeOf(body).toEqualTypeOf<{ id: string }>();
});
});

it("typed via event handler", () => {
eventHandler<{ body: { id: string } }>(async (event) => {
const body = await readBody(event);
expectTypeOf(body).not.toBeAny();
expectTypeOf(body).toEqualTypeOf<{ id: string }>();
});
});
});

describe("getQuery", () => {
it("untyped", () => {
eventHandler((event) => {
const query = getQuery(event);
expectTypeOf(query).not.toBeAny();
expectTypeOf(query).toEqualTypeOf<QueryObject>();
});
});

it("typed via generic", () => {
eventHandler((event) => {
const query = getQuery<{ id: string }>(event);
expectTypeOf(query).not.toBeAny();
expectTypeOf(query).toEqualTypeOf<{ id: string }>();
});
});

it("typed via validator", () => {
eventHandler(async (event) => {
// eslint-disable-next-line unicorn/consistent-function-scoping
const validator = (body: unknown) => body as { id: string };
const body = await getValidatedQuery(event, validator);
expectTypeOf(body).not.toBeAny();
expectTypeOf(body).toEqualTypeOf<{ id: string }>();
});
});

it("typed via event handler", () => {
eventHandler<{ query: { id: string } }>((event) => {
const query = getQuery(event);
expectTypeOf(query).not.toBeAny();
expectTypeOf(query).toEqualTypeOf<{ id: string }>();
});
});
});
});
5 changes: 4 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"baseUrl": ".",
"target": "ESNext",
"module": "ESNext",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "Node",
"lib": [
"WebWorker",
Expand All @@ -16,6 +18,7 @@
]
},
"include": [
"src"
"src",
"test/types.test-d.ts"
]
}

0 comments on commit fd687b3

Please sign in to comment.