AWSLambdaAPI GatewayDynamoDB | 17 Min Read
How to Build a REST API With the AWS CDK Using API Gateway, Lambda, and Dynamodb With API Key Authentication
Learn how to build a REST API with API key authentication using AWS API Gateway, DynamoDB, Lambda, and the AWS CDK as well as how to test it with Postman.
APIs are a common occurrence in the developer world and for a good reason, they allow us to easily connect separate systems and products together. It’s highly likely that at some point in your developer journey, you’ll use one but today we’re going to go one step further and look at how we can create one with AWS using API Gateway, Lambda, DynamoDB, and the AWS CDK.
But, first, why might you want to create one? There are many reasons why you might want to create an API but one of the most common would be to give external developers an easy way to integrate with your product or platform to perform operations as if they were using the product directly.
So, by the end of this post, we’re going to have created an example REST API that allows users to create, delete, and retrieve example blog posts from a DynamoDB table. We’re also going to protect our API by requiring an API key to be used in the requests so any unauthenticated requests are responded to with a `403 Forbidden`
response.
Prerequisites
Before we jump into the tutorial and get started with building our example API, there are a couple of things to take care of. You’ll need to have an AWS account, as well as the AWS CLI and CDK, configured on your local machine. You will also need a CDK project initialized locally, this can either be an existing one you’d like to add an API to or a brand new one.
Finally, you’ll need a way of testing the API we build. You could opt for something like `curl`
but I’d recommend using Postman as it provides a nice UI for testing APIs and their responses.
With that all covered and out of the way, let’s get started with building our AWS API.
DynamoDB
The first thing we need to do in our CDK Stack is define our new DynamoDB table which we’ll use to store the post data created via our API. To define your new table, add the below code into the class in your stack definition file in the `lib`
directory.
1// 1. Create our DynamoDB table2const dbTable = new Table(this, 'DbTable', {3 partitionKey: { name: 'pk', type: AttributeType.STRING },4 removalPolicy: RemovalPolicy.DESTROY,5 billingMode: BillingMode.PAY_PER_REQUEST,6});
tsWith this code, we create a new DynamoDB table that uses a `partiionKey`
of `pk`
and is set to use on-demand mode as well as to be removed when we destroy the stack so we don’t leave any lingering data.
API Gateway
With our DynamoDB table taken care of; let’s move on to step 2 and create our new REST API in API Gateway. To do this add the code below under the code we just added for our DB table.
1const api = new RestApi(this, 'RestAPI', {2 restApiName: 'RestAPI',3 defaultCorsPreflightOptions: {4 allowOrigins: Cors.ALL_ORIGINS,5 allowMethods: Cors.ALL_METHODS,6 },7 apiKeySourceType: ApiKeySourceType.HEADER,8});
tsWith this code, we create our new REST API, give it a name in the AWS Dashboard, and configure our CORS options. We also configure where the API key will be stored for the requests to our API, which will be in the header of the requests.
Speaking of our API key, let’s create a new one, to do this add the below code under the code we just added to create our REST API.
1// 3. Create our API Key2const apiKey = new ApiKey(this, 'ApiKey');
tsThis code will generate a new API key to use with our API when we deploy our CDK stack to AWS.
It’s worth noting that in an actual deployment of an API if you wanted to allow users to generate their own API keys, you would use the AWS SDK and something like a Lambda function to generate the API key instead of doing it via the CDK. But, for our example project where we only need one API key this method will work fine.
Usage Plan
The final thing we need to configure for API Gateway at the moment is a usage plan. Usage plans are how we can implement things like throttling, quota limits, and monetization on an API. But, for this post, we’re not going to be looking at those features, instead the thing we’re most interested in is the access control functionality that usage plans offer. Read more about API Gateway Usage Plans.
For API key authentication to work, we need to associate the API key we just generated with a usage plan. If we don’t associate the API key with a usage plan then the API key won’t work and we’ll be unable to access our API with it.
But, before we can associate our API key with a usage plan, we first need to create our usage plan in our CDK stack. To do that add the below code under the API key code.
1// 4. Create a usage plan and add the API key to it2const usagePlan = new UsagePlan(this, 'UsagePlan', {3 name: 'Usage Plan',4 apiStages: [5 {6 api,7 stage: api.deploymentStage,8 },9 ],10});
tsThis code creates a new usage plan for the API we specify in the `apiStages`
property. You’ll also notice the `stage`
property in the configuration options, this is if you want to configure different usage plans for different stages of your API like “staging”, “production”, etc.
In this example, we’re just using the default stage of our API which is production so we don’t need to configure anything else here.
Finally, we just need to link our API key to our new usage plan which we can achieve by adding the below code under the code we just added for the usage plan.
1usagePlan.addApiKey(apiKey);
tsLambda
With the base of our new API configured, we’re now ready to move on to defining our Lambda functions and handlers which will contain the actual logic used when a user sends a request to our API. Let’s start by defining the Lambda functions in our CDK stack before then writing them and linking them to our API.
Defining our Lambda functions
Because our API is going to have 2 endpoints `/posts`
and `/posts/{id}`
, we’re going to have two Lambda functions, one for each of the endpoints.
To define these Lambda functions in our CDK stack, add the below code under the usage plan code we just added.
1// 5. Create our Lambda functions to handle requests2const postsLambda = new NodejsFunction(this, 'PostsLambda', {3 entry: 'resources/endpoints/posts.ts',4 handler: 'handler',5 environment: {6 TABLE_NAME: dbTable.tableName,7 },8});9
10const postLambda = new NodejsFunction(this, 'PostLambda', {11 entry: 'resources/endpoints/post.ts',12 handler: 'handler',13 environment: {14 TABLE_NAME: dbTable.tableName,15 },16});
tsWith this code, we define two Lambda functions using the `NodejsFunction`
construct and pass both of them the DynamoDB table name that we created earlier so we can access the table from within the function.
Granting read/write permissions
After defining the Lambda functions, the final thing we need to do is to grant them the necessary permissions to be able to read and write to our DynamoDB table which we can achieve with the below code.
1// 6. Grant our Lambda functions access to our DynamoDB table2dbTable.grantReadWriteData(postsLambda);3dbTable.grantReadWriteData(postLambda);
tsCreating our handlers
Now our Lambda functions are defined and they have the necessary permissions to run, we just need to create them so let’s do that now.
To do this, we’ll need to create a series of new files and directories that will contain the code for the Lambda functions themselves as well as a series of handler functions that the Lambdas will use to access the DB and perform the required operations.
The final file structure should look like the one below.
1// Existing directories in the root directory of the project 👇2
3- /lib4- /bin5- ...6
7// Files/Directories we've added 👇8
9- /resources10 - /endpoints (These are the Lambda functions)11 - post.ts (lambda for /posts/{id})12 - posts.ts (lambda for /posts)13 - /handlers14 - /posts (These are the handlers called from the Lambda functions)15 - create.ts16 - get-all.ts17 - get-one.ts18 - delete.ts
So, after creating the required files and folders, you should now have a `resources`
directory at the root of your project alongside your `lib`
and `bin`
directories. And, then inside that `resources`
directory you should have all of the files required for our Lambda functions to run.
Before we can populate these files with the required code however there are a couple of things we need to do.
- We need to install some AWS NPM packages to allow us to access and operate on the DB table and have the required TS types. You can do this by running the command
`npm i @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @types/aws-lambda aws-lambda`
. - We also need to create a type for our post data to be used in the
`create`
handler function so we can type the data passed to us from the request. To do this, create a new file in the root of the project called`types.ts`
and add the below code into it.
1export interface IPost {2 title: string;3 description: string;4 author: string;5 publicationDate: string;6}
tsWith both of those things taken care of, we’re now ready to populate the contents of the files we created above. Below are the complete code snippets for each file we created.
Endpoints
1// ./resources/endpoints/post.ts2
3import { APIGatewayProxyEvent } from 'aws-lambda';4import { getOne } from '../handlers/posts/get-one';5import { deletePost } from '../handlers/posts/delete';6
7export const handler = async (event: APIGatewayProxyEvent) => {8 const id = event.pathParameters?.id;9
10 if (!id) {11 return {12 statusCode: 400,13 body: JSON.stringify({ message: 'Missing path parameter: id' }),14 };15 }16
17 try {18 // Handle different HTTP methods19 switch (event.httpMethod) {20 case 'GET':21 return await getOne({ id });22 case 'DELETE':23 return await deletePost({ id });24 default:25 return {26 statusCode: 400,27 body: JSON.stringify({ message: 'Invalid HTTP method' }),28 };29 }30 } catch (error) {31 // eslint-disable-next-line no-console32 console.log(error);33
34 return {35 statusCode: 500,36 body: JSON.stringify({ message: error }),37 };38 }39};
tsThe above code will be for our `/posts/{id}`
endpoint so we take the `id`
parameter out of the event that triggered the Lambda function. We then check if the `id`
parameter is present or not and return a `400`
error to the user if required. We then pass the `id`
parameter into the relevant handler function depending on the HTTP method that was used in the request.
1// ./resources/endpoints/posts.ts2
3import { APIGatewayProxyEvent } from 'aws-lambda';4import { getAll } from '../handlers/posts/get-all';5import { create } from '../handlers/posts/create';6
7export const handler = async (event: APIGatewayProxyEvent) => {8 try {9 // Handle different HTTP methods10 switch (event.httpMethod) {11 case 'GET':12 return await getAll();13 case 'POST':14 return await create(event.body);15 default:16 return {17 statusCode: 400,18 body: JSON.stringify({ message: 'Invalid HTTP method' }),19 };20 }21 } catch (error) {22 // eslint-disable-next-line no-console23 console.log(error);24
25 return {26 statusCode: 500,27 body: JSON.stringify({ message: error }),28 };29 }30};
tsThe above function is for our `/posts`
endpoint so we just trigger the relevant handler function depending on the HTTP method that was used. In the case of the `create`
handler, we also pass through the body of the request to the handler.
Handlers
1// ./resources/handlers/posts/create.ts2
3import { DynamoDB } from '@aws-sdk/client-dynamodb';4import { PutCommand } from '@aws-sdk/lib-dynamodb';5import { randomUUID } from 'crypto';6import { IPost } from '../../../types';7
8const dynamodb = new DynamoDB({});9
10export async function create(body: string | null) {11 const uuid = randomUUID();12
13 // If no body, return an error14 if (!body) {15 return {16 statusCode: 400,17 body: JSON.stringify({ message: 'Missing body' }),18 };19 }20
21 // Parse the body22 const bodyParsed = JSON.parse(body) as IPost;23
24 // Creat the post25 await dynamodb.send(26 new PutCommand({27 TableName: process.env.TABLE_NAME,28 Item: {29 pk: `POST#${uuid}`,30 ...bodyParsed,31 },32 })33 );34
35 return {36 statusCode: 200,37 body: JSON.stringify({ message: 'Post created' }),38 };39}
tsWith this function, we handle the creation of a new post in the database. We first check if the body is present and if it isn’t we return an error to the user to inform them. We then parse the body if it was provided, typing it as the `IPost`
we created earlier before then performing the `PutCommand`
with the SDK to add the item to the database.
1// ./resources/handlers/posts/delete.ts2
3import { DynamoDB } from '@aws-sdk/client-dynamodb';4import { DeleteCommand } from '@aws-sdk/lib-dynamodb';5
6const dynamodb = new DynamoDB({});7
8export async function deletePost({ id }: { id: string }) {9 await dynamodb.send(10 new DeleteCommand({11 TableName: process.env.TABLE_NAME,12 Key: {13 pk: `POST#${id}`,14 },15 })16 );17
18 return {19 statusCode: 200,20 body: JSON.stringify({ message: 'Post deleted' }),21 };22}
tsThis function handles the deletion of an existing post. We take in the `id`
parameter from the request and then perform the `DeleteCommand`
request in the SDK to delete the target item in the database.
1// ./resources/handlers/posts/get-all.ts2
3import { DynamoDB } from '@aws-sdk/client-dynamodb';4import { ScanCommand } from '@aws-sdk/lib-dynamodb';5
6const dynamodb = new DynamoDB({});7
8export async function getAll() {9 const result = await dynamodb.send(10 new ScanCommand({11 TableName: process.env.TABLE_NAME,12 })13 );14
15 return {16 statusCode: 200,17 body: JSON.stringify(result.Items),18 };19}
tsIn this function, we fetch all of the existing records in the database using the `ScanCommand`
to read the entire database before returning them to the user.
1// ./resources/handlers/posts/get-one.ts2
3import { DynamoDB } from '@aws-sdk/client-dynamodb';4import { GetCommand } from '@aws-sdk/lib-dynamodb';5
6const dynamodb = new DynamoDB({});7
8export async function getOne({ id }: { id: string }) {9 // Get the post from DynamoDB10 const result = await dynamodb.send(11 new GetCommand({12 TableName: process.env.TABLE_NAME,13 Key: {14 pk: `POST#${id}`,15 },16 })17 );18
19 // If the post is not found, return a 40420 if (!result.Item) {21 return {22 statusCode: 404,23 body: JSON.stringify({ message: 'Post not found' }),24 };25 }26
27 // Otherwise, return the post28 return {29 statusCode: 200,30 body: JSON.stringify(result.Item),31 };32}
tsFinally, with this function, we take the `id`
parameter from the request and retrieve the post from the DB that matches it. If no item matches the provided `id`
then we return a `404`
error to the user to inform them.
Linking together our API and Lambda functions
With all of our Lambda functions and handlers now defined and written, we’re ready to link them to our API from earlier so that when a user sends a request to the API the relevant Lambda function and handler are triggered.
Creating the endpoints
The first thing we need to do when linking our API and Lambda functions together is to create the routes on our API for users to request. As you’ll recall from earlier we’re going to have 2 endpoints `/posts`
and `/posts/{id}`
. To define these on our API we can add the below code under the other code from earlier in the stack file in our `lib`
directory.
1// 7. Define our API Gateway endpoints2const posts = api.root.addResource('posts');3const post = posts.addResource('{id}');
tsWhat this code will create is a new `/posts`
endpoint on the root of our API and then a new child endpoint on the `/posts`
endpoint with the value `{id}`
. It’s important to note the `{}`
surrounding `id`
as this is what denotes it will be a variable the user can pass in for us to retrieve as a parameter in the function.
Creating our Lambda integrations
After we’ve created the endpoints on our API, we need to create Lambda integrations out of our Lambda functions to connect to those endpoints. You can do this by adding the below code.
1// 8. Connect our Lambda functions to our API Gateway endpoints2const postsIntegration = new LambdaIntegration(postsLambda);3const postIntegration = new LambdaIntegration(postLambda);
tsCreating our methods
Now, with our API endpoints, Lambda handlers, and Lambda integrations created and defined, we’re ready to link them all together by defining the methods that can be used on each endpoint. We can do this with the code below.
1// 9. Define our API Gateway methods2posts.addMethod('GET', postsIntegration, {3 apiKeyRequired: true,4});5posts.addMethod('POST', postsIntegration, {6 apiKeyRequired: true,7});8
9post.addMethod('GET', postIntegration, {10 apiKeyRequired: true,11});12post.addMethod('DELETE', postIntegration, {13 apiKeyRequired: true,14});
tsLet’s break down one of these methods to better understand what is happening. We first choose the endpoint we’d like to add the method to (`posts`
or `post`
). We then call `addMethod`
and pass in the HTTP method (`GET`
, `POST`
, `DELETE`
) we’d like to add to that endpoint.
We then pass the name of the Lambda integration we’d like invoked when that endpoint is requested with the specified HTTP method. Finally, we pass in the `apiKeyRequired`
property to the options object to enforce the use of an API key with that method.
This means we have 2 API endpoints which both accept 2 HTTP methods and looks a bit like the diagram below.
1- /posts2 - GET3 - POST4 - /{id}5 - GET6 - DELETE
Outputs
With the above methods configured, we’ve finished defining our REST API using API Gateway, Lambda, and DynamoDB via the AWS CDK.
But, before we can deploy it we need to add a final output statement to the CDK stack to print out our API key ID to the console so we can fetch its value with the AWS CLI to allow us to test the API with a tool like Postman.
To add this into your CDK stack add the below to the bottom of the stack file in the `lib`
directory, just under where we defined our API methods a moment ago.
1// Misc: Outputs2new CfnOutput(this, 'API Key ID', {3 value: apiKey.keyId,4});
tsDeploying our CDK stack
Before we can deploy our CDK stack to our AWS account, we need to ensure we have the `esbuild`
package installed to allow the `NodejsFunction`
construct to deploy successfully. So if you don’t already have `esbuild`
installed, you can install it by using the command `npm i -D esbuild`
.
After you have `esbuild`
installed, you can deploy your CDK stack by running the command `cdk deploy`
.
Testing your REST API
Once your CDK stack has finished deploying you should have two outputs in your terminal, one for the API key ID which we configured a moment ago, and another for the REST API URL.
The first thing we need to do before testing our API is to look up our API key value using the AWS CLI which we can do by using the command `aws apigateway get-api-key --api-key API_KEY_ID --include-value`
. Make sure to switch out `API_KEY_ID`
for the ID outputted from the CDK deploy command.
Once you’ve got your API key value, you’re ready to test your new REST API using a tool like Postman. To test an endpoint using Postman, you’ll want to enter the URL of your API followed by the endpoint you want to test. So for example our `/posts`
endpoint would be `API_URL/posts`
. You can then choose the HTTP method you want to use and add your API key value to the headers of the request with the key set to `x-api-key`
and the value as your API key value.
While I won’t cover every test scenario you could perform against the API, here are some high-level tests you could run to make sure your API functions as intended.
Endpoint | Test | Expected Response |
---|---|---|
`ALL` | No API Key provided | `403` Forbidden |
`GET: /posts` | Returns list of posts | `200` Array of posts |
`POST: /post` | Creates a new post with valid body | `200` “Post Created” |
`POST: /post` | Missing body | `400` “Missing Body” |
`GET: /posts/{id}` | Retrieves the target post ID | `200` Target post's data |
`DELETE: /posts/{id}` | Deletes the target post ID | `200` “Post Deleted” |
When it comes to performing the test to create a new post, you’ll need to pass in the data you want to create the post with to the body of the request. When testing this with Postman, you can use the `raw`
option for the body and use the below JSON object for the body’s data.
1{2 "title": "Example post 1",3 "description": "",4 "author": "me",5 "publicationDate": "some-date"6}
jsonClosing Thoughts
If all of your tests have given the expected response then congratulations you have built and deployed a working REST API using API Gateway, Lambda, DynamoDB, and the AWS CDK as well as tested it using Postman!
Overall, even though there are a lot of steps involved in building a REST API with the AWS CDK when broken down into its individual steps it can be quite logical and straightforward to do. So, I hope you found this post helpful and if so I would be grateful if you would share it with others so they can find it helpful too.
If you would like to see the full example code for this project, you can see it on GitHub here.
Thank you for reading
Coner
*NOTE: Once you’re finished with this CDK project, make sure to remove it from your AWS account to ensure you don’t get billed for it by running
`cdk destroy`
.*