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, with more 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).

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 database tables. Deploying and undeploying these tables alongside other resources can disrupt dependent services. Although not a standard practice, 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.

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) for use by other templates to create additional resources.

  • myDemoDynamoDBTable: Creates a DynamoDB table with a name provided by the parameter pTableName. Defines an attribute id of type String (S) as the primary key. Sets the read and write capacity units to 5 each.
  • Outputs Explanation: The stack outputs following values,
    • DynamoARN: Outputs the ARN of the created DynamoDB table.
    • DynamoTableName: Outputs the name of the created DynamoDB table.

I pass the table name (pTableName) as a parameter to cloudformation and deploy it,

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 eu-west-1

Note: replace your-region-name you to your region name, such as eu-west-1 etc

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

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

Now, we'll create our presentation layer by writing the code for our Lambda function to handle each method of the CRUD API. This layer will serve as the interface between the client requests and our application's logic. We'll start by writing the necessary code in the main.go file, which will define how our Lambda function processes Create, Read, Update, and Delete operations. This will ensure our API is fully functional and able to interact with the DynamoDB data tier we previously set up.

We start with creating our Go module by go mod init which creates a go.mod file in your project directory as,

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
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 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:

GOOS=linux go build main.go

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].

Create Lambda Function Archive:

To deploy the Lambda function, we will first create a zip archive of the function's code and upload it to an S3 bucket. This allows the CloudFormation stack to access and deploy the function from the specified S3 location.

Set environment variable,

set GOOS=linux
set GOARCH=amd64
set CGO_ENABLED=0

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

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

Download the lambda library from GitHub,

go get github.com/aws/aws-lambda-go/lambda

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, allowing them to perform create, read, update, and delete operations on the data stored in our DynamoDB table.

We will automate the deployment of the Lambda function and API Gateway using a CloudFormation stack. In this CloudFormation stack, we create all the required resources, including Lambda functions, the roles they assume, and the policies they need. It also sets up and deploys an API with various methods like GET, POST, PUT, and DELETE.

Let's dissect the main components of the CloudFormation template file. Please find the full cloudformation template file here.

  • 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.

Now we are all set to deploy our lambda functions, required roles and policies and API gateway using cloudformation template, let's run this command to create resource stack,

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.