API-Gateway-Lambda-and-DynamoDB

Building an application often means dealing with servers – setting them up, keeping them running, and making sure they can handle traffic spikes. That can be quite problematic in many cases. This is where the AWS serverless stack can help the developers. It lets you create powerful backends for your applications without managing any servers. This combination of AWS services - API Gateway, Lambda and DynamoDB - allows you to build robust APIs that handle CRUD (Create, Read, Update, Delete) operations effortlessly.

In this article I'll walk through the process of creating a three-tier serverless application:

  • Presentation Tier: API Gateway handles HTTP requests and responses.
  • Logic Tier: Lambda functions process business logic and CRUD operations.
  • Data Tier: DynamoDB stores and retrieves data.

Though here I'll cover each of these tiers, but I will empmore focus on providing detailed insights into the Presentation Tier. By following it, you'll build an entire three-tier application step-by-step.

We will develop each tier in reverse order: starting with the Data tier (DynamoDB), then the Logic tier (Lambda function), and finally the Presentation tier (API Gateway).

You may download source code from my github repository,


For an in-depth look at the Data Tier, I strongly suggest reading this article.

To learn more about the Logic Tier, I recommend checking out this article.

Data Tier

First, I'll create the data tier of our application by automating the creation of a DynamoDB table using a CloudFormation template. In many software environments, multiple applications and services share same database table. Deploying and undeploying these tables alongside other resources can disrupt dependent services. Although not a standard practice, but I prefer creating the database table in a separate CloudFormation template rather than in the main application template that deploys resources like Lambdas, SNS, and SQS.

Data Tier - Cloudformation Template

Find the full cloudformation template file here.

In the Data tier CloudFormation template, I create a DynamoDB table with a single attribute, id, as the partition key. The read and write capacity is set to 5 per second in ProvisionedThroughput, but you can adjust these values as needed. The template outputs the table’s Name (DynamoTableName) and ARN (DynamoARN), which we will use in logic tier cloudformation template.

We deploy the data tier resources by following AWS CLI command,

aws cloudformation deploy \
--template-file ./cf-dynamo.json \
--stack-name my-demo-dynamo-stack \
--capabilities CAPABILITY_IAM \
--parameter-overrides pTableName="my-demo-dynamo-table" \
--region <aws-region>

Note: I pass the table name (pTableName) as a parameter to cloudformation. Also, replace <aws-region>by your aws region name, such as eu-west-1 etc

Cloudformation stack is successfully deployed and created dynamoDB table, my-demo-dynamo-table

Data Tier - Implementation

As we have deployed a dynamoDB table, now we write the data tier code in Go language. I create an DBHandler interface in a new file dbHandler.go, which expose CRUD methods as,

package dynamo_db

import "github.com/azam-akram/aws-apigateway-lambda-demo-go/model"

type DBHandler interface {
	Save(book *model.MyBook) error
	Update(book *model.MyBook) error
	UpdateAttributeByID(id, key, value string) error
	GetByID(id string) (*model.MyBook, error)
	DeleteByID(id string) error
}

DBHandler exposes required CRUD methods to Create (save()), Read (GetByID()), Update (UpdateAttributeByID()) and Delete (DeleteByID())

Let's add their implementations in a seprate file dynamoHandler.go,

package dynamo_db

import (
	"log"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface"

	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/azam-akram/aws-apigateway-lambda-demo-go/model"
)

var handler DBHandler

type DynamoHandler struct {
	tableName   string
	dynamoDBAPI dynamodbiface.DynamoDBAPI
}

func NewDynamoHandler(tName string) DBHandler {
	if handler == nil {
		handler = &DynamoHandler{
			tableName:   tName,
			dynamoDBAPI: getDynamoInterface(),
		}
	}
	return handler
}

func getDynamoInterface() dynamodbiface.DynamoDBAPI {
	dynamoSession := session.Must(session.NewSessionWithOptions(session.Options{
		SharedConfigState: session.SharedConfigEnable,
	}))

	dynamoInstance := dynamodb.New(dynamoSession)

	return dynamodbiface.DynamoDBAPI(dynamoInstance)
}

