Skip to main content

Let's build a RESTful CRUD API using AWS serverless services (part 1)

Greetings!

In a previous post, I talked a little about building serverless APIs with AWS. However, I have used a manual approach in that post and that is not maintainable and reusable in a practical project. Hence in this post, I am using the serverless framework to build our infrastructure.

You can find the complete source code here.

What is the serverless framework?

It is a free and open-source framework for building applications on AWS Lambda (though it supports several other cloud providers now), API Gateway, DynamoDB, and many other services.

Install and configure the serverless framework

We can install the serverless framework using npm.
npm install -g serverless
Then we need to configure AWS credentials for the serverless framework to access our AWS account. Create a new AWS IAM user (IAM -> Users -> Add users -> with programmatic access) and configure the serverless framework using the IAM user key and secret as below.
serverless config credentials --provider aws --key <key> --secret <secret>

What we build

This is a typical CRUD REST API. I am creating a book API in this example.


Create the project

My project name will be serverless-crud-api hence I'm creating a directory first and cd into it. Then I'm generating the project using the serverless framework create command using the aws-nodejs template.
mkdir serverless-crud-api
cd serverless-crud-api
serverless create --template aws-nodejs
npm init -y
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb uuid --save
npm install serverless-iam-roles-per-function --save-dev
  • client-dynamodb, lib-dynamodb - used to connect to Dynamodb and fetch, and create data.
  • uuid - generate uuid
  • serverless-iam-roles-per-function - enable IAM role per lambda function in the serverless framework
As we are using ES6, enable module type in package.json.
"type": "module"
Our final package.json is like below.
{
  "name": "serverless-crud-api",
  "version": "1.0.0",
  "description": "Restful serverless CRUD",
  "author": "Manjula Jayawardana",
  "main": "index.js",
  "type": "module",CODE
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "license": "ISC",
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.188.0",
    "@aws-sdk/lib-dynamodb": "^3.188.0",
    "uuid": "^9.0.0"
  },
  "devDependencies": {
    "serverless-iam-roles-per-function": "^3.2.0"
  }
}
Once the project is initiated we will do a few changes to the serverless.yml file. Remove all the generated commented codes first and change the provider section as below.
provider:
  name: aws
  runtime: nodejs16.x
  stage: dev
  region: us-east-1
  deploymentMethod: direct
  environment:
    CRUD_TABLE_NAME: !Ref crudDynamoDBTable
The serverless framework recommends using "deploymentMethod: direct" hence we use it here. We use a dev stage and the us-east-1 region. You can change those two properties as you wish.

