Three serverless, Lambda-less API patterns with AWS CDK
Tags: aws, serverless, awscdk, api
Published: 2022-03-17 18:21:47

Serverless, Lambda-less [microservice patterns](https://medium.com/lego-engineering/dont-wait-for-functionless-write-less-functions-instead-8f2c331cd651) 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](https://github.com/rogerchi/cdk-lambdaless-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](https://github.com/rogerchi/cdk-lambdaless-api/blob/main/lib/constructs/async-eventbridge.ts))
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:
```typescript
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:
```typescript
interface AsyncEventBridgeApiProps {
  eventBus: IEventBus;
  api: IRestApi;
}
```

We define our construct:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
${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.

```typescript
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:
```bash
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:
![Screenshot of CloudWatch log entry for event](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/scoohrladvre126zawnn.png)
 

## Large payload ([code](https://github.com/rogerchi/cdk-lambdaless-api/blob/main/lib/constructs/async-s3.ts))
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:
```typescript
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](https://github.com/aws/aws-cdk/pull/18614) 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:
```typescript
interface AsyncS3ApiProps {
  eventBus: IEventBus;
  api: IRestApi;
}
```

We define our construct:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
service: 's3',
path: `${bucket.bucketName}/{objectKey}`,
integrationHttpMethod: 'PUT',
```

We override the `objectKey` with the value of `requestId` from the API Gateway request:
```typescript
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.

```typescript
integrationResponses: [
  {
    statusCode: '200',
    responseTemplates: {
      'application/json': JSON.stringify({
        id: '$context.requestId',
      }),
    },
  },
],
```

### Invoking the API
We can invoke our API with a payload:
```bash
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"}
```

We see the object in S3:
![Screenshot of S3 bucket with an object representing the payload](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3zkfl5f9nupk5pz74jsl.png)
![S3 object contents](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/repuns50hrc3nsl1ea30.png)
 
And we see our event in the event bus:
![Screenshot of CloudWatch log entry for event](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p4n704b5tfqeazjwldvr.png)
 
# Synchronous APIs ([code](https://github.com/rogerchi/cdk-lambdaless-api/blob/main/lib/constructs/sync-express-sfn.ts))
For our synchronous API, we will take advantage of the [AWS StepFunctions backed APIs](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_apigateway-readme.html#aws-stepfunctions-backed-apis) construct.

First, we import the constructs required:
```typescript
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:
```typescript
interface SyncExpressSfnApiProps {
  api: IRestApi;
}
```

We define our construct:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```bash
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](https://twitter.com/scythide)

## About me
**I am a Staff Engineer @ [Veho](https://shipveho.com).  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](https://jobs.lever.co/veho?lever-via=00db_GfGxs)!**