Go serverless with aws Api Gateway + Lambda + DynamoDB + CDK Part II

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.