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)