Create a REST API with AWS Lambda and API Gateway using AWS CDK
Building APIs following the REST standard is expected, which provides many benefits for developers and consumers. Defining the architecture that will host the application is also crucial to ensure availability and reliability.
Cloud providers such as AWS provides many services for building backend applications without any hassle, and one common usage is the combination of the AWS API Gateway and Lambda to build serverless API.
In this post, we will see how to build a RESTful API on AWS using these two services, and we will use the AWS CDK to define the infrastructure as code.
The use case
Ghast is a blogging platform that grew hyper-fast, and its users need access to their blog post data outside of the platform to build custom integrations with other third-party services.
To fulfill the request of the users, we must build a public API that exposes endpoints to interact with the system, such as creating, updating, or retrieving blog posts. To have a highly available and scalable system, we want to build it on a Serverless architecture on AWS.
Entities Schema
The database to use is MongoDB, so we are talking more about collections and documents. This is a simplified database diagram of the Ghast system with only the user and post collections.
The API endpoints
The table below summarizes the list of endpoints the API will expose
API Endpoint | Description |
---|---|
[POST] /posts | Create a new blog post |
[PUT] /posts/{id} | Update a post (title, content, status, tags, isFeatured) |
[DELETE] /posts/{id} | Delete a post |
[GET] /posts | Search posts (by user, status, keyword) |
[GET] /posts/{id} | Retrieve a post |
Architecture diagram
The system will have one Lambda function per route and be served through an API Gateway. The Lambda function will connect to a MongoDB database to write and read the data. The picture below shows the architecture of the Ghast API system.
We will use the AWS CDK to define and deploy the system infrastructure on AWS.
Prerequisites
You need the following tools to complete this tutorial
- An AWS account, a free tier is enough
- AWS CLI configured (check out this link to see how to do it)
- A free MongoDB cluster on MongoDB Altas
- Node.js 18+ - download link
- Docker for building the Lambda function code, optional if you test on AWS directly.
Install the AWS CDK v2 and the AWS SAM CLI
We will use the AWS CDK to:
- Generate a CDK project with the required dependencies
- Imperatively write the infrastructure stack using TypeScript
- Generate the CloudFormation template from the infrastructure code
- Deploy the CloudFormation template on AWS.
Run the command below to install and verify if the installation succeeded by checking the version.
npm install -g aws-cdk
cdk --version
The AWS SAM CLI is needed to invoke the Lambda function locally or simulate an instance of the API Gateway. Follow the AWS documentation guide to install it on your computer.
You can skip it if you will deploy the infrastructure on AWS using the AWS CDK and test it there.
Set up the project using the AWS CDK
Create a folder that will hold the source code of our project.
mkdir ghast-api
cd ghast-api
We will write the infrastructure in TypeScript; let's initialize a new project with the TypeScript template by running the command below:
cdk init app --language typescript
This command creates the required files and folders and installs the node modules. The folder structure looks like the one in this picture.
At the project root folder, create a folder src
that will hold all the application logic.
Define the database schema
We will use Mongoose to define the MongoDB schema of the User and Post. Run the command below to install it:
npm install mongoose
Create folder src/models
, then create a file named user.model.ts
and the code below:
import mongoose, { Document, Schema } from 'mongoose';
type UserDocument = Document & {
fullName: string;
email: string;
password: string;
birthDate: Date;
};
type UserInput = {
fullName: UserDocument['fullName'];
email: UserDocument['email'];
password: UserDocument['password'];
birthDate: UserDocument['birthDate'];
};
const userSchema = new Schema({
fullName: {
type: Schema.Types.String,
required: true,
unique: true,
},
email: {
type: Schema.Types.String,
required: true,
unique: true,
},
password: {
type: Schema.Types.String,
required: true,
},
birthDate: {
type: Schema.Types.Date,
required: true,
}
}, {
collection: "users",
timestamps: true,
});
const User = mongoose.model<UserDocument>('User', userSchema);
export { User, UserDocument, UserInput };
Create a file src/models/post.model.ts
and add the code below:
import mongoose, { Document, Schema } from 'mongoose';
enum PostStatusEnum {
draft = 'draft',
published = 'published',
archived = 'archived',
}
type PostDocument = Document & {
title: string;
slug: string;
content: string;
tags: string[];
status: PostStatusEnum;
viewCount: number;
isFeatured: boolean;
author: string;
};
type PostInput = {
title: PostDocument['title'];
slug: PostDocument['slug'];
content: PostDocument['content'];
tags: PostDocument['tags'];
status: PostDocument['status'];
isFeatured: PostDocument['isFeatured'];
author: PostDocument['author'];
};
const userSchema = new Schema({
title: {
type: Schema.Types.String,
required: true,
unique: true,
},
slug: {
type: Schema.Types.String,
required: true,
unique: true,
},
content: {
type: Schema.Types.String,
required: true,
},
tags: {
type: Schema.Types.Array,
required: true,
},
status: {
type: Schema.Types.String,
required: true,
enum: PostStatusEnum,
default: PostStatusEnum.draft,
},
viewCount: {
type: Schema.Types.Number,
required: true,
default: 0,
},
isFeatured: {
type: Schema.Types.Boolean,
required: true,
default: false,
},
author: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
}, {
collection: "posts",
timestamps: true,
});
const Post = mongoose.model<PostDocument>('Post', userSchema);
export { Post, PostDocument, PostInput };
Connect to the database
The application must establish a connection to the database before performing any CRUD operations.
Managing database connections is tricky with AWS Lambda because each invocation will create a new one; we must reuse the previous connection to avoid hitting the maximum database connection limit.
Mongoose provides a source code to reuse the database connection. Create a file src/utils/db-connection.ts
and add the code below:
import mongoose from "mongoose";
let databaseConnection = null;
const databaseURL = process.env.DATABASE_URL;
export const connectToDatabase = async () => {
if (!databaseURL) {
throw new Error('The database connection string is not defined');
}
if (databaseConnection == null) {
databaseConnection = mongoose.connect(databaseURL, {
serverSelectionTimeoutMS: 5000
}).then(() => mongoose);
// `await`ing connection after assigning to the `databaseConnection` variable to avoid multiple function calls creating new connections
await databaseConnection;
}
return databaseConnection;
}
The DATABASE_URL environment variable will be injected into the Lambda function at deployment time. Find the database URL in the MongoDB Atlas console.
Define the API Gateway infrastructure
The API Gateway is the entry point of the system. It will receive HTTP requests and forward them to the rproperLambda function to process the request and return the response.
To define it with the AWS CDK, update the file lib/ghast-api-stack.ts
with the code below:
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
export class GhastApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const api = new apigw.RestApi(this, `GhastApiGateway`, {
restApiName: `ghast-api`,
deployOptions: {
metricsEnabled: true,
loggingLevel: apigw.MethodLoggingLevel.INFO,
dataTraceEnabled: true,
},
cloudWatchRole: true,
});
}
}
The property cloudWatchRole
automagically configure the API gateway to write logs into CloudWatch.
Steps for creating an API route
Creating a working API route consists of the three following steps:
- Write the Lambda function handler logic
- Write the Lambda function infrastructure
- Connect the API Gateway route to the Lambda function using IaC
In the next step of this post, we will start creating API routes.
Build the endpoint to create a post
Create a folder handlers
, then create a file named create-post.ts
and add the code below:
import mongoose from 'mongoose';
import { z } from 'zod';
import { APIGatewayProxyHandler } from 'aws-lambda';
import { Post, PostInput, PostStatusEnum } from "../models/post.model";
import { connectToDatabase } from "../utils/db-connection";
const createPostBodySchema = z.object({
authorId: z.string(),
title: z.string(),
content: z.string(),
tags: z.array(z.string()).min(1),
isFeatured: z.boolean(),
status: z.enum([
PostStatusEnum.draft,
PostStatusEnum.published,
PostStatusEnum.archived
]),
});
type CreatePostBodyInput = z.infer<typeof createPostBodySchema>;
export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
await connectToDatabase();
const payload = JSON.parse(event.body ?? '{}');
const validationResult = createPostBodySchema.safeParse(payload);
if (validationResult.success) {
const { data } = validationResult;
const postInput: PostInput = {
author: new mongoose.Types.ObjectId(data.authorId),
content: data.content,
title: data.title,
isFeatured: data.isFeatured,
tags: data.tags,
status: data.status,
slug: data.title.toLowerCase().replace(' ', '-'),
};
const [createdPost] = await Post.create([postInput]);
return {
statusCode: 200,
headers: { "Content-Type": "text/json" },
body: JSON.stringify(createdPost),
};
} else {
return {
statusCode: 400,
headers: { "Content-Type": "text/json" },
// @ts-ignore
body: JSON.stringify({ message: "Invalid input data", errors: validationResult.error }),
};
}
};
The handler function is typed in APIGatewayProxyHandler
because the API gateway will invoke it and forward the request body, headers, path, etc...
We use Zod to validate the request body at the runtime. The function returns an error status to the API gateway when the input validation fails.
Install the Node.js package for Zod and the AWS Lambda types:
npm install --save-dev @types/aws-lambda
npm install zod
Lambda function infrastructure code
Open the file lib/ghast-api-stack.ts
and append the code below:
const createPostFn = new lambda.NodejsFunction(this, `CreatePostFunction`, {
entry: path.resolve(__dirname, '../src/handlers/create-post.ts'),
functionName: `ghast-api-create-post`,
handler: 'handler',
memorySize: 512,
environment: {
DATABASE_URL: process.env.DATABASE_URL,
},
runtime: Runtime.NODEJS_18_X,
timeout: cdk.Duration.seconds(15),
bundling: {
target: 'es2020',
}
});
The "entry" property value tells the code to execute when we invoke the Lambda function.
The property "handler" indicates the function to execute in the file src/handlers/create-post.ts
Since the code is written in TypeScript and the Lambda function code runs the JavaScript, the Lambda Node.js construct uses Esbuild under the hood the bundle the function code and transpile the TS code to JS code.
The "bundling" property allows setting Esbuild bundle options.
Load the environment variables
To Lambda function infrastructure code load the environment variable DATABASE_URL injected when synthesizing the CDK code.
Create a file .env and add the code below:
DATABASE_URL="mongodb+srv://<user>:<password>@<host>/<database_name>?retryWrites=true&w=majority"
Replace the values <user>
, <password>
, <host>
, and <database_name>
with your own.
To make Typescript recognize the environment variables injected through process.env and provide autocompletion, create a file named env.d.ts
at the root directory and add the code below:
export type EnvironmentVariables = {
DATABASE_URL: string;
};
declare global {
namespace NodeJS {
// @ts-ignore
type ProcessEnv = EnvironmentVariables;
}
}
export {};
Update the tsconfig.json
to add the following code:
{
"files": ["env.d.ts"]
}
Link the API gateway route to the Lambda function
We created the Lambda infrastructure code; now we must tell the API gateway to invoke this Lambda function for POST requests having the URL path /posts
.
Open the file lib/ghast-api-stack.ts
and append the code below:
const createPostLambdaIntegration = new apigw.LambdaIntegration(createPostFn);
const blogPostsResource = api.root.addResource("posts");
blogPostsResource.addMethod("POST", createPostLambdaIntegration);
Test locally with the SAM CLI
You can run the command cdk synth
anytime to validate your code infrastructure.
When the code synthesis succeeds, it generates a folder named cdk.out
containing the infrastructure's CloudFormation template and the bundled Lambda function code.
Docker must be running, and you must log into the AWS ECR public repository where the Docker image of Node.js 18 for the Lambda function will be pulled.
Otherwise, you will get the error below:
Run the command below to log into the AWS public ECR:
aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws
The command to synthesize the CDK stack is cdk synth
; it will work but will not load the environment variable DATABASE_URL
.
We must read the 'env
file first and export the variables. Below is the one-line command that achieves that:
export $(grep -v '^#' .env | xargs) && cdk synth
To start an instance of the API Gateway locally, run the command below:
sam local start-api -t ./cdk.out/GhastApiStack.template.json
You will get the output similar to the screenshot below:
Testing locally
To create a post, we must provide an authorId
which is the post's author. Since we don't have a user in the DB, let's create a seed script to insert two users.
Create a file named src/utils/seed.ts
and add the code below:
import mongoose from "mongoose";
import * as dotenv from "dotenv";
import { User } from "../models/user.model";
dotenv.config();
const usersData = [
{
email: "john.doe@email.com",
fullName: "John Doe",
password: "e10adc3949ba59abbe56e057f20f883e",
birthDate: new Date(1996, 10, 11),
},
{
email: "jane.doe@email.com",
fullName: "Jane Doe",
password: "7d793037a0760186574b0282f2f435e7",
birthDate: new Date(1997, 9, 10),
},
];
(async () => {
const databaseConnection = await mongoose.connect(process.env.DATABASE_URL, {
// and tell the MongoDB driver to not wait more than 5 seconds before erroring out if it isn't connected
serverSelectionTimeoutMS: 5000
});
try {
await User.create(usersData);
} catch (e) {
console.error(e);
} finally {
await databaseConnection.disconnect();
}
})();
Add the following line in the package.json
in the scripts
section:
"scripts": {
"db:seed": "ts-node ./src/utils/seed.ts"
}
Install the dotenv
package and then run the command to seed the user in the database.
npm install --save-dev dotenv
npm run db:seed
Go to the MongoDB Atlas cloud console, browse the users' collections in the ghast-api
database.
Use your favorite HTTP client to send a POST request to http://127.0.0.1:3000/posts
.
The execution takes longer because we are testing locally. Once deployed on the cloud, it will be much faster.
We can see when we pass a body without the required fields, the validation at the API level performed by Zod throws an error which is great for building robust API.
Build the endpoint to retrieve all the posts
Create a file handlers/find-all-posts.ts
and add the code below:
import { APIGatewayProxyHandler } from 'aws-lambda';
import { Post } from "../models/post.model";
import { connectToDatabase } from "../utils/db-connection";
export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
await connectToDatabase();
const posts = await Post.find().sort({ createdAt: 1 });
return {
statusCode: 200,
headers: { "Content-Type": "text/json" },
body: JSON.stringify({ data: posts }),
};
};
Lambda function infrastructure code
Open the file lib/ghast-api-stack.ts
and append the code below:
const findAllPostsFn = new lambda.NodejsFunction(this, `FindAllPostsFunction`, {
entry: path.resolve(__dirname, '../src/handlers/find-all-posts.ts'),
functionName: `ghast-api-all-posts`,
handler: 'handler',
memorySize: 512,
environment: {
DATABASE_URL: process.env.DATABASE_URL,
},
runtime: Runtime.NODEJS_18_X,
timeout: cdk.Duration.seconds(15),
bundling: {
target: 'es2020',
}
});
Let's create the API endpoint that will execute the Lambda function.
const findAllPostsLambdaIntegration = new apigw.LambdaIntegration(findAllPostsFn);
blogPostsResource.addMethod("GET", findAllPostsLambdaIntegration);
Synthesize the CDK stack and start the API gateway with the SAM CLI.
export $(grep -v '^#' .env | xargs) && cdk synth
sam local start-api -t ./cdk.out/GhastApiStack.template.json
Make a GET request to http://127.0.0.1:3000/posts; it will return all the posts.
Build the endpoint to retrieve a single post
Create a file handlers/find-one-post.ts
and add the code below:
import { APIGatewayProxyHandler } from 'aws-lambda';
import { Post } from "../models/post.model";
import { connectToDatabase } from "../utils/db-connection";
export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
await connectToDatabase();
const postId = event.pathParameters.id;
const post = await Post.findOne({ _id: postId });
if (post) {
return {
statusCode: 200,
headers: {"Content-Type": "text/json"},
body: JSON.stringify({ data: post }),
};
}
return {
statusCode: 404,
headers: { "Content-Type": "text/json" },
body: JSON.stringify({ message: `No post found with the ID "${postId}"` }),
};
};
Lambda function infrastructure code
Open the file lib/ghast-api-stack.ts
and append the code below:
const findSinglePostFn = new lambda.NodejsFunction(this, `FindSinglePostFunction`, {
entry: path.resolve(__dirname, '../src/handlers/find-one-post.ts'),
functionName: `ghast-api-single-post`,
handler: 'handler',
memorySize: 512,
environment: {
DATABASE_URL: process.env.DATABASE_URL,
},
runtime: Runtime.NODEJS_18_X,
timeout: cdk.Duration.seconds(15),
bundling: {
target: 'es2020',
}
});
Create the API endpoint that will execute the Lambda function.
const findSinglePostLambdaIntegration = new apigw.LambdaIntegration(findSinglePostFn);
const blogPostResource = blogPostsResource.addResource("{id}");
blogPostResource.addMethod("GET", findSinglePostLambdaIntegration);
Synthesize the CDK stack and start the API gateway with the SAM CLI, then make a GET request to http://127.0.0.1:3000/posts/{id} where the parameter's path {id}
must be replaced by the ID of a blog post in the database.
The request will return the post if it exists.
Build the endpoint to update a blog post
Create a file handlers/update-post.ts
and add the code below:
import { APIGatewayProxyHandler } from 'aws-lambda';
import { z } from "zod";
import { Post, PostStatusEnum, UpdatePostInput } from "../models/post.model";
import { connectToDatabase } from "../utils/db-connection";
const updatePostBodySchema = z.object({
authorId: z.string(),
title: z.string(),
content: z.string(),
tags: z.array(z.string()).min(1),
isFeatured: z.boolean(),
status: z.enum([
PostStatusEnum.draft,
PostStatusEnum.published,
PostStatusEnum.archived
]),
});
export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
const payload = JSON.parse(event.body ?? '{}');
const validationResult = updatePostBodySchema.safeParse(payload);
if (!validationResult.success) {
return {
statusCode: 400,
headers: { "Content-Type": "text/json" },
// @ts-ignore
body: JSON.stringify({ message: "Invalid input data", errors: validationResult.error }),
};
}
const { data } = validationResult;
await connectToDatabase();
const postId = event.pathParameters.id;
const post = await Post.findOne({ _id: postId });
if (!post) {
return {
statusCode: 404,
headers: { "Content-Type": "text/json" },
body: JSON.stringify({ message: `No post found with the ID "${postId}"` }),
};
}
if (!post.author.equals(data.authorId)) {
return {
statusCode: 400,
headers: { "Content-Type": "text/json" },
body: JSON.stringify({ message: `Only the author can edit the post` }),
};
}
const postInput: UpdatePostInput = {
content: data.content,
title: data.title,
isFeatured: data.isFeatured,
tags: data.tags,
status: data.status,
slug: data.title.toLowerCase().replace(/ /gm, '-'),
};
await Post.updateOne({ _id: postId }, postInput);
const updatedPost = await Post.findById(postId);
return {
statusCode: 200,
headers: {"Content-Type": "text/json"},
body: JSON.stringify({ data: updatedPost }),
};
};
Lambda function infrastructure code
Open the file lib/ghast-api-stack.ts
and append the code below:
const updatePostFn = new lambda.NodejsFunction(this, `UpdatePostFunction`, {
entry: path.resolve(__dirname, '../src/handlers/update-post.ts'),
functionName: `ghast-api-update-post`,
handler: 'handler',
memorySize: 512,
environment: {
DATABASE_URL: process.env.DATABASE_URL,
},
runtime: Runtime.NODEJS_18_X,
timeout: cdk.Duration.seconds(15),
bundling: {
target: 'es2020',
}
});
Create the API endpoint that will execute the Lambda function.
const updatePostLambdaIntegration = new apigw.LambdaIntegration(updatePostFn);
const blogPostResource = blogPostsResource.addResource("{id}");
blogPostResource.addMethod("PUT", updatePostLambdaIntegration);
Synthesize the CDK stack and start the API gateway with the SAM CLI, then make a PUT request to http://127.0.0.1:3000/posts/{id} where the parameter's path {id}
must be replaced by the ID of a blog post in the database.
In the request body, provide the required field; the request will return the post updated.
Build the endpoint to delete a blog post
Create a file handlers/delete-post.ts
and add the code below:
import { APIGatewayProxyHandler } from 'aws-lambda';
import { Post } from "../models/post.model";
import { connectToDatabase } from "../utils/db-connection";
export const handler: APIGatewayProxyHandler = async (event, context) => {
context.callbackWaitsForEmptyEventLoop = false;
await connectToDatabase();
const postId = event.pathParameters.id;
await Post.deleteOne({ _id: postId });
return {
statusCode: 204,
headers: {"Content-Type": "text/json"},
body: undefined,
};
};
Lambda function infrastructure code
Open the file lib/ghast-api-stack.ts
and append the code below:
const deletePostFn = new lambda.NodejsFunction(this, `DeletePostFunction`, {
entry: path.resolve(__dirname, '../src/handlers/delete-post.ts'),
functionName: `ghast-api-delete-post`,
handler: 'handler',
memorySize: 512,
environment: {
DATABASE_URL: process.env.DATABASE_URL,
},
runtime: Runtime.NODEJS_18_X,
timeout: cdk.Duration.seconds(15),
bundling: {
target: 'es2020',
}
});
Create the API endpoint that will execute the Lambda function.
const deletePostLambdaIntegration = new apigw.LambdaIntegration(deletePostFn);
const blogPostResource = blogPostsResource.addResource("{id}");
blogPostResource.addMethod("DELETE", deletePostLambdaIntegration);
Synthesize the CDK stack and start the API gateway with the SAM CLI, then make a DELETE request to http://127.0.0.1:3000/posts/{id} where the parameter's path {id}
must be replaced by the ID of a blog post in the database.
You can call the endpoint to retrieve one blog post to ensure the post has been deleted; the request will respond with a 404 HTTP status code.
Deploy the stack on AWS
Our API works locally; we want to deploy it on AWS to make it available over the Internet.
The deployment process is simple since you need to synthesize and deploy the CDK stack. Run the command below and the process, to complete.
export $(grep -v '^#' .env | xargs) && cdk synth
export $(grep -v '^#' .env | xargs) && cdk deploy
Once the stack is deployed on AWS, the URL of the API gateway will be outputted in the terminal.
Test the API on AWS
Replace the local URL with the API gateway URL in the cloud and test it again. The significant difference is the request execution is fast compared to the local one.
You also noted that the first response takes time, and the following are way faster; if you are familiar with our AWS Lambda works, you know it is the Lambda cold start responsible for the latency in the first invocation.
Destroy the stack
All the services used in this post have a generous free tier, so you can test the project without fear. To delete everything created in the cloud, go to the project root directory on your computer and run the command below:
cdk destroy
Confirm the deletion and wait for the stack to be deleted.
Wrap up
Building a REST API using AWS Lambda and the API gateway requires many steps that can be summarized to:
- Defining the Lambda stack and the code to execute
- Create the Lambda integration with the API gateway
- Attach a route and the HTTP method to the Lambda integration
- Synthesize and test locally
Defining the infrastructure in TypeScript using the AWS CDK is great for keeping the configuration close to the code and easily replicating the application in multiple environments.
You can find the code source on the GitHub repository.
Follow me on Twitter or subscribe to my newsletter to avoid missing the upcoming posts and the tips and tricks I occasionally share.