Skip to content

Feature request: first class support for data validation in Event Handler #4516

@dreamorosi

Description

@dreamorosi

Use case

As a customer I want to use Standard Schema-compatible libraries to validate requests and/or responses when using Event Handler.

When enabled, things like body and query parameters (at minimum) should be strongly typed in route handlers and possibly middleware functions.

Solution/User Experience

We will add a new options argument to the route handler registration methods such as app.get, app.posts etc. Users will be able to validate elements of both the request and the response. The registartion methods will habe a new overload that will allow users to use gernics to type the handlers. The solution will support the stadard schema protocol, this will allow users to bring their own validation library rather than us choosing for them.

Validation Options

/**
 * Configuration for request validation
 */
type RequestValidationConfig<T = unknown> = {
  body?: StandardSchemaV1<unknown, T>;
  headers?: StandardSchemaV1<unknown, Record<string, string>>;
  path?: StandardSchemaV1<unknown, Record<string, string>>;
  query?: StandardSchemaV1<unknown, Record<string, string>>;
};

/**
 * Configuration for response validation
 */
type ResponseValidationConfig<T extends HandlerResponse = HandlerResponse> = {
  body?: StandardSchemaV1<HandlerResponse, T>;
  headers?: StandardSchemaV1<Record<string, string>, Record<string, string>>;
};

/**
 * Validation configuration for request and response
 */
type ValidationConfig<
  TReqBody = unknown,
  TResBody extends HandlerResponse = HandlerResponse
> = {
  req?: RequestValidationConfig<TReqBody>;
  res?: ResponseValidationConfig<TResBody>;
};

Example Usage (with Zod)

import { z } from 'zod';

const createUserRequestSchema = z.object({ 
  name: z.string(), 
  email: z.string().email() 
});

const createUserResponseSchema = z.object({ 
  id: z.string(), 
  name: z.string(),
  email: z.string()
});

// Infer types from schemas
type CreateUserRequest = zodInfer<typeof createUserRequestSchema>;
type CreateUserResponse = zodInfer<typeof createUserResponseSchema>;

app.post<CreateUserRequest, CreateUserResponse>('/users', [], async (reqCtx) => {
  return { id: '123', name: body.name };
}, {
  validation: {
    req: {
      body: createUserRequestSchema,
      headers: z.object({ 'x-api-key': z.string() }),
      path: z.object({ userId: z.string() }),
      query: z.object({ filter: z.string().optional() })
    },
    res: {
      body: createUserResponseSchema,
      headers: z.object({ 'x-request-id': z.string() })
    }
  }
});

Open question

Should we add a new field to RequestContext to allow users access validated parameters in a type safe wa rather than just validating them and returning an error if they are not correctly:

app.post('/users', [], async (reqCtx) => {
  // reqCtx.valid.req.body is typed as { name: string; email: string }
  // reqCtx.valid.req.headers is typed as {  'x-api-key': string }
  // etc
  return { id: '123', name: body.name }; // type error if this does not conform to CreateUserResponse type
}, {
  validation: {
    // ...
  }
});

Validation Errors

We should create a custom validation error type that wraps the schema errors in a uniform interface. This will also allow users to catch this specific error and do custom error handling:

class RequestValidationError extends HttpError {

  constructor(message, errors} {
    // ...
  }

}

// ...


app.errorHandler(RequestValidationError, (error) => {
 // do something with error
 throw new BadRequestError('custom message');
});

Implementation

Note: These implementations are rough sketches that are meant to be a guideline. If the the final implementation differs from this, that is perfectly fine.

Under the hood, this can just be another middleware, Using the API from the open question section, we could do this:

/**
 * Validated request data
 */
type ValidatedRequest<TBody = unknown> = {
  body: TBody;
  headers?: Record<string, string>;
  path?: Record<string, string>;
  query?: Record<string, string>;
};

/**
 * Validated response data
 */
