@lafken/api
v0.16.5
Published
Define AWS REST APIs using TypeScript decorators - declarative, type-safe infrastructure for API Gateway
Maintainers
Readme
@lafken/api
Build AWS REST APIs using TypeScript decorators. @lafken/api lets you declare endpoints, request/response models, authorizers, and AWS service integrations directly in your classes — Lafken generates all the API Gateway and Lambda infrastructure for you.
Installation
npm install @lafken/apiGetting Started
Register the ApiResolver in your application and define your first API resource:
import { createApp, createModule } from '@lafken/main';
import { ApiResolver } from '@lafken/api/resolver';
import { Api, Get, Post, Event, ApiRequest, BodyParam } from '@lafken/api/main';
// 1. Define request payload
@ApiRequest()
class CreateTaskPayload {
@BodyParam({ minLength: 1 })
title: string;
}
// 2. Define the API resource
@Api({ path: '/tasks' })
class TaskApi {
@Get()
list() {
return [{ id: 1, title: 'Review PR' }];
}
@Post()
create(@Event(CreateTaskPayload) payload: CreateTaskPayload) {
return { id: 2, title: payload.title };
}
}
// 3. Register it in a module
const taskModule = createModule({
name: 'tasks',
resources: [TaskApi],
});
// 4. Add the resolver to your app
createApp({
name: 'my-app',
resolvers: [
new ApiResolver({
restApi: {
name: 'my-rest-api',
cors: { allowOrigins: true },
stages: [{ stageName: 'dev' }],
},
}),
],
modules: [taskModule],
});If no configuration is passed to ApiResolver, a default API Gateway is created with minimal settings. You can also create multiple APIs within the same application by passing multiple configuration objects.
stages is an array — each entry deploys a separate stage (e.g. dev, staging, prod) of the same API. If omitted, a single default stage named api is created.
API Definition: resource vs openapi
The definition option on restApi controls how API Gateway generates and manages your API structure. It accepts two values:
| Value | Default | Description |
| ---------- | ------- | ----------------------------------------------------------------------------------------------------- |
| resource | ✅ | Each element of the API (resources, methods, integrations, models, validators, authorizers, docs) is created as an individual Terraform resource. |
| openapi | | The whole API structure is assembled into a single OpenAPI 3.0 document (with x-amazon-apigateway-* extensions) and imported into the REST API body. |
new ApiResolver({
restApi: {
name: 'my-app',
definition: 'openapi',
},
});resource mode (default)
Each API Gateway element becomes its own Terraform resource (aws_api_gateway_resource, aws_api_gateway_method, aws_api_gateway_integration, aws_api_gateway_model, aws_api_gateway_request_validator, aws_api_gateway_authorizer, documentation parts, and so on).
- Pros: granular and debuggable — Terraform reports exactly which resource changed, and each element can be inspected independently in the AWS console.
- Cons: on large APIs, deployment is slower because Terraform diffs and verifies every resource on each deploy.
openapi mode
All paths, methods, integrations, schemas, request validators, authorizers, gateway responses, and documentation parts are accumulated into a single OpenAPI 3.0 document and injected into the REST API body. API Gateway imports the whole API in one shot.
- Pros: significantly faster deploys on large APIs — a single
bodyupdate replaces dozens of individual resource diffs. - Cons: less granular — the API structure is managed as one blob, so Terraform diffs it as a whole.
Side resources that the body only references are still created as regular Terraform resources in both modes: Lambda functions and permissions, IAM roles, usage plans and API keys, gateway responses, log groups, deployments, and stages.
Documentation in the exported spec
Both modes support the same decorator-driven documentation — tags and description on @Api, and summary, description, and tags on method decorators:
@Api({
path: '/articles',
tags: ['Articles'],
})
export class ArticleApi {
@Get({
path: '/{id}',
summary: 'Get article by id',
description: 'Get the full article details',
})
getById() { /* ... */ }
}In openapi mode this documentation is emitted inline in the OpenAPI document (tags, summary, description fields) and included in any OAS3 export from the deployed API — whether generated by aws apigateway get-export or the AWS console. To make the export include the full documentation, the stage references a documentation version that Lafken creates automatically.
In resource mode the same information is stored as aws_api_gateway_documentation_part resources and an associated documentation version.
Supported integrations
All AWS service integrations work in both modes (bucket, dynamodb, queue, state-machine, event-bridge, kinesis, and plain Lambda-backed methods). If an integration is not supported in openapi mode, deployment fails with a clear error.
REST API Options Reference
Beyond cors, stages, auth, and definition shown above, restApi accepts:
| Option | Type | Description |
| ------ | ---- | ----------- |
| methodSettings | MethodSettings | Default caching/logging/metrics/throttling for every method of every stage. A stage's own methodSettings overrides this default, and @Api/method-level settings override both. |
| defaultResponses | ApiDefaultResponse | Overrides the body of API Gateway's own gateway responses (unauthorized, throttled, default4xx, wafFiltered, ...) — the responses API Gateway returns before your integration ever runs. |
| outputs | ResourceOutputType<'arn' \| 'id' \| 'executionArn'> | Exports REST API attributes to SSM Parameter Store or as Terraform outputs. |
| ref | string | Registers the API as a named global reference, retrievable elsewhere via Refs.resourceValue('api::<ref>', attr). |
| apiKeySource | 'header' \| 'authorizer' | Where API Gateway reads the API key from for key-validated methods. |
| supportedMediaTypes | string[] | Binary media (Content-Type) types the API accepts/returns. |
| endpointConfiguration | { type: 'edge' \| 'regional' \| 'private', ... } | API Gateway endpoint type; private additionally requires vpcEndpointIds. |
| disableExecuteApiEndpoint | boolean | Disables the default execute-api endpoint — use when the API is only exposed through a custom domain. |
| minCompressionSize | number | Minimum response size (bytes) before API Gateway compresses it. |
new ApiResolver({
restApi: {
name: 'my-rest-api',
methodSettings: {
metricsEnabled: true,
loggingLevel: 'error',
throttlingRateLimit: 100,
},
defaultResponses: {
unauthorized: { message: 'Unauthorized' },
default4xx: { message: 'Client error' },
},
outputs: [{ type: 'output', name: 'api_execution_arn', value: 'executionArn' }],
ref: 'main-api',
endpointConfiguration: { type: 'regional' },
disableExecuteApiEndpoint: true,
},
});Referencing an Existing REST API
Set isExternal: true to attach ApiResolver to an API Gateway REST API that already exists — created outside Lafken, or shared across apps — instead of creating a new one. Lafken looks it up by name and still generates resources, methods, and integrations under it; only the REST API resource itself is skipped:
new ApiResolver({
restApi: {
name: 'shared-rest-api',
isExternal: true,
},
});cors, auth, stages, methodSettings, defaultResponses, description, and ref are still available. The options exclusive to newly created REST APIs (apiKeySource, supportedMediaTypes, endpointConfiguration, disableExecuteApiEndpoint, minCompressionSize, outputs, definition) don't apply, since there's no REST API resource to configure them on.
Features
HTTP Methods
Use @Get, @Post, @Put, @Patch, @Delete, @Head, and @Any to define endpoints. Each decorator creates a Lambda-backed method on the API Gateway resource defined by @Api.
The method path is appended to the base path set in @Api:
import { Api, Get, Post, Put, Delete } from '@lafken/api/main';
@Api({ path: '/articles' })
class ArticleApi {
@Get()
listAll() {
return [{ id: 1, title: 'Getting Started' }];
}
@Get({ path: '{id}' })
getById() {
return { id: 1, title: 'Getting Started' };
}
@Post()
create() {
return { id: 2, title: 'New Article' };
}
@Put({ path: '{id}' })
update() {
return { updated: true };
}
@Delete({ path: '{id}' })
remove() {
return { deleted: true };
}
}Request Events
Handler methods receive structured input through the @Event decorator combined with an @ApiRequest class. Field decorators specify where each value is extracted from in the HTTP request:
| Decorator | Source | Always Required |
| ---------------- | ------------------------------ | --------------- |
| @BodyParam | Request body | Yes (default) |
| @PathParam | URL path parameter | Yes (always) |
| @QueryParam | Query string parameter | Yes (default) |
| @HeaderParam | HTTP header | Yes (default) |
| @ContextParam | API Gateway request context | Yes (always) |
| @VelocityParam | Raw Velocity template expression | — (not part of the OpenAPI schema) |
These decorators generate a fully resolved Velocity requestTemplate internally, mapping each field to the correct source.
import { Api, Post, Event, ApiRequest, PathParam, BodyParam, QueryParam } from '@lafken/api/main';
@ApiRequest()
class CreateCommentPayload {
@PathParam()
articleId: number;
@BodyParam({ minLength: 1, maxLength: 500 })
content: string;
@QueryParam({ required: false })
notify: string;
}
@Api({ path: '/articles' })
class ArticleApi {
@Post({ path: '{articleId}/comments' })
addComment(@Event(CreateCommentPayload) payload: CreateCommentPayload) {
return { articleId: payload.articleId, content: payload.content };
}
}Body Parameter Validation
@BodyParam supports type-specific validation constraints that map to OpenAPI schema attributes:
@ApiRequest()
class SignupPayload {
@BodyParam({ minLength: 3, maxLength: 50 })
username: string;
@BodyParam({ format: 'email' })
email: string;
@BodyParam({ min: 18, max: 120 })
age: number;
@BodyParam({ minItems: 1, uniqueItems: true })
roles: string[];
}For array parameters, minItems / maxItems / uniqueItems constrain the array itself,
while items constrains every element. Element constraints are the same ones the element's
primitive type accepts, so a string[] element takes enum, minLength, pattern,
format, and a number[] element takes min, max, multipleOf:
@ApiRequest()
class CreatePokemonPayload {
@BodyParam({
minItems: 1,
uniqueItems: true,
items: { enum: ['fire', 'water', 'grass'] },
})
types: string[];
@BodyParam({ items: { min: 1, max: 100 } })
levels: number[];
}The generated model schema nests them as OpenAPI does, and the runtime validator rejects any element outside the enum:
{ "type": "array", "minItems": 1, "uniqueItems": true,
"items": { "type": "string", "enum": ["fire", "water", "grass"] } }@ResField accepts the same items option for response fields.
Nested Request Objects
Use @RequestObject (an alias for @ApiRequest) to define nested structures within a request payload:
import { ApiRequest, RequestObject, BodyParam } from '@lafken/api/main';
@RequestObject()
class Address {
@BodyParam()
street: string;
@BodyParam()
city: string;
}
@ApiRequest()
class CreateContactPayload {
@BodyParam({ minLength: 1 })
name: string;
@BodyParam({ type: Address })
address: Address;
}Context Parameters
Access API Gateway context variables such as request IDs or client IPs using @ContextParam:
@ApiRequest()
class AuditedPayload {
@BodyParam()
action: string;
@ContextParam({ name: 'requestId' })
requestId: string;
@ContextParam({ name: 'identity.sourceIp' })
clientIp: string;
}Velocity Template Parameter
@VelocityParam injects a raw Apache Velocity (VTL) expression directly into the generated request mapping template, for cases the other field decorators don't cover. The decorated property name becomes the JSON key; the template string becomes its value, unescaped:
@ApiRequest()
class ForwardRawBody {
@BodyParam()
name: string;
@VelocityParam({ template: "$input.json('$')" })
raw: any;
}
// Generated template: { "name": "...", "raw": $input.json('$') }AWS Service Integrations
HTTP methods can integrate directly with AWS services without an intermediate Lambda function. Set the integration property on the method decorator and reference other infrastructure resources with Refs.resourceValue/Refs.ssmValue, imported directly from @lafken/common.
Supported integrations:
| Integration | Actions |
| ---------------- | ------------------------------ |
| bucket | Download, Upload, Delete |
| dynamodb | Query, Put, Delete |
| queue | SendMessage |
| state-machine | Start, Stop, Status |
| event-bridge | PutEvents |
| kinesis | PutRecord |
S3 Bucket Integration
import { Api, Get, Put, type BucketIntegrationResponse } from '@lafken/api/main';
import { Refs } from '@lafken/common';
@Api({ path: '/documents' })
class DocumentApi {
@Get({
integration: 'bucket',
action: 'Download',
})
download(): BucketIntegrationResponse {
return {
bucket: Refs.resourceValue('bucket::project-documents', 'id'),
object: 'report.pdf',
};
}
@Put({
integration: 'bucket',
action: 'Upload',
})
upload(): BucketIntegrationResponse {
return {
bucket: Refs.resourceValue('bucket::project-documents', 'id'),
object: 'new-report.pdf',
};
}
}DynamoDB Integration
import {
Api,
Get,
Post,
type DynamoQueryIntegrationResponse,
type DynamoPutIntegrationResponse,
} from '@lafken/api/main';
import { Refs } from '@lafken/common';
@Api({ path: '/products' })
class ProductApi {
@Get({
integration: 'dynamodb',
action: 'Query',
})
search(): DynamoQueryIntegrationResponse {
return {
tableName: Refs.resourceValue('dynamo::products-table', 'id'),
partitionKey: { category: 'electronics' },
};
}
@Post({
integration: 'dynamodb',
action: 'Put',
})
add(): DynamoPutIntegrationResponse {
return {
tableName: Refs.resourceValue('dynamo::products-table', 'id'),
data: { name: 'Keyboard', price: 75 },
};
}
}SQS Queue Integration
import {
Api,
Post,
Event,
type QueueSendMessageIntegrationResponse,
} from '@lafken/api/main';
import { Refs } from '@lafken/common';
@Api({ path: '/notifications' })
class NotificationApi {
@Post({
integration: 'queue',
action: 'SendMessage',
})
enqueue(): QueueSendMessageIntegrationResponse {
return {
queueName: Refs.resourceValue('queue::notification-queue', 'id'),
body: { type: 'welcome', recipient: 'new-user' },
};
}
}The body accepts a plain string, the whole @Event object or an object literal that
mixes static values and event fields, including nested ones:
@Post({
path: '/{userId}',
integration: 'queue',
action: 'SendMessage',
})
enqueueUser(@Event(NotificationPayload) e: NotificationPayload): QueueSendMessageIntegrationResponse {
return {
queueName: 'notification-queue',
body: {
type: 'welcome',
userId: e.userId,
email: e.contact.email,
channels: e.channels,
},
};
}State Machine Integration
import {
Api,
Post,
Get,
type StateMachineStartIntegrationResponse,
type StateMachineStatusIntegrationResponse,
} from '@lafken/api/main';
import { Refs } from '@lafken/common';
@Api({ path: '/workflows' })
class WorkflowApi {
@Post({
integration: 'state-machine',
action: 'Start',
})
start(): StateMachineStartIntegrationResponse {
return {
stateMachineArn: Refs.resourceValue('state-machine::processing-workflow', 'arn'),
input: { step: 'begin' },
};
}
@Get({
integration: 'state-machine',
action: 'Status',
})
status(): StateMachineStatusIntegrationResponse {
return {
executionArn: Refs.resourceValue('state-machine::processing-workflow', 'arn'),
};
}
}EventBridge Integration
import {
Api,
Post,
type EventBridgePutEventsIntegrationResponse,
} from '@lafken/api/main';
import { Refs } from '@lafken/common';
@Api({ path: '/events' })
class EventApi {
@Post({
integration: 'event-bridge',
action: 'PutEvents',
})
publish(): EventBridgePutEventsIntegrationResponse {
return {
eventBusName: Refs.resourceValue('event-bus::orders-bus', 'id'),
source: 'orders',
detailType: 'OrderCreated',
detail: { orderId: '123' },
};
}
}The detail payload is serialized into the EventBridge Detail JSON string and
can mix static values with @Event fields, including nested ones:
@Post({
path: '/{orderId}',
integration: 'event-bridge',
action: 'PutEvents',
})
publishOrder(
@Event(OrderPayload) e: OrderPayload,
): EventBridgePutEventsIntegrationResponse {
return {
eventBusName: 'orders-bus',
source: 'orders',
detailType: 'OrderCreated',
detail: {
orderId: e.orderId,
customer: e.customer.email,
},
};
}When eventBusName is omitted, the event is published to the default event bus.
Kinesis Integration
import {
Api,
Post,
type KinesisPutRecordIntegrationResponse,
} from '@lafken/api/main';
import { Refs } from '@lafken/common';
@Api({ path: '/tracking' })
class TrackingApi {
@Post({
integration: 'kinesis',
action: 'PutRecord',
})
track(): KinesisPutRecordIntegrationResponse {
return {
streamName: Refs.resourceValue('kinesis::activity-stream', 'id'),
data: { userId: '123', action: 'login' },
partitionKey: 'user-123',
};
}
}data is base64-encoded automatically and, like queue's body and event-bridge's detail, can mix static values with @Event fields. partitionKey (used to route the record to a shard) and the optional sequenceNumberForOrdering accept either a literal or an @Event parameter.
Direct integration methods can also use getCurrentDate() (from @lafken/api/main) to embed the API Gateway request timestamp into the generated VTL template:
import { getCurrentDate } from '@lafken/api/main';
add(): DynamoPutIntegrationResponse {
return {
tableName: Refs.resourceValue('dynamo::products-table', 'id'),
data: { name: 'Keyboard', createdAt: getCurrentDate() },
};
}Responses
You can return values directly from handler methods without defining a response type. However, for more control over status codes and response models, use the @ApiResponse and @ResField decorators.
Basic Response Model
Define a response class and pass it to the method decorator via the response property:
import { Api, Get, ApiResponse, ResField } from '@lafken/api/main';
@ApiResponse()
class ArticleResponse {
@ResField()
title: string;
@ResField()
views: number;
}
@Api({ path: '/articles' })
class ArticleApi {
@Get({ path: '{id}', response: ArticleResponse })
getById(): ArticleResponse {
return { title: 'Getting Started', views: 42 };
}
}Multiple Status Codes
Map different HTTP status codes to distinct response classes. Use true for responses without a body:
import { Api, Post, ApiResponse, ResField, response } from '@lafken/api/main';
@ApiResponse()
class ErrorResponse {
@ResField()
message: string;
}
@ApiResponse({
responses: {
400: ErrorResponse,
204: true,
},
})
class CreateArticleResponse {
@ResField()
id: number;
@ResField()
title: string;
}
@Api({ path: '/articles' })
class ArticleApi {
@Post({ response: CreateArticleResponse })
create(): CreateArticleResponse {
const isInvalid = false;
if (isInvalid) {
response<ErrorResponse>(400, { message: 'Title is required' });
}
return { id: 1, title: 'New Article' };
}
}The response() function returns a response with a specific status code that API Gateway interprets correctly.
Default Status Codes
If no response property is set, default status codes are generated automatically (20X for success, 400 and 500 for errors). The default success code depends on the HTTP method:
POSTdefaults to201- All other methods default to
200
Override the default code with defaultCode:
@ApiResponse({
defaultCode: 202,
})
class AsyncResponse {
@ResField()
jobId: string;
}Nested Response Objects
Use @ResponseObject to define nested structures within a response:
import { ApiResponse, ResponseObject, ResField } from '@lafken/api/main';
@ResponseObject()
class AuthorInfo {
@ResField()
name: string;
@ResField()
email: string;
}
@ApiResponse()
class ArticleDetailResponse {
@ResField()
title: string;
@ResField({ type: AuthorInfo })
author: AuthorInfo;
}Authorizers
Lafken supports three authorization strategies: API Key, Custom Lambda, and Cognito. Each is defined as a decorated class and registered in the ApiResolver.
API Key Authorizer
Protects endpoints by requiring a valid API key. Optionally configure quota limits and throttling:
import { ApiKeyAuthorizer } from '@lafken/api/main';
@ApiKeyAuthorizer({
name: 'platform-api-key',
defaultKeys: ['default-key'],
quota: { limit: 10000, period: 'month' },
throttle: { burstLimit: 50, rateLimit: 100 },
createUsagePlan: true,
})
export class PlatformApiKey {}createUsagePlan (default true) provisions a usage plan resource holding the quota/throttle settings above.
Custom Authorizer
Implement your own authentication logic with a Lambda-backed authorizer. The class must include a method decorated with @AuthorizerHandler:
import {
CustomAuthorizer,
AuthorizerHandler,
type AuthorizationHandlerEvent,
type AuthorizerResponse,
} from '@lafken/api/main';
@CustomAuthorizer({
name: 'token-auth',
header: 'Authorization',
authorizerResultTtlInSeconds: 300,
})
export class TokenAuthorizer {
@AuthorizerHandler()
validate(event: AuthorizationHandlerEvent): AuthorizerResponse {
const isValid = event.headers?.Authorization === 'Bearer valid-token';
return {
principalId: '[email protected]',
allow: isValid,
};
}
}The handler receives an AuthorizationHandlerEvent — the standard APIGatewayRequestAuthorizerEvent enriched with a permissions array containing the scopes configured for the invoked method. It must return an AuthorizerResponse with allow and principalId.
Cognito Authorizer
Integrates with an Amazon Cognito User Pool for token-based authorization. Requires @lafken/auth to be configured first. userPoolArn typically references the pool created elsewhere in the app via Refs.resourceValue():
import { CognitoAuthorizer } from '@lafken/api/main';
import { Refs } from '@lafken/common';
@CognitoAuthorizer({
userPoolArn: Refs.resourceValue('user-pool::main-user-pool', 'arn'),
name: 'cognito-auth',
header: 'Authorization',
authorizerResultTtlInSeconds: 300,
})
export class MainCognitoAuth {}Registering Authorizers
Pass authorizer classes to the ApiResolver and optionally set a default authorizer for all methods:
new ApiResolver({
restApi: {
name: 'my-rest-api',
auth: {
authorizers: [PlatformApiKey, TokenAuthorizer],
defaultAuthorizerName: 'token-auth',
},
},
});Applying Authorizers
The auth property is available on both @Api (class-level) and method decorators (@Get, @Post, etc.). Method-level settings override class-level ones.
Apply to all methods in a class:
@Api({
path: '/admin',
auth: { authorizerName: 'platform-api-key' },
})
class AdminApi { /* ... */ }Apply to a specific method:
@Get({
path: '{id}',
auth: { authorizerName: 'token-auth' },
})
getById() { /* ... */ }Disable authorization for a specific method or entire class:
@Get({ auth: false })
healthCheck() {
return { status: 'ok' };
}Scopes
Both Custom and Cognito authorizers support scopes — an array of strings delivered to the authorizer handler via the permissions property:
@Delete({
path: '{id}',
auth: {
authorizerName: 'token-auth',
scopes: ['article:delete'],
},
})
remove() { /* ... */ }Extending the API
The ApiResolver accepts an extend function that receives the generated API instance and the app scope. Use it to apply advanced CDKTN configuration such as custom domains or additional settings:
new ApiResolver({
restApi: {
name: 'my-rest-api',
},
extend: ({ api, scope }) => {
// Add a custom domain, WAF, or any CDKTN construct
},
});