Getting Started with AWS CDK: Creating Serverless API with AWS CDK and Typescript

Let's have some fun with AWS CDK!
2020.05.27

What's AWS CDK

AWS Cloud Development Kit (AWS CDK) allows you to write your infrastructure code in your favorite programming language such as Python, C# or Typescript. Your code eventually gets complied as a CloudFormation template and deployed as a CloudFormation Stack.

AWS CDK is available in 5 languages listed below as of May 2020.

  • Typescript
  • JavaScript
  • Python
  • Java
  • C#

AWS CDK Pros vs. Cons

There are couples of tools/frameworks options for writing your Infrastructure these days such as AWS SAM, Serverless Framework and Terraform. So why should you use AWS CDK? Here are some Pros and Cons of AWS CDK.

Pros:

  • The amount of code you need to write decrease significantly compared to CloudFormation.
  • CDK can be written in general programming language which is you're already famillier with.
  • Programming logics can be used such as loop and if statement.
  • You can write tests

Cons:

  • Basic programming skills are required.

Sample Project Overview

In this article, we are creating simple Serverless API with AWS Lambda, DynamoDB and API Gateway with AWS CDK and Typescript.

Typescript version used for this project is 3.7.4.

tsc --v
Version 3.7.4

Prerequisites

  • Node.js >= 10.3.0
  • AWS CDK CLI Tool

AWS CDK CLI Tool Installation:

npm install -g aws-cdk
cdk --version
1.32.2 

Getting Started

Create a brand new folder to get started.

mkdir hello-cdk
cd hello-cdk

Then run cdk init with your language option. This command creates a starting pack of your CDK project.

cdk init --language typescript

Let's install all the modules we need for this sample project. Create package.json file in hello-cdk folder root and add those modules.

package.json

"dependencies": {
  "@aws-cdk/aws-apigateway": "_",
  "@aws-cdk/aws-dynamodb": "_",
  "@aws-cdk/aws-lambda": "\*",
  // more dependencies
}

Then run npm install.

You can also install modules individually.

$ npm install @aws-cdk/aws-apigateway
$ npm install @aws-cdk/aws-lambda
$ npm install @aws-cdk/aws-dynamodb

cdk bootstrap

cdk bootstrap command creates S3 bucket to store all the resources used for CDK. This command only needs to be run once for each AWS account or region.

cdk bootstrap

Watch Mode

Before start coding, it's nice to run npm run watch. This command will turn on the Typescript "Watch Mode" to detect all the changes in .ts files and compile them into Javascript code accordingly.

By keeping this option turned on, the Typescript compiler finds out any syntax errors right away as developer edits the code.

npm run watch


Starting compilation in watch mode...
Found 0 errors. Watching for file changes.
...

We are all set! Let's dive into AWS CDK and gets our hands dirty!

Dynamo DB

To begin, we are going to create DynamoDB table. Open lib/hello-cdk-stack.ts in your IDE and start writing resource definitions in this file as below.

lib/hello-cdk-stack.ts

import * as cdk from "@aws-cdk/core";
import {Table, AttributeType, } as dynamodb from "@aws-cdk/aws-dynamodb";

export class HelloCdkStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

    new Table(this, "items", {
      partitionKey: {
        name: "itemId",
        type: AttributeType.STRING,
      },
      tableName: "items",
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

}
}

const app = new cdk.App();
new HelloCdkStack(app, "HelloCdkStack");
app.synth();

At this moment, we only defined one resource, a DynamoDB Table. Let's deploy our very first AWS resource by running cdk deploy command!

The response should look like as below.

cdk deploy

HelloCdkStack: deploying...
HelloCdkStack: creating CloudFormation changeset...
 0/3 | 2:24:38 AM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata   | CDKMetadata
 0/3 | 2:24:38 AM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table | items (items07D08F4B)
 0/3 | 2:24:39 AM | CREATE_IN_PROGRESS   | AWS::DynamoDB::Table | items (items07D08F4B) Resource creation Initiated
 0/3 | 2:24:39 AM | CREATE_IN_PROGRESS   | AWS::CDK::Metadata   | CDKMetadata Resource creation Initiated
 1/3 | 2:24:39 AM | CREATE_COMPLETE      | AWS::CDK::Metadata   | CDKMetadata
 2/3 | 2:25:09 AM | CREATE_COMPLETE      | AWS::DynamoDB::Table | items (items07D08F4B)
 3/3 | 2:25:11 AM | CREATE_COMPLETE      | AWS::CloudFormation::Stack | HelloCdkStack

If you check out the Management Console, you will see items table has been created.

Tips: DynamoDB removalPolicy

By setting removalPolicy, you can preserve a resource when its stack is deleted.

  • RETAIN: If the table contains data, cdk destroy won't delete the table.
  • DESTROY: cdk destroy will delete the table and all contained data.

To keep the resource, don't forget to specify removalPolicy to RETAIN!

Lambda

