diff --git a/src/handler.ts b/src/handler.ts index befa2e2f1..28c5aa8af 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -13,10 +13,15 @@ import type { FetchableObject, HTTPHandler, } from "./types/handler.ts"; -import type { StandardSchemaV1, InferOutput } from "./utils/internal/standard-schema.ts"; -import type { TypedRequest } from "fetchdts"; +import type { InferOutput, StandardSchemaV1 } from "./utils/internal/standard-schema.ts"; import { NoHandler, type H3Core } from "./h3.ts"; -import { validatedRequest, validatedURL, type OnValidateError } from "./utils/internal/validate.ts"; +import { + validatedRequest, + validatedURL, + syncValidate, + validateResponse, + type OnValidateError, +} from "./utils/internal/validate.ts"; // --- event handler --- @@ -61,39 +66,94 @@ 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, + RouteParams extends StandardSchemaV1 = StandardSchemaV1>, + ResponseBody extends StandardSchemaV1 = StandardSchemaV1, >( def: Omit & { validate?: { body?: RequestBody; headers?: RequestHeaders; query?: RequestQuery; + params?: RouteParams; + response?: ResponseBody; onError?: OnValidateError; }; - handler: EventHandler< - { - body: InferOutput; - query: StringHeaders>; - }, - Res - >; + handler: ( + event: ValidatedH3Event< + EventHandlerRequest & { + body: InferOutput; + query: StringHeaders>; + routerParams: InferOutput; + }, + InferOutput + >, + ) => InferOutput | Promise>; }, -): EventHandlerWithFetch, 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, + def.validate!.onError, + ) as Record; + } + + // Validate request and URL (event as any) /* readonly */.req = validatedRequest(event.req, def.validate!); (event as any) /* readonly */.url = validatedURL(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, + def.validate!.onError as OnValidateError<"response"> | undefined, + ); + } + + return result; }, - }) as any; + }) as EventHandlerWithFetch>; } // --- handler .fetch --- diff --git a/src/index.ts b/src/index.ts index 827dedddd..eab6e72e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,7 @@ 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 1de553aa4..56b50dd97 100644 --- a/src/utils/internal/validate.ts +++ b/src/utils/internal/validate.ts @@ -161,7 +161,7 @@ export function validatedURL( return url; } -function syncValidate( +export function syncValidate( source: Source, data: unknown, fn: StandardSchemaV1, @@ -199,3 +199,33 @@ function createValidationError(cause: Error | HTTPError | ErrorDetails | Failure }, }); } + +/** + * 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, + onError?: OnValidateError<"response">, +): Promise> { + try { + return await validateData(value, schema, { + onError: onError ? (result) => onError({ ...result, _source: "response" }) : undefined, + }); + } catch (error: any) { + throw new HTTPError({ + cause: error, + status: 500, + statusText: + error?.statusText && error.statusText !== VALIDATION_FAILED + ? error.statusText + : "Response validation failed", + message: + error?.message && error.message !== VALIDATION_FAILED + ? error.message + : "Response validation failed", + data: error?.data, + }); + } +} diff --git a/src/utils/route.ts b/src/utils/route.ts index 680809c54..9d2d0b726 100644 --- a/src/utils/route.ts +++ b/src/utils/route.ts @@ -1,13 +1,57 @@ 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 type { OnValidateError } from "./internal/validate.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; + onError?: OnValidateError; +} + +type RouteValidationConfig = Omit & { + onError?: OnValidateError; +}; + +type RouteEventRequest = EventHandlerRequest & { + body: NonNullable extends StandardSchemaV1 + ? InferOutput> + : unknown; + query: NonNullable extends StandardSchemaV1 + ? StringHeaders>> + : Partial>; + routerParams: NonNullable extends StandardSchemaV1 + ? InferOutput> + : Record; +}; + +type RouteEventParams = + NonNullable extends StandardSchemaV1 + ? InferOutput> + : Record; + +type RouteResponse = + NonNullable extends StandardSchemaV1 + ? InferOutput> + : unknown; + /** - * 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 +65,9 @@ export interface RouteDefinition { /** * Handler function for the route. */ - handler: EventHandler; + handler: ( + event: ValidatedRouteEvent, RouteEventParams>, + ) => RouteResponse | Promise>; /** * Optional middleware to run before the handler. @@ -33,38 +79,105 @@ 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?: RouteValidationConfig; } +// Helper type for validated H3Event with typed context.params +type ValidatedRouteEvent = Omit< + H3Event, + "context" +> & { + context: Omit & { + params?: ParamsT; + }; +}; + +// Overload: With validation +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; + onError?: OnValidateError; + }; + 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..63b73df5c --- /dev/null +++ b/test/full-validation-example.test.ts @@ -0,0 +1,226 @@ +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 3d1d588f9..c4950f7b6 100644 --- a/test/handler.test.ts +++ b/test/handler.test.ts @@ -9,6 +9,7 @@ import { import type { ValidateIssues } from "../src/utils/internal/validate.ts"; import type { H3Event } from "../src/event.ts"; +import { H3 } from "../src/h3.ts"; import { z } from "zod/v4"; describe("handler.ts", () => { @@ -238,6 +239,91 @@ 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"); + }); + describe("custom error messages", () => { it("invalid body", async () => { const res = await handlerCustomError.fetch( @@ -285,6 +371,62 @@ describe("handler.ts", () => { }); expect(res.status).toBe(500); }); + + it("invalid params", async () => { + const app = new H3(); + const paramsHandler = defineValidatedHandler({ + validate: { + params: z.object({ + id: z.string().uuid(), + }), + onError: ({ _source, issues }) => ({ + status: 500, + statusText: `Custom Zod ${_source} validation error`, + message: summarize(issues), + }), + }, + handler: (event) => { + return { params: event.context.params }; + }, + }); + app.get("/user/:id", paramsHandler); + + const res = await app.request("/user/invalid-uuid"); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Custom Zod params validation error", + }); + expect(res.status).toBe(500); + }); + + it("invalid response", async () => { + const app = new H3(); + const responseHandler = defineValidatedHandler({ + validate: { + response: z.object({ + id: z.string(), + name: z.string(), + }), + onError: ({ _source, issues }) => ({ + status: 418, + statusText: `Custom Zod ${_source} validation error`, + message: summarize(issues), + }), + }, + // @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"); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Custom Zod response validation error", + }); + expect(res.status).toBe(500); + }); }); }); }); diff --git a/test/route.test.ts b/test/route.test.ts index 8f7496049..a69654495 100644 --- a/test/route.test.ts +++ b/test/route.test.ts @@ -73,4 +73,262 @@ describe("defineRoute", () => { data: { issues: [{ path: ["id"] }] }, }); }); + + it("should support custom validation errors", async () => { + const app = new H3(); + const routePlugin = defineRoute({ + method: "GET", + route: "/users/:id", + validate: { + params: z.object({ id: z.string().uuid() }), + onError: ({ _source, issues }) => ({ + status: 500, + statusText: `Custom Zod ${_source} validation error`, + message: issues[0]?.message || "invalid", + }), + }, + handler: (event) => { + return { userId: event.context.params!.id }; + }, + }); + app.register(routePlugin); + + const res = await app.request("/users/invalid-uuid"); + expect(await res.json()).toMatchObject({ + status: 500, + statusText: "Custom Zod params validation error", + }); + }); + + 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, + }); + }); }); diff --git a/test/unit/types.test-d.ts b/test/unit/types.test-d.ts index e34682b33..199ab0ed2 100644 --- a/test/unit/types.test-d.ts +++ b/test/unit/types.test-d.ts @@ -2,6 +2,8 @@ import type { H3Event } from "../../src/index.ts"; import { describe, it, expectTypeOf } from "vitest"; import { defineHandler, + defineRoute, + type RouteDefinition, getQuery, readBody, readValidatedBody, @@ -123,4 +125,57 @@ describe("types", () => { }); }); }); + + describe("route", () => { + it("typed via route definition", () => { + const params = z.object({ + id: z.string(), + }); + const body = z.object({ + title: z.string(), + }); + const query = z.object({ + search: z.string().optional(), + }); + const response = z.object({ + id: z.string(), + title: z.string(), + }); + + const route = { + method: "POST", + route: "/:id", + validate: { + params, + body, + query, + response, + onError: ({ _source }: { _source?: string }) => ({ + message: _source, + }), + }, + async handler(event) { + expectTypeOf(event.context.params?.id).toEqualTypeOf(); + + const queryValue = getQuery(event); + expectTypeOf(queryValue.search).toEqualTypeOf(); + + const requestBody = await event.req.json(); + expectTypeOf(requestBody).toEqualTypeOf<{ title: string }>(); + + return { + id: event.context.params!.id, + title: requestBody.title, + }; + }, + } satisfies RouteDefinition<{ + params: typeof params; + body: typeof body; + query: typeof query; + response: typeof response; + }>; + + defineRoute(route); + }); + }); });