func convertToDBRecord(book *model.MyBook) map[string]*dynamodb.AttributeValue {
	item := map[string]*dynamodb.AttributeValue{
		"id":     {S: &book.ID},
		"title":  {S: &book.Title},
		"author": {S: &book.Author},
	}
	return item
}

func (h *DynamoHandler) Save(book *model.MyBook) error {
	input := &dynamodb.PutItemInput{
		Item:      convertToDBRecord(book),
		TableName: aws.String(h.tableName),
	}

	savedItem, err := h.dynamoDBAPI.PutItem(input)
	if err != nil {
		log.Fatal("Failed to save Item: ", err.Error())
		return err
	}

	log.Println("Item saved in db: ", savedItem)

	return nil
}

func (h *DynamoHandler) Update(book *model.MyBook) error {
	item, err := dynamodbattribute.MarshalMap(book)
	if err != nil {
		return err
	}

	input := &dynamodb.PutItemInput{
		Item:      item,
		TableName: aws.String(h.tableName),
	}

	updatedItem, err := h.dynamoDBAPI.PutItem(input)
	if err != nil {
		return err
	}

	log.Println("Item updated in db: ", updatedItem)
	return nil
}

func (h *DynamoHandler) UpdateAttributeByID(id, key, value string) error {
	input := dynamodb.UpdateItemInput{
		ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
			":val": {
				S: aws.String(value),
			},
		},
		Key: map[string]*dynamodb.AttributeValue{
			"id": {
				S: aws.String(id),
			},
		},
		TableName:        aws.String(h.tableName),
		UpdateExpression: aws.String("set " + key + " = :val"),
	}

	output, err := h.dynamoDBAPI.UpdateItem(&input)
	if err != nil {
		return err
	}

	log.Println("Item updated in db: ", output)

	return nil
}

func (h *DynamoHandler) GetByID(id string) (*model.MyBook, error) {
	input := &dynamodb.GetItemInput{
		TableName: aws.String(h.tableName),
		Key: map[string]*dynamodb.AttributeValue{
			"id": {S: aws.String(id)},
		},
	}

	item, err := h.dynamoDBAPI.GetItem(input)
	if err != nil {
		return nil, err
	}

	if item.Item == nil {
		log.Fatal("Can't get item by id = ", id)
		return nil, nil
	}

	var book model.MyBook
	err = dynamodbattribute.UnmarshalMap(item.Item, &book)
	if err != nil {
		return nil, err
	}

	return &book, nil
}

func (h *DynamoHandler) DeleteByID(id string) error {
	input := &dynamodb.DeleteItemInput{
		TableName: aws.String(h.tableName),
		Key: map[string]*dynamodb.AttributeValue{
			"id": {S: aws.String(id)},
		},
	}

	_, err := h.dynamoDBAPI.DeleteItem(input)
	if err != nil {
		log.Fatal("Can't delete item by id = ", id)
		return err
	}

	return nil
}

NewDynamoHandler: Creates a new DynamoDB handler with the given table name if one does not already exist.

getDynamoInterface: Initializes and returns a new DynamoDB API session interface.

convertToDBRecord: Converts a book model into a DynamoDB-compatible record format.

Save: Saves a new book record to the DynamoDB table.

Update: Updates an existing book record in the DynamoDB table.

UpdateAttributeByID: Updates a specific attribute of a book identified by its ID.

GetByID: Retrieves a book record from the DynamoDB table by its ID.

DeleteByID: Deletes a book record from the DynamoDB table by its ID.

Logic Tier

In this section, we will develop the Logic Tier by writing the Lambda function code to handle HTTP requests for the CRUD API. This layer acts as an intermediary between the presentation and data tiers, managing the application’s business logic and ensuring smooth communication between the user interface and the database.

