DEV Community

Cover image for Building Serverless APIS with Serverless,Node.js, Typescript, DynamoDB, and Lambda.
Oladele Omoarukhe
Oladele Omoarukhe

Posted on

Building Serverless APIS with Serverless,Node.js, Typescript, DynamoDB, and Lambda.

Node.js has become the go-to platform for building server-side applications with JavaScript. Its ecosystem, starting with foundational tools like Express, has evolved significantly, offering diverse frameworks and libraries catering to various design paradigms. Traditionally, building APIs involved writing code to configure and manage servers.

However, the rise of serverless architectures has shifted this paradigm, enabling developers to focus on application logic without worrying about server management. This approach has gained tremendous popularity, especially with platforms like AWS.

Despite its advantages, beginners often face challenges finding clear resources to start building serverless APIs. This article aims to bridge that gap by providing a step-by-step guide to creating serverless API endpoints for a mini e-commerce application using AWS.
It specifically provides a step-by-step guide to building serverless CRUD API endpoints for managing products in a mini e-commerce application using AWS.

Below are a list of technologies required to build this and how they contribute to the entire makeup.

  1. Node.js - Provides the runtime environment for our application code. It is lightweight and fast for server-side JavaScript.It provides excellent support for asynchronous programming, which is crucial for handling HTTP requests and DynamoDB queries efficiently. It also helps to handle requests and responses using AWS Lambda.
  2. TypeScript- A superset of Javascript, it adds static typing and modern JavaScript features to the codebase thus improving code quality and maintainability. It also detects type-related errors during development, reducing runtime bugs. It also provides better autocompletion and error detection (amazing stuff!๐Ÿ˜ƒ).
  3. AWS Lambda - It helps execute our application code in a serverless manner. It removes the need to manage servers as well as the additional advantage that It scales automatically with usage. It is also cost-effective since you essentially only pay for execution time. Each API operation (e.g., creating or listing products) runs as a separate Lambda function.It also has a smooth integration with AWS API Gateway to respond to HTTP requests.
  4. AWS API Gateway - Acts as the entry point for HTTP requests to our Lambda functions. It exposes Lambda functions as RESTful APIs. Provides features like request validation, rate limiting, and authorization.It routes HTTP requests (e.g., POST /products) to the appropriate Lambda function.
  5. AWS DynamoDB - Stores data for the application (e.g., product information). NoSQL database designed for high performance and scalability. Also, it's "Pay-per-use" model aligns with the serverless approach. Each product has a Partition Key (PK) for efficient lookups and a Sort Key (SK) for additional structuring.
  6. Serverless Framework - It simplifies the deployment and management of serverless applications. It majorly abstracts away AWS-specific configurations. It also provides a unified YAML-based configuration file to define resources, functions, and plugins.
  7. AWS SDK - It allows the application to interact with AWS services programmatically. This provides APIs for DynamoDB,Lambda, S3, and other AWS services. It simplifies authentication and communication with AWS.
  8. Postman - Tool to test the API endpoints.It verifies the functionality of the API during development and after deployment. This is achieved by sending POST, GET, or other HTTP requests to endpoints and examining responses.

This article expects that you have an AWS account set up with most preferably an IAM user set up which is not the root-user (to follow the least privilege approach to AWS user/resource management). It also requires you have your account configured using the AWS-CLI. This cannot be covered here but there are amazing resources on the internet to learn this. Hopefully I can cover this in a future article as well.

Without further ado, Let's dive in ๐Ÿš€

1) PREREQUISITES

Before we begin coding, It is highly important that we have the following:

  • We have the latest installed version of the Node.js runtime, you can find it here
  • We have an AWS account set up as mentioned above, you can start here
  • We also need to have serverless installed globally using the following command (In some cases you would need to add sudo or run administrator access (For Windows) as you might need root permissions
npm install -g serverless
Enter fullscreen mode Exit fullscreen mode
  • You then need to set up the AWS CLI by first installing it here and configure it with your AWS access details using the command below:
aws configure
Enter fullscreen mode Exit fullscreen mode
  • We are then required to enter our AWS Access Key, Secret Key, region, and default output format.

2) PROJECT SETUP

  • With these basic pre-requisites in place, we are then required to create our new serverless project template using the command below.
serverless 
Enter fullscreen mode Exit fullscreen mode

I personally advise selecting the Node.js, Express and DynamoDB template, to reduce the boilerplate code needed to bootstrap the application , an example is shown below
Serverless-setup

I named the created project mini-ecommerce-api (you can select any name of choice). It creates a folder of the same name which one can navigate to as follows.

cd mini-ecommerce-api
Enter fullscreen mode Exit fullscreen mode

We then proceed to install required dependencies and dev-dependencies in addition to those installed when creating the project as follows

