In this tutorial, we'll be using TypeScript and Docker to build a Menu Restaurant API app from scratch with Node, Express, and MongoDB. The Docker part is optional.
Basically, we should be able to:
- retrieve all menus
- retrieve one menu
- create a menu
- update a menu
- delete a menu
Great, let's dive in.
Setting up
To create a new Nodejs project, we'll first run this command on the terminal.
yarn init
It'll ask a couple of questions before initializing the project. Anyway, you can bypass this by adding a -y
flag to the command.
Next step is to create a structure for our project.
├── dist
├── src
├── app.ts
├── controllers
| └── menus
| └── index.ts
├── models
| └── menu.ts
├── routes
| └── index.ts
└── types
└── menu.ts
├── nodemon.json
├── package.json
├── tsconfig.json
Let me quickly explain the structure of the project.
-
dist
will serve as the output folder once the typescript code is compiled to plain JavaScript. -
src
will contains the logic of our API.-
app.ts
is the entry point of the server. -
controllers
will contain functions that handle requests and return data from the model to the client -
models
will contain objects that will allow basic manipulations with our database.
-
-
routes
are used to forward the requests to the appropriate controller. -
types
will contain the interface of our objects in this project.
To continue, let's add some configurations to tsconfig.json
. This will help the computer along following our preferences for development.
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "dist/js",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["src/types/*.ts", "node_modules", ".vscode", ".idea"]
}
Now we can start installing the dependencies to start our project. But first, let's enable TypeScript.
yarn add typescript
Let's also add some dependencies to use Express and MongoDB.
yarn add express cors mongoose
Next, we'll be adding their types as development dependencies. This will help the TypeScript computer understanding the packages.
yarn add -D @types/node @types/express @types/mongoose @types/cors
Let's add some dependencies for auto-reloading the server when a file is modified and start the server concurrently (We'll be able to make changes and start the server simultaneously).
yarn add -D concurrently nodemon
We need to update the package.json
file with the scripts needed to start the server and build the project.
Here's how your package.json
file should look.
{
"name": "menu-node-api",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.17.1",
"mongoose": "^6.0.11",
"nodemon": "^2.0.13",
"typescript": "^4.4.4"
},
"scripts": {
"build": "tsc",
"start": "concurrently \"tsc -w\" \"nodemon dist/js/app.js\""
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/mongoose": "^5.11.97",
"@types/node": "^16.11.1",
"concurrently": "^6.3.0"
}
}
The project is ready. We can start coding now. :)
Building the API
Here's how we'll be working:
- Creating the Menu type
- Creating the Menu model
- Creating the Menu controllers
- Adding the Menu routes
- Configuring
app.ts
to connect to Mongo Atlas and start the server.
Creating the Menu type
We'll be writing a Menu interface that'll be extending the Document
type provided by mongoose
. It'll be useful to interact with MongoDB later.
import { Document } from "mongoose";
export interface IMenu extends Document {
name: string;
description: string;
price: number;
}
Creating a Menu Model
import { IMenu } from "../types/menu";
import { model, Schema } from "mongoose";
const menuSchema: Schema = new Schema(
{
name: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
price: {
type: String,
required: true,
},
},
{ timestamps: true }
);
export default model<IMenu>("Menu", menuSchema);
mongoose
provides with helpful utilities to create a model. Notice that here IMenu
is used as a type for the model before exporting it.
Now that the model is written, we can start interacting with the database on other files.
Creating the controllers
We'll be writing 5 controllers here.
-
getMenus
: To get all the menu objects in the database -
addMenu
: To create a Menu -
updateMenu
: To update a Menu -
deleteMenu
: To delete a Menu -
retrieveMenu
: To retrieve a Menu
Let's start with getMenus
.
// ./src/controllers/menus/index.ts
import { Response, Request } from "express";
import { IMenu } from "../../types/menu";
import Menu from "../../models/menu";
const getMenus = async (req: Request, res: Response): Promise<void> => {
try {
const menus: IMenu[] = await Menu.find();
res.status(200).json({ menus });
} catch (error) {
throw error;
}
};
Firstly, we are importing Request
and Response
types from express
to type the values explicitly. Next step, the getMenus
function is created to fetch data from the database.
- It receives a
req
andres
parameters and returns a promise - And with the help of the
Menu
model created earlier, we can now retrieve all themenus
from MongoDB and return a response containing these objects.
Great, let's move to the addMenu
controller.
const addMenu = async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body as Pick<IMenu, "name" | "description" | "price">;
const menu: IMenu = new Menu({
name: body.name,
description: body.description,
price: body.price,
});
const newMenu: IMenu = await menu.save();
res.status(201).json(newMenu);
} catch (error) {
throw error;
}
};
A bit different from getMenus
, this function now receives a body object that will contain data entered by the user.
Next, we use typecasting to avoid types and make sure the body
variable matches IMenu
, and then we create a new Menu
and then save the Menu
in the database.
const retrieveMenu = async (req: Request, res: Response): Promise<void> => {
try {
const {
params: { id },
} = req;
const menu: IMenu | null = await Menu.findById({ _id: id });
res.status(menu ? 200 : 404).json({ menu });
} catch (error) {
throw error;
}
};
This function will pull out the id
from the req
object and then pass it as an argument to the findById
method to access the object and return it to the client.
const updateMenu = async (req: Request, res: Response): Promise<void> => {
try {
const {
params: { id },
body,
} = req;
const updateMenu: IMenu | null = await Menu.findByIdAndUpdate(
{ _id: id },
body
);
res.status(updateMenu ? 200 : 404).json({
menu: updateMenu,
});
} catch (error) {
throw error;
}
};
This function accepts an id
parameter but also the body
object.
Next, we use the findByIdAndUpdate
to retrieve the corresponding Menu from the database and update it.
const deleteMenu = async (req: Request, res: Response): Promise<void> => {
try {
const deletedMenu: IMenu | null = await Menu.findByIdAndRemove(
req.params.id
);
res.status(204).json({
todo: deletedMenu,
});
} catch (error) {
throw error;
}
};
This function allows us to delete a Menu from the database.
Here, we pull out the id
from req
and pass it as an argument to findByIdAndRemove
method to access the corresponding Menu and delete it from the database.
We have the controllers ready and let's export them.
Here's the final code of the src/controllers/menus/index.ts
file.
import { Response, Request } from "express";
import { IMenu } from "../../types/menu";
import Menu from "../../models/menu";
const getMenus = async (req: Request, res: Response): Promise<void> => {
try {
const menus: IMenu[] = await Menu.find();
res.status(200).json({ menus });
} catch (error) {
throw error;
}
};
const retrieveMenu = async (req: Request, res: Response): Promise<void> => {
try {
const {
params: { id },
} = req;
const menu: IMenu | null = await Menu.findById({ _id: id });
res.status(menu ? 200 : 404).json({ menu });
} catch (error) {
throw error;
}
};
const addMenu = async (req: Request, res: Response): Promise<void> => {
try {
const body = req.body as Pick<IMenu, "name" | "description" | "price">;
const menu: IMenu = new Menu({
name: body.name,
description: body.description,
price: body.price,
});
const newMenu: IMenu = await menu.save();
res.status(201).json(newMenu);
} catch (error) {
throw error;
}
};
const updateMenu = async (req: Request, res: Response): Promise<void> => {
try {
const {
params: { id },
body,
} = req;
const updateMenu: IMenu | null = await Menu.findByIdAndUpdate(
{ _id: id },
body
);
res.status(updateMenu ? 200 : 404).json({
menu: updateMenu,
});
} catch (error) {
throw error;
}
};
const deleteMenu = async (req: Request, res: Response): Promise<void> => {
try {
const deletedMenu: IMenu | null = await Menu.findByIdAndRemove(
req.params.id
);
res.status(204).json({
todo: deletedMenu,
});
} catch (error) {
throw error;
}
};
export { getMenus, addMenu, updateMenu, deleteMenu, retrieveMenu };
API routes
We'll be creating five routes to get, create, update and delete menus from the database. We'll be using the controllers we've created and pass them as parameters to handle the requests when defining the routes.
import { Router } from "express";
import {
getMenus,
addMenu,
updateMenu,
deleteMenu,
retrieveMenu,
} from "../controllers/menus";
const menuRoutes: Router = Router();
menuRoutes.get("/menu", getMenus);
menuRoutes.post("/menu", addMenu);
menuRoutes.put("/menu/:id", updateMenu);
menuRoutes.delete("/menu/:id", deleteMenu);
menuRoutes.get("/menu/:id", retrieveMenu);
export default menuRoutes;
Creating the server
First of all, let's add some env variables that will contain credentials for MongoDB database.
// .nodemon.js
{
"env": {
"MONGO_USER": "your-username",
"MONGO_PASSWORD": "your-password",
"MONGO_DB": "your-db-name"
}
}
You can get the credentials by creating a new cluster on MongoDB Atlas.
As those are database credentials, make sure to not push credentials on a repository or expose them.
// .src/app.ts
import express from "express";
import mongoose from "mongoose";
import cors from "cors";
import menuRoutes from "./routes";
const app = express();
const PORT: string | number = process.env.PORT || 4000;
app.use(cors());
app.use(express.json());
app.use(menuRoutes);
const uri: string = `mongodb+srv://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@cluster0.raz9g.mongodb.net/${process.env.MONGO_DB}?retryWrites=true&w=majority`
mongoose
.connect(uri)
.then(() =>
app.listen(PORT, () =>
console.log(`Server running on http://localhost:${PORT}`)
)
)
.catch((error) => {
throw error;
});
We first start by importing the express
library to work with the use
method to handles the Menus routes.
Next, we use the mongoose package to connect to MongoDB by appending to the URL the credentials held on the nodemon.json
file.
Now, if the connection to the MongoDB database is successful the server will start otherwise an error will be throwing.
We've now done building the API with Node, Express, TypeScript, and MongoDB.
To start your project, run yarn start
and hit http://localhost:4000
.
Here's are some tests you can make to the API using Postman or Insomnia.
Get All Menus -
http://localhost:4000/menu
GET http://localhost:4000/menu
Create a menu -
http://localhost:4000/menu
POST http://localhost:4000/menu
Content-Type: application/json
{
"name": "Hot Dog",
"description": "A hot dog",
"price": 10
}
Update a menu -
http://localhost:4000/menu/<menuId>
PUT http://localhost:4000/menu/<menuId>
Content-Type: application/json
{
"price": 5
}
Let's now dockerize the project.
Docker + Docker Compose (Optional)
Docker is an open platform for developing, shipping, and running applications inside containers.
Why use Docker?
It helps you separate your applications from your infrastructure and helps in delivering code faster.
If it's your first time working with Docker, I highly recommend you go through a quick tutorial and read some documentation about it.
Here are some great resources that helped me:
Dockerfile
The Dockerfile
represents a text document containing all the commands that could call on the command line to create an image.
Add a Dockerfile to the project root:
FROM node:16-alpine
WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
Here, we started with an Alpine-based Docker Image for Node. It's a lightweight Linux distribution designed for security and resource efficiency.
After that, we perform operations like:
- Setting up work variables
- Copying there
package.json
andyarn.lock
file to our app path - Installing the project dependencies
- And last copying the entire project
Also, let's add a .dockerignore
file.
.dockerignore
Dockerfile
node_modules
Once it's done, we can now add docker-compose.
Docker Compose is a great tool (<3). You can use it to define and run multi-container Docker applications.
What do we need? Well, just a YAML file containing all the configuration of our application's services.
Then, with the docker-compose
command, we can create and start all those services.
version: '3.8'
services:
api:
container_name: node_api
restart: on-failure
build: .
volumes:
- ./src:/app/src
ports:
- "4000:4000"
command: >
sh -c "yarn start"
The setup is completed. Let's build our containers and test if everything works locally.
docker-compose up -d --build
Your project will be running on https://localhost:4000/
.
Conclusion
In this article, we've learned how to build an API using NodeJS, TypeScript, Express, MongoDB, and Docker.
And as every article can be made better so your suggestion or questions are welcome in the comment section. 😉
Check the code of this tutorial here.