This is the second tutorial in a two-part series. You can also learn how to automate AWS Lambda function deployments to AWS CDK.

AWS Cloud Development Kit (AWS CDK) is an open-source framework that allows you to use the programming language of your choice to define and deploy your cloud resources. AWS CDK is an infrastructure as code (IaC) solution, similar to Terraform, that lets you use the expressive power of object-oriented programming languages to define your cloud resources. The AWS CDK framework provides libraries in most of the popular programming languages for all the major AWS services. You can use these libraries to easily define a cloud application stack for your entire system. It eliminates context switching and helps accelerate the development process. Developers do not need to learn a new programming language or a new tool to benefit from AWS CDK.

In this tutorial, I will guide you through using AWS CDK to deploy REST APIs with AWS Lambda-based authorizers. You will learn how API Gateway constructs can be used to customize the behavior of the API by adding authorizers, usage plans, throttling, rate limiting, and more.

Prerequisites

For this tutorial, you will need to set up NodeJS on your machine since you will be using it to define the AWS CDK application and the AWS Lambda handlers. You will also need to install AWS CLI and AWS CDK CLI on your system so that you can configure AWS credentials and manually build your CDK application. You will need an AWS account for deploying the application and a CircleCI account for automating the deployments. Here is a list of everything you need in place to follow along with this tutorial:

Our tutorials are platform-agnostic, but use CircleCI as an example. If you don’t have a CircleCI account, sign up for a free one here.

Creating a new AWS CDK project

Create a new directory for the CDK project and navigate into it. Run these commands:

mkdir aws-cdk-api-auth-lambda-circle-ci
cd aws-cdk-api-auth-lambda-circle-ci

Using the CDK CLI, run the cdk init command to create a new CDK project in TypeScript:

cdk init app --language typescript

This command creates a new CDK project with a single stack and a single application.

Note: AWS CDK supports all major programming languages, including TypeScript, Python, Java, and C#. If you choose a different programming language, you can still follow the steps in this tutorial, but the syntax will change based on the programming language that you chose.

Adding a NodeJS Lambda function

In this section, you will define an AWS Lambda function using NodeJS that can be used for proxy integration with AWS API Gateway. API Gateway’s AWS Lambda proxy integration provides a simple and powerful mechanism to build the business logic of an API. The proxy integration allows the clients to call a single AWS Lambda function in the backend whenever a REST API is called through API Gateway.

This example uses an AWS Lambda function very similar to the one defined in the first tutorial in this series, Automate AWS Lambda function deployments to AWS CDK. Just change the request and response objects for the AWS Lambda proxy integration.

First, create a lambda directory at the root of the CDK project. Inside the lambda folder, create another folder named processJob. Create a package.json file in the processJob directory for defining the dependencies. Open the package.json file, and add the following content:

{
  "name": "circle-ci-upload-csv-lambda-function",
  "version": "0.1.0",
  "dependencies": {
    "csv-stringify": "^6.0.5",
    "fs": "0.0.1-security",
    "uuid": "^8.3.2"
  }
}

This script defines the name of the project and adds a few dependencies that will be used by the Lambda handler.

Now, navigate to the processJob folder from the terminal to install the NPM packages.

cd lambda/processJob
npm install

Next, create an index.js file in the processJob directory and add the following code snippet to it. We have taken the entire code snippet from the linked tutorial and have just modified the request and response objects.

"use strict";

const AWS = require('aws-sdk');
const { v4: uuidv4 } = require('uuid');
var fs = require('fs');
const { stringify } = require('csv-stringify/sync');
AWS.config.update({ region: 'us-west-2' });

var ddb = new AWS.DynamoDB();
const s3 = new AWS.S3();

const TABLE_NAME = process.env.TABLE_NAME;
const BUCKET_NAME = process.env.BUCKET_NAME;

exports.handler = async function (event) {
  try {
    const uploadedObjectKey = generateDataAndUploadToS3();
    const eventBody = JSON.parse(event["body"]);
    const jobId = eventBody["jobId"];
    console.log("event", jobId);
    var params = {
      TableName: TABLE_NAME,
      Item: {
        jobId: { S: jobId },
        reportFileName: { S: uploadedObjectKey },
      },
    };

    // Call DynamoDB to add the item to the table
    await ddb.putItem(params).promise();

    return {
      statusCode: 200,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        status: "success",
        jobId: jobId,
        objectKey: uploadedObjectKey,
      }),
    };
  } catch (error) {
    throw Error(`Error in backend: ${error}`);
  }
};

const generateDataAndUploadToS3 = () => {
  var filePath = "/tmp/test_user_data.csv";
  const objectKey = `${uuidv4()}.csv`;
  writeCsvToFileAndUpload(filePath, objectKey);
  return objectKey;
};

const uploadFile = (fileName, objectKey) => {
  // Read content from the file
  const fileContent = fs.readFileSync(fileName);

  // Setting up S3 upload parameters
  const params = {
    Bucket: BUCKET_NAME,
    Key: objectKey,
    Body: fileContent,
  };

  // Uploading files to the bucket
  s3.upload(params, function (err, data) {
    if (err) {
      throw err;
    }
    console.log(`File uploaded successfully. ${data.Location}`);
  });
  return objectKey;
};

function writeCsvToFileAndUpload(filePath, objectKey) {
    var data = getCsvData();
    var output = stringify(data);

    fs.writeFileSync(filePath, output);
    // we will add the uploadFile method later
    uploadFile(filePath, objectKey);
}

function getCsvData() {
    return [
      ['1', '2', '3', '4'],
      ['a', 'b', 'c', 'd']
    ];
}

There is a reason that the request and response object needs to be modified. When the Lambda function is called through API Gateway, the request object consists of a JSON that includes the request body, HTTP method type, REST API resource path, query parameters, headers, and request context. Here is a snippet that shows this:

{
  "body": "{\"jobId\": \"1\"}",
  "path": "/path/to/resource",
  "httpMethod": "POST",
  "isBase64Encoded": false,
  "queryStringParameters": {
    ...
  },
  "headers": {
    ...
  },
  "requestContext": {
    ...
  }
}

The JSON request payload is stringified and set under the body parameter. To extract the payload in the Lambda you will have to modify the code as shown below.

const eventBody = JSON.parse(event["body"]);
const jobId = eventBody["jobId"];

Also, since the Lambda response would be used as it is by the API Gateway, you need to format the response to a JSON REST API response that includes the status code, status, headers, and response body. Therefore, you stringify and add the actual response under the body parameter of the JSON object as shown below.

{
    "statusCode": 200,
    'headers': {'Content-Type': 'application/json'},
    "body": JSON.stringify({
      "status": "success",
      "jobId": jobId,
      "objectKey": uploadedObjectKey
    })
}

Adding a Lambda authorizer

Next, define another AWS Lambda function that will serve as a custom authorizer. Whenever a client calls the REST API, API Gateway will invoke this Lambda function passing the Authorization header value to it. The Lambda handler will validate the token sent in the Authorization header and will return an IAM policy statement if the token has adequate permissions. If the token is not authorized to call the REST API, the Lambda handler will return an error response.

First, create a lambda/authorizer directory at the root of the CDK project. Inside the authorizer directory add a package.json file for defining the dependencies. In the package.json define the name of the project and add a few dependencies that will be used by the Lambda handler.

{
  "name": "circle-ci-auth-lambda-function",
  "version": "0.1.0",
  "dependencies": {}
}

Next, create an index.js file in the authorizer directory for the authorizer Lambda handler and add an empty Lambda handler to it.

exports.handler =  function(event, context, callback) {

};

Before you implement the Lambda handler, define a method that generates the IAM policy statement granting the execute-api:Invoke permission to the REST API that invoked the authorization Lambda.

var generatePolicy = function(principalId, effect, resource) {
    var authResponse = {};

    authResponse.principalId = principalId;
    if (effect && resource) {
        var policyDocument = {};
        policyDocument.Version = '2012-10-17'; 
        policyDocument.Statement = [];
        var statementOne = {};
        statementOne.Action = 'execute-api:Invoke'; 
        statementOne.Effect = effect;
        statementOne.Resource = resource;
        policyDocument.Statement[0] = statementOne;
        authResponse.policyDocument = policyDocument;
    }

    return authResponse;
}

Now that you have the generatePolicy function defined, implement the Lambda handler. The Lambda handler will extract the authorization token from the event parameter and then validate the token. For a valid token, it will call the generatePolicy method to return the appropriate IAM policy.

exports.handler = function (event, context, callback) {
  var token = event.authorizationToken;
  switch (token) {
    case "allow":
      callback(null, generatePolicy("user", "Allow", event.methodArn));
      break;
    case "deny":
      callback(null, generatePolicy("user", "Deny", event.methodArn));
      break;
    case "unauthorized":
      callback("Unauthorized"); // Return a 401 Unauthorized response
      break;
    default:
      callback("Error: Invalid token"); // Return a 500 Invalid token response
  }
};

Now that you have defined the Lambda for the processing job as well as the Lambda for authorization, you can define CDK Constructs for the application.

Defining CDK constructs for the application

AWS CDK constructs encapsulate the configuration detail and gluing logic for multiple AWS services. CDK provides libraries in most of the major programming languages.

Replace the contents of lib/aws-cdk-api-auth-lambda-circle-ci-stack.ts file with the following code snippet that defines the constructs for AWS S3 bucket, AWS Lambda function, and AWS DynamoDB.

import {
  Stack,
  StackProps,
  aws_s3 as s3,
  aws_dynamodb as dynamodb,
  aws_lambda as lambda,
  Duration
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

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

    // we will add all the constructs here
    // replace bucket name with a unique name
    const circleCiGwpBucket = new s3.Bucket(this, "CircleCIGwpAuthExampleBucket", {
      bucketName: "<YOUR_BUCKET_NAME>",
    });

    const circleCiGwpTable = new dynamodb.Table(this, "CircleCIGwpAuthExampleTable", {
      tableName: "CircleCIGwpAuthExampleTable",
      partitionKey: { name: "jobId", type: dynamodb.AttributeType.STRING },
    });

    const circleCiGwpLambda = new lambda.Function(
      this,
      "CircleCiGwpProcessJobLambda",
      {
        runtime: lambda.Runtime.NODEJS_14_X,
        handler: "index.handler",
        timeout: Duration.seconds(30),
        code: lambda.Code.fromAsset("lambda/processJob/"),
        environment: {
          TABLE_NAME: circleCiGwpTable.tableName,
          BUCKET_NAME: circleCiGwpBucket.bucketName
        },
      }
    );

    circleCiGwpBucket.grantPut(circleCiGwpLambda);
    circleCiGwpTable.grantReadWriteData(circleCiGwpLambda);
  }
}

These constructs match the ones defined in the first tutorial of this series, Automate AWS Lambda function deployments to AWS CDK, so I won’t go into detail about creating these constructs. These are basic CDK constructs that create a new S3 bucket, a new DynamoDB table, and a Lambda function using the Lambda handler defined in the lambda/processJob directory. After defining the constructs, grant the appropriate IAM permissions to the Lambda function.

AWS S3 bucket names are unique across all AWS accounts so you will need to provide a unique name for your bucket.

  • TABLE_NAME and BUCKET_NAME are passed as environment variables and they would be available for use in the AWS Lambda handler.

Before defining any more constructs, you need to define in the stack:

  • CDK Construct to define the authorization Lambda.
  • API Gateway TokenAuthorizer construct that uses the authorization Lambda as its handler.
  • API Gateway RestApi construct for the service.
  • API Gateway LambdaIntegration construct that uses the process job Lambda as its handler.

Use the Lambda integration construct to add an authorization handler method to the API token authorizer resource setting.

Defining an authorization Lambda

Next, add a CDK construct to create an AWS Lambda function for custom authorization. The authorization Lambda will use the NodeJS runtime and the code that you defined in the lambda/authorizer directory.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const circleCiAuthLambda = new lambda.Function(
    this,
    "CircleCiAuthLambda",
    {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: "index.handler",
      timeout: Duration.seconds(30),
      code: lambda.Code.fromAsset("lambda/authorizer/"),
    }
  );
}

Defining a token authorizer

To define an API Gateway token authorizer, add a CDK Construct for the TokenAuthorizer. The token authorizer uses the authorization Lambda function that you defined earlier.

import {
  Stack,
  StackProps,
  aws_s3 as s3,
  aws_dynamodb as dynamodb,
  aws_lambda as lambda,
  //update existing import to add aws_apigateway
  aws_apigateway as apigateway,
  Duration
} from 'aws-cdk-lib';

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const circleCiAuthorizer = new apigateway.TokenAuthorizer(this, 'CircleCIGWPAuthorizer', {
    handler: circleCiAuthLambda
  });
}

Defining the REST API service

Next, define an API Gateway REST API service providing a name and description to it. You will use this RestApi service to add resources to it.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const circleCiGwpApi = new apigateway.RestApi(this, "CircleCIGWPAPI", {
    restApiName: "Circle CI GWP API",
    description: "Sample API for Circle CI GWP"
  });
}

You can now add resources to the circleCiGwpApi service. Resources are the actual endpoints that you are creating, excluding the base URL.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const jobResource = circleCiGwpApi.root.addResource("jobs");
}

Adding a Lambda integration

A Lambda integration integrates an AWS Lambda function to an API Gateway method. You will use the process job Lambda function that you defined earlier as the handler for the Lambda integration.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const processJobIntegration = new apigateway.LambdaIntegration(
    circleCiGwpLambda
  );
}

Adding an API Gateway method with Lambda authorizer

Finally, add a POST method to the jobResource and use the authorization Lambda as the auth handler.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  jobResource.addMethod("POST", processJobIntegration, {
    authorizer: circleCiAuthorizer,
    authorizationType: apigateway.AuthorizationType.CUSTOM,
  });
}

Customizing the API usage plan

In this section, you will learn how to customize the behavior and experience of the REST APIs by defining usage plans, throttling settings, and rate-limiting.

The plan uses API keys to identify API clients and who can access the associated API stages for each key.

  • Usage plans: A usage plan specifies who can access the deployed APIs. The usage plan can optionally be set at the method level. API keys are associated with a usage plan and are used to identify the API client who can access the API for each key.
  • API keys: API keys are string values that can be used to grant access to your API.
  • Throttling limits: Throttling limits determine the threshold at which the request throttling should begin and it can be set at the API or method level.

Now you can define a usage plan for the API.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const circleCiUsagePlan = circleCiGwpApi.addUsagePlan('CircleCiUsagePlan', {
    name: 'CircleCiEasyPlan',
    throttle: {
      rateLimit: 100,
      burstLimit: 2
    }
  });
}

Note, that you are also defining the throttling limits along with the usage plan. The rateLimit refers to the API’s average requests per second over an extended period. The burstLimit refers to the maximum API request rate limit over a time ranging from one to a few seconds. Setting throttling limits is optional and you could choose not to enforce any such limits for your API.

Because usage plans require an API key to be associated with it to identify the clients, add an API key to it.

constructor(scope: Construct, id: string, props?: StackProps) {
  // add this snippet below the existing code
  const circleCiApiKey = circleCiGwpApi.addApiKey('CircleCiApiKey');
  circleCiUsagePlan.addApiKey(circleCiApiKey);
}

CircleCiApiKey doesn’t have any association with the CircleCI dashboard. You can use any other name for the API key as per your requirements.

Deploying the CDK stack

Now that you have defined the CDK constructs in the stack, you can go ahead and deploy the application to an AWS account. First, deploy the application manually before automating the deployment using CircleCI. Make sure that you have the AWS CDK CLI installed on your system. In addition to it, you will need to install the AWS CLI and configure access credentials. You can follow the links in the prerequisite section to install the CLI and configure the credentials.

Run the following commands to bootstrap the application and deploy it.

cdk bootstrap
cdk deploy

When you execute the cdk deploy command, it will prompt you to confirm the IAM role/policy changes that would be applied to your account. Notice, that the terminal will display the base URL of the REST API service that you deployed. Grab this URL and keep it handy as you will use it to test the API in the next section.

Testing the deployed API

Now that the application has been deployed to the AWS account, test the API by calling the API endpoint using curl. Replace the base URL that you obtained in the previous section in the curl request.

curl -X POST \
  '<base_URL>/jobs' \
  --header 'Accept: */*' \
  --header 'Authorization: allow' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "jobId": "1"
}'

Notice that you have set the Authorization: allow header which acts as the token that will be validated by the authorization Lambda.

On executing the curl request, you should receive a success response similar to the one shown below.

API success response

Instead of passing the allow value in the Authorization header, try passing deny or some other value to make sure that the API returns a success response only when it receives a valid token.

curl -X POST \
  '<baseURL>/jobs' \
  --header 'Accept: */*' \
  --header 'Authorization: deny' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "jobId": "1"
}'

As expected, the API returns an authorized response since the token is not valid.

API unauthorized response

Automating application deployment using CircleCI

Now that you were able to deploy the CDK application manually using the command line, automate the workflow so that the infrastructure changes can be packaged and deployed automatically every time you push code to the main branch. To automate the deployments you will need to:

  • Update .gitignore
  • Update NPM scripts
  • Add the configuration script
  • Create a CircleCI project
  • Set up environment variables

Update .gitignore

The code generated by the cdk init command contains a .gitignore file that ignores all .js files by default. Make sure to replace the contents of .gitignore with the following code snippet.

!jest.config.js
*.d.ts
node_modules

# CDK asset staging directory
.cdk.staging
cdk.out

Update NPM scripts

Our CircleCI deployment configuration uses NPM scripts for executing the deploy and diff commands. Add the following scripts to the root level package.json file.

// update the aws-cdk-api-auth-lambda-circle-ci/package.json file with the following scripts
{
  ... 
  "scripts": {
    ...
    // add the ci_diff and ci_deploy scripts
    "ci_diff": "cdk diff -c env=${ENV:-stg} 2>&1 | sed -r 's/\\x1B\\[([0-9]{1,2}(;[0-9]{1,2})?)?[mGK]//g' || true",
    "ci_deploy": "cdk deploy -c env=${ENV:-stg} --require-approval never"
  },
  ...
}