Next, we're creating Lambda Function that gets one item from DynamoDB. Let's create a new directory named lambda for lambda source code files.

Then create get-item.ts in /lambda/ directory and write handler code as below.

lib/lambda/get-item.ts

const AWS = require("aws-sdk");
const db = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.TABLE_NAME || "";
const PRIMARY_KEY = process.env.PRIMARY_KEY || "";

export const handler = async (event: any = {}): Promise<any> => {
  const requestedItemId = event.pathParameters.id;
    if (!requestedItemId) {
      return {
        statusCode: 400,
        body: `Error: You are missing the path parameter id`,
      };
    }

  const params = {
    TableName: TABLE_NAME,
      Key: {
        [PRIMARY_KEY]: requestedItemId,
      },
    };

  try {
    const response = await db.get(params).promise();
      return { statusCode: 200, body: JSON.stringify(response.Item) };
    } catch (dbError) {
      return { statusCode: 500, body: JSON.stringify(dbError) };
  }
};

Our first lambda function code is now ready, let's add the resource definition into CDK. We are adding LambdaServiceRole as well to give lambda a read permission of DynamoDB Table.

lib/hello-cdk-stack.ts

import * as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { AssetCode, Function, Runtime } from "@aws-cdk/aws-lambda";

export class HelloCdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
  super(scope, id, props);

      const dynamoTable = new Table(this, "items", {
        partitionKey: {
          name: "itemId",
          type: AttributeType.STRING,
        },
        tableName: "items",
        removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
      });

      const getItemLambda = new Function(this, "getOneItemFunction", {
        code: new AssetCode("lib/lambda"),
        handler: "get-item.handler",
        runtime: Runtime.NODEJS_10_X,
        environment: {
          TABLE_NAME: dynamoTable.tableName,
          PRIMARY_KEY: "itemId",
        },
      });

      // Giving DynamoDB Table Read permission to Lambda
      dynamoTable.grantReadData(getItemLambda);

      const api = new RestApi(this, "itemsApi", {
        restApiName: "Items Service",
      });

  }
}

const app = new cdk.App();
new HelloCdkStack(app, "HelloCdkStack");
app.synth();

Tips: cdk diff

To get a small break, let's run cdk diff. By running this command, you can see the diff of the CDK definition and existing stack.

API Gateway

Lastly, we are adding API Gateway resource definition.

lib/hello-cdk-stack.ts

import * as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { AssetCode, Function, Runtime } from "@aws-cdk/aws-lambda";
import {
RestApi,
LambdaIntegration,
IResource,
MockIntegration,
PassthroughBehavior,
} from "@aws-cdk/aws-apigateway";

export class HelloCdkStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);

    const dynamoTable = new Table(this, "items", {
      partitionKey: {
        name: "itemId",
        type: AttributeType.STRING,
      },
      tableName: "items",
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

    const getItemLambda = new Function(this, "getOneItemFunction", {
      code: new AssetCode("lib/lambda"),
      handler: "get-item.handler",
      runtime: Runtime.NODEJS_10_X,
      environment: {
        TABLE_NAME: dynamoTable.tableName,
        PRIMARY_KEY: "itemId",
      },
    });

    // Giving DynamoDB Table Read permission to Lambda
    dynamoTable.grantReadData(getItemLambda);

    // ApiGateway Resource Definition
    const api = new RestApi(this, "sampleApi", {
      restApiName: "Sample API",
    });
    const items = api.root.addResource("items");

    const singleItem = items.addResource("{id}");
    const getItemIntegration = new LambdaIntegration(getItemLambda);
    singleItem.addMethod("GET", getItemIntegration);
    addCorsOptions(items);

}
}
export function addCorsOptions(apiResource: IResource) {
apiResource.addMethod(
  "OPTIONS",
new MockIntegration({
integrationResponses: [
{
statusCode: "200",
responseParameters: {
  "method.response.header.Access-Control-Allow-Headers":
  "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'",
  "method.response.header.Access-Control-Allow-Origin": "'*'",
  "method.response.header.Access-Control-Allow-Credentials":
  "'false'",
  "method.response.header.Access-Control-Allow-Methods":
  "'OPTIONS,GET,PUT,POST,DELETE'",
},
},
],
passthroughBehavior: PassthroughBehavior.NEVER,
requestTemplates: {
  "application/json": '{"statusCode": 200}',
},
}),
  {
  methodResponses: [
    {
    statusCode: "200",
      responseParameters: {
      "method.response.header.Access-Control-Allow-Headers": true,
      "method.response.header.Access-Control-Allow-Methods": true,
      "method.response.header.Access-Control-Allow-Credentials": true,
      "method.response.header.Access-Control-Allow-Origin": true,
      },
    },
  ],
  }
);
}

const app = new cdk.App();
new HelloCdkStack(app, "HelloCdkStack");
app.synth();

API Gateway CORS Setting

