Building a serverless API with email notifications in AWS with Terraform - Part 1
Published: 2024-01-09 17:42:31


> Cover Image by [Fotis Fotopoulos](https://unsplash.com/@ffstop) on [Unsplash](https://unsplash.com/)

Hi earthlings!

In this journey, we'll build a system using Serverless architecture, Terraform, and GitHub Actions. Buckle up as we embark on an adventure.

Our main project will now be a serverless architecture featuring API Gateway, Lambdas, DynamoDB, SNS, and SQS. We'll work with Terraform, our infrastructure-as-code tool, and use GitHub Actions for our continuous integration and deployment, where we'll deploy our infrastructure and lambda apps with a simple push to the main branch.

We'll organize our HTTP methods, configure the AWS provider in Terraform, and even set up an S3 bucket for storing our precious Terraform state.

## Let us begin!

### Requirements
- An AWS account
- Any Code Editor of your choice - I use Visual Studio Code
- NodeJS
- GitHub account - We'll be using GitHub Actions to deploy our Terraform code

### Regarding AWS Costs
Everything we'll be using here is free or has a very low cost that will not be charged unless you add very high usage. If you are afraid of unknown charges, you can set up a $0.01 budget to alert you if you are being charged for anything.

## The Project

Here, we are going to be building a whole serverless architecture:

- **API Gateway**: This is where the endpoints will be mapped and exposed.
- **Lambdas**: They will handle API Gateway events and the SQS events.
- **DynamoDB**: It will be our database.
- **SQS**: Our message queue, where the email notification lambda will be notified whenever a movie is created, deleted, or updated.
- **SNS**: Notification services to send events to SQS for a fanout pattern
- **SES**: AWS Simple Email System to manage and send emails from AWS

![Project Serverless Architecture](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q81hybjif075qfeb4b53.png)

We'll also be using:

- **Terraform**: Our infrastructure as code that will create and manage our whole AWS infrastructure.
- **GitHub Actions**: Our CI/CD, which will build and deploy our infrastructure and our lambdas

In this part 1, we'll be implementing the following components of our project.


![First part of the project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kcdc17oqmyx3ijot90uj.png)

## Why Serverless?

Serverless computing is a cloud computing model where you don't have to provision or manage servers. Instead, the cloud provider automatically manages the infrastructure, allowing developers to focus solely on writing code and deploying applications.

The term "serverless" doesn't mean no servers are involved. It means you don't have to worry about the underlying server infrastructure.

Some of the benefits of serverless are:

- **Cost Savings**: You only pay for the computing resources your code consumes.
- **Scalability**: Serverless platforms automatically scale your applications based on demand without manual intervention.
- **Zero Idle Capacity**: Your resources are only allocated when needed, so you won't have provisioned resources without being used.

## Let's Start

Let's begin our project. We are adding our first lambda to get a movie by its ID.

### Lambda Module

Create a folder for your project, and inside it, create a folder named `iac`. This is where we'll be adding all our infrastructure as a code. Now create a new folder inside it named `modules`. Here, we'll be adding our reusable Terraform modules. And now, add a folder `lambda` for our Lambda function module.

Inside the `lambda` folder, create three files: `main.tf`, `datasources.tf`, and `variables.tf`.

**main.tf**:
```hcl
resource "aws_iam_role" "iam_for_lambda" {
  name               = "${var.name}-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_lambda_function" "lambda" {
  filename      = data.archive_file.lambda.output_path
  function_name = var.name
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = var.handler
  runtime       = "nodejs20.x"
}
```

We also declare a role, which all lambda functions need, and the lambda code itself in `aws_lambda_function`.

Note the keywords data and var. The first is for data from the data sources, and the second is for anything passed to the module through variables.

Now for the `variables.tf`:
```hcl
variable "name" {
  description = "The name of the Lambda function"
  type        = string
  nullable    = false
}

variable "handler" {
  description = "The handler function in your code for he Lambda function"
  type        = string
  default     = "index.handler"
}
```

And for the `datasources.tf`:
```hcl
locals {
  filename = strcontains(var.runtime, "node") ? "index.mjs" : "main"
}

data "archive_file" "lambda" {
  type        = "zip"
  source_file = "./modules/lambda/init_code/${local.filename}"
  output_path = "${var.name}_lambda_function_payload.zip"
}

data "aws_iam_policy_document" "assume_role" {

  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]

  }
}
```

Here, we define the IAM policy for the lambda role and the file that will be added to the lambda.

This file is required for our Terraform code, even if you are doing deployment in a different flow, which you will be doing. 

This is also seen in the filename local variable, where we assign the file depending on the lambda runtime. So, let's add our seed code.

```hcl
data "aws_iam_policy_document" "lambda_logging" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = ["arn:aws:logs:*:*:*"]
  }
}
```

And then, in the `main.tf`, add the policy attachment:
```hcl
resource "aws_iam_policy" "lambda_logging" {
  name        = "lambda_logging_${aws_lambda_function.lambda.function_name}"
  path        = "/"
  description = "IAM policy for logging from a lambda"
  policy      = data.aws_iam_policy_document.lambda_logging.json
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = aws_iam_policy.lambda_logging.arn
}
```

Create a folder named `init_code` under the `lambda` module folder.

For the Node.js seed code, you can create a new file `index.mjs` and add the following code:
```javascript
// Default handler generated in AWS
export const handler = async (event) => {
  const response = {
    statusCode: 200,
    body: JSON.stringify('Hello from Lambda!'),
  };
  return response;
};
```

Note that it needs to be `mjs` file because we are not adding a `project.json` file to define the module. The file needs to be with this extension so Node.js will handle the code as ECMAScript modules.

### Adding the main infra code

In the `iac` folder, create a `lambdas.tf` file with the following code:
```hcl
module "get_movie_lambda" {
  source        = "./modules/lambda"
  name          = "get-movie"
  runtime       = "nodejs20.x"
  handler       = "index.handler"
}
```

We also need to configure Terraform to use AWS as its provider. Create a `provider.tf` file with the following code:
```hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Configure the AWS Provider
provider "aws" {
  region = var.region
}
```

Now, create two files, `variables.tf` to declare the default variables of our IaC:
```hcl
variable "region" {
  description = "Default region of your resources"
  type        = string
  default     = "eu-central-1"
}
```

And for `variables.tfvars` to pass variable values that are not secret, but we might want to change depending on the deployment configuration:
```hcl
region="eu-central-1" // Chage here to your region here
```

If you'd like Terraform to keep track of the changes to update the components, you need to add where it will save and manage the state. Here, we'll be using an S3 bucket for that.

Create an S3 bucket with the name `terraform-medium-api-notification` and modify the `provider.tf` file with the following code:
```hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "terraform-medium-api-notification"
    key    = "state"
    region = "eu-central-1" // Chage here to your region here
  }
}

# Configure the AWS Provider
provider "aws" {
  region = var.region
}
```

Note that you can choose the region that you are nearest to instead of `eu-central-1`. I just chose it because it is the closest to me. You could also provide this through the `AWS_DEFAULT_REGION` or `AWS_REGION` environment variables.

We are ready to build the workflow to deploy our infrastructure to AWS.

## Deploying the infrastructure

To deploy our infrastructure, we'll be using Github Actions.
The CI solutions in GitHub allow us to run scripts in our code when we change it. If you'd like to know more about it, check the documentation here.

To perform this step, you'll need to generate an AWS Access Key and Secret for a user that has the rights to create the resources you define in AWS.

Add these secrets to your repository action secrets in your in `Settings`:

![GitHub settings](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/13rbv5myskat0l4oac1k.png)

Now, in the root folder, let's create a `.github` folder and a workflows folder inside of it. Now, create a file named `deploy-infrastructure.yml` and add the following code:
```yml
name: Deploy Infrastructure
on:
  push:
    branches:
      - main
    paths:
      - iac/**/*
      - .github/workflows/deploy-infra.yml

defaults:
  run:
    working-directory: iac/

jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: Checkout
        uses: actions/checkout@v3

      - name: Configure AWS Credentials Action For GitHub Actions
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1 # Use your preferred region

      # Install the latest version of Terraform CLI and configure the Terraform CLI configuration file with a Terraform Cloud user API token
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3

      # Initialize a new or existing Terraform working directory by creating initial files, loading any remote state, downloading modules, etc.
      - name: Terraform Init
        run: terraform init

      # Checks that all Terraform configuration files adhere to a canonical format
      - name: Terraform Format
        run: terraform fmt -check

      # Generates an execution plan for Terraform
      - name: Terraform Plan
        run: terraform plan -out=plan -input=false -var-file="variables.tfvars"

        # On push to "main", build or change infrastructure according to Terraform configuration files
        # Note: It is recommended to set up a required "strict" status check in your repository for "Terraform Cloud". See the documentation on "strict" required status checks for more information: https://help.github.com/en/github/administering-a-repository/types-of-required-status-checks
      - name: Terraform Apply
        run: terraform apply -auto-approve -input=false plan
```

Every time you push a change to a file inside the `iac` folder, it will trigger this action, and it will automatically generate the resources in AWS for you:

![Deploy Infrastructure workflow run](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/isfuynliz9t9rdfd0z1e.png)

Note that in the `Terraform Plan` step, terraform outputs all the changes it will perform in AWS based on the current state in the S3 bucket.

Now you can go to AWS on the Lambda page and see your recently created function:

![AWS Lambda page with get-movie lambda](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uxi1k1c6yp6z6jv8kqjc.png)

To test it, you click on it and then on the `Test` tab.

![get-movie lambda details](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1vj6ngejz0ed7kt51gm1.png)

There, you can give a name to the test event that will be sent to the Lambda and then click on the `Test` button.

![Lambda Test tab](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xbr09eplsgwmpzt3city.png)

You should see a success notification with the return from the lambda:

![get-movie lambda test result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ubgnhliysnamhp60z4sh.png)

## Adding GET endpoint

Now, let's add a GET endpoint through API Gateway so we can call our lambda through HTTP requests.

Let's first create a module for our HTTP methods. Under the folder `modules`, create a folder `rest-api-method`. Then, create three files: `main.tf`, `variables.tf`, `and outputs.tf`.

For the `variables.tf`, add the following code:
```hcl
variable "http_method" {
  description = "The HTTP method"
  type        = string
}

variable "resource_id" {
  description = "The ID of the resource this method is attached to"
  type        = string
}

variable "api_id" {
  description = "The ID of the API this method is attached to"
  type        = string
}

variable "integration_uri" {
  description = "The URI of the integration this method will call"
  type        = string
}

variable "resource_path" {
  description = "The path of the resource"
  type        = string
}

variable "lambda_function_name" {
  description = "The name of the Lambda function that will be called"
  type        = string
}

variable "region" {
  description = "The region of the REST API resources"
  type        = string
}

variable "account_id" {
  description = "The ID of the AWS account"
  type        = string
}
```

For the `outputs.tf`:
```hcl
output "id" {
  value = aws_api_gateway_method.method.id
}

output "integration_id" {
  value = aws_api_gateway_integration.integration.id
}
```

Now for the `main.tf`:
```hcl
resource "aws_api_gateway_method" "method" {
  authorization = "NONE"
  http_method   = var.http_method
  resource_id   = var.resource_id
  rest_api_id   = var.api_id
}

resource "aws_api_gateway_integration" "integration" {
  http_method             = aws_api_gateway_method.method.http_method
  integration_http_method = "POST" # Lambda functions can only be invoked via POST
  resource_id             = var.resource_id
  rest_api_id             = var.api_id
  type                    = "AWS_PROXY"
  uri                     = var.integration_uri
}

resource "aws_lambda_permission" "apigw_lambda" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = var.lambda_function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "arn:aws:execute-api:${var.region}:${var.account_if}:${var.api_id}/*/${aws_api_gateway_method.method.http_method}${var.resource_path}"
}
```

This will generate an HTTP method attached to your API and use lambda proxy integration. We want our Lambda to be responsible for the HTTP behavior of the request and response. So, only the API Gateway will pass it through.
Now, in the root `iac` folder, create a `rest-api.tf` file and add the following code:
```hcl
# API Gateway
resource "aws_api_gateway_rest_api" "movies_api" {
  name = "movies-api"
}

resource "aws_api_gateway_deployment" "movies_api_deployment" {
  rest_api_id = aws_api_gateway_rest_api.movies_api.id
  stage_name  = aws_api_gateway_stage.live.stage_name

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.movies_root_resource.id,
      module.get_movie_method.id,
      module.get_movie_method.integration_id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_stage" "live" {
  deployment_id = aws_api_gateway_deployment.movies_api_deployment.id
  rest_api_id   = aws_api_gateway_rest_api.movies_api.id
  stage_name    = "live"
}

resource "aws_api_gateway_resource" "movies_root_resource" {
  parent_id   = aws_api_gateway_rest_api.movies_api.root_resource_id
  path_part   = "movies"
  rest_api_id = aws_api_gateway_rest_api.movies_api.id
}

module "get_movie_method" {
  source               = "./modules/rest-api-method"
  api_id               = aws_api_gateway_rest_api.movies_api.id
  http_method          = "GET"
  resource_id          = aws_api_gateway_resource.movies_root_resource.id
  resource_path        = aws_api_gateway_resource.movies_root_resource.path
  integration_uri      = module.get_movie_lambda.invoke_arn
  lambda_function_name = module.get_movie_lambda.name
  region               = var.region
  account_id           = vat.account_id
}
In the variables.tf, add the variable for account_id:
variable "account_id" {
  description = "The ID of the default AWS account"
  type        = string
}
```

You can either add your account ID to the `variables.tfvars` files, or you can add it as an inline variable to the workflow `Terraform Plan` and the `Terraform Apply` steps:
```yml
# Generates an execution plan for Terraform
- name: Terraform Plan
  run: terraform plan -out=plan -input=false -var-file="variables.tfvars" -var account_id="YOUR_ACCOUNT_ID"

# On push to "main", build or change infrastructure according to Terraform configuration files
# Note: It is recommended to set up a required "strict" status check in your repository for "Terraform Cloud". See the documentation on "strict" required status checks for more information: https://help.github.com/en/github/administering-a-repository/types-of-required-status-checks
- name: Terraform Apply
  run: terraform apply -auto-approve -input=false plan
```

Just remember to replace the value of `YOUR_ACCOUNT_ID` with your account account ID value.

This will generate an API with the resource `movies`, which will be the path `/movies` of your API.

It will also create a stage named `live`. Stages are the equivalent of deployment environments. You need the API deployed to a stage to call it. So for our `movies` endpoint, it will be `/live/movies`.

Then, it will create a deployment that will configure a rule that it should deploy to the `live` stage whenever we make changes to the method, integration, or resource.

Now, push it to GitHub and wait for the workflow and your API to be created. After it is finished, you can go to the API Gateway page of AWS and see your API.

![movies-api in API Gateway page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1s46xxiv2m2foe0s8kw1.png)

And when you click on it, you can see all the details about the resources:

![Resources of movies-api](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1m6llz2lsvv9isnhnz8w.png)

To see the public URL, you can go to the Stages section:

![movies-api stages](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jl3d90q8fbv090sdmfsb.png)

Now, if you call the `/movies` in your browser, you should get the response from the Lambda

![GET /movies test result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j9klhtfgrb3fswuvvl7h.png)

We must make one adjustment to ensure we use the correct path.

We created the resource `/movies` and added the method `GET` there, but our lambda will fetch a movie by ID in the future, so we need to create a new resource to attach our lambda to it correctly.

So, let's create a new resource by adding the following code to the root `rest-api.tf` file:
```hcl
resource "aws_api_gateway_resource" "movie_resource" {
  parent_id   = aws_api_gateway_resource.movies_root_resource.id
  path_part   = "{movieID}"
  rest_api_id = aws_api_gateway_rest_api.movies_api.id
}
```

Add it to the `redeployment` trigger in the `movies_api_deployment` :
```hcl
resource "aws_api_gateway_deployment" "movies_api_deployment" {
  rest_api_id = aws_api_gateway_rest_api.movies_api.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.movies_root_resource.id,
      aws_api_gateway_resource.movie_resource.id,
      module.get_movie_method.id,
      module.get_movie_method.integration_id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }
}
```

And then modifying the `get_movie_method module` to point to the new resource:
```hcl
module "get_movie_method" {
  source               = "./modules/rest-api-method"
  api_id               = aws_api_gateway_rest_api.movies_api.id
  http_method          = "GET"
  resource_id          = aws_api_gateway_resource.movie_resource.id
  resource_path        = aws_api_gateway_resource.movie_resource.path
  integration_uri      = module.get_movie_lambda.invoke_arn
  lambda_function_name = module.get_movie_lambda.name
}
```

Push the code to GitHub, and Terraform will modify your infrastructure.
Your API should look like this:

![Updated Resources with GET /movies/{movieID}](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wuyl1uym445gmt45gg4h.png)

## Adding DynamoDB

Now that we have a functioning API let's add our database, DynamoDB, and hook it to our `GET` endpoint with some seed data.

### Terraforming DynamoDB

So, let's start by adding a new file to our `iac` folder named `dynamodb.tf` with the following code:
```hcl
resource "aws_dynamodb_table" "movies-table" {
  name           = "Movies"
  billing_mode   = "PROVISIONED"
  read_capacity  = 1
  write_capacity = 1
  hash_key       = "ID"
  range_key      = "Title"

  attribute {
    name = "ID"
    type = "S"
  }
}
```

This will generate a minimum capacity table named `Movies`, and with a partition key named `ID` of type string.

When you push the code to GitHub, and the action runs, you can go to the DynamoDB section of AWS and see the Movies table there.

![DynamoDB Movies table in AWS](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/teyjer7qxmfe7qxoxzws.png)

Let's add a few seed items. In the `dynamodb.tf` file, add the following code for four table items:
```hcl
resource "aws_dynamodb_table_item" "the_matrix" {
  table_name = aws_dynamodb_table.movies-table.name
  hash_key   = aws_dynamodb_table.movies-table.hash_key
  range_key  = aws_dynamodb_table.movies-table.range_key

  item = jsonencode(
    {
      ID    = { S = "1" },
      Title = { S = "The Matrix" },
      Genres = { SS = [
        "Action",
        "Sci-Fi",
        ]
      },
      Rating = { N = "8.7" }
    }
  )
}

resource "aws_dynamodb_table_item" "scott_pilgrim" {
  table_name = aws_dynamodb_table.movies-table.name
  hash_key   = aws_dynamodb_table.movies-table.hash_key
  range_key  = aws_dynamodb_table.movies-table.range_key

  item = jsonencode(
    {
      ID    = { S = "2" },
      Title = { S = "Scott Pilgrim vs. the World" },
      Genres = { SS = [
        "Action",
        "Comedy",
        ]
      },
      Rating = { N = "7.5" }
    }
  )
}

resource "aws_dynamodb_table_item" "star_wars" {
  table_name = aws_dynamodb_table.movies-table.name
  hash_key   = aws_dynamodb_table.movies-table.hash_key
  range_key  = aws_dynamodb_table.movies-table.range_key

  item = jsonencode(
    {
      ID    = { S = "3" },
      Title = { S = "Star Wars: Episode IV - A New Hope" },
      Genres = { SS = [
        "Action",
        "Adventure",
        "Fantasy",
        "Sci-Fi",
        ]
      },
      Rating = { N = "8.6" }
    }
  )
}

resource "aws_dynamodb_table_item" "star_wars_v" {
  table_name = aws_dynamodb_table.movies-table.name
  hash_key   = aws_dynamodb_table.movies-table.hash_key
  range_key  = aws_dynamodb_table.movies-table.range_key

  item = jsonencode(
    {
      ID    = { S = "4" },
      Title = { S = "Star Wars: Episode V - The Empire Strikes Back" },
      Genres = { SS = [
        "Action",
        "Adventure",
        "Fantasy",
        "Sci-Fi",
        ]
      },
      Rating = { N = "8.7" }
    }
  )
}
```

Now push to GitHub, wait for the workflow to run, and go to the DynamoDB Table in AWS to explore the table items and see the created records:

![Movies DynamoDB table seed data](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/16bxc5blbga3o3ujcaik.png)

### Updating the Lambda to fetch by ID

Now that we have our data, we need to modify our lambda to fetch our items.
First, we need to give our Lambda role rights to do `GetItem` actions in the `Movies` table.
Add the following code to the `outputs.tf` file in the lambda module folder:
```hcl
output "role_name" {
  value = aws_iam_role.iam_for_lambda.name
}
```

Now, in the `iac` folder, add a file named `iam-polices.tf` with the following code:
```hcl
data "aws_iam_policy_document" "get_movie_item" {
  statement {
    effect = "Allow"

    actions = [
      "dynamodb:GetItem",
    ]

    resources = [
      aws_dynamodb_table.movies-table.arn
    ]
  }
}

resource "aws_iam_policy" "get_movie_item" {
  name        = "get_movie_item"
  path        = "/"
  description = "IAM policy allowing GET Item on Movies DynamoDB table"
  policy      = data.aws_iam_policy_document.get_movie_item.json
}

resource "aws_iam_role_policy_attachment" "allow_getitem_get_movie_lambda" {
  role       = module.get_movie_lambda.role_name
  policy_arn = aws_iam_policy.get_movie_item.arn
}
```

This will generate a policy that allows `GetItem` in the `Movies` table and attach it to the current lambda IAM role.

Now, in the root folder, create a folder named `apps`, and then a folder `get-movie`.

Inside this folder, let's start a npm project with:
```bash
npm init -y
```

This will generate a new `package.json` file.

Most packages required for the lambda to work and connect with AWS are already packed in AWS and updated occasionally. We are creating this mostly to have the packages available in our local development environment and to set up our module types.

In the `package.json` file, add the following property:
```json
"type": "module"
```

Your file should look similar to:
```json
{
  "name": "get-movie",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
```

> Note that if the package is unavailable in the AWS environment, you must pack the node_modules folder with your lambda function code. Or create a Lambda layer that will hold the `node_modules` and can be shared between lambdas.

Let's install the packages we'll need with:
```bash
npm i --save aws-sdk @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
```

Now, create a folder named `src` and add a file `index.js` in it. 

We'll add the code to fetch a movie by its ID with:
```javascript
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

const tableName = "Movies";

export const handler = async (event) => {
  const movieID = event.pathParameters?.movieID;

  if (!movieID) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        message: "Movie ID missing",
      }),
    };
  }

  console.log("Getting movie with ID ", movieID);

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

  const command = new GetCommand({
    TableName: tableName,
    Key: {
      ID: movieID.toString(),
    },
  });

  try {
    const dynamoResponse = await docClient.send(command);
    if (!dynamoResponse.Item) {
      return {
        statusCode: 404,
        body: JSON.stringify({
          message: "Movie not found",
        }),
      };
    }

    const body = {
      title: dynamoResponse.Item.Title,
      rating: dynamoResponse.Item.Rating,
      id: dynamoResponse.Item.ID,
    };

    body.genres = Array.from(dynamoResponse.Item.Genres);

    const response = {
      statusCode: 200,
      body: JSON.stringify(body),
    };

    return response;
  } catch (e) {
    console.log(e);

    return {
      statusCode: 500,
      body: JSON.stringify({
        message: e.message,
      }),
    };
  }
};
```

This lambda gets the event sent by API Gateway and extracts the movie ID. Then we do some simple validations and get the movie from `DynamoDB`, transform the data to an API resource so we don't expose our data model, and return it to the API Gateway to send to the client.

You can see the documentation here if you'd like to learn more about the event from API Gateway with Lambda proxy integration.

> Remember to stringify the body, or you'll face 500 errors.

### Building and deploying

Lastly, we must create a quick build script to organize our code.
First, install the following package:
```bash
npm i -D copyfiles
```

I'm using it because it makes commands to copy files independent from operating systems.

In the `package.json` file, add the following build script:
```json
{
  "name": "get-movie",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "type": "module",
  "scripts": {
    "build": "copyfiles -u 1 src/**/* build/ && copyfiles package.json build/",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.468.0",
    "@aws-sdk/lib-dynamodb": "^3.468.0",
    "aws-sdk": "^2.1513.0"
  },
  "devDependencies": {
    "copyfiles": "^2.4.1"
  }
}
```
And now, let's add the workflow that will push our code to the `get-movie` lambda.

Create a `deploy-get-movie-lambda.yml` file in the `.github/workflows` folder and add the following code:
```yml
name: Deploy Get Movie Lambda
on:
  push:
    branches:
      - main
    paths:
      - apps/get-movie/**/*
      - .github/workflows/deploy-get-movie-lambda.yml

defaults:
  run:
    working-directory: apps/get-movie/

jobs:
  terraform:
    name: "Deploy GetMovie Lambda"
    runs-on: ubuntu-latest
    steps:
      # Checkout the repository to the GitHub Actions runner
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Configure AWS Credentials Action For GitHub Actions
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-central-1

      - name: Install packages
        run: npm install

      - name: Build
        run: npm run build

      - name: Zip build
        run: zip -r -j main.zip ./build

      - name: Update Lambda code
        run: aws lambda update-function-code --function-name=get-movie --zip-file=fileb://main.zip
```

Remember to set the correct region.

Now push the code to GitHub, wait for it to run, and then call the API Gateway endpoint `/movies/1`. You should receive a response similar to:
```json
{
  "id":"1",
  "title":"The Matrix",
  "rating":8.7,
  "genres":[
    "Action",
    "Sci-Fi"
  ]
}
```

Amazing! We have our first endpoint completed!

## Conclusion

In this first part, we built the foundation for our project, a serverless CRUD API with email notification.

We saw how to write and deploy our infrastructure using Terraform, a powerful IaC tool that facilitates how we build and manage our infrastructure.

We wrote our first lambda using NodeJS and easily connected it to DynamoDB to fetch our data.

In the next part, we'll dive deeper and finish our CRUD API with more lambdas, but we'll build some of them using other languages, like Go.

Hope to see you there!

The code for this project can be found [here](https://github.com/alopes2/medium-api-with-notification).

Happy coding! 💻