DEV Community

Cover image for Build an API using Node, Express, MongoDB, and Docker
Mangabo Kolawole
Mangabo Kolawole

Posted on

Build an API using Node, Express, MongoDB, and Docker

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

Now we can start installing the dependencies to start our project. But first, let's enable TypeScript.

yarn add typescript
Enter fullscreen mode Exit fullscreen mode

Let's also add some dependencies to use Express and MongoDB.

yarn add express cors mongoose
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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 and res parameters and returns a promise
  • And with the help of the Menu model created earlier, we can now retrieve all the menus 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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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;
  }
};
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Update a menu - http://localhost:4000/menu/<menuId>

PUT http://localhost:4000/menu/<menuId>
Content-Type: application/json

{
    "price": 5
}
Enter fullscreen mode Exit fullscreen mode

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 . .
Enter fullscreen mode Exit fullscreen mode

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 and yarn.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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The setup is completed. Let's build our containers and test if everything works locally.

docker-compose up -d --build
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (11)

Collapse
racribeiro profile image
Rui Ribeiro

Why mongoose and not just mongodb library? (document models?)

An API should always provide OpenAPI schema, how can you add it to this project? If you have models, it should be straight forward...

Collapse
koladev profile image
Mangabo Kolawole Author

Yes. I went with mongoose because of the abstraction it provides to define schema and make queries.

About the OpenAPI schema, I think that there are swagger packages for Nodejs you can use to document your API. I haven't try it yet.

Let me know if you have some suggestions

Collapse
andrewbaisden profile image
Andrew Baisden

This was great I was planning to write an article like this eventually 😄

Collapse
koladev profile image
Mangabo Kolawole Author

Interesting.

I would love to read it :)

Collapse
juni profile image
Junaid Anwar

Would be great if you improve this article to cover the authenticated/protected routes.

Collapse
meo3w profile image
Phil Hasenkamp

Well done!

Collapse
koladev profile image
Mangabo Kolawole Author

Thank you Phil :)

Collapse
godswillumukoro profile image
Godswill Umukoro

Great article bro

Collapse
koladev profile image
Mangabo Kolawole Author

Thank you man

Collapse
juni profile image
Junaid Anwar

Great article! What would you do to protect the crud routes?

Collapse
koladev profile image
Mangabo Kolawole Author

Looking to add JWT authentication to these routes.

I'll write something on it. Stay tuned :)