diff --git a/src/event.ts b/src/event.ts index 004ecb7a..6d9d40df 100644 --- a/src/event.ts +++ b/src/event.ts @@ -56,7 +56,11 @@ export class H3Event< /** * Event context. */ - readonly context: H3EventContext; + readonly context: H3EventContext<_RequestT["routerParams"]> & { + params: _RequestT["routerParams"] extends undefined + ? undefined + : _RequestT["routerParams"]; + }; /** * @internal diff --git a/src/h3.ts b/src/h3.ts index c7894496..324e6325 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -30,6 +30,7 @@ import type { import { toRequest } from "./utils/request.ts"; import { toEventHandler } from "./handler.ts"; +import type { RouteParams } from "./types/_utils.ts"; export const NoHandler: EventHandler = () => kNotFound; @@ -167,18 +168,36 @@ export const H3 = /* @__PURE__ */ (() => { return this; } + on( + method: HTTPMethod | Lowercase | "", + route: Route, + handler: EventHandler<{ + routerParams: RouteParams; + }>, + opts?: RouteOptions, + ): this; on( method: HTTPMethod | Lowercase | "", route: string, handler: HTTPHandler, opts?: RouteOptions, + ): this; + on( + method: HTTPMethod | Lowercase | "", + route: Route | string, + handler: + | EventHandler<{ + routerParams: RouteParams; + }> + | HTTPHandler, + opts?: RouteOptions, ): this { const _method = (method || "").toUpperCase(); route = new URL(route, "http://_").pathname; this["~addRoute"]({ method: _method as HTTPMethod, route, - handler: toEventHandler(handler)!, + handler: toEventHandler(handler as HTTPHandler)!, middleware: opts?.middleware, meta: { ...(handler as EventHandler).meta, ...opts?.meta }, }); diff --git a/src/types/_utils.ts b/src/types/_utils.ts index 4773ec36..b980111f 100644 --- a/src/types/_utils.ts +++ b/src/types/_utils.ts @@ -1 +1,11 @@ +import type { InferRouteParams } from "rou3"; + export type MaybePromise = T | Promise; + +export type Simplify = { [K in keyof T]: T[K] } & {}; +export type RouteParams = + Simplify> extends infer _Simplified + ? keyof _Simplified extends never + ? undefined + : _Simplified + : never; diff --git a/src/types/context.ts b/src/types/context.ts index 1f4f09e5..6d70af82 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -2,9 +2,14 @@ import type { Session } from "../utils/session.ts"; import type { H3Route } from "./h3.ts"; import type { ServerRequestContext } from "srvx"; -export interface H3EventContext extends ServerRequestContext { - /* Matched router parameters */ - params?: Record; +export interface H3EventContext> + extends ServerRequestContext { + /** + * Matched route parameters + * + * If there are no parameters, this will be `undefined`. + */ + params?: TParams; /* Matched middleware parameters */ middlewareParams?: Record; diff --git a/src/types/h3.ts b/src/types/h3.ts index 791f4cc9..9249bec2 100644 --- a/src/types/h3.ts +++ b/src/types/h3.ts @@ -1,7 +1,7 @@ import type { H3EventContext } from "./context.ts"; import type { HTTPHandler, EventHandler, Middleware } from "./handler.ts"; import type { HTTPError } from "../error.ts"; -import type { MaybePromise } from "./_utils.ts"; +import type { MaybePromise, RouteParams } from "./_utils.ts"; import type { FetchHandler, ServerRequest } from "srvx"; // import type { MatchedRoute, RouterContext } from "rou3"; import type { H3Event } from "../event.ts"; @@ -21,8 +21,32 @@ export type MatchedRoute = { // https://www.rfc-editor.org/rfc/rfc7231#section-4.1 // prettier-ignore -export type HTTPMethod = "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE"; - +export type HTTPMethod = "GET" | "HEAD" | "PATCH" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE"; + +/** + * Interface for HTTP method handlers (GET, POST, PUT, DELETE, etc.). + * + * Automatically infers route parameters from the route pattern and makes them + * available in the event handler context. + * + * @template {H3} This - Passed as `this` to resolve to the declared H3 class type + * rather than the runtime H3 class implementation. This ensures TypeScript uses + * the correct type signature from this declaration file. + * + * NOTE: + * If we used H3 directly in the return type, the bench implementation would + * fail, due to `app._rou3` not being defined on H3. + */ +interface H3HandlerInterface { + ( + route: Route, + handler: EventHandler<{ + routerParams: RouteParams; + }>, + opts?: RouteOptions, + ): This; + (route: string, handler: HTTPHandler, opts?: RouteOptions): This; +} export interface H3Config { /** * When enabled, H3 displays debugging stack traces in HTTP responses (potentially dangerous for production!). @@ -155,6 +179,14 @@ export declare class H3 extends H3Core { /** * Register a route handler for the specified HTTP method and route. */ + on( + method: HTTPMethod | Lowercase | "", + route: Route, + handler: EventHandler<{ + routerParams: RouteParams; + }>, + opts?: RouteOptions, + ): this; on( method: HTTPMethod | Lowercase | "", route: string, @@ -179,15 +211,22 @@ export declare class H3 extends H3Core { /** * Register a route handler for all HTTP methods. */ + all( + route: Route, + handler: EventHandler<{ + routerParams: RouteParams; + }>, + opts?: RouteOptions, + ): this; all(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - get(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - post(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - put(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - delete(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - patch(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - head(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - options(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - connect(route: string, handler: HTTPHandler, opts?: RouteOptions): this; - trace(route: string, handler: HTTPHandler, opts?: RouteOptions): this; + get: H3HandlerInterface; + post: H3HandlerInterface; + put: H3HandlerInterface; + delete: H3HandlerInterface; + patch: H3HandlerInterface; + head: H3HandlerInterface; + options: H3HandlerInterface; + connect: H3HandlerInterface; + trace: H3HandlerInterface; } diff --git a/src/utils/request.ts b/src/utils/request.ts index 949872a4..65038044 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -105,6 +105,14 @@ export function getValidatedQuery( return validateData(query, validate); } +export function getRouterParams( + event: Event, + opts?: { decode?: boolean }, +): Event extends H3Event ? R["routerParams"] : never; +export function getRouterParams( + event: Event, + opts?: { decode?: boolean }, +): NonNullable; /** * Get matched route params. * @@ -115,8 +123,8 @@ export function getValidatedQuery( * const params = getRouterParams(event); // { key: "value" } * }); */ -export function getRouterParams( - event: HTTPEvent, +export function getRouterParams( + event: Event, opts: { decode?: boolean } = {}, ): NonNullable { // Fallback object needs to be returned in case router is not used (#149) @@ -124,12 +132,14 @@ export function getRouterParams( let params = (context.params || {}) as NonNullable< H3Event["context"]["params"] >; + if (opts.decode) { params = { ...params }; for (const key in params) { params[key] = decodeURIComponent(params[key]); } } + return params; } @@ -186,6 +196,21 @@ export function getValidatedRouterParams( return validateData(routerParams, validate); } +export function getRouterParam< + Event extends H3Event, + Key extends Event extends H3Event + ? keyof R["routerParams"] & string + : never, +>( + event: Event, + name: Key, + opts?: { decode?: boolean }, +): Event extends H3Event ? R["routerParams"][Key] : never; +export function getRouterParam( + event: Event, + name: string, + opts?: { decode?: boolean }, +): string | undefined; /** * Get a matched route param by name. * @@ -196,12 +221,13 @@ export function getValidatedRouterParams( * const param = getRouterParam(event, "key"); * }); */ -export function getRouterParam( - event: HTTPEvent, +export function getRouterParam( + event: Event, name: string, opts: { decode?: boolean } = {}, ): string | undefined { const params = getRouterParams(event, opts); + return params[name]; } diff --git a/test/unit/types.test-d.ts b/test/unit/types.test-d.ts index adbac59e..b46422b4 100644 --- a/test/unit/types.test-d.ts +++ b/test/unit/types.test-d.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import type { H3Event } from "../../src/index.ts"; +import { H3 } from "../../src/index.ts"; import { describe, it, expectTypeOf } from "vitest"; import { defineHandler, @@ -8,6 +9,8 @@ import { readValidatedBody, getValidatedQuery, defineValidatedHandler, + getRouterParams, + getRouterParam, } from "../../src/index.ts"; import { z } from "zod"; @@ -124,4 +127,184 @@ describe("types", () => { }); }); }); + + describe("routerParams inference", () => { + it("should infer router params from EventHandlerRequest (non-optional)", () => { + defineHandler<{ + routerParams: { id: string; name: string }; + }>((event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ + id: string; + name: string; + }>(); + expectTypeOf(event.context.params.id).toEqualTypeOf(); + expectTypeOf(event.context.params.name).toEqualTypeOf(); + }); + }); + + it("should default to optional Record when no routerParams specified", () => { + defineHandler((event) => { + expectTypeOf(event.context.params).toEqualTypeOf< + Record | undefined + >(); + }); + }); + + it("should work with specific param types (non-optional)", () => { + defineHandler<{ + routerParams: { userId: string; postId: string }; + }>((event) => { + const userId = event.context.params.userId; + const postId = event.context.params.postId; + expectTypeOf(userId).toEqualTypeOf(); + expectTypeOf(postId).toEqualTypeOf(); + }); + }); + + it("should work with getRouterParams helper", () => { + defineHandler<{ + routerParams: { id: string; slug: string }; + }>((event) => { + const params = getRouterParams(event); + expectTypeOf(params).toEqualTypeOf<{ id: string; slug: string }>(); + expectTypeOf(params.id).toEqualTypeOf(); + expectTypeOf(params.slug).toEqualTypeOf(); + }); + }); + + it("should work with getRouterParam helper", () => { + defineHandler<{ + routerParams: { id: string; slug: string }; + }>((event) => { + const id = getRouterParam(event, "id"); + const slug = getRouterParam(event, "slug"); + expectTypeOf(id).toEqualTypeOf(); + expectTypeOf(slug).toEqualTypeOf(); + }); + }); + + it("getRouterParam should provide autocomplete for param keys", () => { + defineHandler<{ + routerParams: { userId: string; postId: string }; + }>((event) => { + // This should only allow "userId" | "postId" as the second parameter + const userId = getRouterParam(event, "userId"); + expectTypeOf(userId).toEqualTypeOf(); + }); + }); + }); + + describe("app route inference", () => { + describe("simple dynamic routes", () => { + it("should infer params from app.get()", () => { + const app = new H3(); + + app.get("/users/:id", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ id: string }>(); + expectTypeOf(event.context.params.id).toEqualTypeOf(); + }); + }); + + it("should infer params from app.post()", () => { + const app = new H3(); + + app.post("/users/:id", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ id: string }>(); + expectTypeOf(event.context.params.id).toEqualTypeOf(); + }); + }); + + it("should not infer params from static route", () => { + const app = new H3(); + + app.get("/about", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf(); + }); + }); + }); + + describe("multiple dynamic segments", () => { + it("should infer params from app.get()", () => { + const app = new H3(); + + app.get("/users/:userId/posts/:postId", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ + userId: string; + postId: string; + }>(); + expectTypeOf(event.context.params.userId).toEqualTypeOf(); + expectTypeOf(event.context.params.postId).toEqualTypeOf(); + }); + }); + + it("should infer params from app.post()", () => { + const app = new H3(); + + app.post("/users/:userId/posts/:postId", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ + userId: string; + postId: string; + }>(); + expectTypeOf(event.context.params.userId).toEqualTypeOf(); + expectTypeOf(event.context.params.postId).toEqualTypeOf(); + }); + }); + }); + + it("should infer params from app.on()", () => { + const app = new H3(); + + app.on("GET", "/products/:productId", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ + productId: string; + }>(); + expectTypeOf(event.context.params.productId).toEqualTypeOf(); + }); + }); + + it("should infer static routes as undefined params", () => { + const app = new H3(); + + app.get("/users", (event) => { + expectTypeOf(event.context.params).toEqualTypeOf(); + }); + + app.post("/users/list", (event) => { + const params = getRouterParams(event); + expectTypeOf(params).toEqualTypeOf(); + }); + }); + + it("should use generic types for reusable handlers", () => { + const app = new H3(); + + const handler = defineHandler((event) => { + expectTypeOf(event.context.params).toEqualTypeOf< + Record | undefined + >(); + + const params = getRouterParams(event); + expectTypeOf(params).toEqualTypeOf< + Record | undefined + >(); + + const id = getRouterParam(event, "id"); + expectTypeOf(id).toEqualTypeOf(); + }); + + app.get("/users/:id", handler); + app.get( + "/posts/:id", + defineHandler((event) => { + expectTypeOf(event.context.params).toEqualTypeOf<{ id: string }>(); + + const params = getRouterParams(event); + expectTypeOf(params).toEqualTypeOf<{ id: string }>(); + + const id = getRouterParam(event, "id"); + expectTypeOf(id).toEqualTypeOf(); + }), + ); + }); + }); });