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.
- 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.
- 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!๐).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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
- 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
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
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
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
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
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"]
}
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
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
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
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
}
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',
}),
};
}
};
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'
})
}
}
}
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}`})
}
}
}
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." }),
};
}
}
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.",
}),
};
}
}
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
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}
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
To Get all Products
To Get a Single product by ID
To Update a Single 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.
To Delete a Single 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.
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! ๐๐บ๐ฟ
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)