DEV Community

Cover image for Create NestJS API using Typescript, MongoDB, Docker, Docker Compose
Nyoman Sunima
Nyoman Sunima

Posted on • Originally published at hashnode.com

Create NestJS API using Typescript, MongoDB, Docker, Docker Compose

Ok, let's create a simple CRUD backend API for your app using the:

  • NestJS

  • MongoDB

  • Docker

  • Docker Compose

  • Mongoose

  • Typescript

What you're learn

In this tutorial you will learn how to create a basic API using Nestjs as the core backend, MongoDB as your database, Mongoose as the database client to connect with NestJS, Docker & Docker Compose as your application runner in the container.

We will make something basic just running the crud operation for user data including the name, email, and bio. You can customize and expand this tutorial based on your preference. At the end of the tutorial, I will share the project repository, so you can easily clone and try it on your machine computer.

Architecture

So, before creating the application let's see how the application work, and how the application can be structured.

Backend API Architecture

Create application

First, we need to create our base application using NestJS. Start to create boilerplate code by running

npx @nestjs/cli new nestjs-backend-api
Enter fullscreen mode Exit fullscreen mode

This command will trigger the NestJS cli to create a Nest application starter. Nest also already install some dependencies for the application. Wait a second after the installation is finished.

Install dependencies

Let's install some dependencies to support the application. I will cover and explain what, and why you should install this dependency in your application.

Now run this command in your application terminal.

npm i -S @nestjs/config @nestjs/mongoose @nestjs/swagger @nestjs/throttler class-transformer class-validator compression helmet mongoose
Enter fullscreen mode Exit fullscreen mode

By running the command above you will be install several dependencies including:

  • @nestjs/config - Allow to resolve the configuration including using the .env file

  • @nestjs/mongoose - The mongoose module in nestjs

  • @nestjs/swagger - Allow NestJS to use OpenAPI Specification, and use the mapped type including partial, omit, etc.

  • @nestjs/throttler - Rate limiter module for application

  • class-transformer, class-validator - Used to manage the application object transform and validation.

  • compression - Allow to compress the body response through Gzip

  • helmet - App protection to avoid the brute force XSS

  • mongoose - Using to connect MongoDB to the application

Update the project script

Update the script to become more clear. We need to change the preview and start using a new command.

"scripts": {
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "preview": "nest start",
    "dev": "nest start --watch",
    "debug": "nest start --debug --watch",
    "start": "node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
},
Enter fullscreen mode Exit fullscreen mode

After changing to this object, now you can easily run applications using

  • npm run dev To run on a development server

  • npm run preview Running the prebuild application to preview

  • npm run start To run the application on production mode

Bootstrap the application

Now let's jump into the src/main.ts file. Then change the code using this code.

import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { ValidationPipe, VersioningType } from '@nestjs/common'
import helmet from 'helmet'
import * as compression from 'compression'

const PORT = parseInt(process.env.PORT, 10) || 4000

async function bootstrap() {
  const app = await NestFactory.create(AppModule)

  // register all plugins and extension
  app.enableCors({ origin: '*' })
  app.useGlobalPipes(new ValidationPipe({}))
  app.enableVersioning({ type: VersioningType.URI })
  app.use(helmet())
  app.use(compression())

  await app.listen(PORT, () => {
    console.log(`🚀 Application running at port ${PORT}`)
  })
}

bootstrap()
Enter fullscreen mode Exit fullscreen mode

lets me explain. First, we need to import all dependencies, and settings into. Then start to use some middleware including the compression, and helmet. Then start o enable the cross-origin sharing resources. You also can change the Cors settings.

Enable to global validation pipe, by using global validation, we automatically use the class-transform and class-validator class. When something error happens in the object, the error will be shown as a response.

Enable the versioning by using URI Type. You can easily manage the version in the controller. This way helps you add some changes in your API without effect the below version.

Wrap the app module

To ensure all applications running you should import all feature modules and another module into the application module. Because if you see in the main.ts file the best application is to create the app from AppModule.

Change the app module using this code

import { Module } from '@nestjs/common'
import { AppController } from './app.controller'
import { ConfigModule } from '@nestjs/config'
import { ThrottlerModule } from '@nestjs/throttler'
import { UserModule } from './user/user.module'
import { MongooseModule } from '@nestjs/mongoose'

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    ThrottlerModule.forRoot({ limit: 10, ttl: 60 }),
    MongooseModule.forRoot(process.env.DATABASE_URI, {
      dbName: process.env.DATABASE_NAME,
      auth: {
        username: process.env.DATABASE_USER,
        password: process.env.DATABASE_PASS,
      },
    }),

    // feature module
    UserModule,
  ],
  controllers: [AppController],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

First, we define the class with a module decorator and define the properties inside. Inside the import is used to import the application module. From, your local module or from dependencies.

So first let's import the Config module, remember the isGlobal is set to true. This will allow you to use your configuration, environment, and settings inside all of your application scopes.

Add the Throttler module and give the limit and ttl. This will protect our backend API from brute-force attacks. The user will only allow access to the same API resource up to 10 times, then wait a minute to try. If you wonder about this, you can learn more about brute force attack security.

Install the Mongoose module to connect with MongoDB. Please specify the URI, and the authentication using username, and password. I will cover this using the .env file later. So stay tuned.

Install the feature module, we will cover this later.

Create User feature

Moving into the user feature, we will need set up and create a new module called. User. inside the user feature, we will handle the user information including username, password, bio, email, and full name.

In this module, we will handle the CRUD a.k.a (Create, Read, Update, Delete) operation for the user. So with that said let's jump into it.

Inside the src folder create a new folder called user. All of your module files including schema, service, provider, and model inside this folder.

so make a new file called user.schema.ts . inside the model folder Then paste this code inside

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument } from 'mongoose'

export type UserDocument = HydratedDocument<User>

@Schema({ collection: 'users', timestamps: true })
export class User {
  @Prop()
  fullName: string

  @Prop()
  email: string

  @Prop()
  bio: string

  @Prop()
  password: string
}

export const UserSchema = SchemaFactory.createForClass(User)
Enter fullscreen mode Exit fullscreen mode

this file that is used to manage the user collection on MongoDB. using timestamp allows us to automatically add the createdAt and updatedAt properties in Mongodb. After the model was defined. We need to create the user's schema using schema factory from Mongoose.

Create a new file called user.input.ts inside the model folder. Then put this code inside.

import { OmitType } from '@nestjs/swagger'
import { IsEmail, IsString } from 'class-validator'

export class CreateUserInput {
  @IsString()
  fullName: string

  @IsEmail()
  email: string

  @IsEmail()
  bio: string

  @IsString()
  password: string
}

export class UpdateUserInput extends OmitType(CreateUserInput, [
  'password'
] as const) {}
Enter fullscreen mode Exit fullscreen mode

This model is specific to using input from the controller. So we set all input in one place. Don't forget to use the class validator to validate the input before the user is processed.

The UpdateUserInput extends the omit type from CreateUserInput class. This comes from @nestjs/swagger. As I say you can use the same class and omit the type to remove the specific property. So you don't need to create it twice. You can explore more about Swagger later.

Create UserPayload

We need to create a type to specify what return to the client. That's why we called it payload. Create a new file called user.payload.ts inside the model folder, then put this code inside.

import { PartialType } from '@nestjs/swagger'
import { User } from './user.schema'

export class UserPayload extends PartialType(User) {
  createdA?: string
  updateAt?: string
}
Enter fullscreen mode Exit fullscreen mode

As I say we use the @nestjs/swagger package here. The PartialType allows us to use the same object to extend it.

Create User Service

Create a new file called user.service.ts then put this code inside the file. This will create a new logic to handle the user.

import { Injectable, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { User } from './model/user.schema'
import { Model } from 'mongoose'
import { CreateUserInput, UpdateUserInput } from './model/user.input'
import { UserPayload } from './model/user.payload'

@Injectable()
export class UserService {
  constructor(@InjectModel(User.name) private userModel: Model<User>) {}

  async createUser(body: CreateUserInput): Promise<UserPayload> {
    const createdUser = new this.userModel(body)
    const user = await createdUser.save()
    return user
  }

  async findUser(id: string): Promise<UserPayload> {
    const user = await this.userModel.findOne({ _id: id }).exec()

    if (!user) {
      throw new NotFoundException(`User with email id:${id} not found `)
    }
    return user
  }

  async listUser(): Promise<UserPayload[]> {
    const users = await this.userModel.find()
    return users
  }

  async updateUser(id: string, body: UpdateUserInput): Promise<UserPayload> {
    await this.userModel.updateOne({ _id: id }, body)
    const updatedUser = this.userModel.findById(id)
    return updatedUser
  }

  async deleteUser(id: string): Promise<void> {
    await this.userModel.deleteOne({ _id: id })
  }
Enter fullscreen mode Exit fullscreen mode

This service handles the CRUD Operation for users, including creating a new user, listing all users, reading details, deleting, and updating. Inside the service constructor, we inject the UserModel using UserSchema.

Create user controller

All the API endpoints, paths, versions, and methods are placed here. Now create a new file called user.controller.ts inside the top-level user folder. Then put this code inside.

import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'
import { CreateUserInput } from './model/user.input'
import { UserService } from './user.service'

@Controller({
  path: 'users',
  version: '1',
})
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  createUser(@Body() body: CreateUserInput) {
    return this.userService.createUser(body)
  }

  @Get('/list')
  listUser() {
    return this.userService.listUser()
  }

  @Get('/:id')
  findUser(@Param('id') id: string) {
    return this.userService.findUser(id)
  }

  @Put('/:id')
  updateUser(@Param('id') id: string, @Body() body: CreateUserInput) {
    return this.userService.updateUser(id, body)
  }

  @Delete('/:id')
  deleteUser(@Param('id') id: string) {
    return this.userService.deleteUser(id)
  }
}
Enter fullscreen mode Exit fullscreen mode

Create user module

Now import all code and wrap it inside the user module. Create a new file called user.module.ts Inside the user. Then put this code inside

import { Module } from '@nestjs/common'
import { UserService } from './user.service'
import { UserController } from './user.controller'
import { MongooseModule } from '@nestjs/mongoose'
import { User, UserSchema } from './model/user.schema'

@Module({
  imports: [
    MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
  ],
  providers: [UserService],
  controllers: [UserController],
})
export class UserModule {}
Enter fullscreen mode Exit fullscreen mode

Don't forget to register the MongoDB schema Using forFeature. then import the controller and service.

Creating environment

Now let's create a new file called .env Inside the root folder. Then put this code inside.

# APPS CONFIG
PORT = # YOUR_DATABASE_PORT

# DATABASE CONFIGS
DATABASE_NAME = # YOUR_DATABASE_NAME
DATABASE_USER = # YOUR_DATABASE_USER
DATABASE_PASS = # YOUR DATABASE_PASS
DATABASE_URI = # YOUR_DATABASE_URI, example: mongodb://localhost:2701
Enter fullscreen mode Exit fullscreen mode

Change this environment using your own value.

Dockerize the application

We need to set up our database using docker. So we need to ensure the application running correctly.

Create Dockerfile

Let's create a new file called Dockerfile in the root folder, then put this code inside.

# Application Docker file Configuration
# Visit https://docs.docker.com/engine/reference/builder/
# Using multi stage build

# Prepare the image when build
# also use to minimize the docker image
FROM node:14-alpine as builder

WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build


# Build the image as production
# So we can minimize the size
FROM node:14-alpine

WORKDIR /app
COPY package*.json ./
ENV PORT=4000
ENV NODE_ENV=Production
RUN npm install
COPY --from=builder /app/dist ./dist
EXPOSE ${PORT}

CMD ["npm", "run", "start"]
Enter fullscreen mode Exit fullscreen mode

This file will be used to configure our application to become a docker image. When running the docker-compose. Service will create a new image from our backend app. and then run it as a container.

The concept is just to copy the package.json and package-lock.json file into the working directory, then install the deps. The command will build the application by running npm run build, then copy all dist folders into a last image builder. At least we also add an env file for PORT. So you can change the port dynamically without breaking the app.

Create Docker Compose

Docker Compose allow you to manage all service and container running inside your computer more easily. Compose allow you to define the service, image, and network in the same place without worrying about remembering command in your terminal. So that's why I recommended using docker-compose rather than using manual commands to run the container.

Create a new file called .docker-compose.yaml inside your root folder, then put this code inside.

# Docker Compose Configuration
# visit https://docs.docker.com/compose/

version: '3.8'
services:
  # app service for your backend
  app:
    container_name: backend
    build:
      context: ./
      dockerfile: Dockerfile
    environment:
      DATABASE_NAME: # DATABASE_NAME
      DATABASE_USER: # DATABASE_USER
      DATABASE_PASS: # DATABASE_PASS
      DATABASE_URI: # DATABASE_URI, example: mongodb://database:27017
    ports:
      - '4000:4000'
    depends_on:
      - database

  # start the mongodb service as container
  database:
    image: mongo:6.0
    container_name: mongodb
    restart: always
    ports:
      - '27017:27017'
    environment:
      MONGO_INITDB_ROOT_USERNAME: # DATABASE_NAME
      MONGO_INITDB_ROOT_PASSWORD: # DATABASE_USER
Enter fullscreen mode Exit fullscreen mode

This compose will create two services called app and database. Inside the app, you can see that without an image found. however, we found a context that reference to Dockerfile. This will allow the user to create a new image for the service.

Otherside the mongo, it's come from dockerhub image with a 6.0 version. You also can define any variables and environment for the image including username, and password.

NOTE: when connect database using docker network in application you will no longer access localhost, intead, you access using service name like database. Example if in local you can connect to database using mongodb://localhost:27017, however after using docker you should use the service name like mongodb://database:27017 . This only work you don't expose the new network for database.

Run backend api

To run this application you can run by using this command.

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

This command will trigger docker compose to run 2 service at the sametime, also pulling the image and tun as docker container. To access you application, you can type http://localhost:4000/v1/users.

Last words

Before jumping please explore and try to code by yourlsef or understand the flow. All of this code are placed in my repository You can clone or fork this repo to understand how it's work.

Top comments (1)

Collapse
 
er0r profile image
Er0r • Edited

For those, who are getting error at app.module.ts can change the throttler like this:

ThrottlerModule.forRoot([{
ttl: 60,
limit: 10
}]),