Building a Todo List API with AWS CDK: A Step-by-Step Guide

Yaroslav Kharchenko
9 min readMar 31, 2024

Introduction

In this article we are going to build a todo list API which allows to create new todo list items and get a paginated list of already created items. We will use the following AWS services:

  • API Gateway: to define our API endpoints
  • Lambda: for handling API requests
  • DynamoDB: for storing todo list items
  • Cognito: for user management and authentication

All the code can be found here on GitHub.

Constructs

CDK constructs serve as reusable building blocks for defining and deploying AWS resources. Here are the custom constructs tailored specifically for our API.

Cognito User Pool

AWS Cognito provides a comprehensive solution for managing user identities, authentication, and authorization. In this section, we’ll walk through the process of setting up Cognito for identity management within an AWS environment.

export class CognitoUserPool extends Construct {
public userPool: cognito.UserPool;

constructor(scope: Construct, id: string) {
super(scope, id);
this.userPool = new cognito.UserPool(this, id, { //(1)
signInAliases: {email: true},
selfSignUpEnabled: false,
});
const callbackUrls = [];
callbackUrls.push('http://localhost:3000');
const domainId = this.userPool.node.addr;
const domain = this.userPool.addDomain(`${id}-hosted-ui`, { //(2)
cognitoDomain: {
domainPrefix: `${domainId}-hosted-ui`
}
});
const client = this.userPool.addClient(`${id}-client`, { //(3)
oAuth: {
flows: {
implicitCodeGrant: true
},
callbackUrls: callbackUrls,
},
idTokenValidity: Duration.hours(8),
accessTokenValidity: Duration.hours(8),
});
new cdk.CfnOutput(this, 'CognitoSignInUrl', { //(4)
value: domain.signInUrl(client, {
redirectUri: callbackUrls[0]
}),
});
}
}
  1. Creating a User Pool
    The first step is to create a user pool, which allows managing user accounts and defining authentication settings. We define a user pool with specific configurations such as allowing only administrators to create user accounts and allowing to login with email.
  2. Configuring the Hosted UI
    The Hosted UI offers a pre-built interface for user registration and login, simplifying the authentication process for developers.
    this.userPool.node.addr in this context is to generate a unique identifier for the Cognito domain. By incorporating this unique identifier into the domain prefix, it helps ensure that the domain name is globally unique, which is necessary for setting up the Cognito hosted UI.
  3. Adding an Application Client
    To enable user authorization within our API, we need to add an application client to our user pool. This client will be used to generate JWT tokens.
  • implicitCodeGrant: true will force hosted UI to return JWT token instead of authorization code. While this simplifies testing, it’s essential to note that this approach is less secure and not advisable for production environments.
  • localhost:3000 is a URL to which hosted UI will redirect user after login. In real world it should be the frontend app URL, but we are using a fake one as we are only covering API part here.

4. Outputting the Hosted UI URL
Finally, we use cdk.CfnOutput() function to output the URL to the hosted UI after deploying our application (cdk deploy). We will use this URL to test our application.

Todo list DynamoDB table

DynamoDB, a fully managed NoSQL database service provided by AWS, is a good choice for storing and managing data in serverless projects due to its scalability, flexibility, and low-latency performance. In this section, we’ll explore how to set up a DynamoDB table to manage a todo list for our application.

Table schema
In DynamoDB, every table requires a partition key to be defined, along with the option for a sort key. The rest of the table’s record data can be customized to suit your needs.

Data filtering primarily relies on partition and sort keys, unless additional indexes are utilized. Therefore, careful schema design is crucial. I came up with this schema for our table:

+-----------+------------------+-------+--------------+
| PK | SK | name | description |
+-----------+------------------+-------+--------------+
| user#{id} | todo#{timestamp} | Todo1 | Description1 |
| user#{id} | todo#{timestamp} | Todo2 | Description2 |
+-----------+------------------+-------+--------------+
  • PK (Partition Key): Represents the user ID, allowing us to partition data for each user and filter data by user.
  • SK (Sort Key): Contains a todo item id based on timestamp to ensure uniqueness and enable sorting by creation time. Alternatively, ULID (Universally Unique Lexicographically Sortable Identifier) can be employed for enhanced uniqueness.
  • Attributes: Additional attributes such as name and description store the details of each todo item. We can only retrieve this attributes but not filter by them.

Here is the CDK code for it:

export class TodoListTable extends Construct {
public table: dynamodb.TableV2;
constructor(scope: Construct, id: string) {
super(scope, id);
this.table = new dynamodb.TableV2(this, id, {
partitionKey: {name: 'PK', type: dynamodb.AttributeType.STRING},
sortKey: {name: 'SK', type: dynamodb.AttributeType.STRING},
});
}
}

Add Todo To DynamoDB Lambda

This construct encapsulates the logic for provisioning the Lambda function and granting it the necessary permissions to interact with DynamoDB table.

