-
Notifications
You must be signed in to change notification settings - Fork 181
Description
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
- This feature request meets Powertools for AWS Lambda (TypeScript) Tenets
- Should this be considered in other Powertools for AWS Lambda languages? i.e. Python, Java, and .NET
Future readers
Please react with 👍 and your use case to help us understand customer demand.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status