Go serverless with aws Api Gateway + Lambda + DynamoDB + CDK Part II
In this Article
Overview
This is the second part of the serverless tutorial with aws Api Gateway + Lambda + DynamoDB + CDK. I am going to cover the services implementation. If you haven’t checked it yet, you can access the first part of this tutorial here. Let the fun part begin!
Infrastructure Definition
I am going to create a class that will serve as a template for a DynamoDB table creation under the services/infra/dynamo directory.
import { Stack } from "aws-cdk-lib";
import { AttributeType, Table } from "aws-cdk-lib/aws-dynamodb"
/**
* Table properties required for creating a DynamoDB table
*/
export interface TableProperties {
name: string,
primaryKey: string,
primaryKeyType: AttributeType,
secondaryIndexes: SecondaryIndex[]
}
/**
* Secondary index type definition
*/
export class SecondaryIndex {
private name: string;
private type: AttributeType;
constructor(name: string, type: AttributeType) {
this.name = name;
this.type = type;
}
public getName(): string {
return this.name;
}
public getType(): AttributeType {
return this.type;
}
}
/**
* Template for creating a DynamoDB table
*/
export class DynamoTable {
private stack: Stack;
private table: Table;
private tableProperties: TableProperties
constructor(stack: Stack, tableProperties: TableProperties) {
this.stack = stack;
this.tableProperties = tableProperties;
// create the table
this.table = new Table(this.stack, this.tableProperties.name, {
partitionKey: {
name: this.tableProperties.primaryKey,
type: this.tableProperties.primaryKeyType
},
tableName: this.tableProperties.name
});
// add secondary indexes if any
this.tableProperties.secondaryIndexes.forEach((secondaryIndex) => {
this.table.addGlobalSecondaryIndex({
indexName: secondaryIndex.getName(),
partitionKey: {
name: secondaryIndex.getName(),
type: secondaryIndex.getType()
}
})
});
}
public getTable(): Table {
return this.table;
}
}
Here I have defined a TableProperties interface to pass to the class constructor as well as a SecondaryIndex class that defines a secondary index type for the table. In the DynamoTable constructor I am creating the table setting partitionKey and name from the tableProperties. I then proceed to add any secondary index specified in the properties. This provides me with a template for creating a DynamoDB table while specifying any secondary indexes. What I really like about using CDK is that I can write unit tests to make sure my template initialization works as expected.
import { DynamoTable, SecondaryIndex } from "./dynamo";
import { Stack } from "aws-cdk-lib";
import { AttributeType, Table } from "aws-cdk-lib/aws-dynamodb";
describe('The DynamoDB table template', () => {
let stack: Stack;
let mockTable: jest.Mock<typeof Table>
beforeEach(() => {
stack = new Stack();
jest.mock('aws-cdk-lib/aws-dynamodb/lib/table');
mockTable = <jest.Mock<typeof Table>><unknown>Table;
jest.spyOn(mockTable.prototype, 'addGlobalSecondaryIndex');
});
it('should allow to create a table with no secondary indexes', () => {
const table: DynamoTable = new DynamoTable(stack, {
name: 'mario',
primaryKey: 'name',
primaryKeyType: AttributeType.STRING,
secondaryIndexes: []
});
expect(table).toBeDefined();
expect(table.getTable()).toBeDefined();
expect(mockTable.prototype.addGlobalSecondaryIndex).toHaveBeenCalledTimes(0);
});
it('should allow to create a table with secondary indexes', () => {
const table: DynamoTable = new DynamoTable(stack, {
name: 'mario',
primaryKey: 'name',
primaryKeyType: AttributeType.STRING,
secondaryIndexes: [
new SecondaryIndex('foo', AttributeType.STRING),
new SecondaryIndex('count', AttributeType.NUMBER)
]
});
expect(table).toBeDefined();
expect(table.getTable()).toBeDefined();
expect(mockTable.prototype.addGlobalSecondaryIndex).toHaveBeenCalledTimes(2);
});
});
Let’s run those tests:
The next class I want to create is for binding lambdas to the APIGateway. This class is going to help me keep my stack invocation class tidy and clear later on.
import { LambdaIntegration, Resource, RestApi } from "aws-cdk-lib/aws-apigateway";
export class ApiBinder {
private api: RestApi;
private resource: Resource | undefined;
private lambdaIntegrations: Map<string, LambdaIntegration>;
constructor(api: RestApi) {
this.api = api;
this.lambdaIntegrations = new Map();
}
public toResource(name: string): ApiBinder {
this.resource = this.api.root.addResource(name);
return this;
}
public withCRUDOperation(method: string, lambdaIntegration: LambdaIntegration): ApiBinder {
this.lambdaIntegrations.set(method, lambdaIntegration);
return this;
}
public bind(): void {
const keys: IterableIterator<string> = this.lambdaIntegrations.keys();
let key = keys.next();
while(key.done === false) {
const lambdaIntegration: LambdaIntegration = this.lambdaIntegrations.get(key.value)!;
this.resource?.addMethod(key.value, lambdaIntegration);
key = keys.next();
}
}
}
This bind method could be improved to better handle the possibility of the resource field being undefined, however I am going to keep it leaner for the purpose of this tutorial. Finally under the services/todo.item/infra folder create a todo.item.stack.ts file and pop in the following for now:
import { Stack, StackProps } from "aws-cdk-lib";
import { LambdaIntegration, RestApi } from "aws-cdk-lib/aws-apigateway";
import { Construct } from "constructs";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { DynamoTable } from "../../infra/dynamo/dynamo";
import { ApiBinder } from "../../infra/api/api.binder";
import { join } from "path";
export class TodoItemStack extends Stack {
private restApi: RestApi;
private dynamoTable: DynamoTable;
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// TODO initialize table and lambdas
}
}
Implementing Lambdas
First things first, I am going to create under services/todo.item a file named todo.item.ts and use it to define a todo item type. This is the type of object that I will be storing into the DynamoDB table.
export class TodoItem {
private id: string;
private text: string;
constructor(id: string, text: string) {
this.id = id;
this.text = text;
}
public getId(): string {
return this.id;
}
public getText(): string {
return this.text;
}
}
The object is simple, a todo item will have an id and a text field. Let’s go ahead and install uuid as a dependency so we can use it to populate the id field. We will also need to install the aws-sdk
$ yarn add uuid && yarn add --dev @types/uuid && yarn add aws-sdk
Now to the lambdas part. Create create.ts and read.ts under the services/todo.item/lambdas folder. Let’s look at the create lambda first:
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda";
import { v4 } from "uuid";
import { TodoItem } from "../todo.item";
import { DynamoDB } from "aws-sdk";
// get the table name from the env variable
const TABLE_NAME = process.env.TABLE_NAME;
// create the client outside the handler
const DB_CLIENT = new DynamoDB.DocumentClient();
async function handler(event: APIGatewayProxyEvent, context: Context): Promise<APIGatewayProxyResult> {
// assume the body is never null or undefined
const params: any = JSON.parse(event.body!);
// create the item
const item = new TodoItem(v4(), params.text);
// prepare the temp response
const response: APIGatewayProxyResult = {
statusCode: 201,
body: ''
};
try {
// persist the item
await DB_CLIENT.put({
TableName: TABLE_NAME!,
Item: item
}).promise();
// set the body with the created item
response.body = JSON.stringify(item);
} catch (error: any) {
// catch the error and change the response
console.error(error);
response.statusCode = 500;
response.body = JSON.stringify(error);
}
// return the response
return response;
}
export { handler };
The lambda is pretty simple, it reads the text from the request body and use it to create a TodoItem that is then inserted into the DynamoDB table. I didn’t add any error handling to keep the code simple enough. Now let’s put together the stack and wire this lambda to an api gateway. Go back to the todo.item.stack.ts file:
...
super(scope, id, props);
// init the rest api
this.restApi = new RestApi(this, 'TodoItemApiGateway');
// init the DynamoDB table
this.dynamoTable = new DynamoTable(this, {
name: TodoItemStack.TABLE_NAME,
primaryKey: TodoItemStack.PRIMARY_KEY,
primaryKeyType: AttributeType.STRING,
secondaryIndexes: []
});
const createTodoItemLambda = new NodejsFunction(this, 'createTodoItemLambda', {
entry: join(__dirname, '..', 'lambdas', 'create.ts'),
handler: 'handler',
environment: {
TABLE_NAME: TodoItemStack.TABLE_NAME
}
});
// grant dynamo db permissions
this.dynamoTable.getTable().grantWriteData(createTodoItemLambda);
// bind the api
new ApiBinder(this.restApi)
.toResource('todo')
.withCRUDOperation('POST', new LambdaIntegration(createTodoItemLambda))
.bind();
...
Right after calling the parent constructor I am creating a RestApi a DynamoDB table and a CreateTodoItemLambda. I have to grant write permission to the lambda to make sure it can successfully write the todo item to the table. Finally I am using the api binder to create a todo resource on the RestApi and binding a POST method with the create todo lambda. If you remember during the setup we have created a cdk.json file from where we are invoking the services/infra/main.ts file. Let’s go back there and call the TodoItemStack:
import { App } from "aws-cdk-lib";
import { TodoItemStack } from "../todo.item/infra/todo.item.stack";
const app = new App();
new TodoItemStack(app, 'TodoItemStack', {
stackName: 'TodoItem'
});
Alright we are almost there, time to deploy!
Deploying the services
To deploy the lambda, make sure you have the aws-cdk npm package installed globally and then from your root folder run
$ cdk deploy
Once deployed you will see a url output to your terminal use that url to test your /todo post request:
You can navigate to the DyanmoDB service in your aws console to verify that the item has been stored:
That’s it! You can check out the repository to see the read lambda implementation and how it has been wired in the TodoItemStack class.