type ValidatedResponse<TBody extends HandlerResponse = HandlerResponse> = {
  body?: TBody;
  headers?: Record<string, string>;
};

type RequestContext = {
  req: Request;
  res: Response
  // ...
  valid?: {
    req: ValidatedRequest
    res: ValidatedResponse
  }
};

function validation<TReqBody, TResBody extends HandlerResponse>(
  validation: ValidationConfig<TReqBody, TResBody>
): Middleware {
  return async ({ reqCtx, next }) => {
    // Initialize valid object
    reqCtx.valid = {
      req: {},
      res: {}
    };

    // Validate request body
    if (validation.req?.body) {
      const body = await reqCtx.req.json();
      const result = await validation.req.body['~standard'].validate(body);
      
      if (!result.success) {
        throw new RequestValidationError('Validation failed!, result.issues );
      }
      
      reqCtx.valid.req.body = result.value;
    }
   
    // ...headers, path etc
    
    await next();

    // Validate response body
    if (validation.res?.body && reqCtx.res.ok) {
      const responseBody = await reqCtx.res.json();
      const result = await validation.res.body['~standard'].validate(responseBody);
      
      if (!result.success) {
        throw new RequestValidationError('Validation failed!, result.issues );
      }
    }
  };
}

We will also need to update the existing handler registration methods to thread the new types through.

#handleHttpMethod<TReqBody = never, TResBody extends HandlerResponse = HandlerResponse>(
  method: HttpMethod,
  path: Path,
  middlewareOrHandler?: Middleware[] | RouteHandler,
  handlerOrOptions?: RouteHandler | TypedRouteHandler<TReqBody, TResBody>,
  options?: { validation: ValidationConfig<TReqBody, TResBody> }
): MethodDecorator | undefined {
  if (Array.isArray(middlewareOrHandler)) {
    if (typeof handlerOrOptions === 'function') {
      // Check if options has validation
      if (options?.validation) {
        const validationMiddleware = this.#createValidationMiddleware(options.validation);
        this.route(handlerOrOptions, { 
          method, 
          path, 
          middleware: [...middlewareOrHandler, validationMiddleware] 
        });
        return;
      }
      // Existing: app.post(path, [middleware], handler)
      this.route(handlerOrOptions, { method, path, middleware: middlewareOrHandler });
      return;
    }
    // Decorator case...
  }
  // ... rest of existing logic
}

// Existing overloads (unchanged)
public post(path: Path, handler: RouteHandler): void;
public post(path: Path, middleware: Middleware[], handler: RouteHandler): void;
public post(path: Path): MethodDecorator;
public post(path: Path, middleware: Middleware[]): MethodDecorator;

// New overload with validation
public post<TReqBody = never, TResBody extends HandlerResponse = HandlerResponse>(
  path: Path,
  middleware: Middleware[],
  handler: TypedRouteHandler<TReqBody, TResBody>,
  options: { validation: ValidationConfig<TReqBody, TResBody> }
): void;

// Implementation
public post<TReqBody = never, TResBody extends HandlerResponse = HandlerResponse>(
  path: Path,
  middlewareOrHandler?: Middleware[] | RouteHandler,
  handler?: RouteHandler | TypedRouteHandler<TReqBody, TResBody>,
  options?: { validation: ValidationConfig<TReqBody, TResBody> }
): MethodDecorator | undefined {
  return this.#handleHttpMethod<TReqBody, TResBody>(
    HttpVerbs.POST,
    path,
    middlewareOrHandler,
    handler,
    options
  );
}

Alternative solutions

Acknowledgment

Future readers

Please react with 👍 and your use case to help us understand customer demand.

Metadata

Metadata

Assignees

No one assigned

    Labels

    event-handlerThis item relates to the Event Handler Utilityfeature-requestThis item refers to a feature request for an existing or new utilityon-holdThis item is on-hold and will be revisited in the future

    Type

    No type

    Projects

    Status

    On hold

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions