diff --git a/src/handler.ts b/src/handler.ts index 879efbdaf..32b5954c1 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -17,9 +17,13 @@ import type { InferOutput, StandardSchemaV1, } from "./utils/internal/standard-schema.ts"; -import type { TypedRequest } from "fetchdts"; import { NoHandler, type H3Core } from "./h3.ts"; -import { validatedRequest, validatedURL } from "./utils/internal/validate.ts"; +import { + validatedRequest, + validatedURL, + syncValidate, + validateResponse, +} from "./utils/internal/validate.ts"; // --- event handler --- @@ -66,36 +70,67 @@ type StringHeaders = { /** * @experimental defineValidatedHandler is an experimental feature and API may change. */ +// Helper type to create a validated H3Event with typed context.params +// After validation, params will have the inferred type from the schema +// Note: params remains optional for TypeScript compatibility, but is guaranteed at runtime +type ValidatedH3Event = Omit< + H3Event, + "context" +> & { + context: Omit & { + params?: Params; // Typed from schema (optional for TS, guaranteed after validation) + }; +}; + export function defineValidatedHandler< - RequestBody extends StandardSchemaV1, - RequestHeaders extends StandardSchemaV1, - RequestQuery extends StandardSchemaV1, - Res extends EventHandlerResponse = EventHandlerResponse, + RequestBody extends StandardSchemaV1 = StandardSchemaV1, + RequestHeaders extends StandardSchemaV1 = StandardSchemaV1, + RequestQuery extends StandardSchemaV1 = StandardSchemaV1, + RouteParams extends StandardSchemaV1 = StandardSchemaV1< + Record + >, + ResponseBody extends StandardSchemaV1 = StandardSchemaV1, >( def: Omit & { validate?: { body?: RequestBody; headers?: RequestHeaders; query?: RequestQuery; + params?: RouteParams; + response?: ResponseBody; }; - handler: EventHandler< - { - body: InferOutput; - query: StringHeaders>; - }, - Res - >; + handler: ( + event: ValidatedH3Event< + EventHandlerRequest & { + body: InferOutput; + query: StringHeaders>; + routerParams: InferOutput; + }, + InferOutput + >, + ) => InferOutput | Promise>; }, -): EventHandlerWithFetch< - TypedRequest, InferOutput>, - Res -> { +): EventHandlerWithFetch> { if (!def.validate) { - return defineHandler(def) as any; + return defineHandler(def) as EventHandlerWithFetch< + EventHandlerRequest, + InferOutput + >; } return defineHandler({ ...def, - handler: function _validatedHandler(event) { + handler: async function _validatedHandler(event) { + // Validate route params + if (def.validate!.params) { + const params = event.context.params || {}; + event.context.params = syncValidate( + "params", + params, + def.validate!.params, + ) as Record; + } + + // Validate request and URL (event as any) /* readonly */.req = validatedRequest( event.req, def.validate!, @@ -104,9 +139,27 @@ export function defineValidatedHandler< event.url, def.validate!, ); - return def.handler(event as any); + + // Execute handler - context.params is validated at this point + const result = await def.handler( + event as ValidatedH3Event< + EventHandlerRequest & { + body: InferOutput; + query: StringHeaders>; + routerParams: InferOutput; + }, + InferOutput + >, + ); + + // Validate response + if (def.validate!.response) { + return await validateResponse(result, def.validate!.response); + } + + return result; }, - }) as any; + }) as EventHandlerWithFetch>; } // --- handler .fetch --- diff --git a/src/index.ts b/src/index.ts index f7957b1b8..b714f596a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,7 +91,11 @@ export { // Route -export { type RouteDefinition, defineRoute } from "./utils/route.ts"; +export { + type RouteDefinition, + type RouteValidation, + defineRoute, +} from "./utils/route.ts"; // Request diff --git a/src/utils/internal/validate.ts b/src/utils/internal/validate.ts index 9fbd23bcf..d212420d9 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -135,7 +135,7 @@ export function validatedURL( return url; } -function syncValidate( +export function syncValidate( type: string, data: unknown, fn: StandardSchemaV1, @@ -152,6 +152,28 @@ function syncValidate( return result.value; } +/** + * Validates a response value against a schema. + * Response validation errors use 500 status (server error) instead of 400. + */ +export async function validateResponse( + value: unknown, + schema: Schema, +): Promise> { + try { + return await validateData(value, schema); + } catch (error: any) { + // Response validation errors are server errors (500), not client errors (400) + throw new HTTPError({ + status: 500, + statusText: "Response validation failed", + message: error?.message || "Response validation failed", + data: error?.data, + cause: error, + }); + } +} + function createValidationError(validateError?: any) { return new HTTPError({ status: 400, diff --git a/src/utils/request.ts b/src/utils/request.ts index 949872a4f..fe8813d24 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -62,7 +62,7 @@ export function getQuery< export function getValidatedQuery< Event extends HTTPEvent, - S extends StandardSchemaV1, + S extends StandardSchemaV1, >(event: Event, validate: S): Promise>; export function getValidatedQuery< Event extends HTTPEvent, diff --git a/src/utils/route.ts b/src/utils/route.ts index 680809c54..b81cf07ac 100644 --- a/src/utils/route.ts +++ b/src/utils/route.ts @@ -1,13 +1,32 @@ import type { H3RouteMeta, HTTPMethod } from "../types/h3.ts"; -import type { EventHandler, Middleware } from "../types/handler.ts"; +import type { EventHandlerRequest, Middleware } from "../types/handler.ts"; import type { H3Plugin, H3 } from "../types/h3.ts"; -import type { StandardSchemaV1 } from "./internal/standard-schema.ts"; +import type { H3Event } from "../event.ts"; +import type { + StandardSchemaV1, + InferOutput, +} from "./internal/standard-schema.ts"; import { defineValidatedHandler } from "../handler.ts"; +type StringHeaders = { + [K in keyof T]: Extract; +}; + +/** + * Route validation schemas + */ +export interface RouteValidation { + body?: StandardSchemaV1; + headers?: StandardSchemaV1; + query?: StandardSchemaV1; + params?: StandardSchemaV1; + response?: StandardSchemaV1; +} + /** - * Route definition options + * Route definition options with type-safe validation */ -export interface RouteDefinition { +export interface RouteDefinition { /** * HTTP method for the route, e.g. 'GET', 'POST', etc. */ @@ -21,7 +40,29 @@ export interface RouteDefinition { /** * Handler function for the route. */ - handler: EventHandler; + handler: ( + event: H3Event< + EventHandlerRequest & { + body: V["body"] extends StandardSchemaV1 + ? InferOutput + : unknown; + query: V["query"] extends StandardSchemaV1 + ? StringHeaders> + : Partial>; + routerParams: V["params"] extends StandardSchemaV1 + ? InferOutput + : Record; + } + >, + ) => + | (V["response"] extends StandardSchemaV1 + ? InferOutput + : unknown) + | Promise< + V["response"] extends StandardSchemaV1 + ? InferOutput + : unknown + >; /** * Optional middleware to run before the handler. @@ -33,38 +74,110 @@ export interface RouteDefinition { */ meta?: H3RouteMeta; - // Validation schemas - // TODO: Support generics for better typing `handler` input - validate?: { - body?: StandardSchemaV1; - headers?: StandardSchemaV1; - query?: StandardSchemaV1; - }; + /** + * Validation schemas for request and response + */ + validate?: V; } +// Helper type for validated H3Event with typed context.params +type ValidatedRouteEvent = Omit< + H3Event, + "context" +> & { + context: Omit & { + params?: ParamsT; + }; +}; + +// Overload: With validation (any combination of validation schemas) +export function defineRoute< + Body extends StandardSchemaV1 = never, + Headers extends StandardSchemaV1 = never, + Query extends StandardSchemaV1 = never, + Params extends StandardSchemaV1 = never, + Response extends StandardSchemaV1 = never, +>(def: { + method: HTTPMethod; + route: string; + validate: { + body?: Body; + headers?: Headers; + query?: Query; + params?: Params; + response?: Response; + }; + handler: ( + event: ValidatedRouteEvent< + EventHandlerRequest & { + body: [Body] extends [never] ? unknown : InferOutput; + query: [Query] extends [never] + ? Partial> + : StringHeaders>; + routerParams: [Params] extends [never] + ? Record + : InferOutput; + }, + [Params] extends [never] ? Record : InferOutput + >, + ) => + | ([Response] extends [never] ? unknown : InferOutput) + | Promise<[Response] extends [never] ? unknown : InferOutput>; + middleware?: Middleware[]; + meta?: H3RouteMeta; +}): H3Plugin; + +// Overload: Without validation +export function defineRoute(def: { + method: HTTPMethod; + route: string; + handler: (event: H3Event) => unknown | Promise; + middleware?: Middleware[]; + meta?: H3RouteMeta; + validate?: never; +}): H3Plugin; + /** * Define a route as a plugin that can be registered with app.register() * + * Routes defined with this function automatically get type-safe validation + * for params, query, body, and response based on the provided schemas. + * * @example * ```js * import { z } from "zod"; * * const userRoute = defineRoute({ * method: 'POST', + * route: '/api/users/:id', * validate: { - * query: z.object({ id: z.string().uuid() }), + * params: z.object({ id: z.string().uuid() }), + * query: z.object({ include: z.string().optional() }), * body: z.object({ name: z.string() }), + * response: z.object({ id: z.string(), name: z.string() }), * }, * handler: (event) => { - * return { success: true }; + * // event.context.params, await event.req.json(), and return value are all typed! + * const { id } = event.context.params; + * const body = await event.req.json(); + * return { id, name: body.name }; * } * }); * - * app.register(userRoute); + * app.use(userRoute); * ``` */ -export function defineRoute(def: RouteDefinition): H3Plugin { - const handler = defineValidatedHandler(def) as any; +export function defineRoute( + def: RouteDefinition, +): H3Plugin { + // TypeScript cannot infer complex conditional types between RouteDefinition and + // defineValidatedHandler parameters. Runtime types are identical and safe. + type ValidatedHandlerParam = Parameters[0]; + + const handler = defineValidatedHandler( + def as unknown as ValidatedHandlerParam, + ); + return (h3: H3) => { h3.on(def.method, def.route, handler); }; diff --git a/test/full-validation-example.test.ts b/test/full-validation-example.test.ts new file mode 100644 index 000000000..de245b3e4 --- /dev/null +++ b/test/full-validation-example.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; +import { H3 } from "../src/h3.ts"; +import { defineRoute } from "../src/utils/route.ts"; +import { defineValidatedHandler } from "../src/handler.ts"; +import { z } from "zod"; + +describe("Full validation type inference", () => { + it("should infer ALL validation types in defineRoute", async () => { + const app = new H3(); + + // Complete validation example with ALL schemas + const fullRoute = defineRoute({ + method: "POST", + route: "/users/:id", + validate: { + // 1. Route params validation + params: z.object({ + id: z.string().uuid(), + }), + // 2. Query validation + query: z.object({ + include: z.string().optional(), + limit: z.string().default("10"), + }), + // 3. Request body validation + body: z.object({ + name: z.string().min(3), + email: z.string().email(), + age: z.number().int().positive(), + }), + // 4. Response validation + response: z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + age: z.number(), + limit: z.string(), + }), + }, + handler: async (event) => { + // Type inference test: ALL types should be properly inferred + + // ✅ 1. params is inferred as { id: string } + const userId: string = event.context.params!.id; + + // ✅ 2. query is inferred as { include?: string, limit: string } + const query = new URL(event.req.url).searchParams; + const limit: string = query.get("limit") || "10"; + + // ✅ 3. body is inferred as { name: string, email: string, age: number } + const body = await event.req.json(); + const userName: string = body.name; + const userEmail: string = body.email; + const userAge: number = body.age; + + // ✅ 4. Return type is enforced as { id: string, name: string, email: string, age: number, limit: string } + return { + id: userId, + name: userName, + email: userEmail, + age: userAge, + limit, + }; + + // ❌ This would be a TypeScript error: + // return { wrong: "type" }; + }, + }); + + app.register(fullRoute); + + // Test with valid data + const res = await app.request( + "/users/123e4567-e89b-12d3-a456-426614174000?limit=20", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "John Doe", + email: "john@example.com", + age: 30, + }), + }, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "123e4567-e89b-12d3-a456-426614174000", + name: "John Doe", + email: "john@example.com", + age: 30, + limit: "20", + }); + }); + + it("should infer ALL validation types in defineValidatedHandler", async () => { + const app = new H3(); + + // Complete validation example with ALL schemas using defineValidatedHandler + const handler = defineValidatedHandler({ + validate: { + // 1. Route params validation + params: z.object({ + userId: z.string().uuid(), + }), + // 2. Query validation + query: z.object({ + format: z.enum(["json", "xml"]), + }), + // 3. Request body validation + body: z.object({ + title: z.string(), + content: z.string(), + }), + // 4. Response validation + response: z.object({ + postId: z.string(), + userId: z.string(), + title: z.string(), + format: z.string(), + }), + }, + handler: async (event) => { + // Type inference test: ALL types should be properly inferred + + // ✅ 1. params is inferred as { userId: string } + const userId: string = event.context.params!.userId; + + // ✅ 2. query types are available via URL + const query = new URL(event.req.url).searchParams; + const format: string = query.get("format") || "json"; + + // ✅ 3. body is inferred as { title: string, content: string } + const body = await event.req.json(); + const title: string = body.title; + + // ✅ 4. Return type is enforced + return { + postId: "post-123", + userId, + title, + format, + }; + }, + }); + + app.post("/posts/:userId", handler); + + const res = await app.request( + "/posts/123e4567-e89b-12d3-a456-426614174000?format=json", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Test Post", + content: "This is a test", + }), + }, + ); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + postId: "post-123", + userId: "123e4567-e89b-12d3-a456-426614174000", + title: "Test Post", + format: "json", + }); + }); + + it("should fail validation for each schema type", async () => { + const app = new H3(); + + const strictRoute = defineRoute({ + method: "POST", + route: "/api/:id", + validate: { + params: z.object({ id: z.string().uuid() }), + query: z.object({ key: z.string().min(5) }), + body: z.object({ value: z.number() }), + response: z.object({ result: z.string() }), + }, + handler: async (event) => { + const body = await event.req.json(); + return { result: String(body.value) }; + }, + }); + + app.register(strictRoute); + + // Test 1: Invalid params + const invalidParams = await app.request("/api/not-a-uuid?key=validkey", { + method: "POST", + body: JSON.stringify({ value: 123 }), + }); + const paramsError = await invalidParams.json(); + expect(paramsError.status).toBe(400); + expect(paramsError.statusText).toBe("Validation failed"); + + // Test 2: Invalid query + const invalidQuery = await app.request( + "/api/123e4567-e89b-12d3-a456-426614174000?key=bad", + { + method: "POST", + body: JSON.stringify({ value: 123 }), + }, + ); + const queryError = await invalidQuery.json(); + expect(queryError.status).toBe(400); + expect(queryError.data.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["key"], + }), + ]), + ); + + // Test 3: Invalid body + const invalidBody = await app.request( + "/api/123e4567-e89b-12d3-a456-426614174000?key=validkey", + { + method: "POST", + body: JSON.stringify({ value: "not-a-number" }), + }, + ); + const bodyError = await invalidBody.json(); + expect(bodyError.status).toBe(400); + expect(bodyError.data.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: ["value"], + }), + ]), + ); + }); +}); diff --git a/test/handler.test.ts b/test/handler.test.ts index 64ca3d979..aa9ef8157 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -7,7 +7,8 @@ import { defineValidatedHandler, } from "../src/index.ts"; -import type { H3Event } from "../src/event.ts"; +import { H3Event } from "../src/event.ts"; +import { H3 } from "../src/h3.ts"; import { z } from "zod"; describe("handler.ts", () => { @@ -209,5 +210,92 @@ describe("handler.ts", () => { }); expect(res.status).toBe(400); }); + + it("with params validation", async () => { + // Create a mini app to test params validation + const app = new H3(); + const paramsHandler = defineValidatedHandler({ + validate: { + params: z.object({ + id: z.string().uuid(), + }), + }, + handler: (event) => { + return { params: event.context.params }; + }, + }); + app.get("/user/:id", paramsHandler); + + const res = await app.request( + "/user/123e4567-e89b-12d3-a456-426614174000", + ); + expect(await res.json()).toMatchObject({ + params: { id: "123e4567-e89b-12d3-a456-426614174000" }, + }); + }); + + it("invalid params", async () => { + const app = new H3(); + const paramsHandler = defineValidatedHandler({ + validate: { + params: z.object({ + id: z.string().uuid(), + }), + }, + handler: (event) => { + return { params: event.context.params }; + }, + }); + app.get("/user/:id", paramsHandler); + + const res = await app.request("/user/invalid-uuid"); + const json = await res.json(); + expect(json.status).toBe(400); + expect(json.statusText).toBe("Validation failed"); + }); + + it("with response validation", async () => { + const app = new H3(); + const responseHandler = defineValidatedHandler({ + validate: { + response: z.object({ + id: z.string(), + name: z.string(), + }), + }, + handler: () => { + return { id: "123", name: "test" }; + }, + }); + app.get("/test", responseHandler); + + const res = await app.request("/test"); + expect(await res.json()).toMatchObject({ + id: "123", + name: "test", + }); + }); + + it("invalid response", async () => { + const app = new H3(); + const responseHandler = defineValidatedHandler({ + validate: { + response: z.object({ + id: z.string(), + name: z.string(), + }), + }, + // @ts-expect-error - Testing: intentionally returning wrong type to verify validation catches it + handler: () => { + return { id: 123, invalid: "field" }; + }, + }); + app.get("/test", responseHandler); + + const res = await app.request("/test"); + const json = await res.json(); + expect(json.status).toBe(500); + expect(json.statusText).toBe("Response validation failed"); + }); }); }); diff --git a/test/route.test.ts b/test/route.test.ts index c8c68baf8..da6d4d644 100644 --- a/test/route.test.ts +++ b/test/route.test.ts @@ -75,4 +75,238 @@ describe("defineRoute", () => { data: { issues: [{ path: ["id"] }] }, }); }); + + it("should validate route params", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "GET", + route: "/users/:id", + validate: { + params: z.object({ id: z.string().uuid() }), + }, + handler: (event) => { + // Type test: params should be { id: string } not Record + // After validation, params is guaranteed to exist + const id: string = event.context.params!.id; + return { userId: id }; + }, + }); + app.register(routePlugin); + + // Valid UUID + const validRes = await app.request( + "/users/123e4567-e89b-12d3-a456-426614174000", + ); + expect(await validRes.json()).toEqual({ + userId: "123e4567-e89b-12d3-a456-426614174000", + }); + + // Invalid UUID + const invalidRes = await app.request("/users/invalid-uuid"); + expect(await invalidRes.json()).toMatchObject({ + status: 400, + statusText: "Validation failed", + }); + }); + + it("should validate response", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "GET", + route: "/api/data", + validate: { + response: z.object({ id: z.string(), name: z.string() }), + }, + handler: () => { + return { id: "123", name: "test" }; + }, + }); + app.register(routePlugin); + + const res = await app.request("/api/data"); + expect(await res.json()).toEqual({ id: "123", name: "test" }); + }); + + it("should fail on invalid response", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "GET", + route: "/api/bad", + validate: { + response: z.object({ id: z.string(), name: z.string() }), + }, + handler: () => { + return { id: 123, invalid: "data" } as any; + }, + }); + app.register(routePlugin); + + const res = await app.request("/api/bad"); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Response validation failed", + }); + }); + + it("should validate request body", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "POST", + route: "/api/users", + validate: { + body: z.object({ + name: z.string().min(3), + email: z.string().email(), + age: z.number().int().positive(), + }), + }, + handler: async (event) => { + // Type test: body should be { name: string, email: string, age: number } + const body = await event.req.json(); + const name: string = body.name; + const email: string = body.email; + const age: number = body.age; + return { name, email, age }; + }, + }); + app.register(routePlugin); + + // Valid body + const validRes = await app.request("/api/users", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "John Doe", + email: "john@example.com", + age: 30, + }), + }); + expect(await validRes.json()).toEqual({ + name: "John Doe", + email: "john@example.com", + age: 30, + }); + + // Invalid body - missing field + const invalidRes = await app.request("/api/users", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Jo", // too short + email: "invalid-email", + age: -5, // negative + }), + }); + const error = await invalidRes.json(); + expect(error.status).toBe(400); + expect(error.statusText).toBe("Validation failed"); + expect(error.data.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ["name"] }), + expect.objectContaining({ path: ["email"] }), + expect.objectContaining({ path: ["age"] }), + ]), + ); + }); + + it("should validate request headers", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "GET", + route: "/api/protected", + validate: { + headers: z.object({ + "x-api-key": z.string().min(10), + "x-client-version": z.string().regex(/^\d+\.\d+\.\d+$/), + }), + }, + handler: (event) => { + const apiKey = event.req.headers.get("x-api-key"); + const version = event.req.headers.get("x-client-version"); + return { apiKey, version }; + }, + }); + app.register(routePlugin); + + // Valid headers + const validRes = await app.request("/api/protected", { + headers: { + "x-api-key": "valid-api-key-123", + "x-client-version": "1.2.3", + }, + }); + expect(await validRes.json()).toEqual({ + apiKey: "valid-api-key-123", + version: "1.2.3", + }); + + // Invalid headers + const invalidRes = await app.request("/api/protected", { + headers: { + "x-api-key": "short", // too short + "x-client-version": "invalid", // wrong format + }, + }); + const error = await invalidRes.json(); + expect(error.status).toBe(400); + expect(error.statusText).toBe("Validation failed"); + expect(error.data.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ["x-api-key"] }), + expect.objectContaining({ path: ["x-client-version"] }), + ]), + ); + }); + + it("should validate all fields together", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "POST", + route: "/api/complete/:userId", + validate: { + params: z.object({ userId: z.string().uuid() }), + query: z.object({ include: z.string().optional() }), + headers: z.object({ "x-token": z.string() }), + body: z.object({ action: z.string() }), + response: z.object({ + userId: z.string(), + action: z.string(), + included: z.boolean(), + }), + }, + handler: async (event) => { + // All types should be inferred + const userId: string = event.context.params!.userId; + const body = await event.req.json(); + const action: string = body.action; + const query = new URL(event.req.url).searchParams; + const include = query.get("include"); + + return { + userId, + action, + included: !!include, + }; + }, + }); + app.register(routePlugin); + + const res = await app.request( + "/api/complete/123e4567-e89b-12d3-a456-426614174000?include=details", + { + method: "POST", + headers: { + "content-type": "application/json", + "x-token": "test-token", + }, + body: JSON.stringify({ action: "update" }), + }, + ); + + expect(await res.json()).toEqual({ + userId: "123e4567-e89b-12d3-a456-426614174000", + action: "update", + included: true, + }); + }); });