We begin by creating a Go module using go mod init, which generates a go.mod file in the project directory as follows:

module github.com/azam-akram/aws-apigateway-lambda-demo-go

go 1.22

require (
	github.com/aws/aws-lambda-go v1.47.0
	github.com/aws/aws-sdk-go v1.51.21
)

require github.com/jmespath/go-jmespath v0.4.0 // indirect

Next, in the main.go file, we will define how our Lambda function handles Create, Read, Update, and Delete (CRUD) operations. This will ensure that our API is fully functional and can interact with the data tier we set up earlier. Add the following code to the main.go file:

package main

import (
	"context"
	"encoding/json"
	"net/http"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/azam-akram/aws-apigateway-lambda-demo-go/dynamo_db"
	"github.com/azam-akram/aws-apigateway-lambda-demo-go/model"
)

func HandleRequest(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	dynamoHandler := dynamo_db.NewDynamoHandler("my-demo-dynamo-table")

	switch req.HTTPMethod {
	case "POST":
		var book model.MyBook
		if err := json.Unmarshal([]byte(req.Body), &book); err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusBadRequest}, err
		}
		if err := dynamoHandler.Save(&book); err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
		}
		return events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: req.Body}, nil

	case "GET":
		id := req.QueryStringParameters["id"]
		book, err := dynamoHandler.GetByID(id)
		if err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
		}
		if book == nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusNotFound}, nil
		}
		body, err := json.Marshal(book)
		if err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
		}
		return events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: string(body)}, nil

	case "PUT":
		var book model.MyBook
		if err := json.Unmarshal([]byte(req.Body), &book); err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusBadRequest}, err
		}
		if err := dynamoHandler.Update(&book); err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
		}
		return events.APIGatewayProxyResponse{StatusCode: http.StatusOK, Body: req.Body}, nil

	case "DELETE":
		id := req.QueryStringParameters["id"]
		if err := dynamoHandler.DeleteByID(id); err != nil {
			return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
		}
		return events.APIGatewayProxyResponse{StatusCode: http.StatusOK}, nil

	default:
		return events.APIGatewayProxyResponse{StatusCode: http.StatusMethodNotAllowed}, nil
	}
}

func main() {
	lambda.Start(HandleRequest)
}

We also need to define a data structure, MyBook struct to unmarshal json payload in HTTP request, let's create a new file `model.go`,

package model

type MyBook struct {
	ID     string `json:"id,omitempty"`
	Title  string `json:"title,omitempty"`
	Author string `json:"author,omitempty"`
}

Resolve the dependencies by running,

go mod tidy
go mod vendor

Build Go Project

Set environment variable,

set GOOS=linux GOARCH=amd64 CGO_ENABLED=0

Setting GOOS to Linux ensures that the compiled executable is compatible with the Go runtime, even if you compile it in a non-Linux environment[1]. Now build the application,

GOOS=linux go build main.go

Create Lambda Function Archive

To deploy the Lambda function, we will first package the function’s code into a zip file and upload it to an S3 bucket. This enables the CloudFormation stack to retrieve and deploy the function from the specified S3 location.

We install build-lambda-zip to create an archive file,

go install github.com/aws/aws-lambda-go/cmd/build-lambda-zip@latest

Add path (%USERPROFILE%\Go\bin) to build-lambda-zip in GOPATH.

Create a .zip file to include an executable,

build-lambda-zip.exe -o aws-lambda-demo-go.zip main

import aws-lambda-demo-go.zip file to S3 bucket,

aws s3 cp aws-api-lambda-dynamo-go.zip s3://<<you-bucket-name>>/demo-lambda/aws-api-lambda-dynamo-go.zip

You can read detailed steps in thie article.

Presentation Tier

With the Data and Logic tiers now complete, we are ready to move on to the Presentation tier. This involves creating and deploying an API Gateway that will expose our CRUD API methods: POST, GET, PUT, and DELETE. The API Gateway will serve as the entry point for client requests, routing them to the our Lambda functions that handle the underlying business logic and data interactions. By setting up the API Gateway, we'll provide a robust interface for clients to interact with our application to retrieve data from DynamoDB.

