In this blog post, I want to show you how to write a basic Serverless application using the AWS CDK to deploy an API Gateway, Lambda functions, and a DynamoDB table. We will be using the asset support of the CDK to bundle our business logic during the deployment.
What are we implementing?
Our application will be a straightforward ToDo list that supports adding new items to the list and displaying it. Further actions are left to the reader to experiment. We will store the data in a DynamoDB table and implement the business logic using AWS Lambda with our code written in TypeScript. As the API endpoint, an HTTP API will be used.
Initialize your CDK application
To start our project, we need to initialize our CDK project. In my previous blog post, I describe the necessary steps.
After these steps are done, we enhance the setup by installing further libraries with the following command:
npm install --save-exact @aws-cdk/aws-lambda @aws-cdk/aws-lambda-nodejs @aws-cdk/aws-dynamodb @aws-cdk/aws-apigatewayv2
Creating the datastore
To create our backing datastore we create a new DynamoDB table and use PK
as the hash key of the table. In CDK, we accomplish this by using the Table construct. To ease the setup, we use on-demand billing for DynamoDB, so we do not need to calculate throughput. Be aware that this is not covered by the Free Tier of your AWS account!
const table = new dynamodb.Table(this, 'Table', {
partitionKey: {
name: 'PK',
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
To be able to look into the database, later on, we create an output to know the table name CloudFormation chose.
new cdk.CfnOutput(this, 'TableName', {value: table.tableName});
Creating our business logic
For our business logic, we create two lambda functions—one to fetch a list of all tasks and one to create a new task inside the table. To hold the code of these lambdas, we create a folder lambda/
and initialize it as a new NodeJS project. I am using the following settings to create my Lambda in TypeScript:
{
"name": "lambda",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "tslint -p tsconfig.json && jest"
},
"author": "",
"license": "ISC",
"dependencies": {
"aws-sdk": "^2.714.0",
"uuid": "^8.3.0"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.59",
"@types/jest": "^26.0.4",
"@types/node": "14.0.23",
"@types/uuid": "^8.3.0",
"jest": "^26.1.0",
"sinon": "^9.0.2",
"ts-jest": "^26.1.2",
"ts-mock-imports": "^1.3.0",
"ts-node": "^8.10.2",
"tslint": "^6.1.3",
"typescript": "~3.9.6"
}
}
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"lib": ["es2018"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"esModuleInterop": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"typeRoots": ["./node_modules/@types"]
},
"exclude": ["cdk.out"]
}
The most important part is to have the AWS SDK and the uuid library installed.
The header of my lambda code, sitting in lib/tasks.ts
, is:
import { DynamoDB } from 'aws-sdk';
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { env } from 'process';
import { v4 } from 'uuid';
const dynamoClient = new DynamoDB.DocumentClient();
To add new tasks to the database, we will post a JSON object to the API with two fields named "name" and "state". We generate a new UUID to be used as the primary id of the task.
// Export new function to be called by Lambda
export async function post(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
// Log the event to debug the application during development
console.log(event);
// If we do not receive a body, we cannot continue...
if (!event.body) {
// ...so we return a Bad Request response
return {
statusCode: 400,
};
}
// As we made sure we have a body, let's parse it
const task = JSON.parse(event.body);
// Let's create a new UUID for the task
const id = v4();
// define a new task entry and await its creation
const put = await dynamoClient.put({
TableName: env.TABLE_NAME!,
Item: {
// Hash key is set to the new UUID
PK: id,
// we just use the fields from the body
Name: task.name,
State: task.state,
},
}).promise();
// Tell the caller that everything went great
return {
statusCode: 200,
body: JSON.stringify({...task, id}),
};
}
To fetch data from the table, we implement another function that resides in the same file. For better readability, I split it into two functions, one to handle the API call and a helper function that is reading from the database.
// Export new function to be called by Lambda
export async function get(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
// Log the event to debug the application during development
console.log(event);
// Get a list of all tasks from the DB, extract the method to do paging
const tasks = (await getTasksFromDatabase()).map((task) => ({
// let's reformat the data to our API model
id: task.PK,
name: task.Name,
state: task.State,
}));
// Return the list as JSON objects
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
// Body needs to be string so render the JSON to string
body: JSON.stringify(tasks),
};
}
// Helper method to fetch all tasks
async function getTasksFromDatabase(): Promise<DynamoDB.DocumentClient.ItemList> {
// This variable will hold our paging key
let startKey;
// start with an empty list of tasks
const result: DynamoDB.DocumentClient.ItemList = [];
// start a fetch loop
do {
// Scan the table for all tasks
const res: DynamoDB.DocumentClient.ScanOutput = await dynamoClient.scan({
TableName: env.TABLE_NAME!,
// Start with the given paging key
ExclusiveStartKey: startKey,
}).promise();
// If we got tasks, store them into our list
if (res.Items) {
result.push(...res.Items);
}
// Keep the new paging token if there is one and repeat when necessary
startKey = res.LastEvaluatedKey;
} while (startKey);
// return the accumulated list of tasks
return result;
}
After we put this code into the tasks.ts
file, we can instantiate the functions inside our CDK app and configure automatic bundling.
To setup the Lambda functions, the NodejsFunction construct is excellent. We can specify the file to use as the entry point and the name of the exported function. CDK will then transpile and package this code during synth using Parcel. For this step, you need a running Docker setup on your machine as the bundling happens inside a container.
const postFunction = new lambdaNode.NodejsFunction(this, 'PostFunction', {
runtime: lambda.Runtime.NODEJS_12_X,
// name of the exported function
handler: 'post',
// file to use as entry point for our Lambda function
entry: __dirname + '/../lambda/lib/tasks.ts',
environment: {
TABLE_NAME: table.tableName,
},
});
// Grant full access to the data
table.grantReadWriteData(postFunction);
const getFunction = new lambdaNode.NodejsFunction(this, 'GetFunction', {
runtime: lambda.Runtime.NODEJS_12_X,
handler: 'get',
entry: __dirname + '/../lambda/lib/tasks.ts',
environment: {
TABLE_NAME: table.tableName,
},
});
// Grant only read access for this function
table.grantReadData(getFunction);
Setup of the API
As the entry point to our API, we use the new HTTP API of the service API Gateway. To create the API and print out the URL, we use this code:
const api = new apiGW.HttpApi(this, 'Api');
new cdk.CfnOutput(this, 'ApiUrl', {value: api.url!});
We then need to add routes for every path and method we want to expose and supply a Lambda function that implements this.
api.addRoutes({
path: '/tasks',
methods: [apiGW.HttpMethod.POST],
integration: new apiGW.LambdaProxyIntegration({handler: postFunction})
});
api.addRoutes({
path: '/tasks',
methods: [apiGW.HttpMethod.GET],
integration: new apiGW.LambdaProxyIntegration({handler: getFunction})
});
Deploy to our AWS account
By running cdk synth
, we can synthesize the CloudFormation template and let CDK package our Lambda code. The cdk.out
folder will then contain the code bundles and the templates, ready to be deployed.
Using the cdk deploy
command, our application will go live inside our AWS account and print out the table name of our DynamoDB table and the API URL.
Testing the API
We can then use Postman or cURL to add tasks to the database or retrieve the list of entries.
API_URL=https://XXXXX.execute-api.eu-central-1.amazonaws.com/
# Add new task to the table
curl -v -X POST -d '{"name":"Testtask","state":"OPEN"}' -H "Content-Type: application/json" ${API_URL}tasks
# Retrieve the list
curl -v ${API_URL}tasks
Conclusion
In this post, we learned to set up an AWS CDK application that creates a DynamoDB table, two Lambda functions, and an HTTP API. It uses CDK native bundling of code to transpile TypeScript to JavaScript and to package the code ready to be deployed to Lambda.
Top comments (0)