javascript

Building Serverless Apps with the AWS CDK Using TypeScript

Camilo Reyes

Camilo Reyes on

Building Serverless Apps with the AWS CDK Using TypeScript

The AWS Cloud Development Kit (CDK) lets you build serverless applications with the expressive power of a programming language like TypeScript. The CDK defines cloud infrastructure in code and deploys via AWS CloudFormation.

In this post, we will build a Lambda function, an AWS Gateway API, and an S3 bucket to upload CSV files. The API will take requests in JSON and seamlessly convert them to CSV format. We will use the AWS CDK in TypeScript to reliably deploy our app through AWS CloudFormation.

Ready? Let’s go!

Quick Start

Some familiarity with Node and TypeScript is expected. You can follow along by copy-pasting the code or by cloning this GitHub repo.

The first thing you will need is the CDK in Node.

shell
> npm i -g aws-cdk

This global tool lets you invoke the AWS CDK via the cdk command.

Next, initialize the project. You can name it aws-typescript-cdk.

shell
> mkdir aws-typescript-cdk > cd aws-typescript-cdk > cdk init --language typescript

This single cdk command should spin up an entire TypeScript project for you, including the tsconfig.json file, package.json, CDK dependencies, and unit tests in Jest.

The files of interest are:

  • bin/aws-typescript-cdk.ts: Main entry point of the app to run the CDK.
  • lib/aws-typescript-cdk-stack.ts: Defines the CDK stack that provisions in CloudFormation.
  • test/aws-typescript-cdk-test.ts: Unit test file to validate the stack and shorten the feedback loop.

To spin up the app that will be provisioned with this CDK, create a resources folder and simply create a lambda.ts file. You should end up with a resources/lambda.ts folder structure from the root folder. In the test folder, create a matching test/lambda.test.ts file for the unit tests.

To validate what we have so far, use the synth command.

shell
> cdk synth

This outputs the resources in the CDK. Pay close attention to the format:

text
Resources: CDKMetadata: Type: AWS::CDK::Metadata Properties: ...

This declares which resources will deploy via CloudFormation, and you can use it as a good reference for what gets included in the CDK. We mostly only care about the Type and Properties.

Lambda Function

Open the lambda.ts file and add the code below. Note the REGION: be sure to specify a correct region, like us-east-2, that is closest to your geographical location.

typescript
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { APIGatewayProxyEventV2 } from "aws-lambda"; const s3Client = new S3Client({ region: "REGION", }); export const handler = async (event: APIGatewayProxyEventV2) => { const fileName = event.queryStringParameters?.fileName; const body = JSON.parse(event.body ?? "[]"); const csv = convertToCSV(body); const command = new PutObjectCommand({ Key: fileName, Bucket: process.env.BUCKET, Body: csv, }); await s3Client.send(command); return { statusCode: 202, }; }; function convertToCSV(arr: string[]) { const array = [Object.keys(arr[0])].concat(arr); return array .map((it) => { return Object.values(it).toString(); }) .join("\n"); }

The S3 bucket comes from an environment variable that we can set in the CDK. The actual conversion into CSV takes the request as a JSON array in the body.

If you get a compiler error because it can’t find the APIGatewayProxyEventV2 type, simply add the @types/aws-lambda dependency via NPM. Also, be sure to add @aws-sdk/client-s3 to get the S3 client.

For example:

shell
> npm i @types/aws-lambda --save-dev > npm i @aws-sdk/client-s3 --save

Now, to validate these changes, simply write the unit test. Open the lambda.test.ts file and put this in:

typescript
import { S3Client } from "@aws-sdk/client-s3"; import { handler } from "../resources/lambda"; jest.mock("@aws-sdk/client-s3"); const send = jest.mocked(S3Client.prototype.send); beforeEach(() => send.mockClear()); test("POST convert JSON request to CSV file", async () => { const request: any = { queryStringParameters: { fileName: "test-file-name", }, body: '[{"a": "test"}]', }; const response = await handler(request); expect(response.statusCode).toBe(202); expect(send).toHaveBeenCalled(); });

What we have so far is a basic Lambda function that makes assumptions about the underlying infrastructure. It assumes there is an S3 bucket and takes in an AWS Gateway event parameter.

CDK Service in AWS

To declare what resources get deployed via CloudFormation, create a lib/aws-cdk-service.ts file right in the same folder as the CDK stack file.

We will now create a service that extends from a Construct, then a new Bucket, Function, and RestApi. These are the resources in AWS that are necessary for our Lambda function.

The S3 bucket name is automatically generated and put in the Lambda function environment variables. This gets us over the limitation of having to create a unique bucket name.

typescript
import { Duration } from "aws-cdk-lib"; import { Construct } from "constructs"; import { RestApi, LambdaIntegration } from "aws-cdk-lib/aws-apigateway"; import { Function, Runtime, Code, Architecture } from "aws-cdk-lib/aws-lambda"; import { Bucket } from "aws-cdk-lib/aws-s3"; export class AwsCdkService extends Construct { constructor(scope: Construct, id: string) { super(scope, id); const bucket = new Bucket(this, "s3-bucket"); const handler = new Function(this, "lambda-function", { runtime: Runtime.NODEJS_18_X, code: Code.fromAsset("resources"), architecture: Architecture.ARM_64, memorySize: 1024, functionName: "csv-file-lambda-uploader", handler: "lambda.handler", timeout: Duration.seconds(5), environment: { BUCKET: bucket.bucketName, }, }); bucket.grantReadWrite(handler); const api = new RestApi(this, "rest-api", { restApiName: "csv-file-api-uploader", description: "This service converts JSON requests to CSV files", }); const apiIntegration = new LambdaIntegration(handler, { requestTemplates: { "application/json": '{ "statusCode": "202" }', }, }); api.root.addMethod("POST", apiIntegration); } }

