DEV Community

Roger Chi for AWS Community Builders

Posted on • Updated on

Three serverless, Lambda-less API patterns with AWS CDK

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';
Enter fullscreen mode Exit fullscreen mode

In this particular example we pass our event bus and REST API into our construct:

interface AsyncEventBridgeApiProps {
  eventBus: IEventBus;
  api: IRestApi;
}
Enter fullscreen mode Exit fullscreen mode

We define our construct:

export class AsyncEventBridgeApi extends Construct {
  constructor(
    scope: Construct,
    id: string,
    { eventBus, api }: AsyncEventBridgeApiProps,
  ) {
    super(scope, id);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' }],
});
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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,
    },
  ],
})}
Enter fullscreen mode Exit fullscreen mode

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')",
      }),
    },
  },
],
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

And see our event in the event bus:
Screenshot of CloudWatch log entry for event

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';
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

We define our construct:

export class AsyncS3Api extends Construct {
  constructor(
    scope: Construct,
    id: string,
    { eventBus, api }: AsyncS3ApiProps,
  ) {
    super(scope, id);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' }],
});
Enter fullscreen mode Exit fullscreen mode

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',
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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',
      }),
    },
  },
],
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

We see the object in S3:
Screenshot of S3 bucket with an object representing the payload
S3 object contents

And we see our event in the event bus:
Screenshot of CloudWatch log entry for event

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';
Enter fullscreen mode Exit fullscreen mode

In this particular example we pass our REST API into our construct:

interface SyncExpressSfnApiProps {
  api: IRestApi;
}
Enter fullscreen mode Exit fullscreen mode

We define our construct:

export class SyncExpressSfnApi extends Construct {
  constructor(scope: Construct, id: string, { api }: SyncExpressSfnApiProps) {
    super(scope, id);
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' }],
});
Enter fullscreen mode Exit fullscreen mode

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!"}}
Enter fullscreen mode Exit fullscreen mode

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!

Latest comments (0)