npm install ts-node @types/node aws-sdk
npm install --save-dev typescript @types/aws-lambda

Enter fullscreen mode Exit fullscreen mode

This is a breakdown of the major packages installed

  • Typescript: TypeScript support.
  • ts-node: Runs TypeScript directly without compiling to JS first.
  • @types/node: Type definitions for Node.js.
  • aws-sdk: AWS library to interact with AWS services like DynamoDB.
  • @types/aws-lambda: Provides type support for writing AWS Lambda functions

3) Configure Typescript

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

This creates a tsconfig.json which we then edit as follows

{
  "compilerOptions": {
    "target": "ES6",
    "module": "CommonJS",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Enter fullscreen mode Exit fullscreen mode

4) Project Structure

We then proceed to create files and folders in the structure below

mini-ecommerce-api/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ handlers/
โ”‚   โ”‚   โ”œโ”€โ”€ createProduct.ts
โ”‚   โ”‚   โ”œโ”€โ”€ listProducts.ts
        โ”œโ”€โ”€ getProductId.ts
        โ”œโ”€โ”€ updateProduct.ts
        โ”œโ”€โ”€ deleteProduct.ts
โ”‚   โ”œโ”€โ”€ utils/
โ”‚   โ”‚   โ”œโ”€โ”€ dynamoClient.ts
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ serverless.yml
โ”œโ”€โ”€ tsconfig.json
โ”œโ”€โ”€ package.json

Enter fullscreen mode Exit fullscreen mode

5) Configure Serverless.yml

We then replace default serverless.yml file generated in the project to the following which gives a proper definition to what we are building for this example. This example can be adjusted to your specific project need(s)

#app metadata
org: geocoderserverless 
app: my-serverless-app
service: mini-ecommerce-api-serverless

frameworkVersion: "4"

provider:
  name: aws
  runtime: nodejs18.x
  region: ca-central-1 ## Change ca-central to your  region of choice
  environment: ## This binds your env variable to the dynamoDB product table
    PRODUCTS_TABLE:
      Ref: ProductsTable  
## Lambda permissions.
  iamRoleStatements: 
    - Effect: Allow
      Action:
        - dynamodb:PutItem
        - dynamodb:Scan
        - dynamodb:GetItem
        - dynamodb:UpdateItem
        - dynamodb:DeleteItem
      Resource: arn:aws:dynamodb:ca-central-1:${aws:accountId}:table/ProductsTable ## Change the ca-central to the region of choice
## handler build configuration

