Getting Started with AWS CDK: Creating Serverless API with AWS CDK and Typescript
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.
"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.
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.
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.
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.
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.
- Add
headers: {"Access-Control-Allow-Origin": "*"}
in your lambda function response. - 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.
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 |