HTTP router for API Gateway (V1/V2), ALB, and Lambda Function URLs with middleware, error handling, and response streaming.
class Router {
constructor(options?: { logger?: GenericLogger; prefix?: Path });
// Resolution
resolve(event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent, context: Context, options?: ResolveOptions): Promise<APIGatewayProxyResult | APIGatewayProxyStructuredResultV2 | ALBResult>;
resolveStream(event, context, options: { responseStream: ResponseStream; scope?: unknown }): Promise<void>;
// Method handlers (all methods follow same signature)
get|post|put|patch|delete|head|options(path: Path, handler: RouteHandler): void;
get|post|put|patch|delete|head|options(path: Path, middleware: Middleware[], handler: RouteHandler): void;
get|post|put|patch|delete|head|options(path: Path): MethodDecorator;
get|post|put|patch|delete|head|options(path: Path, middleware: Middleware[]): MethodDecorator;
// Generic route
route(handler: RouteHandler, options: { method: HttpMethod | HttpMethod[]; path: Path; middleware?: Middleware[] }): void;
// Middleware & composition
use(middleware: Middleware): void;
includeRouter(router: Router, options?: { prefix: Path }): void;
// Error handling
errorHandler<T extends Error>(errorType: ErrorConstructor<T> | ErrorConstructor<T>[], handler: ErrorHandler<T>): void;
errorHandler<T extends Error>(errorType: ErrorConstructor<T> | ErrorConstructor<T>[]): MethodDecorator;
notFound(handler: ErrorHandler<NotFoundError>): void;
notFound(): MethodDecorator;
methodNotAllowed(handler: ErrorHandler<MethodNotAllowedError>): void;
methodNotAllowed(): MethodDecorator;
}Setup
import { Router } from '@aws-lambda-powertools/event-handler/http';
const app = new Router({ logger, prefix: '/api/v1' });
export const handler = async (event, context) => app.resolve(event, context);Functional API
app.get('/users', async () => ({ users: ['Alice', 'Bob'] }));
app.post('/users', async ({ req }) => ({ created: await req.json() }));
app.put('/users/:id', async ({ params, req }) => ({ updated: params.id, data: await req.json() }));
app.patch('/users/:id', async ({ params, req }) => ({ id: params.id, updates: await req.json() }));
app.delete('/users/:id', async ({ params }) => ({ deleted: params.id }));
app.head('/users/:id', async () => new Response(null, { status: 200 }));
app.options('/users', async () => new Response(null, { status: 204, headers: { 'Allow': 'GET, POST, OPTIONS' } }));Decorator API
class UserAPI {
@app.get('/users') async listUsers() { return { users: [] }; }
@app.get('/users/:id') async getUser({ params }) { return { user: params.id }; }
@app.post('/users') async createUser({ req }) { return { created: await req.json() }; }
}
const api = new UserAPI();
export const handler = async (event, context) => app.resolve(event, context, { scope: api });Multiple Methods
app.route(async ({ req }) => {
if (req.method === 'GET') return { users: [] };
return { created: await req.json() };
}, { method: ['GET', 'POST'], path: '/users' });Parameters: /users/:id, /users/:userId/posts/:postId
app.get('/users/:id', async ({ params }) => ({ userId: params.id }));RegExp: Match patterns, numeric IDs, file extensions
app.get(/^\/users\/(\d+)$/, async ({ req }) => {
const id = req.url.match(/\/users\/(\d+)/)?.[1];
return { userId: id };
});
app.get(/^\/files\/.*\.(jpg|png|gif)$/, async () => ({ message: 'Image file' }));type RequestContext = {
req: Request; // Web API Request
event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent;
context: Context; // Lambda context
res: Response; // Web API Response
params: Record<string, string>; // Path parameters
responseType: 'ApiGatewayV1' | 'ApiGatewayV2' | 'ALB';
isBase64Encoded?: boolean;
};
type RouteHandler = (reqCtx: RequestContext) => Promise<HandlerResponse> | HandlerResponse;
type HandlerResponse = Response | JSONValue | ExtendedAPIGatewayProxyResult | BinaryResult;Global Middleware (executes for all routes)
const logger: Middleware = async ({ reqCtx, next }) => {
console.log(`${reqCtx.req.method} ${reqCtx.req.url}`);
await next();
};
const auth: Middleware = async ({ reqCtx, next }) => {
if (!reqCtx.req.headers.get('Authorization')) {
return new Response('Unauthorized', { status: 401 });
}
await next();
};
app.use(logger);
app.use(auth);Route-Specific Middleware
app.post('/users', [validateBody], async ({ req }) => ({ created: await req.json() }));Execution Order (Onion model: before next() → handler → after next() in reverse)
app.use(middleware1); // 1: before, 4: after
app.use(middleware2); // 2: before, 3: after
app.get('/test', [middleware3], handler); // middleware3: before/after, then handlerBuilt-in Middleware
import { cors, compress } from '@aws-lambda-powertools/event-handler/http/middleware';
app.use(cors({ origin: 'https://example.com', credentials: true, maxAge: 3600 }));
app.use(compress({ threshold: 2048, encoding: 'deflate' }));Custom Error Handlers
import { BadRequestError, UnauthorizedError } from '@aws-lambda-powertools/event-handler/http';
app.errorHandler(BadRequestError, async (error, reqCtx) => ({
statusCode: 400,
error: 'Bad Request',
message: error.message,
details: error.details
}));
app.errorHandler([UnauthorizedError, ForbiddenError], async (error) => ({
statusCode: error.statusCode,
error: error.errorType,
message: 'Access denied'
}));404 and 405 Handlers
app.notFound(async (error, reqCtx) => ({ statusCode: 404, message: `Route ${reqCtx.req.url} not found` }));
app.methodNotAllowed(async (error, reqCtx) => ({ statusCode: 405, message: `Method ${reqCtx.req.method} not allowed` }));const userRouter = new Router();
userRouter.get('/users', () => ({ users: [] }));
userRouter.post('/users', ({ req }) => ({ created: true }));
const postRouter = new Router();
postRouter.get('/posts', () => ({ posts: [] }));
const app = new Router();
app.includeRouter(userRouter, { prefix: '/api/v1' });
app.includeRouter(postRouter, { prefix: '/api/v1' });
// Routes: GET /api/v1/users, POST /api/v1/users, GET /api/v1/postsMiddleware Inheritance
const protectedRouter = new Router();
protectedRouter.use(authMiddleware);
protectedRouter.get('/profile', () => ({ profile: 'data' }));
const publicRouter = new Router();
publicRouter.get('/health', () => ({ status: 'ok' }));
const app = new Router();
app.includeRouter(publicRouter);
app.includeRouter(protectedRouter, { prefix: '/api' });
// /health is public, /api/profile requires authimport { streamify } from '@aws-lambda-powertools/event-handler/http';
app.get('/stream', async () => {
const stream = new ReadableStream({
start(controller) {
for (let i = 0; i < 100; i++) {
controller.enqueue(`Chunk ${i}\n`);
}
controller.close();
}
});
return new Response(stream, { headers: { 'Content-Type': 'text/plain' } });
});
export const handler = streamify(app);
// With scope binding
export const handler = streamify(app, { scope: myInstance });// Event to Request
proxyEventToWebRequest(event: APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBEvent): Request;
// Response to Result
webResponseToProxyResult<T extends ResponseType>(
response: Response,
responseType: T,
options?: { isBase64Encoded?: boolean }
): Promise<ResponseTypeMap[T]>;
// Handler result to Response
handlerResultToWebResponse(
response: HandlerResponse,
options?: { statusCode?: HttpStatusCode; resHeaders?: Headers }
): Response;Example
const request = proxyEventToWebRequest(event);
console.log(request.method, request.url, request.headers);
const body = await request.text();
const response = new Response(JSON.stringify({ message: 'Hello' }), { status: 200 });
const result = await webResponseToProxyResult(response, 'ApiGatewayV2');isALBEvent(event: unknown): event is ALBEvent;
isAPIGatewayProxyEventV1(event: unknown): event is APIGatewayProxyEvent;
isAPIGatewayProxyEventV2(event: unknown): event is APIGatewayProxyEventV2;
isExtendedAPIGatewayProxyResult(result: unknown): result is ExtendedAPIGatewayProxyResult;
isHttpMethod(method: string): method is HttpMethod;Usage
if (isAPIGatewayProxyEventV2(event)) console.log(event.routeKey);
else if (isAPIGatewayProxyEventV1(event)) console.log(event.resource);
else if (isALBEvent(event)) console.log(event.requestContext.elb.targetGroupArn);All HTTP error classes extend HttpError with statusCode, errorType, message, and optional details.
abstract class HttpError extends Error {
abstract readonly statusCode: HttpStatusCode;
abstract readonly errorType: string;
readonly details?: Record<string, unknown>;
toJSON(): HandlerResponse;
}Error Classes
new BadRequestError(message?, options?, details?) // 400
new UnauthorizedError(message?, options?, details?) // 401
new ForbiddenError(message?, options?, details?) // 403
new NotFoundError(message?, options?, details?) // 404
new MethodNotAllowedError(message?, options?, details?) // 405
new RequestTimeoutError(message?, options?, details?) // 408
new RequestEntityTooLargeError(message?, options?, details?) // 413
new InternalServerError(message?, options?, details?) // 500
new ServiceUnavailableError(message?, options?, details?) // 503
new RouteMatchingError(message, path, method) // Route matching failed
new ParameterValidationError(issues: string[]) // Parameter validation failedUsage
app.post('/users', async ({ req }) => {
const body = await req.json();
if (!body.email || !body.name) {
throw new BadRequestError('Missing required fields', undefined, {
required: ['email', 'name'],
received: Object.keys(body)
});
}
return { created: body };
});
app.get('/users/:id', async ({ params, req }) => {
const token = req.headers.get('Authorization');
if (!token) throw new UnauthorizedError('Authentication required');
const user = await findUser(params.id);
if (!user) throw new NotFoundError(`User ${params.id} not found`);
if (!hasPermission(token, user)) throw new ForbiddenError('Access denied');
return { user };
});const HttpVerbs = { GET: 'GET', POST: 'POST', PUT: 'PUT', PATCH: 'PATCH', DELETE: 'DELETE', HEAD: 'HEAD', OPTIONS: 'OPTIONS' } as const;
const HttpStatusCodes = {
// 2xx
OK: 200, CREATED: 201, ACCEPTED: 202, NO_CONTENT: 204,
// 3xx
MOVED_PERMANENTLY: 301, FOUND: 302, NOT_MODIFIED: 304, TEMPORARY_REDIRECT: 307,
// 4xx
BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, METHOD_NOT_ALLOWED: 405,
REQUEST_TIMEOUT: 408, REQUEST_ENTITY_TOO_LARGE: 413, TOO_MANY_REQUESTS: 429,
// 5xx
INTERNAL_SERVER_ERROR: 500, BAD_GATEWAY: 502, SERVICE_UNAVAILABLE: 503, GATEWAY_TIMEOUT: 504
} as const;Usage
import { HttpStatusCodes } from '@aws-lambda-powertools/event-handler/http';
app.post('/users', async ({ req }) => {
return new Response(JSON.stringify({ created: await req.json() }), {
status: HttpStatusCodes.CREATED,
headers: { 'Content-Type': 'application/json' }
});
});type Path = `/${string}` | RegExp;
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
type HttpStatusCode = (typeof HttpStatusCodes)[keyof typeof HttpStatusCodes];
type Middleware = (args: { reqCtx: RequestContext; next: NextFunction }) => Promise<HandlerResponse | void>;
type RouteHandler<TReturn = HandlerResponse> = (reqCtx: RequestContext) => Promise<TReturn> | TReturn;
type ErrorHandler<T extends Error> = (error: T, reqCtx: RequestContext) => Promise<HandlerResponse>;
type HandlerResponse = Response | JSONValue | ExtendedAPIGatewayProxyResult | BinaryResult;
interface HttpRouterOptions {
logger?: GenericLogger;
prefix?: Path;
}
interface HttpRouteOptions {
method: HttpMethod | HttpMethod[];
path: Path;
middleware?: Middleware[];
}
interface CorsOptions {
origin?: string | string[]; // default: '*'
allowMethods?: string[]; // default: ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']
allowHeaders?: string[]; // default: ['Authorization', 'Content-Type', ...]
exposeHeaders?: string[]; // default: []
credentials?: boolean; // default: false
maxAge?: number;
}
interface CompressionOptions {
encoding?: 'gzip' | 'deflate'; // default: 'gzip'
threshold?: number; // default: 1024
}
interface ResolveStreamOptions {
scope?: unknown;
responseStream: ResponseStream;
}
type ResponseType = 'ApiGatewayV1' | 'ApiGatewayV2' | 'ALB';RESTful API
const users = new Map();
app.get('/users', async () => ({ users: Array.from(users.values()), count: users.size }));
app.get('/users/:id', async ({ params }) => {
const user = users.get(params.id);
if (!user) throw new NotFoundError(`User ${params.id} not found`);
return { user };
});
app.post('/users', async ({ req }) => {
const body = await req.json();
if (!body.name || !body.email) throw new BadRequestError('Name and email required');
const id = crypto.randomUUID();
const user = { id, ...body, createdAt: new Date().toISOString() };
users.set(id, user);
return new Response(JSON.stringify({ user }), { status: 201 });
});
app.put('/users/:id', async ({ params, req }) => {
if (!users.has(params.id)) throw new NotFoundError(`User ${params.id} not found`);
const updates = await req.json();
const user = { ...users.get(params.id), ...updates, updatedAt: new Date().toISOString() };
users.set(params.id, user);
return { user };
});
app.delete('/users/:id', async ({ params }) => {
if (!users.has(params.id)) throw new NotFoundError(`User ${params.id} not found`);
users.delete(params.id);
return new Response(null, { status: 204 });
});Authentication & Authorization
const authenticate: Middleware = async ({ reqCtx, next }) => {
const token = reqCtx.req.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError('Authentication required');
const user = await verifyToken(token);
if (!user) throw new UnauthorizedError('Invalid token');
(reqCtx as any).user = user;
await next();
};
const requireRole = (...roles: string[]): Middleware => async ({ reqCtx, next }) => {
const user = (reqCtx as any).user;
if (!user) throw new UnauthorizedError('Authentication required');
if (!roles.includes(user.role)) throw new ForbiddenError(`Requires: ${roles.join(', ')}`);
await next();
};
app.get('/health', () => ({ status: 'ok' }));
app.get('/profile', [authenticate], async ({ reqCtx }) => ({ profile: (reqCtx as any).user }));
app.get('/admin/users', [authenticate, requireRole('admin')], async () => ({ users: await getAllUsers() }));Validation
const validateBody = <T>(schema: { [K in keyof T]: (value: any) => boolean }): Middleware => async ({ reqCtx, next }) => {
const body = await reqCtx.req.json();
const errors: string[] = [];
for (const [key, validator] of Object.entries(schema)) {
if (!validator(body[key])) errors.push(`Invalid ${key}`);
}
if (errors.length > 0) throw new BadRequestError('Validation failed', undefined, { errors });
await next();
};
const userSchema = {
name: (v: any) => typeof v === 'string' && v.length > 0,
email: (v: any) => typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
age: (v: any) => typeof v === 'number' && v >= 0 && v <= 150,
};
app.post('/users', [validateBody(userSchema)], async ({ req }) => ({ created: await req.json() }));Rate Limiting
const rateLimit = (maxRequests: number, windowMs: number): Middleware => {
const store = new Map<string, { count: number; resetAt: number }>();
return async ({ reqCtx, next }) => {
const clientId = reqCtx.req.headers.get('x-api-key') || reqCtx.event.requestContext.identity?.sourceIp || 'anonymous';
const now = Date.now();
const record = store.get(clientId);
if (!record || now > record.resetAt) {
store.set(clientId, { count: 1, resetAt: now + windowMs });
await next();
return;
}
if (record.count >= maxRequests) {
const retryAfter = Math.ceil((record.resetAt - now) / 1000);
return new Response(JSON.stringify({ error: 'Too Many Requests', retryAfter }), {
status: 429,
headers: { 'Retry-After': retryAfter.toString() }
});
}
record.count++;
await next();
};
};
app.use(rateLimit(100, 60000)); // 100 requests per minuteAPI Versioning
const v1Router = new Router();
v1Router.get('/users', () => ({ users: ['v1 format'] }));
const v2Router = new Router();
v2Router.get('/users', () => ({ data: ['v2 format'], meta: { version: 2 } }));
const app = new Router();
app.includeRouter(v1Router, { prefix: '/api/v1' });
app.includeRouter(v2Router, { prefix: '/api/v2' });
app.includeRouter(v2Router, { prefix: '/api' }); // Default to latest