export class AddTodoToDynamodbLambda extends Construct {
public lambdaFunction: lambda.Function;
constructor(scope: Construct, id: string, props: { table: dynamodb.TableV2 }) { //(1)
super(scope, id);
this.lambdaFunction = new lambda.Function(this, id, {
runtime: lambda.Runtime.NODEJS_18_X,
code: lambda.Code.fromAsset('lambda'),
handler: 'addTodoToDynamoDB.addTodoToDynamoDB',
environment: {
TODOLIST_TABLE_NAME: props.table.tableName, //(2)
},
});
props.table.grantWriteData(this.lambdaFunction); //(3)
}
}
  1. Constructor expects a reference to an instance of the DynamoDB table.
  2. We also use env variable TODOLIST_TABLE_NAME in order to provide a DynamoDB table name for our lambda function.
  3. To ensure that our Lambda function has the necessary permissions to write data to the DynamoDB table, we utilize the grantWriteData() method provided by AWS CDK. Internally it will create an AWS role for lambda with permission to write data to our DynamoDB table:
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"dynamodb:BatchWriteItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable",
"dynamodb:PutItem",
"dynamodb:UpdateItem"
],
"Resource": "arn:aws:dynamodb:eu-north-1:*:table/***",
"Effect": "Allow"
}
]
}

The Lambda construct above references addTodoToDynamoDB lambda function:

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

export const addTodoToDynamoDB = async (event) => {
const timestamp = (new Date()).getTime();
const body = JSON.parse(event.body);
const userId = event.requestContext.authorizer?.claims['cognito:username'] ?? 'root';

const todoItem = {
PK: `user#${userId}`,
SK: `todo#${timestamp}`,
name: body.name,
description: body.description,
};

const command = new PutCommand({
TableName: process.env.TODOLIST_TABLE_NAME,
Item: todoItem,
});

await docClient.send(command);

return {
statusCode: 200,
body: JSON.stringify({
id: todoItem.SK,
name: todoItem.name,
description: todoItem.description
}),
};
};
  • Lambda function will be triggered through API Gateway with configured Cognito Authorizer. User information is accessible through event.requestContext.authorizer
  • Adding a new item to DynamoDB is done with help with PutCommand
  • Upon successful insertion, we return a HTTP status code of 200 along with the details of the newly added todo item.

Get Todos Lambda

Similarly to addTodoToDynamoDB lambda construct we define one for getting todos from DynamoDB:

export class GetTodosLambda extends Construct {

public lambdaFunction: lambda.Function;

constructor(scope: Construct, id: string, props: { table: dynamodb.TableV2 }) { //(1)
super(scope, id);

this.lambdaFunction = new lambda.Function(this, id, {
runtime: lambda.Runtime.NODEJS_18_X,
code: lambda.Code.fromAsset("lambda"),
handler: 'getTodos.getTodos',
environment: {
TODOLIST_TABLE_NAME: props.table.tableName //(2)
}
});

props.table.grantReadData(this.lambdaFunction); //(3)
}
}
  1. Constructor expects a reference to an instance of the DynamoDB table.
  2. We also use env variable TODOLIST_TABLE_NAME in order to provide a DynamoDB table name for our lambda function.
  3. To ensure that our Lambda function has the necessary permissions to read data to the DynamoDB table, we utilize the grantReadData() method provided by AWS CDK.

And the lambda code:

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

export const getTodos = async (event) => {

const userId = event.requestContext.authorizer?.claims['cognito:username'] ?? 'root';
const nextToken = event.queryStringParameters?.nextToken;

const command = new QueryCommand({
TableName: process.env.TODOLIST_TABLE_NAME,
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: {
":pk": `user#${userId}`,
},
Limit: 10,
// base64 encoded string
ExclusiveStartKey: nextToken ? JSON.parse(Buffer.from(nextToken, 'base64').toString('utf-8')) : undefined,
});
const result = await docClient.send(command);
const todos = result.Items.map((item) => {
return {
id: item.SK,
name: item.name,
description: item.description,
};
});
return {
statusCode: 200,
body: JSON.stringify({
todos,
nextToken: result.LastEvaluatedKey ? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64') : undefined,
}),
}

};
  • A QueryCommand is constructed to retrieve todos from the DynamoDB table based on the user's ID. A limit of 10 todos per query is set to manage response sizes effectively.
  • The most interesting part is pagination. DynamoDB query result returns LastEvaluatedKey that can be used in subsequent requests by providing ExclusiveStartKey to return next items. We use base64 encoding for the ExclusiveStartKey parameter to handle pagination. Base64 encoding allows us to pass the key as a string parameter within the URL, ensuring compatibility with HTTP requests. This encoded key is decoded within the Lambda function to retrieve the next set of items.
  • Finally we return a HTTP status code of 200 along with the retrieved todo items and nextToken.

Todos API Gateway

The last part is defining API Gateway. API Gateway enables the creation and management of RESTful APIs, allows to define HTTP endpoints and connect them to other AWS services e.g. Lambdas.

