Introduction
Welcome to my blog! In this tutorial, I will guide you through the process of building a robust micro e-commerce API using Node.js, Express, and TypeScript. Together, we will explore various features and techniques that will empower you to create a powerful API for your e-commerce applications.
One of our key decisions in this project was to implement a file-based storage system instead of relying on traditional databases like MongoDB. This approach offers simplicity and ease of implementation, making it ideal for smaller-scale applications or scenarios where a full-fledged database management system may be unnecessary.
The tutorial will cover essential topics such as user management, product handling, and authentication.
You'll gain hands-on experience working with features that span both user and product data, demonstrating how these entities interact within an e-commerce API. By the end of this tutorial, you'll have a comprehensive understanding of building a powerful API that enables seamless interactions with user and product resources.
So, join me on this exciting journey as we dive into creating a micro e-commerce API using Node.js, Express, and TypeScript.
Get Started with TypeScript in Node.js
Start by creating a project directory that looks like this.
Next, initialize a Node.js project within the project directory by creating a package.json file with default settings, using this command :
npm init -y
Install Project Dependencies
Your Node.js project requires a couple of dependencies to create a secure Express server with TypeScript. Install them like so:
npm i express dotenv helmet cors http-status-codes uuid bcryptjs
To use TypeScript, you also need to install a stable version of typescript as a developer dependency:
npm i -D typescript
To use TypeScript effectively, you need to install type definitions for the packages you installed previously:
npm i -D @types/express @types/dotenv @types/helmet @types/cors @types/http-status-codes @types/uuid @types/bcryptjs
Populate the .env hidden file with the following variable that defines the port your server can use to listen for requests:
PORT=7000
Next, locate the app.js file in the root of the src folder and import the project dependencies you installed earlier and load any environmental variables from the local .env file using the dotenv.config() method:
import express from "express"
import * as dotevnv from "dotenv"
import cors from "cors"
import helmet from "helmet"
dotevnv.config()
if (!process.env.PORT) {
console.log(`No port value specified...`)
}
const PORT = parseInt(process.env.PORT as string, 10)
const app = express()
app.use(express.json())
app.use(express.urlencoded({extended : true}))
app.use(cors())
app.use(helmet())
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`)
})
In this code snippet, a Node.js application is being set up using the Express framework. Here's a breakdown of what's happening:
The required modules are imported:
express is imported as the main framework for building the web application.
dotenv is imported to handle environment variables.
cors is imported to enable Cross-Origin Resource Sharing.
helmet is imported to add security headers to HTTP responses.
The code checks if the PORT environment variable is defined. If not, a message is logged to the console.
The PORT variable is parsed from a string to an integer using parseInt().
An instance of the Express application is created using express() and assigned to the app variable.
Middleware functions are added to the Express application:
express.json() is used to parse JSON bodies of incoming requests.
express.urlencoded({extended : true}) is used to parse URL-encoded bodies of incoming requests.
cors() is used to enable Cross-Origin Resource Sharing.
helmet() is used to enhance the security of the application by setting various HTTP headers.
The Express application starts listening on the specified PORT by calling app.listen(). Once the server is running, a message indicating the port number is logged to the console.
Improve TypeScript Development Workflow
The TypeScript compilation process can increase the bootstrapping time of an application. However, you don't need to recompile the entire project whenever there's a change in its source code. You can set up ts-node-dev to significantly decrease the time it takes to restart your application when you make a change.
Start by installing this package to power up your development workflow:
npm i -D ts-node-dev
ts-node-dev restarts a target Node.js process when any of the required files change. However, it shares the Typescript compilation process between restarts, which can significantly increase the restart speed.
You can create a dev npm script in package.json to run your server. Update your package.json file like this.
{
"name": "typescript-nodejs",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "ts-node-dev --pretty --respawn ./src/app.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/nanoid": "^3.0.0",
"@types/uuid": "^9.0.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.0",
"express": "^4.18.2",
"helmet": "^7.0.0",
"http-status-codes": "^2.2.0",
"nanoid": "^4.0.2",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/cors": "^2.8.13",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.17",
"@types/helmet": "^4.0.0",
"@types/http-status-codes": "^1.2.0",
"ts-node-dev": "^2.0.0"
}
}
Let's briefly break down the options that ts-node-dev takes:
--respawn: Keep watching for changes after the script has exited.
--pretty: Use pretty diagnostic formatter (TS_NODE_PRETTY).
./src/app.ts: This is the application's entry file.
Now, simply run the dev script to launch your project:
npm run dev
If everything is working correctly, you'll see a message indicating that the server is listening for requests on port 7000.
Model Data with TypeScript Interfaces
Before creating any routes, define the structure of the data you want to manage. Our user database will have the following properties:
id : (string) Unique identifier for the item record.
username : (string) Name of the item.
email : (number) Price of the item in cents.
password : (string) Description of the item.
Populate src/users/user.interface.ts with the following definition:
export interface User {
username : string,
email : string,
password : string
}
export interface UnitUser extends User {
id : string
}
export interface Users {
[key : string] : UnitUser
}
This code defines three TypeScript interfaces:
- The User interface represents a basic user object with three properties:
username, which is a string representing the username of the user.
email, which is a string representing the email address of the user.
password, which is a string representing the password of the user.
- The UnitUser interface extends the User interface and adds an id property:
id, which is a string representing the unique identifier of the user.
- The Users interface represents a collection of user objects with dynamic keys:
[key: string] indicates that the keys of the Users object can be any string.
The values of the Users object are of type UnitUser, which means each user object in the collection should conform to the UnitUser interface.
In simpler terms, these interfaces define the structure and types of user objects. The User interface defines the basic properties of a user, while the UnitUser interface adds an id property to represent a user with a unique identifier. The Users interface represents a collection of user objects, where the keys are strings and the values are UnitUser objects.
Next, we will create the logic for our data storage. you can call it a database if you like.
Populate src/users/user.database.ts with the following code:
import { User, UnitUser, Users } from "./user.interface";
import bcrypt from "bcryptjs"
import {v4 as random} from "uuid"
import fs from "fs"
let users: Users = loadUsers()
function loadUsers () : Users {
try {
const data = fs.readFileSync("./users.json", "utf-8")
return JSON.parse(data)
} catch (error) {
console.log(`Error ${error}`)
return {}
}
}
function saveUsers () {
try {
fs.writeFileSync("./users.json", JSON.stringify(users), "utf-8")
console.log(`User saved successfully!`)
} catch (error) {
console.log(`Error : ${error}`)
}
}
export const findAll = async (): Promise<UnitUser[]> => Object.values(users);
export const findOne = async (id: string): Promise<UnitUser> => users[id];
export const create = async (userData: UnitUser): Promise<UnitUser | null> => {
let id = random()
let check_user = await findOne(id);
while (check_user) {
id = random()
check_user = await findOne(id)
}
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(userData.password, salt);
const user : UnitUser = {
id : id,
username : userData.username,
email : userData.email,
password: hashedPassword
};
users[id] = user;
saveUsers()
return user;
};
export const findByEmail = async (user_email: string): Promise<null | UnitUser> => {
const allUsers = await findAll();
const getUser = allUsers.find(result => user_email === result.email);
if (!getUser) {
return null;
}
return getUser;
};
export const comparePassword = async (email : string, supplied_password : string) : Promise<null | UnitUser> => {
const user = await findByEmail(email)
const decryptPassword = await bcrypt.compare(supplied_password, user!.password)
if (!decryptPassword) {
return null
}
return user
}
export const update = async (id : string, updateValues : User) : Promise<UnitUser | null> => {
const userExists = await findOne(id)
if (!userExists) {
return null
}
if(updateValues.password) {
const salt = await bcrypt.genSalt(10)
const newPass = await bcrypt.hash(updateValues.password, salt)
updateValues.password = newPass
}
users[id] = {
...userExists,
...updateValues
}
saveUsers()
return users[id]
}
export const remove = async (id : string) : Promise<null | void> => {
const user = await findOne(id)
if (!user) {
return null
}
delete users[id]
saveUsers()
}
Let me explain every function in the code above :
loadUsers: This function reads the data from a file called "users.json" using the fs module. It attempts to parse the data as JSON and returns it as the users object. If an error occurs during the process, it logs the error and returns an empty object.
saveUsers: This function saves the users object to the "users.json" file by writing the JSON string representation of the users object using the fs module's writeFileSync method. If an error occurs during the process, it logs the error.
findAll: This function returns a promise that resolves to an array of UnitUser objects. It uses Object.values(users) to extract the values (users) from the users object.
findOne: This function takes an id parameter and returns a promise that resolves to the UnitUser object corresponding to that id in the users object.
create: This function takes a userData object as input and returns a promise that resolves to the newly created UnitUser object. It generates a random id using the uuid package and checks if a user with that id already exists. If a user with that id exists, it generates a new id until a unique one is found. It then hashes the userData object's password using bcrypt and saves the hashed password in the UnitUser object. The UnitUser object is added to the users object, saved using saveUsers, and returned.
findByEmail: This function takes a user_email parameter and returns a promise that resolves to a UnitUser object if a user with the specified email exists, or null otherwise. It retrieves all users using findAll and finds the user with the matching email using the find method.
comparePassword: This function takes an email and supplied_password as parameters and returns a promise that resolves to a UnitUser object if the supplied password matches the user's stored password, or null otherwise. It calls findByEmail to retrieve the user by email and then uses bcrypt.compare to compare the hashed stored password with the supplied password.
update: This function takes an id and updateValues as parameters and returns a promise that resolves to the updated UnitUser object if the user with the specified id exists. It checks if the user exists using findOne and updates the user's password if updateValues contains a new password. The user's properties are updated with the values from updateValues, and the users object is saved using saveUsers.
remove: This function takes an id parameter and returns a promise that resolves to null if the user with the specified id doesn't exist, or void otherwise. It uses findOne to check if the user exists and deletes the user from the users object using the delete keyword. The updated users object is then saved using saveUsers.
These functions serve as the methods our API can use to process and retrieve information from the database.
Next, let all import all the required functions and modules into the routes file ./src/users.routes.ts and populate as follows :
import express, {Request, Response} from "express"
import { UnitUser, User } from "./user.interface"
import {StatusCodes} from "http-status-codes"
import * as database from "./user.database"
export const userRouter = express.Router()
userRouter.get("/users", async (req : Request, res : Response) => {
try {
const allUsers : UnitUser[] = await database.findAll()
if (!allUsers) {
return res.status(StatusCodes.NOT_FOUND).json({msg : `No users at this time..`})
}
return res.status(StatusCodes.OK).json({total_user : allUsers.length, allUsers})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
userRouter.get("/user/:id", async (req : Request, res : Response) => {
try {
const user : UnitUser = await database.findOne(req.params.id)
if (!user) {
return res.status(StatusCodes.NOT_FOUND).json({error : `User not found!`})
}
return res.status(StatusCodes.OK).json({user})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
userRouter.post("/register", async (req : Request, res : Response) => {
try {
const { username, email, password } = req.body
if (!username || !email || !password) {
return res.status(StatusCodes.BAD_REQUEST).json({error : `Please provide all the required parameters..`})
}
const user = await database.findByEmail(email)
if (user) {
return res.status(StatusCodes.BAD_REQUEST).json({error : `This email has already been registered..`})
}
const newUser = await database.create(req.body)
return res.status(StatusCodes.CREATED).json({newUser})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
userRouter.post("/login", async (req : Request, res : Response) => {
try {
const {email, password} = req.body
if (!email || !password) {
return res.status(StatusCodes.BAD_REQUEST).json({error : "Please provide all the required parameters.."})
}
const user = await database.findByEmail(email)
if (!user) {
return res.status(StatusCodes.NOT_FOUND).json({error : "No user exists with the email provided.."})
}
const comparePassword = await database.comparePassword(email, password)
if (!comparePassword) {
return res.status(StatusCodes.BAD_REQUEST).json({error : `Incorrect Password!`})
}
return res.status(StatusCodes.OK).json({user})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
userRouter.put('/user/:id', async (req : Request, res : Response) => {
try {
const {username, email, password} = req.body
const getUser = await database.findOne(req.params.id)
if (!username || !email || !password) {
return res.status(401).json({error : `Please provide all the required parameters..`})
}
if (!getUser) {
return res.status(404).json({error : `No user with id ${req.params.id}`})
}
const updateUser = await database.update((req.params.id), req.body)
return res.status(201).json({updateUser})
} catch (error) {
console.log(error)
return res.status(500).json({error})
}
})
userRouter.delete("/user/:id", async (req : Request, res : Response) => {
try {
const id = (req.params.id)
const user = await database.findOne(id)
if (!user) {
return res.status(StatusCodes.NOT_FOUND).json({error : `User does not exist`})
}
await database.remove(id)
return res.status(StatusCodes.OK).json({msg : "User deleted"})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
This is what each function does :
userRouter.get("/users"): This function handles a GET request to "/users". It calls the findAll function from the database module to retrieve all users. If no users are found, it returns a 404 status code with a message. If users are found, it returns a 200 status code with the total number of users and the array of all users.
userRouter.get("/user/:id"): This function handles a GET request to "/user/:id" where :id represents a specific user's ID. It calls the findOne function from the database module to retrieve the user with the specified ID. If the user is not found, it returns a 404 status code with an error message. If the user is found, it returns a 200 status code with the user object.
userRouter.post("/register"): This function handles a POST request to "/register" for user registration. It extracts the username, email, and password from the request body. If any of these fields are missing, it returns a 400 status code with an error message. It calls the findByEmail function from the database module to check if the email is already registered. If the email is found, it returns a 400 status code with an error message. If the email is not found, it calls the create function from the database module to create a new user and returns a 201 status code with the newly created user object.
userRouter.post("/login"): This function handles a POST request to "/login" for user login. It extracts the email and password from the request body. If any of these fields are missing, it returns a 400 status code with an error message. It calls the findByEmail function from the database module to check if the email exists. If the email is not found, it returns a 404 status code with an error message. If the email is found, it calls the comparePassword function from the database module to check if the supplied password matches the stored password. If the passwords don't match, it returns a 400 status code with an error message. If the passwords match, it returns a 200 status code with the user object.
userRouter.put('/user/:id'): This function handles a PUT request to "/user/:id" where :id represents a specific user's ID. It extracts the username, email, and password from the request body. If any of these fields are missing, it returns a 401 status code with an error message. It calls the findOne function from the database module to check if the user with the specified ID exists. If the user is not found, it returns a 404 status code with an error message. If the user is found, it calls the update function from the database module to update the user's details and returns a 201 status code with the updated user object.
userRouter.delete("/user/:id"): This function handles a DELETE request to "/user/:id" where :id represents a specific user's ID. It extracts the id from the request parameters. It calls the findOne function from the database module to check if the user with the specified ID exists. If the user is not found, it returns a 404 status code with an error message. If the user is found, it calls the remove function from the database module to delete the user and returns a 200 status code with a success message.
All These functions define the routes and corresponding logic for user-related operations such as retrieving all users, retrieving a specific user, registering a new user, logging in a user, updating a user's details, and deleting a user.
finally, to make API calls to these routes we need to import them into our app.ts file and update our code like this :
import express from "express"
import * as dotevnv from "dotenv"
import cors from "cors"
import helmet from "helmet"
import { userRouter } from "./users/users.routes"
dotevnv.config()
if (!process.env.PORT) {
console.log(`No port value specified...`)
}
const PORT = parseInt(process.env.PORT as string, 10)
const app = express()
app.use(express.json())
app.use(express.urlencoded({extended : true}))
app.use(cors())
app.use(helmet())
app.use('/', userRouter)
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`)
})
Great! now let's start our server and test our API using Postman.
run npm run dev
in your terminal
your terminal should be similar to this
[INFO] 20:55:40 ts-node-dev ver. 2.0.0 (using ts-node ver. 10.9.1, typescript ver. 5.1.3)
Server is listening on port 7000
Great! Let's make calls to our endpoints.
Register users
Login users
Get all users
Get a single user
Update user
Delete user :
NOTE : If you have added users, your users.json file should continuously append new users and should look like this.
Users-data-storage-file :
Finally, let us create the login and routes for our products.
So let's duplicate the contents of our users interface with minor changes into the file ./src/product.interface.ts
export interface Product {
name : string,
price : number;
quantity : number;
image : string;
}
export interface UnitProduct extends Product {
id : string
}
export interface Products {
[key : string] : UnitProduct
}
You can reference the section on the Users interface for details about what these interfaces do.
Next, just like in the ./src/users.database.ts
file, let us populate the ./src/products.database.ts
with a similar logic.
import { Product, Products, UnitProduct } from "./product.interface";
import { v4 as random } from "uuid";
import fs from "fs";
let products: Products = loadProducts();
function loadProducts(): Products {
try {
const data = fs.readFileSync("./products.json", "utf-8");
return JSON.parse(data);
} catch (error) {
console.log(`Error ${error}`);
return {};
}
}
function saveProducts() {
try {
fs.writeFileSync("./products.json", JSON.stringify(products), "utf-8");
console.log("Products saved successfully!")
} catch (error) {
console.log("Error", error)
}
}
export const findAll = async () : Promise<UnitProduct[]> => Object.values(products)
export const findOne = async (id : string) : Promise<UnitProduct> => products[id]
export const create = async (productInfo : Product) : Promise<null | UnitProduct> => {
let id = random()
let product = await findOne(id)
while (product) {
id = random ()
await findOne(id)
}
products[id] = {
id : id,
...productInfo
}
saveProducts()
return products[id]
}
export const update = async (id : string, updateValues : Product) : Promise<UnitProduct | null> => {
const product = await findOne(id)
if (!product) {
return null
}
products[id] = {
id,
...updateValues
}
saveProducts()
return products[id]
}
export const remove = async (id : string) : Promise<null | void> => {
const product = await findOne(id)
if (!product) {
return null
}
delete products[id]
saveProducts()
}
Again, you can reference the user's section for more details on what these functions provide to our API.
Once our logic checks out, it's time to implement the routes for our products.
Populate the ./src/products.routes.ts
file with the following code :
import express, {Request, Response} from "express"
import { Product, UnitProduct } from "./product.interface"
import * as database from "./product.database"
import {StatusCodes} from "http-status-codes"
export const productRouter = express.Router()
productRouter.get('/products', async (req : Request, res : Response) => {
try {
const allProducts = await database.findAll()
if (!allProducts) {
return res.status(StatusCodes.NOT_FOUND).json({error : `No products found!`})
}
return res.status(StatusCodes.OK).json({total : allProducts.length, allProducts})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
productRouter.get("/product/:id", async (req : Request, res : Response) => {
try {
const product = await database.findOne(req.params.id)
if (!product) {
return res.status(StatusCodes.NOT_FOUND).json({error : "Product does not exist"})
}
return res.status(StatusCodes.OK).json({product})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
productRouter.post("/product", async (req : Request, res : Response) => {
try {
const {name, price, quantity, image} = req.body
if (!name || !price || !quantity || !image) {
return res.status(StatusCodes.BAD_REQUEST).json({error : `Please provide all the required parameters..`})
}
const newProduct = await database.create({...req.body})
return res.status(StatusCodes.CREATED).json({newProduct})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
productRouter.put("/product/:id", async (req : Request, res : Response) => {
try {
const id = req.params.id
const newProduct = req.body
const findProduct = await database.findOne(id)
if (!findProduct) {
return res.status(StatusCodes.NOT_FOUND).json({error : `Product does not exist..`})
}
const updateProduct = await database.update(id, newProduct)
return res.status(StatusCodes.OK).json({updateProduct})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
productRouter.delete("/product/:id", async (req : Request, res : Response) => {
try {
const getProduct = await database.findOne(req.params.id)
if (!getProduct) {
return res.status(StatusCodes.NOT_FOUND).json({error : `No product with ID ${req.params.id}`})
}
await database.remove(req.params.id)
return res.status(StatusCodes.OK).json({msg : `Product deleted..`})
} catch (error) {
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({error})
}
})
Don't forget to import and call the product's route in our app.ts file, which should now look like this :
import express from "express"
import * as dotevnv from "dotenv"
import cors from "cors"
import helmet from "helmet"
import { userRouter } from "./users/users.routes"
import { productRouter } from "./products/product.routes"
dotevnv.config()
if (!process.env.PORT) {
console.log(`No port value specified...`)
}
const PORT = parseInt(process.env.PORT as string, 10)
const app = express()
app.use(express.json())
app.use(express.urlencoded({extended : true}))
app.use(cors())
app.use(helmet())
app.use('/', userRouter)
app.use('/', productRouter)
app.listen(PORT, () => {
console.log(`Server is listening on port ${PORT}`)
})
Perfect. We now have a full-fledged API built with Typescript and Nodejs. Hurray!!
Let's test our endpoints.
Create product
All products
Single product
Update product
Delete product
If you add new products they will be appended to the products.json
file and it will look like this :
And that's we are done. If you came this far, Congratulations and Thank you!
Comments and recommendations are welcome.
You can find the complete code on github here -> GITHUB
Top comments (12)
import 'dotenv/config';
is more conciseJust takes up one line π
Great, thank you
You are welcome
Thank you for this blog really helped me out to know how to work in typescript with nodejs and express.
Thank you very much. π
Thank you, very much.
thanks to you, I learned I little more about nodejs and express and how work with typescript.
You are welcome Juan.
Thank you
Now without using the terminal if I need to host my type script based rest API as a Windows Service, what are the steps to follow.
Can you please help?
If not as windows service any other hosting process is also fine to learn for me.
Please update the post with one more thing βΊοΈ like file uploading system please!!
Thank a lot
Excellent ! Thank you very much !