Serverless, Lambda-less microservice patterns are growing in popularity these days. The goal of this article is to help jump-start your entry into these patterns by describing three different methods for implementing an API boundary using the AWS CDK.
All code can be found in this repo: rogerchi/cdk-functionless-api
API patterns
The three basic patterns that will be described in this article:
- Asynchronous, small payload (<256kb, the EventBridge payload limit)
- Asynchronous, large payload (<10mb, the API Gateway payload limit)
- Synchronous, small payload (<256kb, the Step Functions state size limit)
These examples are bare-bones on purpose, additional functionality such as API Gateway Authorizers, validators, processing the payloads with event targets, etc. can be layered on top to provide a fully robust interface.
Asynchronous APIs
For each of our asynchronous APIs, we will ingest a payload and emit an event to our event bus for asynchronous processing. We will immediately return an id to the caller that is matched with the event, which can be used later to retrieve further information about the request.
Small payload (code)
This example sets up a direct service integration from API Gateway to the EventBridge PutEvents API call. The response from the direct integration is the ID of the event that is created, and which can then be used by the caller to check on progress and by the backend services to update tables, etc. The size of the payload is limited by the maximum size of an EventBridge event: 256kb.
First, we import the constructs required:
import { Construct } from 'constructs';
import { AwsIntegration, IRestApi } from 'aws-cdk-lib/aws-apigateway';
import { IEventBus } from 'aws-cdk-lib/aws-events';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
In this particular example we pass our event bus and REST API into our construct:
interface AsyncEventBridgeApiProps {
eventBus: IEventBus;
api: IRestApi;
}
We define our construct:
export class AsyncEventBridgeApi extends Construct {
constructor(
scope: Construct,
id: string,
{ eventBus, api }: AsyncEventBridgeApiProps,
) {
super(scope, id);
We create the credentials role that API Gateway will use to perform PutEvents on our event bus:
const role = new Role(this, 'role', {
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
eventBus.grantPutEventsTo(role);
We define the AWS service integration and set up the resource path in the API:
const eventbridgeIntegration = new AwsIntegration({
service: 'events',
action: 'PutEvents',
integrationHttpMethod: 'POST',
options: {
credentialsRole: role,
requestTemplates: {
'application/json': `
#set($context.requestOverride.header.X-Amz-Target ="AWSEvents.PutEvents")
#set($context.requestOverride.header.Content-Type ="application/x-amz-json-1.1")
${JSON.stringify({
Entries: [
{
DetailType: 'putEvent',
Detail: "$util.escapeJavaScript($input.json('$'))",
Source: 'async-eventbridge-api',
EventBusName: eventBus.eventBusArn,
},
],
})}
`,
},
integrationResponses: [
{
statusCode: '200',
responseTemplates: {
'application/json': JSON.stringify({
id: "$input.path('$.Entries[0].EventId')",
}),
},
},
],
},
});
const resource = api.root.addResource('async-eventbridge');
resource.addMethod('POST', eventbridgeIntegration, {
methodResponses: [{ statusCode: '200' }],
});
These header overrides are required to be able to put events to the event bus, we define them in the VTL request template:
#set($context.requestOverride.header.X-Amz-Target ="AWSEvents.PutEvents")
#set($context.requestOverride.header.Content-Type ="application/x-amz-json-1.1")
In this example, we put the whole body of the request into the detail of the event. This part of the VTL template might be customized to perform further processing of the input body:
${JSON.stringify({
Entries: [
{
DetailType: 'putEvent',
Detail: "$util.escapeJavaScript($input.json('$'))",
Source: 'async-eventbridge-api',
EventBusName: eventBus.eventBusArn,
},
],
})}
Our integration response template maps the service response from EventBridge to only give the id of the event back to the caller.
integrationResponses: [
{
statusCode: '200',
responseTemplates: {
'application/json': JSON.stringify({
id: "$input.path('$.Entries[0].EventId')",
}),
},
},
],
Invoking the API
We can invoke our API with a payload:
curl -X POST -d '{"message": "Hello world!"}' -H 'Content-Type: application/json' https://xxxxxxxxxx.execute-api.us-east-2.amazonaws.com/prod/async-eventbridge
{"id":"3a82be5b-d2fd-a85b-3ba4-5a37b98dd9e1"}
And see our event in the event bus:
Large payload (code)
In cases where the payload exceeds the 256kb size limit of EventBridge, we can use S3 to store the payload prior to downstream asynchronous processing.
This example sets up a direct service integration from API Gateway to the S3 PutObject API. The response from the direct integration is the ID of the API Gateway request, which is also used as the object key in S3. The size of the payload is limited by the maximum size of an API Gateway payload: 10mb.
First, we import the constructs required:
import { AwsIntegration, IRestApi } from 'aws-cdk-lib/aws-apigateway';
import { IEventBus } from 'aws-cdk-lib/aws-events';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { EventsEnabledBucket, S3DetailType } from './events-enabled-bucket';
Note: there is an open issue currently for enabling EventBridge S3 bucket notifications, so the sample code includes a workaround construct EventsEnabledBucket
.
Again, we pass our event bus and REST API into our construct:
interface AsyncS3ApiProps {
eventBus: IEventBus;
api: IRestApi;
}
We define our construct:
export class AsyncS3Api extends Construct {
constructor(
scope: Construct,
id: string,
{ eventBus, api }: AsyncS3ApiProps,
) {
super(scope, id);
We create the bucket and the credentials role that API Gateway will use to perform PutObject on our bucket:
const bucket = new EventsEnabledBucket(this, 'bucket', {
ruleActions: [S3DetailType.OBJECT_CREATED],
eventBus,
});
const executionRole = new Role(this, 'ExecutionRole', {
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
bucket.grantWrite(executionRole);
We define the AWS service integration and set up the resource path in the API:
function requestTemplate() {
const statements = [
'#set($context.requestOverride.path.objectKey = $context.requestId)',
'$input.body',
];
return statements.join('\n');
}
const s3Integration = new AwsIntegration({
service: 's3',
path: `${bucket.bucketName}/{objectKey}`,
integrationHttpMethod: 'PUT',
options: {
requestTemplates: {
'application/json': requestTemplate(),
},
integrationResponses: [
{
statusCode: '200',
responseTemplates: {
'application/json': JSON.stringify({
id: '$context.requestId',
}),
},
},
],
credentialsRole: executionRole,
},
});
const resource = api.root.addResource('async-s3');
resource.addMethod('POST', s3Integration, {
methodResponses: [{ statusCode: '200' }],
});
For an S3 integration, we use a PUT method and set the path to the bucket name and object key:
service: 's3',
path: `${bucket.bucketName}/{objectKey}`,
integrationHttpMethod: 'PUT',
We override the objectKey
with the value of requestId
from the API Gateway request:
function requestTemplate() {
const statements = [
'#set($context.requestOverride.path.objectKey = $context.requestId)',
'$input.body',
];
return statements.join('\n');
}
Our integration response template maps the requestId from API gateway to give an id back to the caller.
integrationResponses: [
{
statusCode: '200',
responseTemplates: {
'application/json': JSON.stringify({
id: '$context.requestId',
}),
},
},
],
Invoking the API
We can invoke our API with a payload:
curl -X POST -d '{"message": "Hello world!"}' -H 'Content-Type: application/json' https://xxxxxxxxxx.execute-api.us-east-2.amazonaws.com/prod/async-s3
{"id":"24e15c2a-543a-47bf-93be-6dfada6a9820"}
And we see our event in the event bus:
Synchronous APIs (code)
For our synchronous API, we will take advantage of the AWS StepFunctions backed APIs construct.
First, we import the constructs required:
import { IRestApi, StepFunctionsIntegration } from 'aws-cdk-lib/aws-apigateway';
import { Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import {
JsonPath,
Pass,
StateMachine,
StateMachineType,
} from 'aws-cdk-lib/aws-stepfunctions';
import { Construct } from 'constructs';
In this particular example we pass our REST API into our construct:
interface SyncExpressSfnApiProps {
api: IRestApi;
}
We define our construct:
export class SyncExpressSfnApi extends Construct {
constructor(scope: Construct, id: string, { api }: SyncExpressSfnApiProps) {
super(scope, id);
We create a simple express step function which will be invoked from our API:
const machine = new StateMachine(this, 'sfn', {
definition: new Pass(this, 'pass', {
parameters: {
message: 'Hello from express API',
echo: JsonPath.stringAt('$.body'),
},
}),
stateMachineType: StateMachineType.EXPRESS,
});
We create the credentials role that API Gateway will use to perform StartSyncExecution on our express step function:
const executionRole = new Role(this, 'ExecutionRole', {
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
});
machine.grantStartSyncExecution(executionRole);
We define the AWS service integration and set up the resource path in the API:
const sfnIntegration = StepFunctionsIntegration.startExecution(machine, {
credentialsRole: executionRole,
});
const resource = api.root.addResource('sync-sfn');
resource.addMethod('POST', sfnIntegration, {
methodResponses: [{ statusCode: '200' }],
});
The AWS StepFunctions backed APIs construct returns the state machine output as the response from the API.
Invoking the API
We can invoke our API with a payload and receive a synchronous response:
curl -X POST -d '{"message": "Hello world!"}' -H 'Content-Type: application/json' https://xxxxxxxxxx.execute-api.us-east-2.amazonaws.com/prod/sync-sfn
{"message":"Hello from express API","echo":{"message":"Hello world!"}}
Conclusion
Hopefully these examples will allow you to quickly get up and running with serverless, lambda/function-less APIs!
If you liked this article, consider following me on Twitter: @scythide
About me
I am a Staff Engineer @ Veho. We recently raised our Series A and B funding rounds and are actively hiring! If you are passionate about serverless, or even looking to grow your skills, have a look at our open positions!
Top comments (0)