Skip to content
Open
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
95 changes: 74 additions & 21 deletions src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -66,36 +70,67 @@ type StringHeaders<T> = {
/**
* @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<RequestT extends EventHandlerRequest, Params> = Omit<
H3Event<RequestT>,
"context"
> & {
context: Omit<H3Event["context"], "params"> & {
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<any>,
RequestHeaders extends StandardSchemaV1 = StandardSchemaV1<any>,
RequestQuery extends StandardSchemaV1 = StandardSchemaV1<any>,
RouteParams extends StandardSchemaV1 = StandardSchemaV1<
Record<string, string>
>,
ResponseBody extends StandardSchemaV1 = StandardSchemaV1<any>,
>(
def: Omit<EventHandlerObject, "handler"> & {
validate?: {
body?: RequestBody;
headers?: RequestHeaders;
query?: RequestQuery;
params?: RouteParams;
response?: ResponseBody;
};
handler: EventHandler<
{
body: InferOutput<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
},
Res
>;
handler: (
event: ValidatedH3Event<
EventHandlerRequest & {
body: InferOutput<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
routerParams: InferOutput<RouteParams>;
},
InferOutput<RouteParams>
>,
) => InferOutput<ResponseBody> | Promise<InferOutput<ResponseBody>>;
},
): EventHandlerWithFetch<
TypedRequest<InferOutput<RequestBody>, InferOutput<RequestHeaders>>,
Res
> {
): EventHandlerWithFetch<EventHandlerRequest, InferOutput<ResponseBody>> {
if (!def.validate) {
return defineHandler(def) as any;
return defineHandler(def) as EventHandlerWithFetch<
EventHandlerRequest,
InferOutput<ResponseBody>
>;
}
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<string, string>;
}

// Validate request and URL
(event as any) /* readonly */.req = validatedRequest(
event.req,
def.validate!,
Expand All @@ -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<RequestBody>;
query: StringHeaders<InferOutput<RequestQuery>>;
routerParams: InferOutput<RouteParams>;
},
InferOutput<RouteParams>
>,
);

// Validate response
if (def.validate!.response) {
return await validateResponse(result, def.validate!.response);
}

return result;
},
}) as any;
}) as EventHandlerWithFetch<EventHandlerRequest, InferOutput<ResponseBody>>;
}

// --- handler .fetch ---
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 23 additions & 1 deletion src/utils/internal/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function validatedURL(
return url;
}

function syncValidate<T = unknown>(
export function syncValidate<T = unknown>(
type: string,
data: unknown,
fn: StandardSchemaV1<T>,
Expand All @@ -152,6 +152,28 @@ function syncValidate<T = unknown>(
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<Schema extends StandardSchemaV1>(
value: unknown,
schema: Schema,
): Promise<InferOutput<Schema>> {
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,
Expand Down
2 changes: 1 addition & 1 deletion src/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function getQuery<

export function getValidatedQuery<
Event extends HTTPEvent,
S extends StandardSchemaV1<any, any>,
S extends StandardSchemaV1,
>(event: Event, validate: S): Promise<InferOutput<S>>;
export function getValidatedQuery<
Event extends HTTPEvent,
Expand Down
147 changes: 130 additions & 17 deletions src/utils/route.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
[K in keyof T]: Extract<T[K], string>;
};

/**
* 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<V extends RouteValidation = RouteValidation> {
/**
* HTTP method for the route, e.g. 'GET', 'POST', etc.
*/
Expand All @@ -21,7 +40,29 @@ export interface RouteDefinition {
/**
* Handler function for the route.
*/
handler: EventHandler;
handler: (
event: H3Event<
EventHandlerRequest & {
body: V["body"] extends StandardSchemaV1
? InferOutput<V["body"]>
: unknown;
query: V["query"] extends StandardSchemaV1
? StringHeaders<InferOutput<V["query"]>>
: Partial<Record<string, string>>;
routerParams: V["params"] extends StandardSchemaV1
? InferOutput<V["params"]>
: Record<string, string>;
}
>,
) =>
| (V["response"] extends StandardSchemaV1
? InferOutput<V["response"]>
: unknown)
| Promise<
V["response"] extends StandardSchemaV1
? InferOutput<V["response"]>
: unknown
>;

/**
* Optional middleware to run before the handler.
Expand All @@ -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<RequestT extends EventHandlerRequest, ParamsT> = Omit<
H3Event<RequestT>,
"context"
> & {
context: Omit<H3Event["context"], "params"> & {
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<Body>;
query: [Query] extends [never]
? Partial<Record<string, string>>
: StringHeaders<InferOutput<Query>>;
routerParams: [Params] extends [never]
? Record<string, string>
: InferOutput<Params>;
},
[Params] extends [never] ? Record<string, string> : InferOutput<Params>
>,
) =>
| ([Response] extends [never] ? unknown : InferOutput<Response>)
| Promise<[Response] extends [never] ? unknown : InferOutput<Response>>;
middleware?: Middleware[];
meta?: H3RouteMeta;
}): H3Plugin;

// Overload: Without validation
export function defineRoute(def: {
method: HTTPMethod;
route: string;
handler: (event: H3Event) => unknown | Promise<unknown>;
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<V extends RouteValidation>(
def: RouteDefinition<V>,
): H3Plugin {
// TypeScript cannot infer complex conditional types between RouteDefinition and
// defineValidatedHandler parameters. Runtime types are identical and safe.
type ValidatedHandlerParam = Parameters<typeof defineValidatedHandler>[0];

const handler = defineValidatedHandler(
def as unknown as ValidatedHandlerParam,
);

return (h3: H3) => {
h3.on(def.method, def.route, handler);
};
Expand Down
Loading