We will create separate Lambda functions for our crud operations. When we do that we will have to duplicate the database table name. Hence we create an environment variable for the database table. What we have done here is reference the table name from the serverless resources section (I'll describe it below). And also I have changed the node.js version to 16.

Create the database

Create a file named resources.yml under the project. We define all our resources there to maintain it separately as our main serverless.yml file will grow and to learn best practices. As you can see, this is a standard CloudFormation template.
crudDynamoDBTable:
  Type: AWS::DynamoDB::Table
  Properties:
    BillingMode: PAY_PER_REQUEST
    TableName: serverless-crud-table
    AttributeDefinitions:
      - AttributeName: "bookId"
        AttributeType: "S"
    KeySchema:
      - AttributeName: "bookId"
        KeyType: "HASH"
Once we created the resources.yml file we can reference it from the serverless.yml file as below.
${file(file_name)}
resources:
  Resources: ${file(resources.yml)}
With that, we have defined our DynamoDB table for our crud application.

Enable IAM role per function

We can define IAM roles under the provider section but it will apply to all the Lambda functions. This is where we use the "serverless-iam-roles-per-function" plugin. Enable the plugin by adding it to the serverless.yml plugins section.
plugins:
  - serverless-iam-roles-per-function

Define the create handler

Our next step is defining the functions for the book create operation. First of all, we create an src folder to hold our Lambda functions. Under that create a JavaScript file named create-book.js to write our Node.js logic.
functions:
  createBook:
    handler: src/create-book.handler
    iamRoleStatements:
      - Effect: Allow        
        Action:
          - dynamodb:PutItem        
        Resource: !GetAtt crudDynamoDBTable.Arn
    events:
      - http:
          path: /books
          method: post
          cors: true
Have a look at "iamRoleStatements" where we allow DynamoDB put operation to our Lambda function. We have used GetAtt intrinsic function to get the DynamoDB table Arn.

Under the "events" section we have defined our REST API endpoint as /books and it is an HTTP post method because this is for saving data. Also, note that we have enabled cors.

The next step is to write actual code.

Write the Lambda function

Instead of writing the logic in a single file, we create separate files for each Lambda function. This helps us to maintain modularity. Also instead of writing duplicate codes, we will share common logic by creating specific modules.

Access the environment variables

As we have defined our database table name in the environment section, we can access it as below.
process.env.CRUD_TABLE_NAME

Connecting to the database

First of all, we need to connect to the DynamoDB table. As we need this operation for all our Lambda functions, we create a common module under src/database.js.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";

const ddbClient = new DynamoDBClient({ region: "us-east-1" });
const ddbDocClient = DynamoDBDocumentClient.from(ddbClient);

const CRUD_TABLE_NAME = process.env.CRUD_TABLE_NAME;

export { ddbDocClient, CRUD_TABLE_NAME };

Common response function

Each of the Lambda functions will have a common way to return the response. Hence we create a util module to share it (src/utils.js).
export const response = (statusCode, data) => {
  return {
    statusCode,
    body: JSON.stringify(data)
  }
}

Lambda function for creating a book

Now it is time to write the actual Lambda function. It took a while to create the basic setup though. We use "uuid" lib to generate our primary key. Also, note that we need to JSON parse the event body. Other than that, it is the usual Node.js code. This new and improved DynamoDB document client code is very beautiful though it took me a while to create a working code because I missed the correct library. Oh, I almost forgot to mention that I have used ES6 syntax here and we need to "export handler" instead of "module.exports.handler". Took me a while for this as well.
"use strict";

import { PutCommand } from "@aws-sdk/lib-dynamodb";
import { v4 as uuidv4 } from "uuid";

import { response } from "./utils.js";
import { ddbDocClient, CRUD_TABLE_NAME } from "./database.js";

export const handler = async (event) => {
  try {
    const book = JSON.parse(event.body);
    const params = {
      TableName: CRUD_TABLE_NAME,
      Item: {
        bookId: uuidv4(),
        title: book.title,
        author: book.author,
      },
    };
    const data = await ddbDocClient.send(new PutCommand(params));
    return response(201, { message: "Book saved", book: params.Item });
  } catch (err) {
    console.log("Error", err);
    return response(500, { message: err.message });
  }
};

Let's deploy it

All done, it is time to deploy and test this. Use serverless frameworks' deploy command to deploy our infrastructure. Once everything is completed it will print the endpoint name(s) into the console. Use that to create a new record.
serverless deploy
You can use Postman, curl, or any other client.
curl --location --request POST 'https://yj9oxcdqp3.execute-api.us-east-1.amazonaws.com/dev/books' \
--header 'Content-Type: application/json' \
--data-raw '{
    "title": "Clean Code",
    "author": "Robert C. Martin"
}'
That is it. If you go to DynamoDB and check your table, you can see the created record there.

Explore what is done

Well, a lot has happened under the hood thanks to the serverless framework. What it has really done is, create a CloudFormation template and upload it to an S3 bucket. Then the CloudFormation stack has been created. You can check those from the AWS console. Also do not forget to check API Gateway and the Lambda function in the AWS console. Also do not forget to explore the IAM role created for the Lambda function.

Conclusion

We have taken our first step of creating serverless services using the serverless framework. I had to refer to a lot of material for this which I have mentioned below. We will create other operations in the next post.


Comments