export class TodosApiGateway extends Construct {
constructor(scope: Construct, id: string, props: {
userPool: cognito.UserPool,
addTodoLambda: lambda.Function,
getTodosLambda: lambda.Function
}) {
super(scope, id);

const authorizer = new apigateway.CognitoUserPoolsAuthorizer(this, `${id}-authorizer`, { //(1)
cognitoUserPools: [props.userPool],
});

const api = new apigateway.RestApi(this, id);
const todos = api.root.addResource('todos');
todos.addMethod('POST', new apigateway.LambdaIntegration(props.addTodoLambda), { //(2)
authorizer: authorizer
});
todos.addMethod('GET', new apigateway.LambdaIntegration(props.getTodosLambda), {
authorizer: authorizer
});
}
}
  1. First, we create an authorizer using our Cognito user pool.
  2. Then we are defining 2 API endpoints: POST /todos and GET /todos and attach our authorizer to them.

Todolist stack

Finally we create a CloudFormation stack that contains all the constructs above and connects them together:

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

const cognitoUserPool = new CognitoUserPool(this, 'todolist-user-pool');

const todoListTable = new TodoListTable(this, 'todolist-table');

const addTodoToDynamodbLambda = new AddTodoToDynamodbLambda(this, 'add-todo', {
table: todoListTable.table,
});
const getTodosLambda = new GetTodosLambda(this, 'get-todos', {table: todoListTable.table});
const todosApiGateway = new TodosApiGateway(this, 'todos-api-gateway', {
userPool: cognitoUserPool.userPool,
addTodoLambda: addTodoToDynamodbLambda.lambdaFunction,
getTodosLambda: getTodosLambda.lambdaFunction
});

}
}

Testing

Let’s test our API now:

  1. Begin by deploying our application using the AWS CDK:
    cdk deploy
    The output will be similar to this:
 ✅  TodoListStack

✨ Deployment time: 19.22s

Outputs:
TodoListStack.todolistuserpoolCognitoSignInUrl523EE470 = https://*-hosted-ui.auth.eu-north-1.amazoncognito.com/login?client_id=*&response_type=token&redirect_uri=http://localhost:3000
TodoListStack.todosapigatewayEndpoint51F63FB3 = https://*.execute-api.eu-north-1.amazonaws.com/prod/
Stack ARN:
arn:aws:cloudformation:eu-north-1:*:stack/TodoListStack/*

Upon deployment, note down the following outputs:

  • todolistuserpoolCognitoSignInUrl: This URL will redirect you to the Cognito Hosted UI for user authentication.
  • todosapigatewayEndpoint: This URL serves as the endpoint for accessing our API.

2. We need a user account in Cognito for testing our API so let’s create one through the AWS console:

3. Getting JWT token for Authorization

  • Open the CognitoSignInUrl in a web browser, which will redirect you to the Cognito Hosted UI.
  • Log in with the newly created user credentials. Upon the first login, you’ll be prompted to change the temporary password.
  • After successfully logging in, the Hosted UI will redirect you to a URL containing the id_token:

http://localhost:3000/#id_token=eyJraWQ…&access_token=eyJraWQ…expires_in=28800&token_type=Bearer

We need id_token — it is our JWT token that we will pass in Authorization HTTP header to our API.

4. Send test requests
You can use tools like Postman or CURL to test the API endpoint.. The examples below are using CURL. Requests are sent to todosapigatewayEndpoint.

curl -X POST \
https://*.execute-api.eu-north-1.amazonaws.com/prod/todos \
-H 'Authorization: Bearer <YOUR_JWT_TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"name": "Item 1",
"description": "Description 1"
}'

The result is 200 OK:

{
"id": "todo#1711490347111",
"name": "Item 1",
"description": "Description 1"
}

Now let’s try to fetch items:

curl -X GET \
https://*.execute-api.eu-north-1.amazonaws.com/prod/todos \
-H 'Authorization: Bearer <YOUR_JWT_TOKEN>'
{
"todos": [
{
"id": "todo#1711490347111",
"name": "Item 1",
"description": "Description 1"
}
]
}

If we add more then 10 todos same GET request will include nextToken in the response:

{
"todos": [
{
"id": "todo#1711490035437",
"name": "Item 1",
"description": "Description 1"
},
{
"id": "todo#1711490044574",
"name": "Item 2",
"description": "Description 2"
},
...
{
"id": "todo#1711490044579",
"name": "Item 10",
"description": "Description 10"
},
],
"nextToken": "eyJQSyI6InVzZXIjOGNkMDI5MTQtYmJlZS00NWJmLWIyYTctODMzMjU4M2RiMDU3IiwiU0siOiJ0b2RvIzE3MTE0OTA1MDI2MDkifQ=="
}

Enhancing Our API

To further enhance the functionality and performance of our API, consider the following improvements:

  1. Request Validation: Implementing request validation ensures that incoming data adheres to specified formats and constraints, enhancing the reliability and security of our API. By validating requests, we can prevent malformed or malicious input from causing errors or compromising our system.
  2. Caching: Introducing caching mechanisms for GET requests can significantly improve the speed and efficiency of our API.
  3. Rate Limiting: Implementing rate limiting helps control the number of requests a user or client can make to our API within a specified time period. This helps prevent abuse or overloading of our system, ensuring fair access to resources for all users.

--

--