If you wish to call the API from browser, you need to configure those two settings to enable CORS.

  1. Add headers: {"Access-Control-Allow-Origin": "*"} in your lambda function response.
  2. Add Options method to your API Gateway method.

Let's Call the API!

Congratulations! We are all done writing resource definitions for our application. Let's deploy all the resources by running cdk deploy.

Then put some data into the items table and call the API.

We are going to get an item with the itemId of 123.

curl -v https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/prod/items/123
*   Trying xx.xxx.xxx.xx...

...
< HTTP/2 200
< content-type: application/json
< content-length: 39
< date: Tue, 26 May 2020 12:00:31 GMT
< x-amzn-requestid: daab105f-f66e-44c6-a4ec-9cc9d80d8bd9
< x-amz-apigw-id: NI2y7GssNjMFpBw=
< x-amzn-trace-id: Root=1-5ecd04df-c068ca1ce325b22cc88a43f6;Sampled=0
< x-cache: Miss from cloudfront
< via: 1.1 xxxxxxxxx.cloudfront.net (CloudFront)
< x-amz-cf-pop: NRT51-C1
< x-amz-cf-id: nahVrJ04K6zfUzuoXTQcBhH6ywmhjTlyLdKw6cAyUczEW8nG_VfpcQ==
<
* Connection #0 to host xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com left intact
{"itemId":"123","itemName":"すあま"}

If the API returned with status code 200 and the item of itemId 123, we succeeded!

The Whole CDK Template

Here is the source code we wrote in this sample project.

lib/hello-cdk-stack.ts

import * as cdk from "@aws-cdk/core";
import { Table, AttributeType } from "@aws-cdk/aws-dynamodb";
import { AssetCode, Function, Runtime } from "@aws-cdk/aws-lambda";
import {
RestApi,
LambdaIntegration,
IResource,
MockIntegration,
PassthroughBehavior,
} from "@aws-cdk/aws-apigateway";

export class HelloCdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // DynamoDB
    const dynamoTable = new Table(this, "items", {
      partitionKey: {
        name: "itemId",
        type: AttributeType.STRING,
      },
      tableName: "items",
      removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
    });

    // Lambda
    const getItemLambda = new Function(this, "getOneItemFunction", {
      code: new AssetCode("lib/lambda"),
      handler: "get-item.handler",
      runtime: Runtime.NODEJS_10_X,
      environment: {
        TABLE_NAME: dynamoTable.tableName,
        PRIMARY_KEY: "itemId",
      },
    });

    // Giving DynamoDB Table Read permission to Lambda
    dynamoTable.grantReadData(getItemLambda);

    // ApiGateway
    const api = new RestApi(this, "sampleApi", {
      restApiName: "Sample API",
    });
    const items = api.root.addResource("items");

    const singleItem = items.addResource("{id}");
    const getItemIntegration = new LambdaIntegration(getItemLambda);
    singleItem.addMethod("GET", getItemIntegration);
    addCorsOptions(items);

}
}
// Add Options Method
export function addCorsOptions(apiResource: IResource) {
  apiResource.addMethod(
  "OPTIONS",
  new MockIntegration({
  integrationResponses: [
  {
  statusCode: "200",
  responseParameters: {
    "method.response.header.Access-Control-Allow-Headers":
    "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,X-Amz-User-Agent'",
    "method.response.header.Access-Control-Allow-Origin": "'*'",
    "method.response.header.Access-Control-Allow-Credentials":
    "'false'",
    "method.response.header.Access-Control-Allow-Methods":
    "'OPTIONS,GET,PUT,POST,DELETE'",
  },
  },
  ],
  passthroughBehavior: PassthroughBehavior.NEVER,
  requestTemplates: {
    "application/json": '{"statusCode": 200}',
  },
  }),
    {
    methodResponses: [
      {
        statusCode: "200",
          responseParameters: {
            "method.response.header.Access-Control-Allow-Headers": true,
            "method.response.header.Access-Control-Allow-Methods": true,
            "method.response.header.Access-Control-Allow-Credentials": true,
            "method.response.header.Access-Control-Allow-Origin": true,
          },
      },
    ],
    }
  );
}

const app = new cdk.App();
new HelloCdkStack(app, "HelloCdkStack");
app.synth();

Cleaning Up

Don't forget to delete the AWS resource to avoid consuming unnecessary resource.

cdk destroy

Are you sure you want to delete: HelloCdkStack (y/n)? y
HelloCdkStack: destroying...

 ✅  HelloCdkStack: destroyed

CDK Cheet Sheet

Here is the cheet sheet of CDK commands.

cdk init --language <LANGUAGE> Creates new CDK project
cdk bootstrap A magical command you need to run only once at the beginning (Creates S3 Bucket for all the resources that CDK uses.)
cdk ls Shows the list of Stacks created with CDK
cdk diff Shows the diff of current stack and CDK
cdk synth Show Cfn template that is generated with CDK
cdk deploy Deploy Stack
cdk destroy <STACK NAME> Delete Stack

References