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: add event handler generics for typed request body and query #417

Merged
merged 19 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
* 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;

Check warning on line 102 in src/utils/body.ts

View check run for this annotation

Codecov / codecov/patch

src/utils/body.ts#L102

Added line #L102 was not covered by tests
}

const contentType = request.headers["content-type"] || "";
Expand All @@ -116,13 +118,14 @@
}

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"
]
}