## This causes each lambda function to be built and packaged individually, this was done to save space
package: 
  individually: true 
  exclude:
    - node_modules/**
    - .serverless/**
    - .git/**
    - test/**
    - README.md
    - package-lock.json

## Handler functions
functions:
  createProduct:
    handler: src/handlers/createProduct.createProduct ## The handler to create products
    events:
      - http:
          path: products
          method: post

  listProducts:
    handler: src/handlers/listProducts.listProducts
    events:
      - http:
          path: products
          method: get

  getProductById:
    handler: src/handlers/getProductById.getProductById
    events:
      - http:
          path: products/{id}
          method: get

  updateProduct:
    handler: src/handlers/updateProduct.updateProduct
    events:
      - http:
          path: products/{id}
          method: put

  deleteProduct:
    handler: src/handlers/deleteProduct.deleteProduct
    events:
      - http:
          path: products/{id}
          method: delete

#DynamoDB table configuration
resources:
  Resources:
    ProductsTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ProductsTable
        AttributeDefinitions:
          - AttributeName: PK
            AttributeType: S
          - AttributeName: SK
            AttributeType: S
        KeySchema:
          - AttributeName: PK
            KeyType: HASH
          - AttributeName: SK
            KeyType: RANGE
        BillingMode: PAY_PER_REQUEST

Enter fullscreen mode Exit fullscreen mode

6) Code

With the major part of the configuration sorted, we can then proceed to write the code.

a) DynamoDBClient set up (src/utils/DynamoDBClient.ts)

First of all we need code to make database calls to DynamoDB, so we write the following to create a dynamoDBClient utility function which helps us abstract the logic to interact with DynamoDB as follows:

src/utils/DynamoDBClient.ts

import { DynamoDB } from "aws-sdk";

const dynamoDB = new DynamoDB.DocumentClient()

export default dynamoDB
Enter fullscreen mode Exit fullscreen mode

b) Create a Product Interface (src/utils/DynamoDBClient.ts)

For proper type definitions, We can choose to create an interface which defines what a product would require, this helps provide type safety when writing our Lambda functions.

export interface Product {
  name: string;
  price: number;
  description?: string  
}
Enter fullscreen mode Exit fullscreen mode

c) With this set up, we can proceed to create handlers which call lambda functions to execute our CRUD operations. The handler definitions are shown below.

i. Create Product Handler (src/handlers/createProduct.ts)

import { APIGatewayProxyHandler } from 'aws-lambda';
import dynamoDB from '../utils/DynamoDbClient';
import { Product } from '../interfaces/Product';
import { DynamoDB } from 'aws-sdk';

// Handler definition
export const createProduct: APIGatewayProxyHandler = async (event) => {
  const body: Partial<Product> = JSON.parse(event.body || '{}');
  const { name, price, description } = body;
  if (!name || !price) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Name and price are required!' }),
    };
  }

// Definition of parameters including input 
  const params: DynamoDB.DocumentClient.PutItemInput = {
    TableName: process.env.PRODUCTS_TABLE!,
    Item: {
      PK: `PRODUCT#${name}`,
      SK: `PRODUCT`,
      name,
      price,
      description,
    } as Product,
  };



  try {
    await dynamoDB.put(params).promise();
    return {
      statusCode: 201,
      body: JSON.stringify({
        message: 'Product created successfully',
        data: params.Item,
      }),
    };
  } catch (error) {
    console.error('Error creating product:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({
        error: 'Could not create product',
      }),
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

ii. List Products Handler (src/handlers/listProducts.ts)

import { APIGatewayProxyHandler } from 'aws-lambda'
import dynamoDB from '../utils/DynamoDbClient'
import {DynamoDB} from 'aws-sdk'
import { Product } from '../interfaces/Product'
export const listProducts: APIGatewayProxyHandler = async(event)=> {

 const params: DynamoDB.DocumentClient.ScanInput = {

      TableName: process.env.PRODUCTS_TABLE!,
    }

    try {
     const result: DynamoDB.DocumentClient.ScanOutput = await dynamoDB.scan(params).promise()
     const products: Product[] = result.Items as Product[] || []
     return {
      statusCode: 200,
      body: JSON.stringify({
        success: "true",
        data: products
      })
     }
    } catch (error) {
      console.error('Error Fetching products:',error)
      return {
        statusCode: 500,
        body: JSON.stringify({
          error: 'Could not fetch products'
        })
      }
    }
  } 
Enter fullscreen mode Exit fullscreen mode

iii. Fetch Products by ID (PK) Handler (src/handlers/getProductById.ts)

  • For ease of understanding this article I chose to make the name of the product the Primary/Partition Key (PK) and the implementation for this is shown below.
import { APIGatewayProxyHandler } from "aws-lambda";
import { DynamoDB } from "aws-sdk";
import dynamoDB from "../utils/DynamoDbClient";
import { Product } from "../interfaces/Product";

export const getProductById: APIGatewayProxyHandler = async(event)=>{

const productId = event.pathParameters?.id

  if(!productId) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        error: "Product ID is required"
      })
    }
  }

  const params: DynamoDB.DocumentClient.GetItemInput = {
    TableName: process.env.PRODUCTS_TABLE!,
    Key: {
      PK: `PRODUCT#${productId}`,
      SK: "PRODUCT"
    }
  }

  try {

    const result: DynamoDB.DocumentClient.GetItemOutput = await dynamoDB.get(params).promise()
    const product: Product | undefined = result.Item as Product

    if(!product) {
      return {
        statusCode: 404,
        body: JSON.stringify({
          error: "Product not found!"
        })
      }
    }

    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        data: product
      })
    }

  } catch (error) {
    console.error('Error fetching product:', error)
    return {
      statusCode: 500,
      body: JSON.stringify({error: `Could not fetch product with id ${productId}`})
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

iv Update a product by ID handler (src/handlers/updateProduct.ts)

import { APIGatewayProxyHandler } from "aws-lambda";
import { Product } from "../interfaces/Product";
import { DynamoDB } from "aws-sdk";
import dynamoDB from "../utils/DynamoDbClient";

export const updateProduct: APIGatewayProxyHandler = async (event)=>{
  const productId = event.pathParameters?.id
  const body: Partial<Product> = JSON.parse(event.body || "{}")

  const {name, price, description} = body

  if(!productId || (!name && !price && !description)) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        error: 'PRODUCT ID and least one field should be updated'
      })
    }
  }

  // set up to store edited values
  const updateExpressions: string[] = []
  const expressionAttributeNames: Record<string,string> = {}
  const expressionAttributeValues: Record<string,any> = {}

  if(name) {
    updateExpressions.push("#name = :name")
    expressionAttributeNames["#name"] = "name"
    expressionAttributeValues[":name"] = name

  }
  if(description) {
    updateExpressions.push("#description = :description")
    expressionAttributeNames["#description"] = "description"
    expressionAttributeValues[":description"] = description

  }
  if(price) {
    updateExpressions.push("#price = :price")
    expressionAttributeNames["#price"] = "price"
    expressionAttributeValues[":price"] = price

  }

  // set up params
  const params: DynamoDB.DocumentClient.UpdateItemInput = {
    TableName: process.env.PRODUCTS_TABLE!,
    Key: { PK: `PRODUCT#${productId}`, SK: "PRODUCT" },
    UpdateExpression: `SET ${updateExpressions.join(", ")}`,
    ExpressionAttributeNames: expressionAttributeNames,
    ExpressionAttributeValues: expressionAttributeValues,
    ReturnValues: "ALL_NEW"
  }


  try {
    const result: DynamoDB.DocumentClient.UpdateItemOutput = await dynamoDB.update(params).promise()
    const updatedProduct: Product = result.Attributes as Product

    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        data: updatedProduct
      })
    }
  } catch (error) {
    console.error("Error updating product:", error);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: "Could not update product." }),
    };
  }

}

Enter fullscreen mode Exit fullscreen mode

v. Delete a product by ID handler (src/handlers/updateProduct.ts)

import { APIGatewayProxyHandler } from "aws-lambda";
import { DynamoDB } from "aws-sdk";
import dynamoDB from "../utils/DynamoDbClient";

export const deleteProduct: APIGatewayProxyHandler = async (event)=> {
  const productId = event.pathParameters?.id

  if(!productId) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        success: false,
        error: 'Product ID is required'
      })
    }
  }

  const params: DynamoDB.DocumentClient.DeleteItemInput = {
    TableName: process.env.PRODUCTS_TABLE!,
    Key: {
      PK: `PRODUCT#${productId}`,
      SK: "PRODUCT",
    },
    ConditionExpression: "attribute_exists(PK)"
  }

  try {
    await dynamoDB.delete(params).promise()
    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        message: "Product deleted successfully"
      })
    }
  } catch (error: any) {
    console.error('Error deleting product:', error)

    if (error.code === "ConditionalCheckFailedException") {
      return {
        statusCode: 404,
        body: JSON.stringify({
          success: false,
          error: "Product not found.",
        }),
      };
    }

    return {
      statusCode: 500,
      body: JSON.stringify({
        success: false,
        error: "Could not delete product.",
      }),
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

7) Deploy Code

We can then proceed to deploy the service with completed code to AWS via the serverless.yml definitions using the following command:

serverless deploy
Enter fullscreen mode Exit fullscreen mode

There might be need to prepend sudo (for linux users) or add administrator access (for windows users) due to permission issues.

This process provisions scalable Lambda functions/handlers to handle requests and responses, creates a Product DynamoDB table, and establishes an optimized architecture using CloudFormation.

If successful we should get the following output, with the api-id and region being your personal api-id and AWS region in an example format shown below:

endpoints:
  POST - https://<api-id>.execute-api.<region>.amazonaws.com/dev/products
  GET - https://<api-id>.execute-api.<region>.amazonaws.com/dev/products
  GET - https://<api-id>.execute-api.<region>.amazonaws.com/dev/products/{id}
  PUT - https://<api-id>.execute-api.<region>.amazonaws.com/dev/products/{id}
  DELETE - https://<api-id>.execute-api.<region>.amazonaws.com/dev/products/{id}
Enter fullscreen mode Exit fullscreen mode

We can then proceed to test the endpoints gotten via a frontend client,in my case POSTMAN, below are screenshots of the results

To create a product

Create a product

To Get all Products

To get all Products

To Get a Single product by ID

To Get a single product by Id

To Update a Single Product by ID

Update Product by Id

We can then confirm if the product was truly updated by checking for all products once more, as we can see the product was updated.

Get all products-2

To Delete a Single Product by ID

Delete-product-by-id

We can then proceed to make one more call to get this product and see if it exists, as we can see below the items count has reduced by one, indicating the product was indeed deleted.

get-all-products-3

The ultimate test however, is to verify that our Lambda functions and DynamoDB table were successfully created and configured correctly. As shown below, both were successfully set up, yay! ๐Ÿ˜๐Ÿ•บ๐Ÿฟ

lambda

DynamoDB

In conclusion, weโ€™ve successfully built serverless Lambda functions to perform CRUD operations and interact with a DynamoDB database using Node.js and TypeScript. While this serves as a foundational guide, thereโ€™s ample room to expand and enhance these concepts. This article however aims to provide a solid starting point for developers exploring the potential of serverless architecture and its practical applications.

You can find the source code on Github

I am also open to more questions and interactions on this and other technical coding subjects.

Stay Learning, Happy Coding ๐Ÿš€

Follow me on LinkedIn here

Top comments (0)