diff --git a/.gitignore b/.gitignore index fdbadd4621..c8312c40ff 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,5 @@ tsconfig.tsbuildinfo .claude .amazonq .kiro -.github/instructions \ No newline at end of file +.github/instructions +aidlc-docs \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7f508e9d48..9a3bdc4835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11146,7 +11146,8 @@ "version": "2.29.0", "license": "MIT-0", "dependencies": { - "@aws-lambda-powertools/commons": "2.29.0" + "@aws-lambda-powertools/commons": "2.29.0", + "@standard-schema/spec": "^1.0.0" }, "devDependencies": { "@aws-lambda-powertools/testing-utils": "file:../testing" diff --git a/packages/event-handler/package.json b/packages/event-handler/package.json index 38876a98e8..a40b5f3f66 100644 --- a/packages/event-handler/package.json +++ b/packages/event-handler/package.json @@ -130,7 +130,8 @@ "url": "https://github.com/aws-powertools/powertools-lambda-typescript/issues" }, "dependencies": { - "@aws-lambda-powertools/commons": "2.29.0" + "@aws-lambda-powertools/commons": "2.29.0", + "@standard-schema/spec": "^1.0.0" }, "keywords": [ "aws", diff --git a/packages/event-handler/src/http/Route.ts b/packages/event-handler/src/http/Route.ts index 57fca99a65..43e76d5cbf 100644 --- a/packages/event-handler/src/http/Route.ts +++ b/packages/event-handler/src/http/Route.ts @@ -1,21 +1,26 @@ import type { + HandlerResponse, HttpMethod, Middleware, Path, RouteHandler, + TypedRouteHandler, } from '../types/http.js'; -class Route { +class Route< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, +> { readonly id: string; readonly method: string; readonly path: Path; - readonly handler: RouteHandler; + readonly handler: RouteHandler | TypedRouteHandler; readonly middleware: Middleware[]; constructor( method: HttpMethod, path: Path, - handler: RouteHandler, + handler: RouteHandler | TypedRouteHandler, middleware: Middleware[] = [] ) { this.id = `${method}:${path}`; diff --git a/packages/event-handler/src/http/RouteHandlerRegistry.ts b/packages/event-handler/src/http/RouteHandlerRegistry.ts index ca5b9f3247..70307ecedb 100644 --- a/packages/event-handler/src/http/RouteHandlerRegistry.ts +++ b/packages/event-handler/src/http/RouteHandlerRegistry.ts @@ -2,9 +2,11 @@ import type { GenericLogger } from '@aws-lambda-powertools/commons/types'; import { isRegExp } from '@aws-lambda-powertools/commons/typeutils'; import type { DynamicRoute, + HandlerResponse, HttpMethod, HttpRouteHandlerOptions, Path, + RouteHandler, RouteRegistryOptions, ValidationResult, } from '../types/http.js'; @@ -94,7 +96,10 @@ class RouteHandlerRegistry { * * @param route - The route to register */ - public register(route: Route): void { + public register< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >(route: Route): void { this.#shouldSort = true; const { isValid, issues } = validatePathPattern(route.path); if (!isValid) { @@ -115,14 +120,14 @@ class RouteHandlerRegistry { this.#regexRoutes.set(route.id, { ...route, ...compiled, - }); + } as DynamicRoute); return; } if (compiled.isDynamic) { const dynamicRoute = { ...route, ...compiled, - }; + } as DynamicRoute; if (this.#dynamicRoutesSet.has(route.id)) { this.#logger.warn( `Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.` @@ -144,7 +149,7 @@ class RouteHandlerRegistry { `Handler for method: ${route.method} and path: ${route.path} already exists. The previous handler will be replaced.` ); } - this.#staticRoutes.set(route.id, route); + this.#staticRoutes.set(route.id, route as unknown as Route); } } /** @@ -179,7 +184,7 @@ class RouteHandlerRegistry { const staticRoute = this.#staticRoutes.get(routeId); if (staticRoute != null) { return { - handler: staticRoute.handler, + handler: staticRoute.handler as RouteHandler, rawParams: {}, params: {}, middleware: staticRoute.middleware, @@ -241,7 +246,7 @@ class RouteHandlerRegistry { } return { - handler: route.handler, + handler: route.handler as RouteHandler, params: processedParams, rawParams: params, middleware: route.middleware, diff --git a/packages/event-handler/src/http/Router.ts b/packages/event-handler/src/http/Router.ts index 8dd7889b20..f596bfa891 100644 --- a/packages/event-handler/src/http/Router.ts +++ b/packages/event-handler/src/http/Router.ts @@ -23,17 +23,21 @@ import type { ErrorConstructor, ErrorHandler, ErrorResolveOptions, + HandlerOrOptions, HttpMethod, HttpResolveOptions, HttpRouteOptions, HttpRouterOptions, Middleware, + MiddlewareOrHandler, Path, RequestContext, ResolveStreamOptions, ResponseStream, RouteHandler, RouterResponse, + TypedRouteHandler, + ValidationConfig, } from '../types/http.js'; import type { HandlerResponse, ResolveOptions } from '../types/index.js'; import { HttpStatusCodes, HttpVerbs } from './constants.js'; @@ -51,6 +55,7 @@ import { MethodNotAllowedError, NotFoundError, } from './errors.js'; +import { createValidationMiddleware } from './middleware/validation.js'; import { Route } from './Route.js'; import { RouteHandlerRegistry } from './RouteHandlerRegistry.js'; import { @@ -442,14 +447,30 @@ class Router { } } - public route(handler: RouteHandler, options: HttpRouteOptions): void { - const { method, path, middleware = [] } = options; + public route< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + handler: RouteHandler | TypedRouteHandler, + options: HttpRouteOptions + ): void { + const { method, path, middleware = [], validation } = options; const methods = Array.isArray(method) ? method : [method]; const resolvedPath = resolvePrefixedPath(path, this.prefix); + // Create validation middleware if validation config provided + const allMiddleware = validation + ? [ + ...middleware, + createValidationMiddleware( + validation as ValidationConfig + ), + ] + : middleware; + for (const method of methods) { this.routeRegistry.register( - new Route(method, resolvedPath, handler, middleware) + new Route(method, resolvedPath, handler, allMiddleware) ); } } @@ -564,15 +585,25 @@ class Router { ); } - #handleHttpMethod( + #handleHttpMethod< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( method: HttpMethod, path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { + // Case 1: method(path, [middleware], handler, { validation }) if (Array.isArray(middlewareOrHandler)) { - if (handler && typeof handler === 'function') { - this.route(handler, { method, path, middleware: middlewareOrHandler }); + if (handlerOrOptions && typeof handlerOrOptions === 'function') { + this.route(handlerOrOptions, { + method, + path, + middleware: middlewareOrHandler, + ...options, + }); return; } return (_target, _propertyKey, descriptor: PropertyDescriptor) => { @@ -580,16 +611,29 @@ class Router { method, path, middleware: middlewareOrHandler, + ...options, }); return descriptor; }; } + // Case 2: method(path, handler, { validation }) or method(path, handler) if (middlewareOrHandler && typeof middlewareOrHandler === 'function') { + // Check if handlerOrOptions is an options object (not a function) + if ( + handlerOrOptions && + typeof handlerOrOptions === 'object' && + !Array.isArray(handlerOrOptions) + ) { + this.route(middlewareOrHandler, { method, path, ...handlerOrOptions }); + return; + } + // No options provided this.route(middlewareOrHandler, { method, path }); return; } + // Case 3: Decorator usage return (_target, _propertyKey, descriptor: PropertyDescriptor) => { this.route(descriptor.value, { method, path }); return descriptor; @@ -600,16 +644,38 @@ class Router { public get(path: Path, middleware: Middleware[], handler: RouteHandler): void; public get(path: Path): MethodDecorator; public get(path: Path, middleware: Middleware[]): MethodDecorator; - public get( + public get< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public get< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public get< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.GET, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } @@ -621,16 +687,38 @@ class Router { ): void; public post(path: Path): MethodDecorator; public post(path: Path, middleware: Middleware[]): MethodDecorator; - public post( + public post< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public post< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public post< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.POST, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } @@ -638,16 +726,38 @@ class Router { public put(path: Path, middleware: Middleware[], handler: RouteHandler): void; public put(path: Path): MethodDecorator; public put(path: Path, middleware: Middleware[]): MethodDecorator; - public put( + public put< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public put< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public put< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.PUT, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } @@ -659,16 +769,38 @@ class Router { ): void; public patch(path: Path): MethodDecorator; public patch(path: Path, middleware: Middleware[]): MethodDecorator; - public patch( + public patch< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public patch< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public patch< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.PATCH, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } @@ -680,16 +812,38 @@ class Router { ): void; public delete(path: Path): MethodDecorator; public delete(path: Path, middleware: Middleware[]): MethodDecorator; - public delete( + public delete< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public delete< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public delete< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.DELETE, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } @@ -701,16 +855,38 @@ class Router { ): void; public head(path: Path): MethodDecorator; public head(path: Path, middleware: Middleware[]): MethodDecorator; - public head( + public head< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public head< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public head< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.HEAD, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } @@ -722,16 +898,38 @@ class Router { ): void; public options(path: Path): MethodDecorator; public options(path: Path, middleware: Middleware[]): MethodDecorator; - public options( + public options< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public options< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( + path: Path, + middleware: Middleware[], + handler: TypedRouteHandler, + options: { validation: ValidationConfig } + ): void; + public options< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, + >( path: Path, - middlewareOrHandler?: Middleware[] | RouteHandler, - handler?: RouteHandler + middlewareOrHandler?: MiddlewareOrHandler, + handlerOrOptions?: HandlerOrOptions, + options?: { validation: ValidationConfig } ): MethodDecorator | undefined { - return this.#handleHttpMethod( + return this.#handleHttpMethod( HttpVerbs.OPTIONS, path, middlewareOrHandler, - handler + handlerOrOptions, + options ); } diff --git a/packages/event-handler/src/http/errors.ts b/packages/event-handler/src/http/errors.ts index 56a3085917..447458063f 100644 --- a/packages/event-handler/src/http/errors.ts +++ b/packages/event-handler/src/http/errors.ts @@ -1,4 +1,5 @@ import type { JSONValue } from '@aws-lambda-powertools/commons/types'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { HandlerResponse, HttpStatusCode } from '../types/http.js'; import { HttpStatusCodes } from './constants.js'; @@ -173,6 +174,44 @@ class ServiceUnavailableError extends HttpError { } } +class RequestValidationError extends HttpError { + readonly statusCode = HttpStatusCodes.UNPROCESSABLE_ENTITY; + readonly errorType = 'RequestValidationError'; + + constructor( + message?: string, + issues?: StandardSchemaV1.FailureResult['issues'], + options?: ErrorOptions + ) { + super(message, options, { + issues: issues?.map((issue) => ({ + message: issue.message, + path: issue.path, + })), + }); + this.name = 'RequestValidationError'; + } +} + +class ResponseValidationError extends HttpError { + readonly statusCode = HttpStatusCodes.INTERNAL_SERVER_ERROR; + readonly errorType = 'ResponseValidationError'; + + constructor( + message?: string, + issues?: StandardSchemaV1.FailureResult['issues'], + options?: ErrorOptions + ) { + super(message, options, { + issues: issues?.map((issue) => ({ + message: issue.message, + path: issue.path, + })), + }); + this.name = 'ResponseValidationError'; + } +} + class InvalidEventError extends Error { constructor(message?: string) { super(message); @@ -198,6 +237,8 @@ export { ParameterValidationError, RequestEntityTooLargeError, RequestTimeoutError, + RequestValidationError, + ResponseValidationError, RouteMatchingError, HttpError, ServiceUnavailableError, diff --git a/packages/event-handler/src/http/index.ts b/packages/event-handler/src/http/index.ts index f2ee9d1664..7d1c7e496c 100644 --- a/packages/event-handler/src/http/index.ts +++ b/packages/event-handler/src/http/index.ts @@ -14,6 +14,8 @@ export { ParameterValidationError, RequestEntityTooLargeError, RequestTimeoutError, + RequestValidationError, + ResponseValidationError, RouteMatchingError, ServiceUnavailableError, UnauthorizedError, diff --git a/packages/event-handler/src/http/middleware/index.ts b/packages/event-handler/src/http/middleware/index.ts index 96686c578b..9b860ba95a 100644 --- a/packages/event-handler/src/http/middleware/index.ts +++ b/packages/event-handler/src/http/middleware/index.ts @@ -1,2 +1,3 @@ export { compress } from './compress.js'; export { cors } from './cors.js'; +export { createValidationMiddleware } from './validation.js'; diff --git a/packages/event-handler/src/http/middleware/validation.ts b/packages/event-handler/src/http/middleware/validation.ts new file mode 100644 index 0000000000..71f64405ce --- /dev/null +++ b/packages/event-handler/src/http/middleware/validation.ts @@ -0,0 +1,191 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { + HandlerResponse, + Middleware, + TypedRequestContext, + ValidatedRequest, + ValidatedResponse, + ValidationConfig, +} from '../../types/http.js'; +import { RequestValidationError, ResponseValidationError } from '../errors.js'; + +/** + * Creates a validation middleware from the provided validation configuration. + * + * @param config - Validation configuration for request and response + * @returns Middleware function that validates request/response + */ +export const createValidationMiddleware = < + TReqBody = unknown, + TResBody extends HandlerResponse = HandlerResponse, +>( + config: ValidationConfig +): Middleware => { + const reqSchemas = config?.req; + const resSchemas = config?.res; + + return async ({ reqCtx, next }) => { + const typedReqCtx = reqCtx as TypedRequestContext; + typedReqCtx.valid = { + req: {} as ValidatedRequest, + res: {} as ValidatedResponse, + }; + + if (reqSchemas) { + await validateRequestData(typedReqCtx, reqSchemas); + } + + await next(); + + if (resSchemas) { + await validateResponseData(typedReqCtx, resSchemas); + } + }; +}; + +async function validateRequestData( + typedReqCtx: TypedRequestContext, + reqSchemas: NonNullable['req']> +): Promise { + if (reqSchemas.body) { + const bodyData = await extractBody(typedReqCtx.req); + typedReqCtx.valid.req.body = await validateRequest( + reqSchemas.body, + bodyData, + 'body' + ); + } + + if (reqSchemas.headers) { + const headers = Object.fromEntries(typedReqCtx.req.headers.entries()); + typedReqCtx.valid.req.headers = await validateRequest( + reqSchemas.headers, + headers, + 'headers' + ); + } + + if (reqSchemas.path) { + typedReqCtx.valid.req.path = await validateRequest( + reqSchemas.path, + typedReqCtx.params, + 'path' + ); + } + + if (reqSchemas.query) { + const query = Object.fromEntries( + new URL(typedReqCtx.req.url).searchParams.entries() + ); + typedReqCtx.valid.req.query = await validateRequest( + reqSchemas.query, + query, + 'query' + ); + } +} + +async function validateResponseData( + typedReqCtx: TypedRequestContext, + resSchemas: NonNullable['res']> +): Promise { + const response = typedReqCtx.res; + + if (resSchemas.body && response.body) { + const bodyData = await extractBody(response); + typedReqCtx.valid.res.body = await validateResponse( + resSchemas.body, + bodyData, + 'body' + ); + } + + if (resSchemas.headers) { + const headers = Object.fromEntries(response.headers.entries()); + typedReqCtx.valid.res.headers = await validateResponse( + resSchemas.headers, + headers, + 'headers' + ); + } +} + +async function extractBody(source: Request | Response): Promise { + const cloned = source.clone(); + const contentType = source.headers.get('content-type'); + + if (contentType?.includes('application/json')) { + try { + return await cloned.json(); + } catch { + if (source instanceof Request) { + throw new RequestValidationError( + 'Validation failed for request body', + [], + { + cause: new Error('Invalid JSON body'), + } + ); + } + throw new ResponseValidationError( + 'Validation failed for response body', + [], + { + cause: new Error('Invalid JSON body'), + } + ); + } + } + + return await cloned.text(); +} + +async function validateRequest( + schema: StandardSchemaV1, + data: unknown, + component: 'body' +): Promise; +async function validateRequest( + schema: StandardSchemaV1, + data: unknown, + component: 'headers' | 'path' | 'query' +): Promise>; +async function validateRequest( + schema: StandardSchemaV1, + data: unknown, + component: 'body' | 'headers' | 'path' | 'query' +): Promise> { + const result = await schema['~standard'].validate(data); + + if ('issues' in result) { + const message = `Validation failed for request ${component}`; + throw new RequestValidationError(message, result.issues); + } + + return result.value as T | Record; +} + +async function validateResponse( + schema: StandardSchemaV1, + data: unknown, + component: 'body' +): Promise; +async function validateResponse( + schema: StandardSchemaV1, + data: unknown, + component: 'headers' +): Promise>; +async function validateResponse( + schema: StandardSchemaV1, + data: unknown, + component: 'body' | 'headers' +): Promise> { + const result = await schema['~standard'].validate(data); + + if ('issues' in result) { + const message = `Validation failed for response ${component}`; + throw new ResponseValidationError(message, result.issues); + } + + return result.value as T | Record; +} diff --git a/packages/event-handler/src/types/http.ts b/packages/event-handler/src/types/http.ts index 8d7cd898ce..d247c2f26c 100644 --- a/packages/event-handler/src/types/http.ts +++ b/packages/event-handler/src/types/http.ts @@ -3,6 +3,7 @@ import type { GenericLogger, JSONValue, } from '@aws-lambda-powertools/commons/types'; +import type { StandardSchemaV1 } from '@standard-schema/spec'; import type { ALBEvent, ALBResult, @@ -24,6 +25,24 @@ type ResponseTypeMap = { ALB: ALBResult; }; +/** + * Validated request data + */ +type ValidatedRequest = { + body: TBody; + headers: Record; + path: Record; + query: Record; +}; + +/** + * Validated response data + */ +type ValidatedResponse = { + body: TBody; + headers: Record; +}; + type RequestContext = { req: Request; event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent; @@ -34,6 +53,16 @@ type RequestContext = { isBase64Encoded?: boolean; }; +type TypedRequestContext< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, +> = RequestContext & { + valid: { + req: ValidatedRequest; + res: ValidatedResponse; + }; +}; + type HttpResolveOptions = ResolveOptions & { isHttpStreaming?: boolean }; type ErrorResolveOptions = RequestContext & ResolveOptions; @@ -94,6 +123,13 @@ type RouteHandler = ( reqCtx: RequestContext ) => Promise | TReturn; +type TypedRouteHandler< + TReqBody, + TResBody extends HandlerResponse = HandlerResponse, +> = ( + reqCtx: TypedRequestContext +) => Promise | TResBody; + type HttpMethod = keyof typeof HttpVerbs; type HttpStatusCode = (typeof HttpStatusCodes)[keyof typeof HttpStatusCodes]; @@ -111,13 +147,14 @@ type HttpRouteOptions = { method: HttpMethod | HttpMethod[]; path: Path; middleware?: Middleware[]; + validation?: ValidationConfig; }; // biome-ignore lint/suspicious/noConfusingVoidType: To ensure next function is awaited type NextFunction = () => Promise; type Middleware = (args: { - reqCtx: RequestContext; + reqCtx: RequestContext | TypedRequestContext; next: NextFunction; // biome-ignore lint/suspicious/noConfusingVoidType: To ensure next function is awaited }) => Promise; @@ -253,6 +290,97 @@ type RouterResponse = | APIGatewayProxyStructuredResultV2 | ALBResult; +/** + * Configuration for request validation. + * At least one of body, headers, path, or query must be provided. + */ +type RequestValidationConfig = + | { + body: StandardSchemaV1; + headers?: StandardSchemaV1>; + path?: StandardSchemaV1>; + query?: StandardSchemaV1>; + } + | { + body?: StandardSchemaV1; + headers: StandardSchemaV1>; + path?: StandardSchemaV1>; + query?: StandardSchemaV1>; + } + | { + body?: StandardSchemaV1; + headers?: StandardSchemaV1>; + path: StandardSchemaV1>; + query?: StandardSchemaV1>; + } + | { + body?: StandardSchemaV1; + headers?: StandardSchemaV1>; + path?: StandardSchemaV1>; + query: StandardSchemaV1>; + }; + +/** + * Configuration for response validation. + * At least one of body or headers must be provided. + */ +type ResponseValidationConfig = + | { + body: StandardSchemaV1; + headers?: StandardSchemaV1< + Record, + Record + >; + } + | { + body?: StandardSchemaV1; + headers: StandardSchemaV1, Record>; + }; + +/** + * Validation configuration for request and response. + * At least one of req or res must be provided. + */ +type ValidationConfig< + TReqBody = unknown, + TResBody extends HandlerResponse = HandlerResponse, +> = + | { + req: RequestValidationConfig; + res?: ResponseValidationConfig; + } + | { + req?: RequestValidationConfig; + res: ResponseValidationConfig; + }; + +/** + * Validation error details + */ +type ValidationErrorDetail = { + component: 'body' | 'headers' | 'path' | 'query'; + message: string; +}; + +/** + * Union type for middleware array or route handler + */ +type MiddlewareOrHandler< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, +> = Middleware[] | RouteHandler | TypedRouteHandler; + +/** + * Union type for route handler or validation options + */ +type HandlerOrOptions< + TReqBody = never, + TResBody extends HandlerResponse = HandlerResponse, +> = + | RouteHandler + | TypedRouteHandler + | { validation: ValidationConfig }; + export type { BinaryResult, ExtendedAPIGatewayProxyResult, @@ -271,6 +399,7 @@ export type { Middleware, Path, RequestContext, + TypedRequestContext, ResponseType, ResponseTypeMap, HttpRouterOptions, @@ -286,4 +415,13 @@ export type { NextFunction, V1Headers, WebResponseToProxyResultOptions, + RequestValidationConfig, + ResponseValidationConfig, + ValidationConfig, + ValidationErrorDetail, + ValidatedRequest, + ValidatedResponse, + TypedRouteHandler, + MiddlewareOrHandler, + HandlerOrOptions, }; diff --git a/packages/event-handler/tests/unit/http/errors.test.ts b/packages/event-handler/tests/unit/http/errors.test.ts index c0269b552b..d3fc60fd20 100644 --- a/packages/event-handler/tests/unit/http/errors.test.ts +++ b/packages/event-handler/tests/unit/http/errors.test.ts @@ -8,6 +8,8 @@ import { NotFoundError, RequestEntityTooLargeError, RequestTimeoutError, + RequestValidationError, + ResponseValidationError, ServiceUnavailableError, UnauthorizedError, } from '../../../src/http/index.js'; @@ -68,15 +70,17 @@ describe('HTTP Error Classes', () => { statusCode: HttpStatusCodes.SERVICE_UNAVAILABLE, customMessage: 'Maintenance mode', }, - ])( - '$errorType uses custom message when provided', - ({ ErrorClass, errorType, statusCode, customMessage }) => { - const error = new ErrorClass(customMessage); - expect(error.message).toBe(customMessage); - expect(error.statusCode).toBe(statusCode); - expect(error.errorType).toBe(errorType); - } - ); + ])('$errorType uses custom message when provided', ({ + ErrorClass, + errorType, + statusCode, + customMessage, + }) => { + const error = new ErrorClass(customMessage); + expect(error.message).toBe(customMessage); + expect(error.statusCode).toBe(statusCode); + expect(error.errorType).toBe(errorType); + }); describe('toJSON', () => { it.each([ @@ -134,19 +138,21 @@ describe('HTTP Error Classes', () => { statusCode: HttpStatusCodes.SERVICE_UNAVAILABLE, message: 'Maintenance mode', }, - ])( - '$errorType serializes to JSON format', - ({ ErrorClass, errorType, statusCode, message }) => { - const error = new ErrorClass(message); - const json = error.toJSON(); - - expect(json).toEqual({ - statusCode, - error: errorType, - message, - }); - } - ); + ])('$errorType serializes to JSON format', ({ + ErrorClass, + errorType, + statusCode, + message, + }) => { + const error = new ErrorClass(message); + const json = error.toJSON(); + + expect(json).toEqual({ + statusCode, + error: errorType, + message, + }); + }); it('includes details in JSON when provided', () => { const details = { field: 'value', code: 'VALIDATION_ERROR' }; @@ -180,4 +186,178 @@ describe('HTTP Error Classes', () => { expect(error.cause).toBe(cause); }); + + describe('RequestValidationError', () => { + it('creates error with correct statusCode', () => { + const error = new RequestValidationError( + 'Validation failed for request body' + ); + + expect(error.statusCode).toBe(HttpStatusCodes.UNPROCESSABLE_ENTITY); + expect(error.statusCode).toBe(422); + }); + + it('creates error with correct errorType', () => { + const error = new RequestValidationError( + 'Validation failed for request body' + ); + + expect(error.errorType).toBe('RequestValidationError'); + expect(error.name).toBe('RequestValidationError'); + }); + + it('stores validation issues', () => { + const issues = [ + { message: 'Required field missing', path: ['name'] }, + { message: 'Invalid type', path: ['age'] }, + ]; + const error = new RequestValidationError( + 'Validation failed for request body', + issues + ); + + expect(error.details?.issues).toEqual([ + { message: 'Required field missing', path: ['name'] }, + { message: 'Invalid type', path: ['age'] }, + ]); + }); + + it('passes options to Error superclass', () => { + const cause = new Error('Root cause'); + const error = new RequestValidationError( + 'Validation failed for request body', + undefined, + { cause } + ); + + expect(error.cause).toBe(cause); + }); + + it('converts to JSON response', () => { + const error = new RequestValidationError( + 'Validation failed for request body' + ); + + const json = error.toJSON(); + expect(json).toEqual({ + statusCode: 422, + error: 'RequestValidationError', + message: 'Validation failed for request body', + details: { + issues: undefined, + }, + }); + }); + + it('includes issues in JSON when provided', () => { + const issues = [ + { message: 'Required field missing', path: ['name'] }, + { message: 'Invalid type', path: ['age'] }, + ]; + const error = new RequestValidationError( + 'Validation failed for request body', + issues + ); + + const json = error.toJSON(); + expect(json).toEqual({ + statusCode: 422, + error: 'RequestValidationError', + message: 'Validation failed for request body', + details: { + issues: [ + { message: 'Required field missing', path: ['name'] }, + { message: 'Invalid type', path: ['age'] }, + ], + }, + }); + }); + }); + + describe('ResponseValidationError', () => { + it('creates error with correct statusCode', () => { + const error = new ResponseValidationError( + 'Validation failed for response body' + ); + + expect(error.statusCode).toBe(HttpStatusCodes.INTERNAL_SERVER_ERROR); + expect(error.statusCode).toBe(500); + }); + + it('creates error with correct errorType', () => { + const error = new ResponseValidationError( + 'Validation failed for response body' + ); + + expect(error.errorType).toBe('ResponseValidationError'); + expect(error.name).toBe('ResponseValidationError'); + }); + + it('stores validation issues', () => { + const issues = [ + { message: 'Required field missing', path: ['id'] }, + { message: 'Invalid format', path: ['email'] }, + ]; + const error = new ResponseValidationError( + 'Validation failed for response body', + issues + ); + + expect(error.details?.issues).toEqual([ + { message: 'Required field missing', path: ['id'] }, + { message: 'Invalid format', path: ['email'] }, + ]); + }); + + it('passes options to Error superclass', () => { + const cause = new Error('Root cause'); + const error = new ResponseValidationError( + 'Validation failed for response body', + undefined, + { cause } + ); + + expect(error.cause).toBe(cause); + }); + + it('converts to JSON response', () => { + const error = new ResponseValidationError( + 'Validation failed for response body' + ); + + const json = error.toJSON(); + expect(json).toEqual({ + statusCode: 500, + error: 'ResponseValidationError', + message: 'Validation failed for response body', + details: { + issues: undefined, + }, + }); + }); + + it('includes issues in JSON when provided', () => { + const issues = [ + { message: 'Required field missing', path: ['id'] }, + { message: 'Invalid format', path: ['email'] }, + ]; + const error = new ResponseValidationError( + 'Validation failed for response body', + issues + ); + + const json = error.toJSON(); + expect(json).toEqual({ + statusCode: 500, + error: 'ResponseValidationError', + message: 'Validation failed for response body', + details: { + issues: [ + { message: 'Required field missing', path: ['id'] }, + { message: 'Invalid format', path: ['email'] }, + ], + }, + }); + }); + }); }); diff --git a/packages/event-handler/tests/unit/http/middleware/validation.test.ts b/packages/event-handler/tests/unit/http/middleware/validation.test.ts new file mode 100644 index 0000000000..55940dd305 --- /dev/null +++ b/packages/event-handler/tests/unit/http/middleware/validation.test.ts @@ -0,0 +1,488 @@ +import context from '@aws-lambda-powertools/testing-utils/context'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { Router } from '../../../../src/http/index.js'; +import { createTestEvent } from '../helpers.js'; + +describe('Router Validation Integration', () => { + let app: Router; + + beforeEach(() => { + app = new Router(); + }); + + it('validates request body successfully', async () => { + // Prepare + const requestBodySchema = z.object({ name: z.string() }); + type RequestBodyType = z.infer; + + app.post( + '/users', + (reqCtx) => { + const { name } = reqCtx.valid.req.body; + return { statusCode: 201, body: `Created ${name}` }; + }, + { + validation: { req: { body: requestBodySchema } }, + } + ); + + const event = createTestEvent('/users', 'POST', { + 'content-type': 'application/json', + }); + event.body = JSON.stringify({ name: 'John' }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(201); + expect(result.body).toBe('Created John'); + }); + + it('returns 422 on request body validation failure', async () => { + // Prepare + const requestBodySchema = z.object({ name: z.string() }); + type RequestBodyType = z.infer; + + app.post( + '/users', + () => ({ statusCode: 201, body: 'Created' }), + { + validation: { req: { body: requestBodySchema } }, + } + ); + + const event = createTestEvent('/users', 'POST', { + 'content-type': 'application/json', + }); + event.body = JSON.stringify({ invalid: 'data' }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(422); + const body = JSON.parse(result.body); + expect(body.error).toBe('RequestValidationError'); + }); + + it('validates request body successfully when it is non-JSON', async () => { + // Prepare + const requestBodySchema = z.string(); + type RequestBodyType = z.infer; + + app.post( + '/users', + (reqCtx) => { + const name = reqCtx.valid.req.body; + return { statusCode: 201, body: `Created ${name}` }; + }, + { + validation: { req: { body: requestBodySchema } }, + } + ); + + const event = createTestEvent('/users', 'POST', { + 'content-type': 'text/plain', + }); + event.body = 'John'; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(201); + expect(result.body).toBe('Created John'); + }); + + it('returns 422 when the request is a malformed JSON', async () => { + // Prepare + const requestBodySchema = z.object({ + name: z.string(), + }); + type RequestBodyType = z.infer; + + app.post( + '/users', + (reqCtx) => { + const name = reqCtx.valid.req.body; + return { statusCode: 201, body: `Created ${name}` }; + }, + { + validation: { req: { body: requestBodySchema } }, + } + ); + + const event = createTestEvent('/users', 'POST', { + 'Content-Type': 'application/json', + }); + event.body = "{'name': 'John'"; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(422); + expect(result.body).toContain('RequestValidationError'); + }); + + it('validates request headers successfully', async () => { + // Prepare + const headerSchema = z.object({ 'x-api-key': z.string() }); + + app.get( + '/protected', + (reqCtx) => { + const apiKey = reqCtx.valid.req.headers['x-api-key']; + return { statusCode: 200, body: `Authenticated with ${apiKey}` }; + }, + { + validation: { req: { headers: headerSchema } }, + } + ); + + const event = createTestEvent('/protected', 'GET', { + 'x-api-key': 'test-key', + }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.body).toBe('Authenticated with test-key'); + }); + + it('returns 422 on request headers validation failure', async () => { + // Prepare + const headerSchema = z.object({ 'x-api-key': z.string() }); + + app.get('/protected', () => ({ statusCode: 200, body: 'OK' }), { + validation: { req: { headers: headerSchema } }, + }); + + const event = createTestEvent('/protected', 'GET', {}); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(422); + }); + + it('validates path parameters successfully', async () => { + // Prepare + const pathSchema = z.object({ id: z.string() }); + + app.get( + '/users/:id', + (reqCtx) => { + const { id } = reqCtx.valid.req.path; + return { id, validated: true }; + }, + { + validation: { req: { path: pathSchema } }, + } + ); + + const event = createTestEvent('/users/123', 'GET', {}); + event.pathParameters = { id: '123' }; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.id).toBe('123'); + expect(body.validated).toBe(true); + }); + + it('returns 422 on path parameters validation failure', async () => { + // Prepare + const pathSchema = z.object({ id: z.string().uuid() }); + + app.get('/users/:id', () => ({ body: { id: '123' } }), { + validation: { req: { path: pathSchema } }, + }); + + const event = createTestEvent('/users/123', 'GET', {}); + event.pathParameters = { id: '123' }; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(422); + }); + + it('validates query parameters successfully', async () => { + // Prepare + const querySchema = z.object({ page: z.string(), limit: z.string() }); + + app.get( + '/users', + (reqCtx) => { + const { page, limit } = reqCtx.valid.req.query; + return { + users: [], + page: Number.parseInt(page, 10), + limit: Number.parseInt(limit, 10), + }; + }, + { + validation: { req: { query: querySchema } }, + } + ); + + const event = createTestEvent('/users', 'GET', {}); + event.queryStringParameters = { page: '1', limit: '10' }; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.page).toBe(1); + expect(body.limit).toBe(10); + }); + + it('returns 422 on query parameters validation failure', async () => { + // Prepare + const querySchema = z.object({ page: z.string(), limit: z.string() }); + + app.get('/users', () => ({ body: { users: [] } }), { + validation: { req: { query: querySchema } }, + }); + + const event = createTestEvent('/users', 'GET', {}); + event.queryStringParameters = { page: '1' }; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(422); + }); + + it('validates response body successfully', async () => { + // Prepare + const responseSchema = z.object({ id: z.string(), name: z.string() }); + type ResponseType = z.infer; + + app.get( + '/users/:id', + () => { + return { id: '123', name: 'John' }; + }, + { + validation: { res: { body: responseSchema } }, + } + ); + + const event = createTestEvent('/users/123', 'GET', {}); + event.pathParameters = { id: '123' }; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + }); + + it('returns 500 on response body validation failure', async () => { + // Prepare + const responseSchema = z.object({ id: z.string(), name: z.string() }); + type ResponseType = z.infer; + + //@ts-expect-error testing for validation failure + app.get('/users/:id', () => ({ id: '123' }), { + validation: { res: { body: responseSchema } }, + }); + + const event = createTestEvent('/users/123', 'GET', {}); + event.pathParameters = { id: '123' }; + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(500); + }); + + it('validates response body successfully when it is non-JSON', async () => { + // Prepare + const responseBodySchema = z.string(); + type ResponseBodyType = z.infer; + + app.post( + '/users', + () => { + return 'Plain text response'; + }, + { + validation: { res: { body: responseBodySchema } }, + } + ); + + const event = createTestEvent('/users', 'POST'); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + expect(result.body).toBe('"Plain text response"'); + }); + + it('returns 500 when the response is an invalid JSON', async () => { + // Prepare + const responseSchema = z.object({ name: z.string() }); + type ResponseType = z.infer; + + app.get( + '/invalid', + //@ts-expect-error testing for validation failure + () => { + return new Response('{"name": "John"', { + headers: { + 'content-type': 'application/json', + }, + }); + }, + { + validation: { res: { body: responseSchema } }, + } + ); + + const event = createTestEvent('/invalid', 'GET'); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(500); + }); + + it('validates response headers successfully', async () => { + // Prepare + const responseHeaderSchema = z.object({ 'x-custom-header': z.string() }); + + app.get( + '/test', + () => { + return new Response('OK', { + headers: { 'x-custom-header': 'test-value' }, + }); + }, + { + validation: { res: { headers: responseHeaderSchema } }, + } + ); + + const event = createTestEvent('/test', 'GET', {}); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + }); + + it('returns 500 on response headers validation failure', async () => { + // Prepare + const responseHeaderSchema = z.object({ 'x-required': z.string() }); + + app.get( + '/test', + () => { + return new Response('OK', { + headers: { 'x-other': 'value' }, + }); + }, + { + validation: { res: { headers: responseHeaderSchema } }, + } + ); + + const event = createTestEvent('/test', 'GET', {}); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(500); + }); + + it('validates both request and response', async () => { + // Prepare + const requestSchema = z.object({ name: z.string(), email: z.string() }); + const responseSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string(), + }); + type RequestType = z.infer; + type ResponseType = z.infer; + + app.post( + '/users', + (reqCtx) => { + const { name, email } = reqCtx.valid.req.body; + return { id: '123', name, email }; + }, + { + validation: { + req: { body: requestSchema }, + res: { body: responseSchema }, + }, + } + ); + + const event = createTestEvent('/users', 'POST', { + 'content-type': 'application/json', + }); + event.body = JSON.stringify({ name: 'John', email: 'john@example.com' }); + + // Act + const result = await app.resolve(event, context); + + // Assess + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.name).toBe('John'); + expect(body.email).toBe('john@example.com'); + }); + + it('applies validation only to configured routes', async () => { + // Prepare + const bodySchema = z.object({ name: z.string() }); + type BodyType = z.infer; + + app.post('/validated', () => ({ statusCode: 201 }), { + validation: { req: { body: bodySchema } }, + }); + + app.post('/unvalidated', () => ({ statusCode: 201 })); + + const validatedEvent = createTestEvent('/validated', 'POST', { + 'content-type': 'application/json', + }); + validatedEvent.body = JSON.stringify({ data: 'test' }); + + const unvalidatedEvent = createTestEvent('/unvalidated', 'POST', { + 'content-type': 'application/json', + }); + unvalidatedEvent.body = JSON.stringify({ data: 'test' }); + + // Act + const validatedResult = await app.resolve(validatedEvent, context); + const unvalidatedResult = await app.resolve(unvalidatedEvent, context); + + // Assess + expect(validatedResult.statusCode).toBe(422); + expect(unvalidatedResult.statusCode).toBe(200); + }); +});