Logic and Presentation Tiers Cloudformation Template

We will automate the Logic and Prsentation Tiers deployment using same CloudFormation template. In this template, we create all the required resources, including Lambda functions, the roles they assume, and the policies they need. It also deploys an API with various methods like GET, POST, PUT, and DELETE.

Please find the full cloudformation template file here.

Let's dissect the main components of the CloudFormation template file.

  • lfnMyDemoLambda: Defines a Lambda function that uses the provided custom runtime and handler, and sets its execution role, timeout, and code location in S3.
  • lfnLambdaRole: Creates an IAM Role for the Lambda function, allowing it to assume the role and granting permissions to interact with CloudWatch Logs and DynamoDB.
  • ApiGatewayRestApi: Creates a new API Gateway REST API named "MyDemoApi".
  • ApiGatewayResourceBooks:Defines a new API Gateway resource at the "/books" path under the created API.
  • ApiGatewayMethodBooksPost: Adds a POST method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
  • ApiGatewayMethodBooksGet: Adds a GET method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
  • ApiGatewayMethodBooksPut: Adds a PUT method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
  • ApiGatewayMethodBooksDelete: Adds a DELETE method to the "/books" resource, integrated with the Lambda function using AWS_PROXY.
  • ApiGatewayDeployment: Deploys the API Gateway with all the defined methods to the "prod" stage.
  • LambdaInvokePermission: Grants API Gateway permission to invoke the Lambda function.
  • Outputs.ApiUrl: Provides the endpoint URL for the deployed API in the "prod" stage.

We are now ready to deploy the required resources using a CloudFormation stack. You can create the stack by running the following AWS CLI command:

aws cloudformation deploy \
--region <<your_region_name>> \
--template-file ./deploy/cf.json \
--stack-name apigateway-lambda-dynamo-stack \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
    pLambdaCodeBucket=<<your_s3_bucket_name>> \
    pLambdaCodeS3KeyPath=demo-lambda/aws-api-lambda-dynamo-go.zip \
    pDynamoARN=<<your_dynamo_table_arn>> \
    pApiName=my-bookstore-api \
    pStageName=v1

Note: replace <<region-name>> by your region name, <<your_s3_bucket_name>> by your s3 bucket name and <<your_dynamo_table_arn>> by your dynamo table ARN

cloudformation-stack-successfuly-deployed-lambda-api
cloudformation stack successfuly deployed lambda function and api
api gateway deployed
api gateway deployed

Test

With API Gateway, Lambda function, and DynamoDB deployed, we're now ready to test our API. Let's proceed by sending individual POST, GET, PUT, and DELETE HTTP requests.

POST:

curl -X POST https://<<api-url>>/prod/books \
     -H "Content-Type: application/json" \
     -d '{
           "ID": "1",
           "Title": "My Great Go book",
           "Author": "Azam Akram"
         }'

GET:

curl -X GET "https://<<api-url>>/prod/books?id=1"

PUT:

curl -X PUT https://<<api-url>>/prod/books \
     -H "Content-Type: application/json" \
     -d '{
           "ID": "1",
           "Title": "My Good Go book",
           "Author": "Azam Akram"
         }'

DELETE:

curl -X DELETE "https://<<api-url>>/prod/books?id=1"

You can see the response of each request here,

api request and response
api request and response

and see cloudwatch logs,

cloudwatch logs
cloudwatch logs

Conclusion

We have successfully built a serverless application using AWS services: API Gateway, Lambda, and DynamoDB. By implementing a three-tier architecture - where API Gateway serves as the presentation layer, Lambda functions handle the business logic in the logic tier, and DynamoDB stores data in the data tier. Leveraging CloudFormation for infrastructure management ensured consistency and repeatability in deployment.

© 2024 Solution Toolkit . All rights reserved.