How to build Serverless API with database using AWS CDK
Igor Soroka
Posted on September 27, 2021
Serverless gives us tremendous opportunities to ship new digital products faster at no cost in the beginning. The most classical way of using serverless is to have API backed by AWS Lambdas. This blog will guide the provision and deployment of an application using AWS CDK version 2. However, when I have started implementing it, issues started to appear. I would tell about them while depicting the process and code. Serverless will give the ability to have a full API setup for free.
First of all, what are we building? It will be an API Gateway backed by AWS Lambdas. They will be reading and writing items to the DynamoDB table. So, here we are, creating the CRUD with REST API. One can find the GitHub repository here.
API Gateway -> AWS Lambda -> DynamoDB
Reading is great for many things. So our items will be books! The model will be like that:
{
"title": "name of the book>",
"author": "<author of the book>",
"yearPublished": "<published year of the book>",
"isbn": "<universal identifier of the book>"
}
Implementation
In this demo application, we are using Dynamo DB. It is a NoSQL database where AWS manages infrastructure. It is working like a magic spell with the Lambda functions. This app will do classic CRUD operations.
import { DocumentClient } from 'aws-sdk/clients/dynamodb';
import { Book } from '../models/book';
import uuid from 'uuid';
const dynamo = new DocumentClient();
export async function create(table: string, book: Book) {
const params = {
TableName: table,
Item: {
id: uuid.v4(),
...book
}
}
const dbResponse = await dynamo.put(params).promise();
return params.Item;
}
The start will be related to the writing items to the table. Typescript allows having a strongly typed object. The only thing which will be great to have as unique as possible is UUID. In this case, getting a book by the ID will be straightforward. The deleting operation will be very similar.
export async function get(table: string, id: string) {
const params = {
TableName: table,
Key: {
id
}
};
const dbResponse = await dynamo.get(params).promise();
return dbResponse.Item;
}
export async function deleteItem(table: string, id: string) {
const params = {
TableName: table,
Key: {
id
}
}
await dynamo.delete(params).promise();
}
It would be great also to check the created items. Here I am using the 'Scan' operation, which goes through the whole table. For demo purposes, this should be enough. Whenever possible, one should use 'Query.'
export async function list(table: string): Promise<DocumentClient.ItemList> {
const params = {
TableName: table,
}
const dbResponse = await dynamo.scan(params).promise();
if (dbResponse.Items) {
return dbResponse.Items;
}
throw new Error('Cannot get all books');
}
The most complicated request is to update the book from the database. It needs to have parameters and values to edit.
export async function update(table: string, id: string, book: Book) {
const params = {
TableName: table,
Key: {
id,
},
ExpressionAttributeValues: {
':title': book.title,
':author': book.author,
':yearPublished': book.yearPublished,
':isbn': book.isbn
},
UpdateExpression: 'SET title = :title, ' +
'author = :author, yearPublished = :yearPublished, isbn = :isbn',
ReturnValues: 'UPDATED_NEW',
}
await dynamo.update(params).promise();
}
Dependency injection is the first complication that is happening with lambdas. If one packs it to the AWS by using CDK or CloudFormation, there will be no dependencies. I am using typescript in the project, so in theory, running will create a working js file:
npm i -D typescript
tsc init
tsc
However, it is not so straightforward. There are two issues here:
- packing additional files with functionality because having Lambda with the implementation inside one file is a bad practice
- packing external dependencies. In our case, it will be UUID one.
I tried to pack just src/functions/create. Running Lambda will give that:
That is why the next logical step will be to find out how to pack Lambda code together with dependencies and don't upload 100 Mb of node_modules. I decided to use webpack for that. It does the job right and will allow running just this command after installation: webpack
Lambdas' main handler functions will be as simple as calling the DynamoDB file with the needed operations. For example, creating the item would be like that.
import { APIGatewayProxyEventV2, Callback, Context } from 'aws-lambda';
import { create } from '../connectors/dynamo-db-connector';
export async function handler (event: APIGatewayProxyEventV2, context: Context, callback: Callback) {
if (typeof event.body === 'string') {
const bookItem = JSON.parse(event.body);
const createBook = await create(process.env.table as string, bookItem);
const response = {
statusCode: 201,
}
callback(null, response);
}
callback(null, {
statusCode: 400,
body: JSON.stringify('Request body is empty!')
});
}
One could find handlers for other operations in the repository.
Infrastructure
As previously mentioned, CDK will be in charge of the infrastructure provisioning. I will be using just one stack with DynamoDB, 5 Lambdas, Rest API, permissions for table connections, and integrations for endpoints. We will put the infrastructure application in a separate folder /infra.
To create DynamoDB with Lambda calling it, one will need ready-made typed constructs for DynamoDB, Lambda, and permissions. The code is situated one level above, so it is essential to put it like this. The good thing here is that CDK will error when one calls the template's 'synthesizing' with the wrong parameters. After that, Lambda will need a permission to put an item to DynamoDB. One could do this by adding one line of code.
const dynamoTable = new ddb.Table(this, 'BookTable', {
tableName: 'BookStorage',
readCapacity: 1,
writeCapacity: 1,
partitionKey: {
name: 'id',
type: ddb.AttributeType.STRING,
},
})
const createBookFunction = new lambda.Function(this, 'CreateHandler', {
runtime: lambda.Runtime.NODEJS_14_X,
code: lambda.Code.fromAsset('../code'),
handler: 'create.handler',
environment: {
table: dynamoTable.tableName
},
logRetention: RetentionDays.ONE_WEEK
});
dynamoTable.grant(createBookFunction, 'dynamodb:PutItem')
After that, the function could be attached to the API. First, we are initializing RestAPI. Then, we add a resource to root for having a path like POST /.
const api = new apigw.RestApi(this, `BookAPI`, {
restApiName: `book-rest-api`,
});
const mainPath = api.root.addResource('books');
const createBookIntegration = new apigw.LambdaIntegration(createBookFunction);
mainPath.addMethod('POST', createBookIntegration);
The steps shown in this section will be repeated for other endpoints also. The best part comes when one can see that lambda creation is the same but with different parameters. CDK could wrap it into the function for saving space and having reusable code for provisioning handlers.
Testing endpoints
After the manipulations mentioned in the previous sections, we can try out the API mentioned in this article. Let's start with the list of all books. The result should be empty.
curl --location --request GET 'https://<api-id>.execute-api.<region>.amazonaws.com/prod/books'
RESPONSE:
Status: 200
[]
After that, let's create a couple of books.
curl --location --request POST 'https://<api-id>.execute-api.<region>.amazonaws.com/prod/books' \
--header 'Content-Type: application/json' \
--data-raw '{
"title": "Life of PI",
"author": "Yann Martel",
"yearPublished": "2000",
"isbn": "0-676-97376-0"
}'
RESPONSE:
Status: 201
And one more:
curl --location --request POST 'https://<api-id>.execute-api.<region>.amazonaws.com/prod/books' \
--header 'Content-Type: application/json' \
--data-raw '{
"title": "Simulacra and Simulation",
"author": "Jean Baudrillard",
"yearPublished": "0-472-06521-1",
"isbn": "0-676-97376-0"
}'
RESPONSE:
Status: 201
Now we can call a list to make sure that all the books are in their places.
curl --location --request GET 'https://<api-id>.execute-api.<region>.amazonaws.com/prod/books'
RESPONSE:
Status: 200
[{
"isbn": "0-676-97376-0",
"id": "617d6b3e-ce6d-4e8d-a10f-05d6703ad7ac",
"yearPublished": "2000",
"title": "Life of PI",
"author": "Yann Martel"
}, {
"isbn": "0-676-97376-0",
"id": "2a8251ee-73ee-4717-8f6f-0f11dd2b861f",
"yearPublished": "0-472-06521-1",
"title": "Simulacra and Simulation",
"author": "Jean Baudrillard"
}]
Lambda added the books, and a listing is also working. However, there is an error in the database. I noticed that 'Life of PI' was published in 2001 and not in 2000. So, we need to call the update endpoint.
curl --location --request PUT 'https://y55xcv8jmc.execute-api.eu-west-1.amazonaws.com/prod/books/617d6b3e-ce6d-4e8d-a10f-05d6703ad7ac' \
--header 'Content-Type: application/json' \
--data-raw '{
"isbn": "0-676-97376-0",
"yearPublished": "2001",
"title": "Life of PI",
"author": "Yann Martel"
}'
Status: 200
Another list call will show that the book was successfully updated.
[{
"isbn": "0-676-97376-0",
"id": "617d6b3e-ce6d-4e8d-a10f-05d6703ad7ac",
"yearPublished": "2001",
"author": "Yann Martel",
"title": "Life of PI"
}, {
"isbn": "0-676-97376-0",
"id": "2a8251ee-73ee-4717-8f6f-0f11dd2b861f",
"yearPublished": "0-472-06521-1",
"title": "Simulacra and Simulation",
"author": "Jean Baudrillard"
}]
Let's delete one of the books by calling the DELETE endpoint.
curl --location --request DELETE 'https://<api-id>.execute-api.<region>.amazonaws.com/prod/books/2a8251ee-73ee-4717-8f6f-0f11dd2b861f'
Status: 200
After calling it multiple times with different ids, one will get empty response for the list of books. This is what we are expecting here.
In this article, I showed how to deploy Serverless API with the NoSQL Database using AWS Lambda, DynamoDB, and API Gateway deployed by CDK. Lambdas have all permissions for CRUD operations. This can be a sample project for a more complicated setup. Also, I was using the webpack to have all the node dependencies in place. The future work will include the CodePipeline for CI/CD connected to the GitHub hook. Thanks for reading this article!
Want to learn more about AWS, Serverless, and CDK? Subscribe to my blog where I am posting about these topics on a regular basis.
Posted on September 27, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.