Node.js and TypeScript Tutorial: Build a rest API with Typescript, NodeJS and a file-based storage system.

Welcome to my blog! In this tutorial, I'll guide you through the process of building a robust micro-ecommerce API using Node.js, Express, and TypeScript. Together, we'll explore the various capabilities and techniques that will enable you to create robust APIs for e-commerce applications.

A key decision we made in this project was to implement a file-based storage system rather than relying on traditional databases like MongoDB. The simplicity of this approach makes it ideal for smaller applications or scenarios that may not require a full-fledged database management system.

This tutorial will cover basic topics such as user management, product handling, and authentication.

You'll gain hands-on experience working with functions that span user and product data, showing how these entities interact within the e-commerce API. By the end of this tutorial, you'll have a solid understanding of how to build robust APIs that enable seamless interaction with user and product resources.

So join me on this exciting journey as we delve into creating a tiny eCommerce API using Node.js, Express , and TypeScript.

To start using TypeScript in Node.js
first create a project directory as shown below.

Next, initialize a Node.js project in the project directory by creating a package.json file with default settings using the following command:

npm init -y

Install project dependencies

Your Node.js project needs some dependencies to create a secure Express server with TypeScript. Install them like this:

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 previously installed package:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>npm i -D @types/express @types/dotenv @types/helmet @types/cors @types/http-status-codes @types/uuid @types/bcryptjs
</code></span></span>

Populate the .env stash file with the following variables defining the ports the server can use to listen for requests:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>PORT=7000
</code></span></span>

Next, find the app.js file in the root of the src folder and import the project dependencies you installed earlier, and use the dotenv.config() method to load any environment variables from your local .env file:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>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}`)
})
</code></span></span>

In this code snippet, a Node.js application is set up using the Express framework. Here's a breakdown of what's happening:

Import the required modules:

express is imported as the main framework for building web applications.

Import dotenv to handle environment variables.

Import cors to enable cross-origin resource sharing.

Import helmet to add security headers to HTTP responses.

The code checks to see if the PORT environment variable is defined. If not, a message will be logged to the console.

Use parseInt() to parse the PORT variable from a string to an integer.

An instance of an Express application is created using express() and assigned to the app variable.

Middleware functionality added to Express applications:

express.json() is used to parse the JSON body of an incoming request.

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 application security by setting various HTTP headers.

An Express application starts listening on a specified port by calling app.listen(). Once the server is running, a message indicating the port number will be logged to the console.

Improving the TypeScript development workflow

The TypeScript compilation process can increase your application's bootstrap time. However, you don't need to recompile the entire project whenever the source code changes. You can set up ts-node-dev to significantly reduce the time it takes to restart the application when making changes.

First install this package to start your development workflow:

npm i -D ts-node-dev

ts-node-dev restarts the target Node.js process on any required file change. However, it shares the Typescript compilation process between restarts, which can significantly speed up restarts.

You can create a development 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 for ts-node-dev:

--respawn : Continue watching for changes after the script exits.

--pretty : Use pretty diagnostic formatter (TS_NODE_PRETTY).

./src/app.ts : This is the entry file for the application.

Now, just run the development script to start your project:

npm run dev

If all is well, you'll see a message that the server is listening on port 7000 for requests.

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 project record.
username : (String) The name of the project.
email  : (number) The price of the item in cents.
Password : (String) A description of the item.

Populate src/users/user.interface.ts with the following definitions:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>export interface User {
    username : string,
    email : string,
    password : string
}

export interface UnitUser extends User {
    id : string
}

export interface Users {
    [key : string] : UnitUser
}
</code></span></span>

This code defines three TypeScript interfaces:

  • A user interface represents a basic user object with three properties:

username , which is a string representing the user's username.
email , which is a string representing the user's email address.
password , which is a string representing the user's password.

  • The UnitUser interface extends the User interface and adds an id property:

id, 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 key of the Users object can be any string.
The value of the Users object is of type UnitUser, which means that every user object in the collection should conform to the UnitUser interface.
In simple terms, these interfaces define the structure and type 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 the data store. You can call it a database if you want.
Populate src/users/user.database.ts with the following code:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><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()
}


</code></span></span>

Let me explain each function in the code above:

loadUsers : This function uses the fs module to read data from a file named "users.json". It tries to parse the data as JSON and return it as a user object. If an error occurs during this process, it logs the error and returns an empty object.

saveUsers : This function saves a user object to the "users.json" file by writing a JSON string representation of the user 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 user object.

findOne : This function takes an id parameter and returns a promise that resolves to the UnitUser object corresponding to that id in the user object.

create : This function takes a userData object as input and returns a promise that resolves to a newly created UnitUser object. It uses the uuid package to generate a random ID and checks to see if a user with that ID already exists. If a user with that ID exists, it generates a new one until it finds a unique one. It then hashes the password of the userData object using bcrypt and saves the hashed password in the UnitUser object. UnitUser objects are 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 uses findAll to retrieve all users, and the find method to find users with matching emails.

comparePassword : This function takes email and supplied_password as parameters and returns a promise that resolves to a UnitUser object if the supplied password matches the password stored by the user, otherwise it returns null. It calls findByEmail to retrieve the user by email, 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 an updated UnitUser object if a user with the specified id exists. It uses findOne to check if the user exists, and updates the user's password if updateValues ​​contains the new password. Update the user's attributes with the values ​​in updateValues, and save the user object with saveUsers.

remove : This function takes an id parameter and returns a promise that resolves to null if the user with the specified id does not exist, or void otherwise. It uses findOne to check if the user exists, and uses the delete keyword to delete the user from the user object. Then use saveUsers to save the updated user object.

These functions serve as methods that our API can use to process and retrieve database information.

Next, let all import all needed functions and modules into the routes file ./src/users.routes.ts and populate it as follows:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>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})
    }
})
</code></span></span>

Here's 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 the user is not found, it returns a 404 status code and a message. If a user is found, it returns a 200 status code along with the total number of users and an array of all users.

userRouter.get("/user/:id") : This function handles a GET request to "/user/:id", where :id represents the ID of a specific user. It calls the findOne function from the database module to retrieve the user with the specified ID. If the user cannot be found, it returns a 404 status code and an error message. If a user is found, it returns a 200 status code with a user object.

userRouter.post("/register") : This function handles a POST request to "/register" for user registration. It extracts username, email and password from the request body. If any of these fields are missing, it returns a 400 status code and 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 and an error message. If no email is found, it calls the create function in 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 for user login to "/login". It extracts the email and password from the request body. If any of these fields are missing, it returns a 400 status code and 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 and an error message. If an email is found, it calls the comparePassword function in the database module to check whether the supplied password matches the stored password. If the passwords don't match, it returns a 400 status code and 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 the ID of a specific user. It extracts username, email and password from the request body. If any of these fields are missing, it returns a 401 status code and an error message. It calls the findOne function from the database module to check if a user with the specified ID exists. If the user cannot be found, it returns a 404 status code and an error message. If the user is found, it calls the database module's update function to update the user's details and returns a 201 status code with the updated user object.

userRouter.delete("/user/:id") : This function handles delete requests for "/user/:id", where :id represents the ID of a specific user. It extracts the id from the request parameter. It calls the findOne function from the database module to check if a user with the specified ID exists. If the user cannot be found, it returns a 404 status code and an error message. If it finds the user, it calls the delete function in the database module to delete the user and returns a 200 status code and a success message.

All these functions define 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 user details, and deleting a user.

Finally, in order to make API calls to these routes , we need to import them into our app.ts file and update our code like this:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>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}`)
})
</code></span></span>

