or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

appsync-events.mdappsync-graphql.mdbedrock-agent.mdhttp-middleware.mdhttp-routing.mdindex.md
tile.json

http-routing.mddocs/

HTTP Routing

HTTP router for API Gateway (V1/V2), ALB, and Lambda Function URLs with middleware, error handling, and response streaming.

Router Class

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);

Route Registration

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' });

Path Patterns

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' }));

Request Context

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;

Middleware

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 handler

Built-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' }));

Error Handling

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` }));

Router Composition

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/posts

Middleware 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 auth

Response Streaming

import { 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 });

Converter Functions

// 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');

Type Guards

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);

HTTP Errors

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 failed

Usage

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 };
});

Constants

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' }
  });
});

Types

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';

Advanced Patterns

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 minute

API 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