aws-lambda-api-tools
v0.1.55
Published
A production-proven toolkit for building structured, type-safe REST APIs on AWS Lambda + API Gateway. Define routes declaratively, get automatic OpenAPI documentation, built-in validation, security, and — new in v0.1.39 — **async binding metadata** that t
Readme
AWS Lambda API Tools
A production-proven toolkit for building structured, type-safe REST APIs on AWS Lambda + API Gateway. Define routes declaratively, get automatic OpenAPI documentation, built-in validation, security, and — new in v0.1.39 — async binding metadata that tells AI agents and tooling which API calls have corresponding WebSocket completion events.
Why This Package
Building APIs on Lambda often means reinventing routing, validation, documentation, and security for every project. This package provides a batteries-included framework that:
- Turns a single Lambda function into a full REST API with path-based routing
- Generates OpenAPI/Swagger specs automatically from your Joi schemas
- Handles JWT validation, CORS, security headers, and request validation as middleware
- Runs locally with a built-in dev server (no SAM/Serverless Framework needed for iteration)
- Declares async WebSocket bindings alongside REST routes for event-driven architectures
- Sets up GitHub Actions OIDC IAM with a single command
Battle-tested in enterprise environments handling millions of requests across multiple production applications.
Features
- Declarative Route Configuration — Define routes as data, not code. Method, path, handler, docs, auth, and async bindings in one object.
- Automatic OpenAPI Generation — Joi request/response schemas convert to OpenAPI 3.0 specs. Run
generate-oasand get a complete API spec. - Route-Module Codegen — Generate (and CI-verify) the static handler map from your route config with
generate-route-modules. No more silent 404s from a forgotten registration. - Async Binding Metadata — Declare which WebSocket events correspond to each route's async results. Emitted as
x-async-bindingOpenAPI extensions for AI agents, SDK codegen, and documentation. - Middleware Chain — Composable, type-safe middleware. JWT validation, schema validation, response headers — stack them per-route.
- Schema Validation — Joi-based request/response validation with automatic 400 error responses.
- JWT Authentication — Built-in JWT validation middleware with token rotation support.
- Security Configuration — CORS (exact, regex, pattern-based origins), security headers, credential handling — all from a JSON config file.
- Response Header Helpers — Rate limiting, cache control, security headers, auth headers — add from any middleware.
- Local Dev Server —
createDevServer()runs your Lambda routes as a local HTTP server. No deploy needed during development. - GitHub Actions IAM — One command to create OIDC IAM roles for secure CI/CD deployments.
- TypeScript First — Full type safety across route configs, handlers, middleware, and schemas.
Installation
npm install aws-lambda-api-toolsQuick Start
1. Define Route Configuration
// routes-config.ts
import { ConfigRouteEntry, RouteConfig } from 'aws-lambda-api-tools';
const routes: ConfigRouteEntry[] = [
{
description: 'Create a new user',
swaggerMethodName: 'createUser',
method: 'POST',
path: '/api/v1/users',
handlerPath: 'src/routes/users/create-user',
generateOpenApiDocs: true,
authorizeRoute: true,
},
{
description: 'Get user by ID',
swaggerMethodName: 'getUser',
method: 'GET',
path: '/api/v1/users/{userId}',
handlerPath: 'src/routes/users/get-user',
generateOpenApiDocs: true,
authorizeRoute: true,
},
{
description: 'Generate a report (async)',
swaggerMethodName: 'generateReport',
method: 'POST',
path: '/api/v1/reports',
handlerPath: 'src/routes/reports/generate-report',
generateOpenApiDocs: true,
authorizeRoute: true,
asyncBinding: {
event: 'report:completed',
room: 'user:{userId}',
description: 'Fires when the report generation completes',
lifecycleEvents: [
{ event: 'report:progress', description: 'Progress percentage update' },
{ event: 'report:failed', description: 'Report generation failed' },
],
},
},
];
export const routeConfig: RouteConfig = {
authorizeAllRoutes: false,
routes,
routesBaseUrlPath: '/api/v1',
};2. Create Route Handlers
// src/routes/users/create-user.ts
import Joi from 'joi';
import {
RouteModule,
RouteSchema,
RouteArguments,
jwtValidationMiddleware,
schemaValidationMiddleware,
} from 'aws-lambda-api-tools';
const routeSchema: RouteSchema = {
requestBody: Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(1).max(100).required(),
}),
responseBody: Joi.object({
id: Joi.string().required(),
email: Joi.string().required(),
name: Joi.string().required(),
createdAt: Joi.string().isoDate().required(),
}),
};
const handler = async (args: RouteArguments) => {
const { email, name } = args.body;
const user = await createUser({ email, name });
return { statusCode: 201, body: user };
};
export default {
routeChain: [jwtValidationMiddleware, schemaValidationMiddleware(routeSchema), handler],
routeSchema,
} as RouteModule;3. Register Route Modules
Because the Lambda is esbuild-bundled, handlers must be statically imported — a
dynamic require(handlerPath) can't be bundled. So a route-modules.ts maps each
route's handlerPath to its imported module:
// route-modules.ts
import { RouteModule } from 'aws-lambda-api-tools';
import createUser from './routes/users/create-user';
import getUser from './routes/users/get-user';
import generateReport from './routes/reports/generate-report';
export const routeModules: Record<string, RouteModule> = {
'src/routes/users/create-user': createUser,
'src/routes/users/get-user': getUser,
'src/routes/reports/generate-report': generateReport,
};Don't hand-maintain this. A route that's in the config but missing from this map is a silent 404 at runtime. Generate it from your route config instead:
npx aws-lambda-api-tools generate-route-modulesSee Generate Route Modules below for wiring it into your build + CI.
4. Lambda Entry Point
// index.ts
import { lambdaRouteProxyEntryHandler } from 'aws-lambda-api-tools';
import { routeConfig } from './routes-config';
import { routeModules } from './route-modules';
export const handler = lambdaRouteProxyEntryHandler(routeConfig, routeModules);That's it. One Lambda function serves your entire API with routing, validation, auth, and documentation.
Async Binding Metadata
Modern APIs often trigger asynchronous work — image generation, report compilation, video processing — where the HTTP response acknowledges the request, but the actual result arrives later via WebSocket or Server-Sent Events.
The asyncBinding field on ConfigRouteEntry declares this relationship at the source:
{
description: 'Generate a video clip',
swaggerMethodName: 'generateClip',
method: 'POST',
path: '/api/v1/clips/generate',
handlerPath: 'src/routes/clips/generate-clip',
generateOpenApiDocs: true,
asyncBinding: {
event: 'generation:completed',
room: 'generation:{jobId}',
description: 'Fires when the video clip generation pipeline completes',
lifecycleEvents: [
{ event: 'generation:progress', description: 'Progress update with phase info' },
{ event: 'generation:queued', description: 'Job queued waiting for capacity' },
{ event: 'generation:failed', description: 'Generation failed with error details' },
],
},
}What This Produces
When you run generate-oas, the OpenAPI spec for this operation includes:
/api/v1/clips/generate:
post:
operationId: generateClip
description: Generate a video clip
x-async-binding:
event: "generation:completed"
room: "generation:{jobId}"
description: "Fires when the video clip generation pipeline completes"
lifecycleEvents:
- event: "generation:progress"
description: "Progress update with phase info"
- event: "generation:queued"
description: "Job queued waiting for capacity"
- event: "generation:failed"
description: "Generation failed with error details"Who Consumes This
- AI Agents — An agent calling
generateClip()knows to subscribe togeneration:{jobId}and wait forgeneration:completedbefore continuing its workflow. - API Client Codegen — Downstream tools can generate subscribe/unsubscribe helpers alongside REST methods.
- Documentation — API docs can show developers which WebSocket events to listen for after calling an async endpoint.
- Monitoring — Observability tools can correlate HTTP requests with their eventual async completions.
AsyncBindingConfig Reference
type AsyncBindingConfig = {
/** WebSocket event name emitted on async completion */
event: string;
/** Room/channel pattern for subscription (e.g., 'generation:{jobId}', 'user:{userId}') */
room: string;
/** The response field carrying the job's id, when it is not named like the event's id field */
idField?: string;
/** Additional lifecycle events beyond completion */
lifecycleEvents?: Array<{
event: string;
description?: string;
}>;
/** Description of when the completion event fires */
description?: string;
/** Joi schema for the completion event payload */
payload?: Schema;
};Route Configuration Reference
type ConfigRouteEntry = {
/** Human-readable description (used in OpenAPI) */
description: string;
/** Operation ID / API client method name (e.g., 'createUser') */
swaggerMethodName?: string;
/** HTTP method */
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'ANY';
/** Route path with {param} placeholders */
path: string;
/** Path to the handler module (must match key in routeModules) */
handlerPath: string;
/** Whether to include in generated OpenAPI docs */
generateOpenApiDocs: boolean;
/** Override auth for this route (true = require, false = skip) */
authorizeRoute?: boolean;
/** OpenAPI tag for grouping (auto-derived from path if not set) */
tag?: string;
/** Lambda function name override */
functionName?: string;
/** Async WebSocket binding metadata */
asyncBinding?: AsyncBindingConfig;
/** OpenAPI `x-` extensions for this operation, emitted exactly as given */
extensions?: Record<`x-${string}`, unknown>;
};
type RouteConfig = {
/** Apply auth to all routes by default */
authorizeAllRoutes?: boolean;
/** All route entries */
routes: ConfigRouteEntry[];
/** Base URL path for tag derivation (e.g., '/api/v1') */
routesBaseUrlPath?: string;
/** Security configuration */
security?: SecurityConfig;
/** Request logging: debug verbosity and extra redaction (secrets are always masked) */
logging?: LoggingConfig;
/** Resolve v2 events from rawPath instead of routeKey (required behind greedy routes) */
useRawPath?: boolean;
/** Document-level OpenAPI declarations: `securitySchemes` */
openApi?: { securitySchemes?: Record<string, SecuritySchemeObject> };
/** The body of every error response, as JSON (see Error Handling) */
errorBody?: (failure: { statusCode: number; message: string; error: unknown }) => unknown;
};Declared Responses
A route declares every response it can give, by status code, in routeSchema.responses: success and error alike. generate-oas emits each one on the operation, so a generated client types all of them.
const ErrorBody = Joi.object({
error: Joi.object({ code: Joi.string().required(), message: Joi.string().required() }).required(),
}).meta({ className: 'ErrorBody' });
const routeSchema: RouteSchema = {
params: { orderId: Joi.string().required() },
requestBody: Joi.object({ quantity: Joi.number().integer().min(1).required() }).required(),
responses: {
'201': { description: 'The order was placed', body: Order },
'204': { description: 'Nothing to place' },
'400': { description: 'The request is invalid', body: ErrorBody },
'404': { description: 'The account does not exist', body: ErrorBody },
},
};- Keys are HTTP status codes (
100–599) ordefault. Every response needs adescription; one without abodyhas no content. - A named schema (
.meta({ className })) becomes a component and is referenced by$ref, wherever it appears in a body. - A request body whose schema is
.required()is emitted withrequired: true. - A route declares
responsesorresponseBody, not both; generation fails naming the route. A route that declares neither keeps the single200it always had.
Standard OpenAPI Security
Each operation's security comes from what its middleware chain declares. The library holds no scheme names or permissions of its own: the product declares its schemes once in its route config, and marks its own middleware with declareSecurity.
import { declareSecurity } from 'aws-lambda-api-tools';
// Route config: the document's components.securitySchemes.
export const config: RouteConfig = {
routes,
openApi: {
securitySchemes: {
userToken: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
apiKey: { type: 'apiKey', in: 'header', name: 'x-api-key' },
},
},
};
// Authentication: any one of these schemes authenticates the request.
export const requireUserOrKey = declareSecurity(async (args) => { /* ... */ return args; }, {
authenticates: ['userToken', 'apiKey'],
});
// Permissions: the authenticated caller must hold all of these.
export const requirePermission = (...permissions: string[]) =>
declareSecurity(async (args) => { /* ... */ return args; }, { requires: permissions });
// Route module
export default { routeSchema, routeChain: [requireUserOrKey, requirePermission('orders:write')] };generate-oas then emits, on that operation:
"security": [
{ "userToken": ["orders:write"] },
{ "apiKey": ["orders:write"] }
]- One entry per way of authenticating. Authentication steps in series must all pass, so their alternatives multiply.
- Required permissions are listed as each scheme's scopes: OpenAPI 3.1's role names required for the execution.
- A chain that declares no authentication gets no
security: the document never guesses. - Generation fails, naming the route, when a chain names a scheme missing from
openApi.securitySchemes, or requires permissions without declaring authentication. declareSecurityreturns the same middleware; its behaviour is unchanged.
OpenAPI Extensions
extensions on a route entry are emitted onto its operation exactly as given, so a product can declare its own x- data without the library knowing its meaning:
{
swaggerMethodName: 'placeOrder',
// ...
extensions: { 'x-agent': { expose: true, effect: 'transaction' } },
}Every key must start with x-. x-async-binding is emitted from asyncBinding, not from extensions.
buildOpenApiDocument(config, loadRouteModule, options) is the function behind generate-oas, for products that build their document in code.
OpenAPI 3.1
generate-oas --openapi 3.1 -- ./dist/routes-config.js writes schemas as OpenAPI 3.1 defines them (JSON Schema 2020-12). In code, pass buildOpenApiDocument(..., { openApiVersion: '3.1' }). Set your root document's openapi to 3.1.0 to match.
joi-to-swagger writes 3.0's dialect, and three of its keywords change:
nullable: truebecomes anulltype (["string", "null"], withnulladded to anyenum). On a reference or composition, which has no type to widen, it becomesanyOfwith{ "type": "null" }. A 3.1 reader ignoresnullable, so without this a field that admits null would read as one that does not.- Boolean
exclusiveMinimum/exclusiveMaximumbecome numeric bounds. examplebecomesexamples.
toOpenApi31Schema and toOpenApi31Operation are exported for documents built elsewhere.
Greedy Proxy Routes
The router resolves every request itself, so API Gateway does not need one route per endpoint. Registering one per endpoint does not scale: an HTTP API allows 300 routes by default, and each route is a CloudFormation resource against a stack's 500-resource limit.
With useRawPath: true, register two ANY routes per URL group (the first segment after
the base path) instead:
import { deriveUrlGroups, greedyPathsFor, findMisroutedRoutes } from 'aws-lambda-api-tools';
// CDK: two routes per group, e.g. ANY /api/v1/voices and ANY /api/v1/voices/{proxy+}
for (const group of deriveUrlGroups(routeConfig.routes, routeConfig.routesBaseUrlPath)) {
for (const path of greedyPathsFor(group)) {
new apigatewayv2.HttpRoute(this, `greedy-${path.replace(/[^a-zA-Z0-9]/g, '-')}`, {
httpApi,
integration,
routeKey: apigatewayv2.HttpRouteKey.with(path, apigatewayv2.HttpMethod.ANY),
});
}
}
// Test: every declared route must still resolve to its own handler.
expect(findMisroutedRoutes(routeConfig.routes)).toEqual([]);- One derivation. Any code that registers or partitions routes must use
deriveUrlGroups. Two derivations that differ make two stacks create the same route key, and the deploy fails with a 409. - No catch-all.
urlGroupOfrefuses a group that would answer every path: a parameter as the group segment (/api/v1/{id}), or a path outside the base path that would group as/api.deriveUrlGroupsthrows on those routes rather than leaving them unreachable. - Resolution moves into the Lambda.
getRouteConfigByPathranks static segments above parameters (/users/mebeats/users/{userId}).findMisroutedRouteschecks the whole table: a route that resolves elsewhere would 404 or run the wrong handler. It also reports Express-style:parampaths, which never resolve. - CORS preflight.
ANYincludesOPTIONS, so API Gateway forwards preflights instead of answering them from its CORS configuration. In rawPath mode the handler answers anOPTIONSrequest with no declaredOPTIONSroute with204, before auth or module loading. API Gateway still adds its configured CORS headers. - Migration. CloudFormation creates the new routes before deleting the old ones, so the API briefly holds both sets. Keep that total under the routes-per-API quota.
Middleware
Built-in Middleware
JWT Validation
import { jwtValidationMiddleware } from 'aws-lambda-api-tools';
export default {
routeChain: [jwtValidationMiddleware, handler],
routeSchema,
} as RouteModule;Validates the Authorization: Bearer <token> header. Decoded claims are available on args.routeData.
Schema Validation
import { schemaValidationMiddleware } from 'aws-lambda-api-tools';
const routeSchema: RouteSchema = {
params: { userId: Joi.string().uuid().required() },
query: { include: Joi.string().valid('posts', 'comments') },
requestBody: Joi.object({ name: Joi.string().required() }),
responseBody: Joi.object({ id: Joi.string(), name: Joi.string() }),
};
export default {
routeChain: [schemaValidationMiddleware(routeSchema), handler],
routeSchema,
} as RouteModule;Response Header Helpers
Add headers from any middleware in the chain:
import {
addResponseHeader,
addRateLimitHeaders,
addCacheHeaders,
addSecurityHeaders,
} from 'aws-lambda-api-tools';
const rateLimitMiddleware = (args: RouteArguments) => {
return addRateLimitHeaders(args, 100, 95, Date.now() + 3600000);
};
const cacheMiddleware = (args: RouteArguments) => {
return addCacheHeaders(args, 300, { public: true, mustRevalidate: true });
};Security Configuration
Create an api-security.json in your project root:
{
"cors": {
"allowOrigin": ["https://myapp.com", "https://staging.myapp.com"],
"allowOriginPatterns": ["^https://[a-zA-Z0-9-]+\\.preview\\.myapp\\.com$"],
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
"allowHeaders": ["Content-Type", "Authorization"],
"allowCredentials": true,
"maxAge": 86400
},
"defaultHeaders": {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block"
},
"jwtRotationHeaders": {
"enabled": true,
"rotationRequiredHeader": "X-Token-Rotation-Required",
"rotationReasonHeader": "X-Token-Rotation-Reason"
}
}Supports exact string origins, regex patterns (as strings for JSON compatibility), and wildcard. Validates configuration on load and warns about insecure patterns.
Local Development Server
Run your Lambda routes locally without deploying:
import { createDevServer } from 'aws-lambda-api-tools';
import { routeConfig } from './routes-config';
import { routeModules } from './route-modules';
createDevServer({
port: 3000,
routeConfig,
routeModules,
});# Or as a script in package.json
node -r ts-node/register dev-server.tsThe dev server simulates API Gateway v2 events, handles CORS preflight, and provides the same routing behavior as production.
CLI Tools
Generate OpenAPI Spec
npx generate-oasReads your route configs and handler schemas, produces an OpenAPI 3.0 JSON spec including x-async-binding extensions.
Generate Route Modules
Generates the static route-modules.ts map from your *_routes-config files (the
single source of truth), so the esbuild-bundled handler map can never drift from
your route config. A missing entry would otherwise be a silent production 404.
# Write/refresh the map
npx aws-lambda-api-tools generate-route-modules
# Verify it's up to date (CI / pre-commit) — exits non-zero if stale,
# and reports routes declared-but-unregistered (404s) or dead entries.
npx aws-lambda-api-tools generate-route-modules --checkOptions:
| Flag | Default | Description |
|---|---|---|
| --routes-dir <dir> | src/routes | Directory scanned recursively for *_routes-config* files |
| --out <path> | src/route-modules.ts | Output file |
| --type-import <module> | aws-lambda-api-tools | Module the RouteModule type is imported from |
| --check | false | Verify only; exit non-zero if stale |
It scans config files for handlerPath declarations (no TypeScript execution
required), so it's safe to run as a pre-build / pre-commit step. Recommended wiring:
// package.json
{
"scripts": {
"routes:gen": "aws-lambda-api-tools generate-route-modules",
"prebuild": "npm run routes:gen",
"predev": "npm run routes:gen"
}
}Add aws-lambda-api-tools generate-route-modules --check to CI / your pre-commit
hook to fail fast if the map ever drifts.
A programmatic API is also exported: generateRouteModules, checkRouteModules,
collectHandlerPaths, renderRouteModules, and handlerPathToIdentifier.
GitHub Actions IAM Setup
Create OIDC IAM roles for secure CI/CD (no long-lived credentials):
# Single repo
npx aws-lambda-api-tools create-gha-iam-stack --repo=myorg/my-service
# Multiple repos
npx aws-lambda-api-tools create-gha-iam-stack \
--repo=myorg/service-a \
--repo=myorg/service-b
# Custom policy
npx aws-lambda-api-tools create-gha-iam-stack \
--repo=myorg/my-service \
--policy=AWSLambda_FullAccess
# Manage repos
npx aws-lambda-api-tools create-gha-iam-stack --mode=list
npx aws-lambda-api-tools create-gha-iam-stack --remove-repo=myorg/old-serviceError Handling
import { CustomError } from 'aws-lambda-api-tools';
// Throws a structured error with HTTP status code
throw new CustomError('User not found', 404);
throw new CustomError('Insufficient permissions', 403);By default an error's body is its message. Declare errorBody on the route config to give every error response one JSON body, the one your routes declare in responses:
class ApiError extends CustomError {
constructor(message: string, statusCode: number, readonly code: string) {
super(message, statusCode);
}
}
export const config: RouteConfig = {
routes,
errorBody: ({ statusCode, message, error }) => ({
error: {
code: error instanceof ApiError ? error.code : `HTTP_${statusCode}`,
// An unexpected error's message is internal; say so instead.
message: error instanceof CustomError ? message : 'Internal server error',
},
}),
};It applies to every error the library answers: an error thrown in a route's chain, a request body that is not JSON, and, in rawPath mode and the dev server, a path no route matches. error is what was thrown, or the library's own CustomError for a failure it detects.
A request body that is not valid JSON answers 400 (Request body is not valid JSON). It used to answer 500.
Agent-Ready APIs
When you declare asyncBinding on your routes, your API becomes agent-ready — AI agents can programmatically understand which endpoints trigger async work and how to subscribe for results.
This is the foundation for building AI assistants that can orchestrate complex workflows across your API:
- Agent calls
POST /clips/generate→ gets{ jobId: "abc123" } - Agent reads
x-async-bindingfrom the spec → knows to subscribe to roomgeneration:abc123 - Agent listens for
generation:progress,generation:completed, orgeneration:failed - Agent continues its workflow when the async result arrives
No manual binding configuration needed. The API spec IS the contract.
Closure Agent SDK integration coming Summer 2026.
Roadmap
- Zod schema support — Use Zod as an alternative to Joi for request/response validation and OpenAPI generation
- AsyncAPI spec generation — Generate AsyncAPI documents from
asyncBindingmetadata alongside OpenAPI - WebSocket route support — First-class WebSocket route handlers with the same middleware pattern
- Rate limiting middleware — Built-in token bucket / sliding window rate limiting
- OpenTelemetry integration — Automatic tracing spans per route handler
Contributing
Contributions are welcome. Please open an issue to discuss proposed changes before submitting a PR.
License
MIT