great! Now let's start our server and test our API with Postman.

npm run devin your terminal run

Your terminal should look like 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 call our endpoint.

registered user

login user

get all users

get a single user

update user

delete users:

Note : If you are adding users, your users.json file should keep adding new users and should look like this.

User data store file :

Finally, let's create the login and routes for our product.
So let's copy the contents of our UI, making minor changes to the file./src/product.interface.ts

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>export interface Product {
    name : string,
    price : number;
    quantity : number;
    image : string;
}

export interface UnitProduct extends Product {
    id : string
}

export interface Products {
    [key : string] : UnitProduct
}
</code></span></span>

You can refer to the User Interface section for details on these interface features.

Next, just like in the file, let's populate it with similar logic ./src/users.database.ts../src/products.database.ts

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>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()

}

</code></span></span>

Again, you can refer to the Users section for more details on what these functions provide for our API.

Once our logic checks pass, it's time to implement routes for our product.

./src/products.routes.tsPopulate the file with the following code:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><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})
    }
})
</code></span></span>

Don't forget to import and call the product route in our app.ts file, it should now look like this:

<span style="color:var(--syntax-text-color)"><span style="color:var(--syntax-text-color)"><code>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}`)
})

</code></span></span>

perfect. We now have a mature API built with Typescript and Nodejs. cheer! !

Let's test our endpoint.

create product

All products

single product

update product

delete product

If you add new products, they will be appended to products.jsonthe file, which will look like this:

That's what we've done. If you made it this far, congratulations and thank you!

Comments and suggestions are welcome.

Guess you like

Origin blog.csdn.net/jascl/article/details/131304167