DEV Community

Thorsten Hoeger for AWS Heroes

Posted on • Originally published at taimos.de on

Build a basic serverless application using AWS CDK

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

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

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

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

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

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

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

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

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

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

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.

Bundling

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.

Deploy

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

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)