Add the configuration script

First, add a .circleci/config.yml script in the root of the project containing the configuration file for the CI pipeline. Add the following code snippet to the config.yml.

version: 2.1

orbs:
  aws-cli: circleci/aws-cli@2.0.6
executors:
  default:
    docker:
      - image: "cimg/node:14.18.2"
    environment:
      AWS_REGION: "us-west-2"
jobs:
  build:
    executor: "default"
    steps:
      - aws-cli/setup:
          aws-access-key-id: AWS_ACCESS_KEY
          aws-secret-access-key: AWS_ACCESS_SECRET
          aws-region: AWS_REGION_NAME
      - checkout
      - run:
          name: 'Install Authorizer lambda packages'
          command: |
            cd lambda/processJob && npm install
      - run:
          name: 'Install Process Job lambda packages'
          command: |
            cd lambda/processJob && npm install
      - run:
          name: "build"
          command: |
            npm install
            npm run build
      - run:
          name: "cdk_diff"
          command: |
            if [ -n "$CIRCLE_PULL_REQUEST" ]; then
              export ENV=stg
              if [ "${CIRCLE_BRANCH}" == "develop" ]; then
                export ENV=prd
              fi 
              pr_number=${CIRCLE_PULL_REQUEST##*/}
              block='```'
              diff=$(echo -e "cdk diff (env=${ENV})\n${block}\n$(npm run --silent ci_diff)\n${block}")
              data=$(jq -n --arg body "$diff" '{ body: $body }') # escape
              curl -X POST -H 'Content-Type:application/json' \
                -H 'Accept: application/vnd.github.v3+json' \
                -H "Authorization: token ${GITHUB_TOKEN}" \
                -d "$data" \
                "https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${pr_number}/comments"
            fi
      - run:
          name: "cdk_deploy"
          command: |
            if [ "${CIRCLE_BRANCH}" == "main" ]; then
              ENV=prd npm run ci_deploy
            elif [ "${CIRCLE_BRANCH}" == "develop" ]; then
              ENV=stg npm run ci_deploy
            fi

The CI script uses the aws-cli orb for setting AWS configuration, such as the access key and secret.

The cdk_deploy command checks the branch and accordingly deploys on the prd or stg environment. Note that the cdk_deploy command executes the ci_deploy script defined in the package.json file.

Our pipeline configuration will take care of building, packaging, and deploying the CDK stack to the specified AWS account. Commit the changes and push them to the GitHub repository.

Create a CircleCI project for the application

Next, set up the repository as a CircleCI project using the CircleCI console. On the Circle CI console, click the Projects tab and search for the GitHub repo name. Click the Set Up Project button for your project.

![Circle CI set up project]2022-08-17-circleci-setup-project

A dialog prompt will appear asking you want to manually add a new configuration file or use an existing one. Since you have already pushed the required configuration file to the codebase, select the Fastest option and enter the name of the branch hosting your configuration file. Click Set Up Project to continue.

Select configuration

Completing the setup will automatically trigger the pipeline. The pipeline would fail in its first run since you haven’t define the environment variables.

Set up environment variables

On the project page, click Project settings and go to the Environment variables tab. On the screen that appears, click Add environment variable button and add the following environment variables.

  • AWS_ACCESS_KEY obtained from the IAM role page in the AWS console
  • AWS_ACCESS_SECRET obtained from the IAM role page in the AWS console
  • AWS_REGION_NAME to a region where you want to deploy your application

Once you add the environment variables, it should show the key values on the dashboard.

Now that the environment variables are configured, trigger the pipeline again. This time the build should succeed.

Circle CI pipeline builds successfully

Conclusion

In this tutorial, you saw how to use AWS CDK constructs to easily deploy an application and expose its functionality using REST APIs. CDK can be used to easily plug in a Lambda-based custom authorizer and further customize the application experience by defining usage plans, API keys, and throttling limits. AWS CDK allows you to use familiar tools and programming language for your IaC code. It also lets you write testable code and integrate the infrastructure code with your existing code review workflows.

You can check out the full source code used in this tutorial on GitHub. The GitHub project can also be used as a template for you if you are trying to define a similar kind of stack.


Vivek Kumar Maskara is a Software Engineer at JP Morgan. He loves writing code, developing apps, creating websites, and writing technical blogs about his experiences. His profile and contact information can be found at maskaravivek.com.

Read more posts by Vivek Maskara