Luckily, the AWS CDK comes with a very nice suite of tools, so we can write unit tests in TypeScript. Create a matching test/aws-typescript-cdk.tests.ts file and validate what must be in the CDK.

typescript
import { App } from "aws-cdk-lib"; import { Template } from "aws-cdk-lib/assertions"; import { AwsTypescriptCdkStack } from "../lib/aws-typescript-cdk-stack"; test("S3 bucket created", () => { const app = new App(); const stack = new AwsTypescriptCdkStack(app, "test-stack"); const template = Template.fromStack(stack); template.resourceCountIs("AWS::S3::Bucket", 1); }); test("Lambda function created", () => { const app = new App(); const stack = new AwsTypescriptCdkStack(app, "test-stack"); const template = Template.fromStack(stack); template.hasResourceProperties("AWS::Lambda::Function", { Architectures: ["arm64"], MemorySize: 1024, }); }); test("REST API created", () => { const app = new App(); const stack = new AwsTypescriptCdkStack(app, "test-stack"); const template = Template.fromStack(stack); template.resourceCountIs("AWS::ApiGateway::RestApi", 1); template.hasResourceProperties("AWS::ApiGateway::Method", { HttpMethod: "POST", }); });

Now, run npm t and then cdk synth. Go and inspect the output from the synthesizer. You should be able to find resources like AWS::S3::Bucket and AWS::Lambda::Function. Some of these resources also have properties we can validate in the unit test via template.hasResourceProperties. This makes it seamless for you to write your CDKs and validate what resources must be available.

The Template assertion library also catches trivial, hard-to-spot mistakes in the CDK.

When the test fails because it can’t find a match, the tooling shows you where it finds the closest match so you can fix the test.

For example:

text
Template has 1 resources with type AWS::Lambda::Function, but none match as expected. The 1 closest matches: cdkservicelambdafunction22C0F862 :: { ... "Properties": { "Architectures": [ !! Expected ar64 but received arm64 "arm64" ], ... }, "Type": "AWS::Lambda::Function" }

Note that the Function construct grabs everything in the resources folder and puts it in the Lambda function. The bundle size remains small because Node Lambdas already include the AWS SDK. Alternatively, you can use NodejsFunction to provision the Lambda function: this uses esbuild to bundle up the script and optimize cold starts.

Deploy and Test Your TypeScript App

Open the aws-typescript-cdk-stack.ts file, then include the service in the Stack class constructor. Be sure to import the service class in TypeScript.

typescript
import { StackProps, Stack } from "aws-cdk-lib"; import { Construct } from "constructs"; import { AwsCdkService } from "./aws-cdk-service"; export class AwsTypescriptCdkStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); new AwsCdkService(this, "cdk-service"); } }

Also, we recommend setting the stack id to something memorable and unique like aws-typescript-cdk-stack. This is because changing the stack id will force you to delete the entire stack with all its resources.

You can find this stack id in the main entry point in the bin folder.

For example:

typescript
new AwsTypescriptCdkStack( app, "aws-typescript-cdk-stack", // stack id { // ... } );

Then, flesh out the main entry point in aws-typescript-cdk.ts. Simply uncomment the env property and put in your account number and region.

typescript
env: { account: 'ACCOUNT_NUMBER', region: 'REGION' }

Now, bootstrap the CDK using the following command:

shell
> cdk bootstrap aws://ACCOUNT_NUMBER/REGION

We are now ready to deploy the app:

shell
> cdk deploy

You can track progress in the AWS console by navigating to CloudFormation (which should have a stack name like aws-typescript-cdk-stack with status information).

To test the app, spin up CURL:

shell
> curl -i -s -X POST "https://GATEWAY_ID.execute-api.REGION.amazonaws.com/prod/?fileName=car-models.csv" -H "Content-Type: application/json" -d "[{\"make\":\"Ford\",\"year\":2009},{\"make\":\"BMW\",\"year\":1999},{\"make\":\"Fiat\",\"year\":2013}]"

Look for a 202 HTTP status code, then validate that there is an S3 bucket with a car-models.csv file in it. Keep in mind the bucket name is randomly generated.

And that's it!

Wrapping Up

As we've seen in this post, the TypeScript AWS CDK is a nice way to write infrastructure code that gets deployed in AWS. The tooling makes it seamless to create, unit test, and deploy resources.

Happy coding!

P.S. If you liked this post, subscribe to our JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.

P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.

Camilo Reyes

Camilo Reyes

Our guest author Camilo is a Software Engineer from Houston, Texas. He’s passionate about JavaScript and clean code that runs without drama. When not coding, he loves to cook and work on random home projects.

All articles by Camilo Reyes

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps