DEV Community

Alfi Samudro Mulyo
Alfi Samudro Mulyo

Posted on • Updated on

Build Complete REST API Feature with Nest JS (Using Prisma and Postgresql) from Scratch - Beginner-friendly - PART 1

Scope of discussion:

  1. What we want to build
  2. Nest JS installation + new project initialization
  3. Nest JS overview
  4. Prisma configuration
  5. Validation configuration
  6. Create user endpoints (register, login, update, delete)
  7. Password encryption

I have been looking for a great, nice, superb Node JS framework to build an API. After doing some exploration I found Nest JS which is super great. It's a Node JS framework with many built-in features written in Typescript. Check Nest JS documentation for more details Nest JS Doc.

In this article, I'm going to show how to create a REST API with these features:

  • CRUD (Create Read Update Delete) with Prisma,
  • Validation,
  • Authentication,
  • Route protection,
  • Handle multiple .env files,
  • Unit test,
  • e2e test,
  • Open API for documentation, etc.

Full code of the entire series can be accessed here: https://github.com/alfism1/nestjs-api

Okay, now let's get started.

First thing first, let's create a new Nest JS project called superb-api.

$ npm i -g @nestjs/cli
$ nest new superb-api
Enter fullscreen mode Exit fullscreen mode

It will prompt a question to choose package manager (npm, yarn, or pnpm). Choose whichever you want.

Once the project initialization is completed, go to the project folder, open the code editor, and run it.

$ cd superb-api
$ npm run start
Enter fullscreen mode Exit fullscreen mode

First App Run

By default, the Nest JS use 3000 as the default port.

Hello world

If you want to change the port number, just open up src/main.ts and update the port number await app.listen(ANY_AVAILABLE_PORT_NUMBER);.

Nest JS Overview

Now, let's do a quick overview of the project folder structure.
Folder structure

Most of our codes will be written in the src folder. Let's take a look at the src folder. There are 5 files:

  • app.controller.ts: A basic controller with a single route.
  • app.controller.spec.ts: The unit tests for the controller.
  • app.module.ts: The root module of the application.
  • app.service.ts: A basic service with a single method.
  • main.ts: The entry file of the application which uses the core function NestFactory to create a Nest application instance.

Controller

Controllers are responsible for handling incoming requests and returning responses. As per the Nest JS rules, controller will be written in a class with @Controller() decorator. We can also add a string as the prefix inside the controller decorator @Controller('some-prefix') then the URL will be domain.com/some-prefix. Inside the controller class, there will be functions with HTTP methods decorator @GET(), @POST(), @PUT(), @PATCH(), @DELETE(). Similar to the controller, HTTP methods decorator can also receive a string, for example @GET('test'), and then the endpoint URL will be domain.com/some-prefix/test.

import { Controller, Get } from '@nestjs/common';

// Import the AppService class, which is defined in the app.service.ts file.
import { AppService } from './app.service';

// Controller decorator, which is used to define a basic route.
@Controller()
export class AppController {

  // Get decorator, which is a method decorator that defines a basic GET route.
  @Get()
  getHello(): string {
    // controller process goes here...
  }
}
Enter fullscreen mode Exit fullscreen mode

For more details about the controller, check controller documentation.

Service/Provider

Services or Providers are responsible for handling the main process. We're gonna write all the endpoint logic here. As per the Nest JS rules, provider will be written in a class with @Injectable() decorator.

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we have getHello method which is responsible for returning a string 'Hello World!'. Then we can inject this provider into the controller

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  // inject the AppService into the AppController
  constructor(private readonly appService: AppService) {}

  @Get('test')
  getHello(): string {
    // call the AppService's getHello method
    return this.appService.getHello();
  }
}
Enter fullscreen mode Exit fullscreen mode

For more details about the service or provider, check provider documentation.

Module

In NestJS, a module is a class annotated with the @Module() decorator. It is used to organize code into cohesive blocks of functionality. Modules have a few key responsibilities, such as defining providers (services, factories, repositories, etc.), controllers, and other related stuff. They also allow for defining imports and exports, which are used to manage dependencies between different parts of the application.

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

For more details about the service or provider, check module documentation.

Let's build our superb API

Prisma installation

We're gonna use Prisma as the database ORM. Prisma is a great library for working with databases. It supports PostgreSQL, MySQL, SQL Server, SQLite, MongoDB and CockroachDB.

First, let's install Prisma package in our project

$ npm install prisma --save-dev
Enter fullscreen mode Exit fullscreen mode

Once prisma is installed, run the command below

$ npx prisma init
Enter fullscreen mode Exit fullscreen mode

It will create schema.prisma file inside prisma folder

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
Enter fullscreen mode Exit fullscreen mode

and .env file in the root folder

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
Enter fullscreen mode Exit fullscreen mode

If you haven't installed PostgreSQL, just download it.

Make sure to set the correct DATABASE_URL in .env file.

Next, open up the schema.prisma and let's define the model schema

model User {
  id       Int     @id @default(autoincrement())
  email    String  @unique
  password String
  name     String?
  posts    Post[]
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean? @default(false)
  author    User?    @relation(fields: [authorId], references: [id])
  authorId  Int?
}
Enter fullscreen mode Exit fullscreen mode

Once the schema is created, sync the schema to the database by executing

npx prisma migrate dev --name init
Enter fullscreen mode Exit fullscreen mode

and check the database. It will create these tables:

new tables

Install prisma client

npm install @prisma/client
Enter fullscreen mode Exit fullscreen mode

Note that during installation, Prisma automatically invokes the prisma generate command for you. In the future, you need to run this command after every change to your Prisma models to update your generated Prisma Client.

The prisma generate command reads your Prisma schema and updates the generated Prisma Client library inside node_modules/@prisma/client

Next, let's create a new file called prisma.service.ts inside src/core/services/ directory:

// src/core/services/prisma.service.ts

import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
  async onModuleInit() {
    await this.$connect();
  }
  async onModuleDestroy() {
    await this.$disconnect();
  }
}
Enter fullscreen mode Exit fullscreen mode

For the last configuration of Prisma, let's register PrismaService as a global module so it can be used on any other modules. Create a new file called core.module.ts inside src/core/

// src/core/core.module.ts

import { Global, Module } from '@nestjs/common';
import { PrismaService } from './services/prisma.service';

@Global()
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class CoreModule {}
Enter fullscreen mode Exit fullscreen mode

then update our app.module.ts by adding CoreModule:

// src/app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './modules/users/users.module';
import { CoreModule } from './core/core.module';

@Module({
  imports: [UsersModule, CoreModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Enter fullscreen mode Exit fullscreen mode

Great! Now we're ready to interact with the database :)


Validation config

Nest JS can do request body and query validation very well (more details check here).
First, install the packages:

$ npm i --save class-validator class-transformer
$ npm install @nestjs/mapped-types
Enter fullscreen mode Exit fullscreen mode

and update our main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

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

  // Global pipe that will be applied to all routes
  // This will validate the request body against the DTO
  app.useGlobalPipes(new ValidationPipe());

  await app.listen(3000);
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

We added app.useGlobalPipes(new ValidationPipe()); in main.ts to make it globally applied.


Create users endpoints

Let's move on to the endpoint creation.
In the src folder, create a new folder called modules/users.

Inside modules/users, we'll create endpoints that handle any logic related to user model such as; create user, update user, and delete user.

Before diving into the code, let's install some packages:

bcrypt
We'll encrypt the user's password during the user creation.

$ npm i bcrypt
$ npm install --save @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

Now let's create a new folder src/modules/users/dtos and create these files:

  • create-user.dto.ts Validation for user creation/registration
import { IsEmail, IsNotEmpty } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  password: string;

  @IsNotEmpty()
  name: string;
}
Enter fullscreen mode Exit fullscreen mode
  • update-user.dto.ts Validation for user update
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUsertDto extends PartialType(CreateUserDto) {}
Enter fullscreen mode Exit fullscreen mode
  • login-user.dto.ts Validation for login
import { IsEmail, IsNotEmpty } from 'class-validator';

export class LoginUserDto {
  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  password: string;
}
Enter fullscreen mode Exit fullscreen mode

Let's start with creating a new controller boilerplate called users.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
} from '@nestjs/common';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUsertDto } from './dtos/update-user.dto';
import { LoginUserDto } from './dtos/login-user.dto';

@Controller('users')
export class UsersController {
  @Post('register')
  registerUser(@Body() createUserDto: CreateUserDto): string {
    console.log(createUserDto);
    return 'Post User!';
  }

  @Post('login')
  loginUser(@Body() loginUserDto: LoginUserDto): string {
    console.log(loginUserDto);
    return 'Login User!';
  }

  @Get('me')
  me(): string {
    return 'Get my Profile!';
  }

  @Patch(':id')
  updateUser(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUsertDto,
  ): string {
    console.log(updateUserDto);
    return `Update User ${id}!`;
  }

  @Delete(':id')
  deleteUser(@Param('id', ParseIntPipe) id: number): string {
    return `Delete User ${id}!`;
  }
}
Enter fullscreen mode Exit fullscreen mode

users.controller.ts has five endpoints:

  • POST /users/register for user registration process,
  • POST /users/login for user login process,
  • GET /users/me to Get the user's profile,
  • PATCH /users/:id for user updates,
  • DELETE /users/:id for user delete.

Each endpoint above already implemented validation as well according to the DTO, for example:

  • Registration input validation (DTO create-user.dto)
    Registration input validation

  • Login input validation (DTO login-user.dto)
    Login input validation

  • Update user wrong :id (should be numeric)
    Update user wrong id

Now let's jump to the user service. In the service, we'll create functions to interact with the database via Prisma and they'll represent each endpoint mentioned above.

Here are the users.service boilerplate:

import { ConflictException, HttpException, Injectable } from '@nestjs/common';
import { User } from '@prisma/client';
import { PrismaService } from 'src/core/services/prisma.service';
import { CreateUserDto } from './dtos/create-user.dto';
import { hash } from 'bcrypt';

@Injectable()
export class UsersService {
  constructor(private prisma: PrismaService) {}

  // async registerUser

  // async loginUser

  // async updateUser

  // async deleteUser
}
Enter fullscreen mode Exit fullscreen mode

Let's start with the register function first:

async registerUser(createUserDto: CreateUserDto): Promise<User> {
  try {
    // create new user using prisma client
    const newUser = await this.prisma.user.create({
      data: {
        email: createUserDto.email,
        password: await hash(createUserDto.password, 10), // hash user's password
        name: createUserDto.name,
      },
    });

    // remove password from response
    delete newUser.password;

    return newUser;
  } catch (error) {
    // check if email already registered and throw error
    if (error.code === 'P2002') {
      throw new ConflictException('Email already registered');
    }

    // throw error if any
    throw new HttpException(error, 500);
  }
}
Enter fullscreen mode Exit fullscreen mode

registerUser will be responsible for handling register logic. We can see that now we interact with database using prisma this.prisma.user.create. We also encrypt the password to make our API secure hash(createUserDto.password, 10) and remove it from the response API delete newUser.password.

We also handle the error using throw new ConflictException if there is a duplicate email or any other error throw new HttpException.

Now let's move on to login function:
Login response will return an object with a single property called access_token (interface LoginResponse) which contains some user data (interface UserPaylod) generated by using jwt, so we need to install @nestjs/jwt first:

$ npm install --save @nestjs/jwt
Enter fullscreen mode Exit fullscreen mode

Once @nestjs/jwt is installed, update UsersService's constructor by adding private jwtService: JwtService,

constructor(
  private prisma: PrismaService,
  private jwtService: JwtService,
) {}
Enter fullscreen mode Exit fullscreen mode

Last but not least, update our app.module.ts by adding jwt module as a global:

// src/app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './modules/users/users.module';
import { CoreModule } from './core/core.module';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    UsersModule,
    CoreModule,
    // add jwt module
    JwtModule.register({
      global: true,
      secret: 'super_secret_key',
      signOptions: { expiresIn: '12h' },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Please take note that the secret and expiresIn should be stored in a safe place like .env file. We'll cover this in the next section.

Once jwt configuration is completed, let's create a new interface file called src/modules/users/interfaces/users-login.interface.ts:

export interface UserPayload {
  sub: number;
  name: string;
  email: string;
}

export interface LoginResponse {
  access_token: string;
}
Enter fullscreen mode Exit fullscreen mode

Here is the loginUser code:

async loginUser(loginUserDto: LoginUserDto): Promise<LoginResponse> {
    try {
      // find user by email
    const user = await this.prisma.user.findUnique({
      where: { email: loginUserDto.email },
    });

    // check if user exists
    if (!user) {
      throw new NotFoundException('User not found');
    }

    // check if password is correct by comparing it with the hashed password in the database
    if (!(await compare(loginUserDto.password, user.password))) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const payload: UserPayload = {
      // create payload for JWT
      sub: user.id, // sub is short for subject. It is the user id
      email: user.email,
      name: user.name,
    };

    return {
      // return access token
      access_token: await this.jwtService.signAsync(payload),
    };
  } catch (error) {
    // throw error if any
    throw new HttpException(error, 500);
  }
}
Enter fullscreen mode Exit fullscreen mode

So far we already created two functions, registerUser and loginUser. Now let's continue on updateUser and deleteUser.

Here is the updateUser function:

async updateUser(id: number, updateUserDto: UpdateUsertDto): Promise<User> {
  try {
    // find user by id. If not found, throw error
    await this.prisma.user.findUniqueOrThrow({
      where: { id },
    });

    // update user using prisma client
    const updatedUser = await this.prisma.user.update({
      where: { id },
      data: {
        ...updateUserDto,
        // if password is provided, hash it
        ...(updateUserDto.password && {
          password: await hash(updateUserDto.password, 10),
        }),
      },
    });

    // remove password from response
    delete updatedUser.password;

    return updatedUser;
  } catch (error) {
    // check if user not found and throw error
    if (error.code === 'P2025') {
      throw new NotFoundException(`User with id ${id} not found`);
    }

    // check if email already registered and throw error
    if (error.code === 'P2002') {
      throw new ConflictException('Email already registered');
    }

    // throw error if any
    throw new HttpException(error, 500);
  }
}
Enter fullscreen mode Exit fullscreen mode

and last function deleteUser:

async deleteUser(id: number): Promise<string> {
  try {
    // find user by id. If not found, throw error
    const user = await this.prisma.user.findUniqueOrThrow({
      where: { id },
    });

    // delete user using prisma client
    await this.prisma.user.delete({
      where: { id },
    });

    return `User with id ${user.id} deleted`;
  } catch (error) {
    // check if user not found and throw error
    if (error.code === 'P2025') {
      throw new NotFoundException(`User with id ${id} not found`);
    }

    // throw error if any
    throw new HttpException(error, 500);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the UsersService is completed and here is the final full code:

// src/modules/users/users.service.ts

import {
  ConflictException,
  HttpException,
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import { User } from '@prisma/client';
import { PrismaService } from 'src/core/services/prisma.service';
import { CreateUserDto } from './dtos/create-user.dto';
import { compare, hash } from 'bcrypt';
import { LoginUserDto } from './dtos/login-user.dto';
import { JwtService } from '@nestjs/jwt';
import { LoginResponse, UserPayload } from './interfaces/users-login.interface';
import { UpdateUsertDto } from './dtos/update-user.dto';

@Injectable()
export class UsersService {
  constructor(
    private prisma: PrismaService,
    private jwtService: JwtService,
  ) {}

  async registerUser(createUserDto: CreateUserDto): Promise<User> {
    try {
      // create new user using prisma client
      const newUser = await this.prisma.user.create({
        data: {
          email: createUserDto.email,
          password: await hash(createUserDto.password, 10), // hash user's password
          name: createUserDto.name,
        },
      });

      // remove password from response
      delete newUser.password;

      return newUser;
    } catch (error) {
      // check if email already registered and throw error
      if (error.code === 'P2002') {
        throw new ConflictException('Email already registered');
      }

      // throw error if any
      throw new HttpException(error, 500);
    }
  }

  async loginUser(loginUserDto: LoginUserDto): Promise<LoginResponse> {
    try {
      // find user by email
      const user = await this.prisma.user.findUnique({
        where: { email: loginUserDto.email },
      });

      // check if user exists
      if (!user) {
        throw new NotFoundException('User not found');
      }

      // check if password is correct by comparing it with the hashed password in the database
      if (!(await compare(loginUserDto.password, user.password))) {
        throw new UnauthorizedException('Invalid credentials');
      }

      const payload: UserPayload = {
        // create payload for JWT
        sub: user.id, // sub is short for subject. It is the user id
        email: user.email,
        name: user.name,
      };

      return {
        // return access token
        access_token: await this.jwtService.signAsync(payload),
      };
    } catch (error) {
      // throw error if any
      throw new HttpException(error, 500);
    }
  }

  async updateUser(id: number, updateUserDto: UpdateUsertDto): Promise<User> {
    try {
      // find user by id. If not found, throw error
      await this.prisma.user.findUniqueOrThrow({
        where: { id },
      });

      // update user using prisma client
      const updatedUser = await this.prisma.user.update({
        where: { id },
        data: {
          ...updateUserDto,
          // if password is provided, hash it
          ...(updateUserDto.password && {
            password: await hash(updateUserDto.password, 10),
          }),
        },
      });

      // remove password from response
      delete updatedUser.password;

      return updatedUser;
    } catch (error) {
      // check if user not found and throw error
      if (error.code === 'P2025') {
        throw new NotFoundException(`User with id ${id} not found`);
      }

      // check if email already registered and throw error
      if (error.code === 'P2002') {
        throw new ConflictException('Email already registered');
      }

      // throw error if any
      throw new HttpException(error, 500);
    }
  }

  async deleteUser(id: number): Promise<string> {
    try {
      // find user by id. If not found, throw error
      const user = await this.prisma.user.findUniqueOrThrow({
        where: { id },
      });

      // delete user using prisma client
      await this.prisma.user.delete({
        where: { id },
      });

      return `User with id ${user.id} deleted`;
    } catch (error) {
      // check if user not found and throw error
      if (error.code === 'P2025') {
        throw new NotFoundException(`User with id ${id} not found`);
      }

      // throw error if any
      throw new HttpException(error, 500);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The next step is we need to update our UsersController to interact with usersService and here is the full code:

// src/modules/users/users.controller.ts

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  ParseIntPipe,
  Patch,
  Post,
} from '@nestjs/common';
import { CreateUserDto } from './dtos/create-user.dto';
import { UpdateUsertDto } from './dtos/update-user.dto';
import { LoginUserDto } from './dtos/login-user.dto';
import { UsersService } from './users.service';
import { User } from '@prisma/client';
import { LoginResponse } from './interfaces/users-login.interface';

@Controller('users')
export class UsersController {
  // inject users service
  constructor(private readonly usersService: UsersService) {}

  @Post('register')
  async registerUser(@Body() createUserDto: CreateUserDto): Promise<User> {
    // call users service method to register new user
    return this.usersService.registerUser(createUserDto);
  }

  @Post('login')
  loginUser(@Body() loginUserDto: LoginUserDto): Promise<LoginResponse> {
    // call users service method to login user
    return this.usersService.loginUser(loginUserDto);
  }

  @Get('me')
  me(): string {
    return 'Get my Profile!';
  }

  @Patch(':id')
  async updateUser(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUsertDto,
  ): Promise<User> {
    // call users service method to update user
    return this.usersService.updateUser(+id, updateUserDto);
  }

  @Delete(':id')
  async deleteUser(@Param('id', ParseIntPipe) id: number): Promise<string> {
    // call users service method to delete user
    return this.usersService.deleteUser(+id);
  }
}
Enter fullscreen mode Exit fullscreen mode

We haven't updated the me endpoint yet. Will do it later

We're almost done. We need to register UsersController and UsersService to UsersModule:

// src/modules/users/users.module.ts

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  imports: [],
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

Enter fullscreen mode Exit fullscreen mode

Finally, we've done with users register, login, update, and delete. Now we can test these endpoints:

  • POST /users/register, User register
  • POST /users/login, User login
  • PATCH /users/:id, Update user
  • DELETE /users/:id Delete user

We've been doing great so far :)
The full code of part 1 can be accessed here:
https://github.com/alfism1/nestjs-api/tree/part-one.

Moving on to part 2:
https://dev.to/alfism1/build-complete-rest-api-feature-with-nest-js-using-prisma-and-postgresql-from-scratch-beginner-friendly-part-2-1g25

Top comments (0)