Scalable REST APIs with NestJS: A Testing-Driven Approach

ยท

43 min read

Scalable REST APIs with NestJS: A Testing-Driven Approach

Why NestJS? ๐Ÿค”

NestJS, a powerful and scalable framework, is here to revolutionize the way we build APIs. It leverages TypeScript, ensuring your code is clean, easy to debug, and, most importantly, reliable.

The Power of Modularity ๐Ÿ’ช

NestJS is built around the concept of modularity. This means you can organize your code into separate modules, making it easier to understand, maintain, and scale.

Connect ๐Ÿ‘‹

Repo ๐Ÿ“š

Scalable Server Architecture

TOC

โญ Let's Revise the concepts and implement bookmarks [CRUD]

1. Basic Understanding and Setup

1.1 Let's make a module

  1. Create a Basic Module: Start by creating a new folder called auth inside the src folder. In the auth folder, create a new file called auth.module.ts.

  2. Define the Module: In auth.module.ts, define the AuthModule class and decorate it with @Module({}). This decorator tells NestJS that this class is a module.

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

@Module({})
export class AuthModule {}
  1. Use the Module: You can use this module in your application by adding it to the imports array of the AppModule or any other module where you want to use it.

Before:

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

@Module({
  imports: [],
})
export class AppModule {}

After:

import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';

@Module({
  imports: [AuthModule], // Add the AuthModule to the imports array
})
export class AppModule {}
  1. Create More Modules: You can create more modules using the NestJS CLI. For example, to create a user module, you can use the following command in the terminal:
nest g module user

This command will create a new folder called user and a new file called user.module.ts inside the user folder. It will also automatically import the UserModule into the AppModule.

  1. Create a bookmarks Module: Similarly, you can create a bookmarks module using the following command:
nest g module bookmarks
  1. Final Structure: After creating the auth, user, and bookmark modules, your app.module.ts file should look like this:
import { Module } from '@nestjs/common';
import { AuthModule } from './auth/auth.module';
import { UserModule } from './user/user.module';
import { BookmarkModule } from './bookmark/bookmark.module';

@Module({
  imports: [AuthModule, UserModule, BookmarkModule],
})
export class AppModule {}

This structure allows you to organize your application into distinct modules, each responsible for a specific feature. This modular approach makes your application easier to understand, develop, and test.

1.2 Let's make a basic controller and service

  1. Create the Controller and Service files: In the auth folder, create two files: auth.controller.ts and auth.service.ts.

  2. Define the Controller: In auth.controller.ts, define the AuthController class and decorate it with @Controller('auth'). This decorator tells NestJS that this class is a controller and maps any incoming requests with the path /auth to this controller.

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

@Controller('auth')
export class AuthController {}
  1. Define the Service: In auth.service.ts, define the AuthService class and decorate it with @Injectable(). This decorator will allow NestJS to manage this class as a provider that can be injected into other classes.
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {}
  1. Register the Controller and Service in the Module: In the AuthModule, import AuthController and AuthService and add them to the controllers and providers arrays respectively. This will allow NestJS to recognize them as part of the module.
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';

@Module({
  controllers: [AuthController],
  providers: [AuthService],
})
export class AuthModule {}
  1. Inject the Service into the Controller: In AuthController, inject an instance of AuthService through the constructor. This allows you to use the service's methods in the controller.
import { Controller } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}
}

Note: The above code is a shorthand for manually creating a new instance of AuthService in the constructor.

  1. Use the Service's methods in the Controller: If you have a method in AuthService, you can use it in AuthController. For example, if AuthService has a test() method:
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthService {
  test() {}
}

You can call this method in AuthController like this:

import { Controller } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {
    this.authService.test();
  }
}

This is a basic pattern in NestJS for separating the handling of HTTP requests (in the controller) from the business logic (in the service). The controller's job is to receive the request and send the response, while the service performs the business logic.

2. Setting Up the Auth Controller

2.1 Creating Basic Endpoints

  1. Create Basic Endpoints: Login and Signup are two common endpoints for authentication. Let's create these basic endpoints in the AuthController.
import { Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup() {
    return 'I am a signup';
  }

  @Post('login')
  login() {
    return 'I am a login';
  }
}

Now, if you make a POST request to /auth/signup or /auth/login, you will receive the corresponding response.

  1. Data Types and JSON: With NestJS, you don't have to worry about the data type you send or receive. If you return an object, it will be automatically converted to JSON. If you send a JSON object, it will be automatically parsed. For example:
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup() {
    return { message: 'I am a signup' }; // This will be converted to JSON
  }

  @Post('login')
  login() {
    return { message: 'I am a login' }; // This will be converted to JSON
  }
}
  1. Using the AuthService: You can use the AuthService to handle the logic for the signup and login methods. This keeps your controller and service clean and readable.

In auth.service.ts:

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

@Injectable()
export class AuthService {
  signup() {
    return 'I am a signup';
  }

  login() {
    return 'I am a login';
  }
}

In auth.controller.ts:

import { Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup() {
    return this.authService.signup();
  }

  @Post('login')
  login() {
    return this.authService.login();
  }
}

In this setup, the AuthController handles the HTTP requests and responses, while the AuthService contains the business logic for signup and login. This separation of concerns makes your code more organized and maintainable.

3. Setting up DB with docker(Prisma)

  • Create a docker-compose.yml file in the root of your project with the following content:
version: '3.8'
services:
  dev-db:
    image: postgres:13
    ports:
      - 5434:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: 123
      POSTGRES_DB: nest
  • Then run docker compose up dev-db -d to start the database.

  • Then docker ps to check container id

  • Then docker logs <container_id> to check logs of the container

2024-02-18 19:22:56.603 UTC [1] LOG:  listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
2024-02-18 19:22:56.607 UTC [27] LOG:  database system was shut down at 2024-02-18 19:22:52 UTC
2024-02-18 19:22:56.614 UTC [1] LOG:  database system is ready to accept connections

4. Setting up TypeORM with NestJS (Prisma)

  • Install the Prisma CLI globally by running the following command in your terminal:
npm install -g prisma
  • Also install the Prisma client as a development dependency in your NestJS project:
npm install @prisma/client
  • Now run the following command to initialize Prisma in your project:
npx prisma init
  • It will create postgres by default, you can change it to mysql or sqlite

  • Open prisma in your project now paste the following on schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now())
  email     String
  hash      String
  firstName String?
  lastName  String?
}

model Bookmarks {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  updatedAt   DateTime @default(now())
  title       String
  Description String
  link        String
}
  • Now modify the .env file's DATABASE_URL

default

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

change to

DATABASE_URL="postgresql://postgres:123@localhost:5434/nest?schema=public"
  • Now migrate the database by running the following command:
npx prisma migrate dev --name init
  • You can use prisma studio to check the database by running the following command:
npx prisma studio
  • In your bash run the following command:
nest g module prisma
  • Now create a prisma service by running the following command:
nest g service prisma --no-spec
  • Now modify the prisma.service.ts file to look like this:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient {
  constructor() {
    super({
      datasources: {
        db: {
          url: 'postgresql://postgres:123@localhost:5434/nest?schema=public',
        },
      },
    });
  }
}
  • Now modify the prisma.module.ts file to look like this:

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

@Global() //It will make the service available to all modules
@Module({
  providers: [PrismaService],
  exports: [PrismaService],//you need to export the service to use it in other modules
})
export class PrismaModule {}

5. Understanding DTOs and Class-Validator in NestJS

Data Transfer Objects (DTOs) are used to structure and validate the data that is sent over the network. They ensure that the data adheres to a specific format and meets certain validation criteria. In NestJS, we often use DTOs with the class-validator package for this purpose.

5.1 Installing Class-Validator

First, install the class-validator and class-transformer packages using npm:

npm i class-validator class-transformer

5.2 Creating and Validating DTOs

Create a dto folder inside the auth folder. Inside the dto folder, create a file named auth.dto.ts. This file will define the data structure and validation rules for the authentication data.

import { IsEmail, IsNotEmpty, IsString } from 'class-validator';

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

  @IsString()
  @IsNotEmpty()
  password: string;
}

In the above code, @IsEmail(), @IsNotEmpty(), and @IsString() are decorators provided by class-validator. They specify that the email should be a valid email address, and both the email and password should be non-empty strings.

5.3 Using DTOs in Controllers

You can now use this DTO in your controller. The @Body() decorator in combination with the DTO type will automatically validate the incoming request data and throw an error if the data is invalid.

import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('signup')
  signup(@Body() dto: AuthDto) {
    return this.authService.signup(dto);
  }

  @Post('login')
  login() {
    return this.authService.login();
  }
}

5.4 Global Validation Pipe

To enable automatic validation globally, you can add a global pipe in your main.ts file:

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
  }));
  await app.listen(3333);
}
bootstrap();

The whitelist: true option will strip any properties that don't have any decorators in the DTO.

This way, you can ensure that all incoming request data is validated according to the rules defined in your DTOs. This helps to maintain data integrity and security in your application.

6. Implementing Signup Logic with Argon2 and Prisma

In this section, we will create a signup logic using NestJS, Prisma, and argon2 for password hashing.

6.1 Installing Argon2

Argon2 is a password hashing algorithm that is considered to be very secure. It's a better choice than bcrypt. Install it using npm:

npm i argon2

6.2 Basic Signup Logic

Here is the basic signup logic:

  • Get the email and password from the request body

  • Hash the password using an algorithm

  • Create a new user in the database with the email and hashed password

  • Return the user data

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
import * as argon from 'argon2';

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

  async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password); //hashing the password
    const user = await this.prisma.user.create({ //creating the user
      data: { //data to be created
        email: dto.email, //email field from the dto
        hash, //hashed password
      },
    });
    return user;
  }
}

This will return the following response:

{
    "id": 4,
    "createdAt": "2024-02-28T04:38:40.804Z",
    "updatedAt": "2024-02-28T04:38:40.804Z",
    "email": "subham@gmail.com",
    "hash": "$argon2id$......",
    "firstName": null,
    "lastName": null
}

However, we don't want the hash to be returned to the user. We can modify the signup function to select only the necessary fields:

async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password); //hashing the password
    const user = await this.prisma.user.create({ //creating the user
      data: { //data to be created
        email: dto.email, //email field from the dto
        hash, //hashed password
      },
        select: { //selecting the fields to be returned
          id: true,
          email: true,
          firstName: true,
          lastName: true,
        },
    });
    return user;
  }

Or, you can directly delete the hash:

async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password);
    const user = await this.prisma.user.create({
        data: {
            email: dto.email,
            hash,
        },
    });
    delete user.hash; //directly delete
    return user;
}

6.3 Handling Unique Email Validation

If we sign up multiple times with the same email, it will create multiple users with the same email. We need to check if the user already exists or not.

First, modify the schema.prisma file to make the email field unique:

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int         @id @default(autoincrement())
  createdAt DateTime    @default(now())
  updatedAt DateTime    @default(now())
  email     String      @unique//making the email field unique
  hash      String
  firstName String?
  lastName  String?
  Bookmarks Bookmarks[]

  @@map("users") //mapping the table name
}

model Bookmarks {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  updatedAt   DateTime @default(now())
  title       String
  Description String
  link        String
  userId      Int
  user        User     @relation(fields: [userId], references: [id]) //relation with the user table

  @@map("bookmarks")//mapping the table name
}

Now run npx prisma migrate dev to migrate the database and enter a name for the migration ex: make email unique. Now run npx prisma studio to check the database, and you will see the email field is unique now.

6.4 Handling Errors

If you try to sign up with an email that already exists, you will get an error. This is because we are not handling the error, so let's handle the error:

 async signup(dto: AuthDto) {
    const hash = await argon.hash(dto.password);
    try {
      const user = await this.prisma.user.create({
        data: {
          email: dto.email,
          hash,
        },
      });
      delete user.hash;
      return user;
    } catch (error) {
      if (error instanceof PrismaClientKnownRequestError) {
        if (error.code === 'P2002') {
          throw new ForbiddenException('Credentials taken');
        }
      }
      throw error;
    }
  }

Now you can see the error message in the postman:

{
    "statusCode": 403,
    "message": "Credentials taken",
    "error": "Forbidden"
}

6.5 Creating a Custom Exception Filter

If you want to make it more production-ready, you can create a custom exception filter to handle the error. Make a higher-order function to handle the error:

//async-error-handler.ts

import { ForbiddenException } from '@nestjs/common';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';

export const asyncErrorHandler = (asyncFun: (...args: any[]) => Promise<any>) => {
    return async (...args: any[]) => {
        try {
            return await asyncFun(...args);
        } catch (error) {
            if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
                throw new ForbiddenException('Credentials taken');
            }
            throw error;
        }
    };
};

Now you can use this and without try-catch block:

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
import * as argon from 'argon2';
import { asyncErrorHandler } from '../errors/async-error-handler';

@Injectable()
export class AuthService {
  signup = asyncErrorHandler(async (dto: AuthDto) => {
    const hash = await argon.hash(dto.password);
    const user = await this.prisma.user.create({
      data: {
        email: dto.email,
        hash,
      },
    });
    delete user.hash;
    return user;
  });

  constructor(private prisma: PrismaService) {}

  login() {
    return 'I am a login';
  }
}

7. Implementing Login Logic

In this section, we will create a login logic using NestJS, Prisma, and argon2 for password verification. The basic login logic involves the following steps:

  1. Find the user by email.

  2. If a user does not exist, throw an exception.

  3. Compare the password.

  4. If the password does not match, throw an exception.

  5. Return the user.

Here is the corresponding code:

signin = asyncErrorHandler(async (dto: AuthDto) => {
    const user = await this.prisma.user.findUnique({
        where: {
            email: dto.email,
        },
    });
    if (!user) {
        throw new NotFoundException('User not found');
    }
    const match = await argon.verify(user.hash, dto.password);
    if (!match) {
        throw new UnauthorizedException('Invalid credentials');
    }
    delete user.hash;
    return user;
});

8. Automating Postgres Restart & Prisma Migrations

In this section, we will automate the process of restarting Postgres and running Prisma migrations. Follow these steps:

  1. Delete the package-lock.json file and run yarn to install the dependencies.

  2. Modify the script and add the following lines to the package.json:

"prisma:dev:deploy": "prisma migrate deploy",
"db:dev:rm": "docker compose rm dev-db -s -f -v",
"db:dev:up": "docker compose up dev-db -d",
"db:dev:restart": "yarn db:dev:rm && yarn db:dev:up && node -e \"setTimeout(() => console.log('Done waiting'), 1000)\" && yarn prisma:dev:deploy",

The prisma:dev:deploy command will migrate the database to our dev database. The docker compose rm dev-db -s -f -v command will remove, stop, and remove the volume of the container. The docker compose up dev-db -d command will start the container in the background. The db:dev:restart command will remove the container, start the container, wait for 1 second, and then migrate the database to our dev database.

  1. Run yarn db:dev:restart to restart the database and migrate the database to our dev database.

  2. Run npx prisma studio to check the database.

  3. Run yarn start:dev to start the server.

9. NestJs config module

9.1 Basic Config Setup

  • Install the config module using npm:
yarn add @nestjs/config
  • import in app.module.ts
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,//this is same as @Global() decorator
    }),
  ],
})
  • Now open the prisma.service.ts file and modify it to look like this:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient {
    constructor() {
        super({
            datasources: {
                db: {
                    url: 'postgresql://postgres:123@localhost:5434/nest?schema=public',
                },
            },
        });
    }
}

To simply change

import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';

@Injectable() //inject the config service in the constructor
export class PrismaService extends PrismaClient {
    constructor(config: ConfigService) {//inject the config service in the constructor
        super({
            datasources: {
                db: {
                    url: config.get('DATABASE_URL'),//getting the database url from the .env file
                },
            },
        });
    }
}
  • make sure in you .env file you have the following:
DATABASE_URL="postgresql://postgres:123@localhost:5434/nest?schema=public"
  • Now run yarn start:dev to start the server.

9.2 Production Config Setup

  • Let's try to make it production ready so add a config folder in the root of your project and add a config.ts file in it.
  1. Update your config.ts file: Your config.ts file should return an object with the configuration for your application. Here's an example:
// config.ts
export default () => ({
  database: {
    url: process.env.DATABASE_URL,
  },
});

In this example, config.ts exports a function that returns an object with your database configuration. The DATABASE_URL is read from the environment variables.

  1. Update your app.module.ts file: In your app.module.ts file, import ConfigModule from @nestjs/config, import your configuration from config.ts, and add ConfigModule to the imports array in the module metadata. Here's how you can do it:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
    }),
    // other modules...
  ],
  // controllers, providers, etc...
})
export class AppModule {}

In the above code, load: [configuration] tells ConfigModule to load the configuration from the configuration function.

10. Passport & JWT module setup

10.1 Basic Setup of Passport & JWT

  • Install the passport and jwt module using npm:
yarn add @nestjs/passport passport @nestjs/jwt passport-jwt

yarn add -D @types/passport-jwt
  • Now open auth.module.ts and modify it to look like this:
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [JwtModule.register({})],//import the jwt module
  //controllers, providers, etc...
})
export class AuthModule {}

Purpose is to sing the token and verify the token

  • Now open auth.service.ts and modify it to look like this:
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
//other...
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  signup = asyncErrorHandler(async (dto: AuthDto) => {
   //previous code..
  });
  signin = asyncErrorHandler(async (dto: AuthDto) => {
    //previous code..
  });

  constructor(
    private prisma: PrismaService,
    private jwt: JwtService,//dependency injection
  ) {}
}

so we have injected the jwt service in the constructor, so now we can use it in the service

  • We need to return the token when user signs in, and send the token when user signs up

  • let's make a function which will take the payload and options and return the token

  signToken = asyncErrorHandler(async (userId: number, email: string) => {
    const payload = {
        sub: userId,//sub is the subject of the token 
        email,//optional
    };
    const secret = this.config.get('JWT_SECRET');
    //token will expire in 15 minutes
    //payload is the data you want to store in the token
    const token = this.jwt.sign(payload, {
        expiresIn: '15m',
        secret: secret,
    });
    return { access_token: token };//return the token
});
  • this.config.get('JWT_SECRET'); import the config service and get the JWT_SECRET from the .env file

  • and call this function in the signin function and signup function

import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthDto } from './dto';
import * as argon from 'argon2';
import { asyncErrorHandler } from '../errors/async-error-handler';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
    signToken = asyncErrorHandler(async (userId: number, email: string) => {
        const payload = {
            sub: userId,
            email,
        };
        const secret = this.config.get('JWT_SECRET');
        const token = this.jwt.sign(payload, {
            expiresIn: '15m',
            secret: secret,
        });
        return { access_token: token };
    });
    signup = asyncErrorHandler(async (dto: AuthDto) => {
        const hash = await argon.hash(dto.password);
        const user = await this.prisma.user.create({
            data: {
                email: dto.email,
                hash,
            },
        });
        return this.signToken(user.id, user.email); //return the token
    });
    signin = asyncErrorHandler(async (dto: AuthDto) => {
        const user = await this.prisma.user.findUnique({
            where: {
                email: dto.email,
            },
        });
        if (!user) {
            throw new NotFoundException('User not found');
        }
        const match = await argon.verify(user.hash, dto.password);
        if (!match) {
            throw new UnauthorizedException('Invalid credentials');
        }
        return this.signToken(user.id, user.email); //return the token
    });

    constructor(
        private prisma: PrismaService,
        private jwt: JwtService,
        private config: ConfigService,//call the ConfigService in the constructor
    ) {}
}

10.2 Let's setup strategy

  • We need to validate the token when a user sends the token in the header, and we need to extract the token from the header and validate it

  • So create a folder called strategies in the auth folder and create a file called jwt.strategy.ts in it and index.ts file in the strategies folder

  • You can copy this code from here:

jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()//injectable means it can be injected in other classes as a dependency
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { //extend means it is a subclass of the Strategy class
  constructor(//constructor means the strategy is dependent on the prisma service and the config service
    config: ConfigService,
    private prisma: PrismaService, //private means it is a private property of the class
  ) {
    super({//super means it is calling the constructor of the parent class which is the Strategy class
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //it says to extract the token from the header
      secretOrKey: config.get('JWT_SECRET'), //get the secret from the .env file
    });
  }

    validate(payload: any) { //validate the token and return the payload
        return payload;
    }
}

index.ts

export * from './jwt.strategy';

It also a provider, so we need to add it to the provider array in the auth.module.ts file

providers: [AuthService, JwtStrategy],//add the JwtStrategy to the providers array

11. NestGuard

11.1 Protecting Routes with Guards

  • Now we need to protect the routes with the guards, make a user module and a user controller and a user service and a user entity user module
@Module({
    controllers: [UserController]
})

user controler

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

import { AuthGuard } from '@nestjs/passport';



@Controller('user')

export class UserController {

  @UseGuards(AuthGuard('jwt')) //use the jwt guard

  @Get('me')

  getMe() {

    return 'This is the user me';

  }

}

11.2 Providers

auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { JwtStrategy } from './strategy';

@Module({
  imports: [JwtModule.register({})],
  controllers: [AuthController],
  providers: [AuthService, JwtService, JwtStrategy],//add the JwtStrategy to the providers array
})
export class AuthModule {}

11.3 Return User Payload

jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(
    config: ConfigService,
    private prisma: PrismaService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: config.get('JWT_SECRET'),
    });
  }

  async validate(payload: { sub: number; email: string }) { //return the payload
    const user = await this.prisma.user.findUnique({ //find the user by the id
      where: {
        id: payload.sub,
      },
    });
    delete user.hash; //delete the hash
    return user; //return the user
  }
}

11.4 JWT Guard

Make a folder in auth name it guard and here use jwt.guard.ts and index.ts and then use it in the user.controller.ts file

jwt.guard.ts

import { AuthGuard } from '@nestjs/passport';

export class JwtGuard extends AuthGuard('jwt') {
  constructor() {
    super();
  }
}

index.ts

export * from './jwt.guard';

user.controller.ts



import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';

@Controller('user')
export class UserController {
    @UseGuards(AuthGuard('jwt'))
    @Get('me')
    getMe(@Req() req: Request) {
        return req.user;
    }
}


// Replace with .....

import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtGuard } from '../auth/guard';

@Controller('user')
export class UserController {
  @UseGuards(JwtGuard) //use the jwt guard
  @Get('me')
  getMe(@Req() req: Request) {
    return req.user;
  }
}

12. Custom Param Decorator

12.1 Creating a GetUser Decorator

  • the purpose is to get the user from the request object and use it in the controller

Decorators are a powerful feature in TypeScript and JavaScript that allow you to add metadata to classes, methods, and properties. In NestJS, decorators are used to define routes, inject dependencies, and more. You can also create custom decorators to add your own functionality to your application.

First make a folder called decorators in the auth folder and then make a file called get-user.decorator.ts and index.ts in the decorators folder

get-user.decorator.ts

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetUser = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
    const request: Express.Request = ctx.switchToHttp().getRequest();
    return request.user;
});

index.ts

export * from './get-user.decorator';

user.controller.ts


import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { JwtGuard } from '../auth/guard';

@Controller('user')
export class UserController {
    @UseGuards(JwtGuard)
    @Get('me')
    getMe(@Req() req: Request) {
        return req.user;
    }
}


// Replace with .....

import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { GetUser } from '../auth/decorator';
import { User } from '@prisma/client';

@UseGuards(JwtGuard) //place it here so that our entire controller is protected
@Controller('user')
export class UserController {
  @Get('me')
  getMe(@GetUser() user: User) { //use the GetUser decorator
    return user;
  }
}
  • let's return the user id instead of the entire user object

Modify the get-user.decorator.ts file to look like this:

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
  const request: Express.Request = ctx.switchToHttp().getRequest();
  if (data) { //if data is provided then return the user[data]
    return request.user[data];
  }
  return request.user;
});

If you provide a string, it will return the user[data] else it will return the entire user object

  • Now let's say you want the email of the user, then you can use the GetUser decorator like this:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { GetUser } from '../auth/decorator';
import { User } from '@prisma/client';

@UseGuards(JwtGuard)
@Controller('user')
export class UserController {
  @Get('me')
  getMe(@GetUser() user: User, @GetUser('email') email: string) { //use the GetUser decorator with the email
    console.log({
      email,
    });
    return user;
  }
}

12.2 Http Decorator

  • You can pass you status code which you want to return in the response

auth.controller.ts

import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthDto } from './dto';

@Controller('auth')
export class AuthController {
    constructor(private authService: AuthService) {}

    @HttpCode(201) //use the @HttpCode decorator
    @Post('signup')
    signup(@Body() dto: AuthDto) {
        return this.authService.signup(dto);
    }

    @Post('signin')
    signin(@Body() dto: AuthDto) {
        return this.authService.signin(dto);
    }
}

// Or you can use it like this

@HttpCode(HttpStatus.OK) //use the @HttpCode decorator

13. E2E Testing

13.1 Setting up Pactum JS

https://pactumjs.github.io/

  • Install the pactum js using yarn:
yarn add -D pactum
  • Open the test folder and clear the app.e2e-spec.ts file and modify it to look like this:

  • Open your script in the package.json file and add the following:

"test:e2e": "jest --watch --no-cache --config ./test/jest-e2e.json"

This script allows you to run your end-to-end tests using Jest. The --watch flag means Jest will watch your files for changes and rerun tests when necessary. The --no-cache flag disables caching, ensuring that your tests always run with the latest changes. The --config flag specifies the configuration file for Jest.

// Import necessary modules
import { AppModule } from '../src/app.module';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';

// Declare a test suite named 'App e2e'
describe('App e2e', () => {
    // Declare a variable to hold our app instance
    let app: INestApplication;

    // Before all tests, create and initialize the app
    beforeAll(async () => {
        // Create a module for testing
        const moduleRef = await Test.createTestingModule({
            imports: [AppModule],
        }).compile();//compile the module 

        // Create an instance of the app
        app = moduleRef.createNestApplication();

        // Apply a global validation pipe
        app.useGlobalPipes(
            new ValidationPipe({
                whitelist: true,
            }),
        );

        // Initialize the app
        await app.init();
    });

    // After all tests, close the app
    afterAll(() => {
        app.close();
    });

    // Placeholder for a test case
    it.todo('should pass');
});

14. Setting Up Test Database

  • Open the docker-compose.yml file and modify it to look like this:

Just replicate the dev-db and change the port

version: '3.8'
services:
  dev-db:
    image: postgres:13
    ports:
      - 5434:5432
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: 123
      POSTGRES_DB: nest
  test-db:
    image: postgres:13
    ports:
      - 5435:5432 #this should be different from the dev-db
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: 123
      POSTGRES_DB: nest
  • Similarly modify the script for test database in the package.json file

Replicate the script

"prisma:dev:deploy": "prisma migrate deploy",
"db:dev:rm": "docker compose rm dev-db -s -f -v",
"db:dev:up": "docker compose up dev-db -d",
"db:dev:restart": "yarn db:dev:rm && yarn db:dev:up && node -e \"setTimeout(() => console.log('Done waiting'), 1000)\" && yarn prisma:dev:deploy",
"prisma:test:deploy": "dotenv -e .env.test -- prisma migrate deploy",
"db:test:rm": "docker compose rm test-db -s -f -v",
"db:test:up": "docker compose up test-db -d",
"db:test:restart": "yarn db:test:rm && yarn db:test:up && node -e \"setTimeout(() => console.log('Done waiting'), 1000)\" && yarn prisma:test:deploy",

15. Dotenv for Development and Testing

  • Installation
yarn add -D dotenv-cli
  • Make a file name .env.test and add the following:

  • Now modify the package.json file to look like this:

"prisma:test:deploy": "prisma migrate deploy",

[//]: # (change it to something like this)

"prisma:test:deploy": "dotenv -e .env.test -- prisma migrate deploy",

just add dotenv -e .env.test -- before the prisma migrate deploy It's mean that it will use the .env.test file for the test database and the .env file for the development database

Now modify the .env.test file just copy theDATABASE_URL from the .env file and change the port(which is declared in the docker-compose.yml file)

DATABASE_URL="postgresql://postgres:123@localhost:5435/nest?schema=public"
  • Now we add this
"prebuild": "rimraf dist",

It will remove the dist folder before building the project

  • Now change the test:e2e script in the package.json file to look like this:
"test:e2e": "jest --watch --no-cache --config ./test/jest-e2e.json"

[//]: # (change it to something like this)

"pretest:e2e": "yarn db:test:restart",
"test:e2e": "dotenv -e .env.test -- jest --watch --no-cache --config ./test/jest-e2e.json"
  • pretest:e2e": "yarn db:test:restart, will restart the test database before running the test

  • Just add dotenv -e .env.test -- before the jest command so that it will use the .env.test file for the test database

  • It means that it will use the .env.test file for the test database and the .env file for the development database

  • For checking the migration in the test database, run the following command:
yarn test:e2e  

docker ps   //two container should be running

//for check the prisma studio for the test database

npx dotenv -e .env.test prisma studio


//for check the prisma studio for the dev database

npx prisma studio

16. Database Tear Down

16.1 Cleaning the Database

We need to clean the database after the test, but we don't want to restart docker-compose every time, because it takes time to restart the database

  • Open prisma.service.ts and modify it to look like this:
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class PrismaService extends PrismaClient {
  constructor(config: ConfigService) {
    super({
      datasources: {
        db: {
          url: config.get('DATABASE_URL'),
        },
      },
    });
  }

  cleanDb() { //add the cleanDb function
    return this.$transaction([this.bookmark.deleteMany(), this.user.deleteMany()]);
  }
}

The cleanDb function is used to clean up the database. It's often used in testing (like before running end-to-end tests) to ensure a consistent starting state for each test run. Here's what it does:

  1. Deletes all bookmark: this.bookmark.deleteMany() deletes all records from the bookmarks table. This is done first because the bookmarks table likely has a foreign key constraint on the user table. If we tried to delete the users first, the database would throw an error because there would still be bookmarks in the database referencing those users.

  2. Deletes all users: this.user.deleteMany() deletes all records from the user table. This is done after deleting all bookmarks to avoid violating any foreign key constraints.

Both deletion operations are wrapped in a transaction (this.$transaction([])) to ensure atomicity. This means if one operation fails, the other won't be executed, leaving your database in a consistent state.

In summary, cleanDb is a utility function that helps maintain a clean, predictable state in your database, which is especially useful when running tests. It first deletes bookmarks and then users to respect the foreign key relationship from bookmarks to users.

16.2 Database Tear Down in E2E Tests

  • Open the app.e2e-spec.ts file and modify it to look like this:
import { AppModule } from '../src/app.module';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { PrismaService } from '../src/prisma/prisma.service';

describe('App e2e', () => {
  let app: INestApplication;
  let prisma: PrismaService;//declare the prisma service
  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
      }),
    );
    await app.init();
    prisma = app.get(PrismaService); //get the prisma service from the app
    await prisma.cleanDb();//clean the database before running the test
  });

  afterAll(() => { //if you close the app the datebase will be cleaned up
    app.close();
  });

  it.todo('should pass');
});

16.3 Create the test

  • First declare this
  describe('Auth', () => {});
  describe('User', () => {});
  describe('Bookmarks', () => {});
  • Now define this properly

describe('Auth', () => {
    describe('Signup', () => {
        it.todo('should signup');
    });
    describe('Signin', () => {
        it.todo('should signin');
    });
});
describe('User', () => {
    describe('Get me', () => {});
    describe('Edit user', () => {});
});
describe('Bookmarks', () => {
    describe('Create bookmarks', () => {});
    describe('Get bookmarks', () => {});
    describe('Get bookmark by id', () => {});
    describe('Edit bookmark', () => {});
    describe('Delete bookmark', () => {});
});
  • Now run the test
yarn test:e2e

it is a function used to define a test in Jest. it.todo is a special version of it used when you want to plan a test but aren't ready to write it yet. So, it.todo('should signup') means you're planning to write a test for the signup functionality, but haven't written the test code yet. When Jest runs, it will remind you that this test still needs to be written.

17. Auth E2E Testing with Pactum

17.1 Signup test

import { AppModule } from '../src/app.module';
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { PrismaService } from '../src/prisma/prisma.service';
import { AuthDto } from '../src/auth/dto';
import * as pactum from 'pactum';

describe('App e2e', () => {
  let app: INestApplication;
  let prisma: PrismaService;
  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
      }),
    );
    await app.init();
    await app.listen(3333);
    prisma = app.get(PrismaService);
    await prisma.cleanDb();
    pactum.request.setBaseUrl('http://localhost:3333');//base url set 
  });

  afterAll(() => {
    app.close();
  });

  describe('Auth', () => {
    describe('Signup', () => {
      it('should signup', () => {//remove the todo and write the test
        const dto: AuthDto = { //create a dto object
          email: 'subham@gmail.com',
          password: 'Subham@123',
        };
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201); //send the request and expect the status 201 
        // .inspect(); //if you want to see the response
      });
    });
    describe('Signin', () => {
      it.todo('should signin');
    });
  });
  describe('User', () => {
    describe('Get me', () => {});
    describe('Edit user', () => {});
  });
  describe('Bookmarks', () => {
    describe('Create bookmarks', () => {});
    describe('Get bookmarks', () => {});
    describe('Get bookmark by id', () => {});
    describe('Edit bookmark', () => {});
    describe('Delete bookmark', () => {});
  });

  it.todo('should pass');
});

17.2 Signin test

 describe('Auth', () => {
     const dto: AuthDto = {//delare here for reusing
        email: 'subham@gmail.com',
        password: 'Subham@123',
    };
    describe('Signup', () => {
        it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => { //same as the signup
      it('should signin', () => {
        return pactum.spec().post('/auth/signin').withBody(dto).expectStatus(200);
        // .inspect();
      });
    });

17.3 Error handling


 describe('Auth', () => {
    const dto: AuthDto = {
      email: 'subham@gmail.com',
      password: 'Subham@123',
    };
    describe('Signup', () => {
      it('should throw if email empty', () => { //add  the  expectStatus(400)
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => {
      it('should throw if no body provided', () => {//add  the  expectStatus(400)
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signin', () => {
        return pactum.spec().post('/auth/signin').withBody(dto).expectStatus(200);
        // .inspect();
      });
    });
  });

17.4 Multiple Error handling

describe('Auth', () => {
    const dto: AuthDto = {
      email: 'subham@gmail.com',
      password: 'Subham@123',
    };

    describe('Signup', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signin', () => {
        return pactum.spec().post('/auth/signin').withBody(dto).expectStatus(200);
        // .inspect();
      });
    });
});

17.5 Storing the token

  • We need to check use information for that we need to send bearer token in the header

.stores('userAt', 'access_token');

  • stores is used to store the token in the variable userAt and access_token is the key

  • if you inspect the response you will see the key access_token just copy it


  describe('Auth', () => {
    const dto: AuthDto = {
      email: 'subham@gmail.com',
      password: 'Subham@123',
    };

    describe('Signup', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signup')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signup', () => {
        return pactum.spec().post('/auth/signup').withBody(dto).expectStatus(201);
        // .inspect();
      });
    });
    describe('Signin', () => {
      it('should throw if email empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            password: dto.password,
          })
          .expectStatus(400);
      });
      it('should throw if password empty', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody({
            email: dto.email,
          })
          .expectStatus(400);
      });
      it('should throw if no body provided', () => {
        return pactum.spec().post('/auth/signup').expectStatus(400);
      });
      it('should signin', () => {
        return pactum
          .spec()
          .post('/auth/signin')
          .withBody(dto)
          .expectStatus(200)
          .stores('userAt', 'access_token');//use here
        // .inspect();
      });
    });
  });

18. User E2E Testing with Pactum

18.1 Get User with Bearer Token

describe('User', () => {
    describe('Get me', () => {
      it('should get current user', () => {
        return pactum
          .spec()
          .get('/user/me')
          .withHeaders({//send the token in the header 
            Authorization: 'Bearer $S{userAt}',//herer userAt is the variable name and access_token is the key  
          })
          .expectStatus(200);
      });
    });
    describe('Edit user', () => {});
  });

18.2 Edit User with Bearer Token

18.2.1 DTO for Edit User
  • First write the controller and service for the edit user
  • nest g service user --no-spec

user dto edit

dto use for the validation of the data

import { IsEmail, IsOptional, IsString } from 'class-validator';
export class EditUserDto {
  @IsEmail() //the email should be a valid email
  @IsOptional()//optional
  email?: string;

  @IsString()//the first name should be a string
  @IsOptional()//optional
  firstName?: string;

  @IsString()//the last name should be a string
  @IsOptional()//optional   
  lastName?: string;
}
18.2.2 Service for Edit User

user service

service use for the business logic

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EditUserDto } from './dto';

@Injectable()
export class UserService {
    constructor(private prisma: PrismaService) {} //inject the prisma service

    async editUser(userId: number, dto: EditUserDto) { //It will take the user id and the dto 

        //update the user by the user id 
        const user = await this.prisma.user.update({
            where: {
                id: userId,
            },
            data: {
                ...dto,
            },
        });

        delete user.hash;//delete the hash

        return user;
    }
}
18.2.3 Controller for Edit User

user.controller.ts

import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { GetUser } from '../auth/decorator';
import { User } from '@prisma/client';
import { EditUserDto } from './dto';
import { UserService } from './user.service';

@UseGuards(JwtGuard)
@Controller('user')
export class UserController {
    constructor(private userService: UserService) {}// UserService from the service
    @Get('me')
    getMe(@GetUser() user: User) {
        return user;
    }

    @Patch() //use the patch method 
    editUser(@GetUser('id') userId: number, @Body() dto: EditUserDto) { // use the GetUser decorator to get the user id and the dto  
        return this.userService.editUser(userId, dto); //call the editUser function from the service 
    }
}
18.2.4 Pactum Test for Edit User
  • Now write the test for the edit user
describe('Edit user', () => {
    it('should edit user', () => {
        const dto: EditUserDto = {
            firstName: 'Subham',
            email: 'subham1@gmail.com',
            lastName: 'Maity'
        };
        return pactum
            .spec()
            .patch('/users')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .withBody(dto)
            .expectStatus(200)
            .expectBodyContains(dto.firstName)
            .expectBodyContains(dto.email);;
    });
});

19. Bookmarks Crud with Testing E2E

โœ… Step 0: Schema Define

  • Open prisma folder and modify the schema.prisma file to look like this:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  email String @unique
  hash  String

  firstName String?
  lastName  String?

  bookmarks Bookmark[]

  @@map("users")
}
//Bookmark model
model Bookmark {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  title       String
  description String?
  link        String

  userId Int
  user   User @relation(fields: [userId], references: [id])

  @@map("bookmarks")
}

โœ… Step 1: Installation

First, we need to generate the service and controller for bookmarks. Run the following commands:

nest g service bookmark --no-spec
nest g controller bookmark --no-spec

โœ… Step 2: Controller Structure

Next, we will set up the structure of our controller. Here are the steps:

  1. Declare the UseGuards for authentication.

  2. The constructor should be called inside BookmarkController with private bookmarksService: BookmarksService.

  3. BookmarksService should have private prisma: PrismaService inside it.

  4. Declare all the routes in the bookmark.controller.ts file.

Here's an example of what your bookmark.controller.ts file might look like:

import { Controller, Delete, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../auth/guard';
import { BookmarksService } from './bookmark.service';

@UseGuards(JwtGuard)
@Controller('bookmarks')
export class BookmarkController {
    constructor(private bookmarkService: BookmarkService) {}

    @Get()
    getBookmarks() {}

    @Get()
    getBookmarkById() {}

    @Post()
    createBookmark() {}

    @Patch()
    editBookmarkById() {}

    @Delete()
    deleteBookmarkById() {}
}

โœ… Step 3: - Decorators for the Bookmarks

  • use Param decorator for those routes which need the id of the bookmark

info: we already created a get-user.decorator.ts file in the auth folder, so we can use it here

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const GetUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
  const request: Express.Request = ctx.switchToHttp().getRequest();
  if (data) {
    return request.user[data];
  }
  return request.user;
});
export class BookmarkController {
    constructor(private bookmarkService: BookmarkService) {}
    @Get()
    getBookmarks(@GetUser('id') userId: number) { //a.
        console.log(userId);
    }
    @Get(':id') 
    getBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {//b.
        console.log(userId, bookmarkId);
    }
    @Post()
    createBookmark(@GetUser('id') userId: number, @Body() dto: CreateBookmarkDto) {//c.
        console.log(userId, dto);
    }
    @Patch(':id')
    editBookmarkById(//d.
        @GetUser('id') userId: number,
        @Param('id', ParseIntPipe) bookmarkId: number,
        @Body() dto: EditBookmarkDto,
    ) {
        console.log(userId, bookmarkId, dto);
    }
    @HttpCode(HttpStatus.NO_CONTENT)//e.
    @Delete(':id')
    deleteBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {
        console.log(userId, bookmarkId);
    }
}
  • a. getBookmarks(@GetUser('id') userId: number): This route fetches all bookmark for a user. The @GetUser('id') decorator retrieves the user's ID.

  • b. getBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number): This route fetches a specific bookmark by its ID for a user. The @GetUser('id') decorator retrieves the user's ID, and @Param('id', ParseIntPipe) retrieves and parses the bookmark ID from the URL.

  • c. createBookmark(@GetUser('id') userId: number, @Body() dto: CreateBookmarkDto): This route creates a new bookmark for a user. The @GetUser('id') decorator retrieves the user's ID, and @Body() retrieves the request body data with validation applied.

  • d. editBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number, @Body() dto: EditBookmarkDto): This route edits a specific bookmark by its ID for a user. The @GetUser('id') decorator retrieves the user's ID, @Param('id', ParseIntPipe) retrieves and parses the bookmark ID from the URL, and @Body() retrieves the request body data with validation applied.

  • e. deleteBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number): This route deletes a specific bookmark by its ID for a user. The @GetUser('id') decorator retrieves the user's ID, and @Param('id', ParseIntPipe) retrieves and parses the bookmark ID from the URL. The @HttpCode(HttpStatus.NO_CONTENT) decorator sets the HTTP status code to 204 (No Content) when the operation is successful.

โœ… Step 4: - DTO for Bookmarks

  • Make a folder named dto inside the bookmarks folder and then make a file called create-bookmark.dto.ts,edit-bookmark.dto.ts and index.ts inside the dto folder

create-bookmark.dto.ts

import { IsNotEmpty, IsOptional, IsString } from 'class-validator';

export class CreateBookmarkDto {
  @IsString()
  @IsNotEmpty()
  title: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsString()
  @IsNotEmpty()
  link: string;
}

edit-bookmark.dto.ts

import { IsOptional, IsString } from 'class-validator';

export class EditBookmarkDto {
  @IsString()
  @IsOptional()
  title?: string;

  @IsString()
  @IsOptional()
  description?: string;

  @IsString()
  @IsOptional()
  link?: string;
}

index.ts

export * from './create-bookmark.dto';
export * from './edit-bookmark.dto';

โœ… Step 5: - Service Structure for Bookmarks

  • Construct the BookmarksService with private prisma: PrismaService inside it.
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBookmarkDto, EditBookmarkDto } from './dto';

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

    getBookmarks(userId: number) {
        console.log(userId);
    }

    getBookmarkById(userId: number, bookmarkId: number) {
        console.log(userId, bookmarkId);
    }

    createBookmark(userId: number, dto: CreateBookmarkDto) {
        console.log(userId, dto);
    }

    editBookmarkById(userId: number, bookmarkId: number, dto: EditBookmarkDto) {
        console.log(userId, bookmarkId, dto);
    }

    deleteBookmarkById(userId: number, bookmarkId: number) {
        console.log(userId, bookmarkId);
    }
}

โœ… Step 6: - Bookmarks Service in the Controller

  • Inject the BookmarksService inside the BookmarkController and call the service methods inside the routes.
import {
    Body,
    Controller,
    Delete,
    Get,
    HttpCode,
    HttpStatus,
    Param,
    ParseIntPipe,
    Patch,
    Post,
    UseGuards,
} from '@nestjs/common';

import { JwtGuard } from '../auth/guard';

import { GetUser } from '../auth/decorator';

import { CreateBookmarkDto, EditBookmarkDto } from './dto';
import { BookmarkService } from './bookmark.service';

@UseGuards(JwtGuard)
@Controller('bookmarks')
export class BookmarkController {
    constructor(private bookmarksService: BookmarkService) {}

    @Get()
    getBookmarks(@GetUser('id') userId: number) {
        return this.bookmarksService.getBookmarks(userId);
    }

    @Get(':id')
    getBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {
        return this.bookmarksService.getBookmarkById(userId, bookmarkId);
    }

    @Post()
    createBookmark(@GetUser('id') userId: number, @Body() dto: CreateBookmarkDto) {
        return this.bookmarksService.createBookmark(userId, dto);
    }

    @Patch(':id')
    editBookmarkById(
        @GetUser('id') userId: number,

        @Param('id', ParseIntPipe) bookmarkId: number,

        @Body() dto: EditBookmarkDto,
    ) {
        return this.bookmarksService.editBookmarkById(userId, bookmarkId, dto);
    }

    @HttpCode(HttpStatus.NO_CONTENT)
    @Delete(':id')
    deleteBookmarkById(@GetUser('id') userId: number, @Param('id', ParseIntPipe) bookmarkId: number) {
        return this.bookmarksService.deleteBookmarkById(userId, bookmarkId);
    }
}

โœ… Step 7: - E2E Testing With Business Logic (Service)

  • For testing yarn test:e2e
  1. โ‡จ Get Empty Bookmarks

app.e2e-spec.ts


describe('Get empty bookmarks', () => {
    it('should get empty bookmarks', () => {
        return pactum
            .spec()
            .get('/bookmarks')
            .withHeaders({ Authorization: 'Bearer $S{userAt}' })
            .expectStatus(200)
            .expectBody([]); //body should be an array and length should be 0
    });
});

bookmark.service.ts


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

  getBookmarks(userId: number) {
    return this.prisma.bookmark.findMany({
      where: {
        userId,
      },
    });
  }
}
  1. โ‡จ Create Bookmarks

app.e2e-spec.ts

describe('Create bookmarks', () => {
    const dto: CreateBookmarkDto = {
        title: 'NestJS',
        link: 'https://nestjs.com/',
    };
    it('should create bookmark', () => {
        return pactum
            .spec()
            .post('/bookmarks')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .withBody(dto)
            .expectStatus(201)
            .stores('bookmarkId', 'id')//store the bookmark id in the variable bookmarkId 
            .expectBodyContains(dto.title)
            .expectBodyContains(dto.link)

    });
});

bookmark.service.ts

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

    createBookmark(userId: number, dto: CreateBookmarkDto) {
        return this.prisma.bookmark.create({
            data: {
                userId,
                ...dto,
            },
        });
    }
}
  1. โ‡จ Get Bookmarks

First it expects nothing in the get request, and then it creates a bookmark, and then it expects the length of the array to be 1

app.e2e-spec.ts

   describe('Get bookmarks', () => {
    it('should get bookmarks', () => {
        return pactum
            .spec()
            .get('/bookmarks')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(200)
            .expectJsonLength(1);//body should be an array and length should be 1
    });
});
  1. โ‡จ Get Bookmark by ID

.stores('bookmarkId', 'id') when we create the bookmark we store the id in the variable bookmarkId, and then we use it here app.e2e-spec.ts


describe('Get bookmark by id', () => {
    it('should get bookmark by id', () => {
        return pactum
            .spec()
            .get('/bookmarks/{id}')
            .withPathParams('id', '$S{bookmarkId}')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(200)
            .expectBodyContains('$S{bookmarkId}');//expect the body to contain the bookmark id 
    });
});

bookmark.service.ts

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

    getBookmarkById(userId: number, bookmarkId: number) {
        return this.prisma.bookmark.findFirst({
            where: {
                id: bookmarkId,
                userId,
            },
        });
    }
}
  1. โ‡จ Edit Bookmark by ID

app.e2e-spec.ts

 describe('Edit bookmark by id', () => {
    const dto: EditBookmarkDto = {
        title: 'Kubernetes Course - Full Beginners Tutorial (Containerize Your Apps!)',
        description:
            'Learn how to use Kubernetes in this complete course. Kubernetes makes it possible to containerize applications and simplifies app deployment to production.',
    };
    it('should edit bookmark', () => {
        return pactum
            .spec()
            .patch('/bookmarks/{id}')
            .withPathParams('id', '$S{bookmarkId}')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .withBody(dto)
            .expectStatus(200)
            .expectBodyContains(dto.title)
            .expectBodyContains(dto.description);
    });
});

bookmark.service.ts

async editBookmarkById(userId: number, bookmarkId: number, dto: EditBookmarkDto) {
    // get the bookmark by id
    const bookmark = await this.prisma.bookmark.findUnique({
        where: {
            id: bookmarkId,
        },
    });

    // check if user owns the bookmark
    if (!bookmark || bookmark.userId !== userId)
        throw new ForbiddenException('Access to resources denied');

    return this.prisma.bookmark.update({
        where: {
            id: bookmarkId,
        },
        data: {
            ...dto,
        },
    });
}
  1. โ‡จ Delete Bookmark by ID

First it expects the length of the array to be 1, then it deletes the bookmark, and then it expects the length of the array to be 0 app.e2e-spec.ts

describe('Delete bookmark by id', () => {
    it('should delete bookmark', () => {
        return pactum
            .spec()
            .delete('/bookmarks/{id}')
            .withPathParams('id', '$S{bookmarkId}')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(204);
    });

    it('should get empty bookmarks', () => {
        return pactum
            .spec()
            .get('/bookmarks')
            .withHeaders({
                Authorization: 'Bearer $S{userAt}',
            })
            .expectStatus(200)
            .expectJsonLength(0);
    });
});

bookmark.service.ts

async deleteBookmarkById(userId: number, bookmarkId: number) {
    const bookmark = await this.prisma.bookmark.findUnique({
        where: {
            id: bookmarkId,
        },
    });

    // check if user owns the bookmark
    if (!bookmark || bookmark.userId !== userId)
        throw new ForbiddenException('Access to resources denied');

    await this.prisma.bookmark.delete({
        where: {
            id: bookmarkId,
        },
    });
}

It will give you this output:

PS > yarn test:e2e  
yarn run v1.22.19
$ docker compose rm test-db -s -f -v
[+] Stopping 1/1
 โœ” Container lesson02-test-db-1  Stopped                                                                                                                                                                                                                       0.3s 
Going to remove lesson02-test-db-1
[+] Removing 1/0
 โœ” Container lesson02-test-db-1  Removed                                                                                                                                                                                                                       0.0s 
$ docker compose up test-db -d
[+] Running 1/1
 โœ” Container lesson02-test-db-1  Started                                                                                                                                                                                                                       0.0s 
Done waiting
$ dotenv -e .env.test -- prisma migrate deploy
Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": PostgreSQL database "nest", schema "public" at "localhost:5435"

1 migration found in prisma/migrations

Applying migration `20240307030332_init`

The following migration(s) have been applied:

migrations/
  โ””โ”€ 20240307030332_init/
    โ””โ”€ migration.sql

All migrations have been successfully applied.
$ dotenv -e .env.test -- jest --watch --no-cache --config ./test/jest-e2e.json
 PASS  test/app.e2e-spec.ts (5.369 s)
  App e2e
    Auth
      Signup
        โˆš should throw if email empty (56 ms)
        โˆš should throw if password empty (5 ms)
        โˆš should throw if no body provided (4 ms)
        โˆš should signup (80 ms)
      Signin
        โˆš should throw if email empty (3 ms)
        โˆš should throw if password empty (2 ms)
        โˆš should throw if no body provided (2 ms)
        โˆš should signin (66 ms)
    User
      Get me
        โˆš should get current user (8 ms)
      Edit user
        โˆš should edit user (8 ms)
    Bookmarks
      Get empty bookmarks
        โˆš should get bookmarks (6 ms)
      Create bookmark
        โˆš should create bookmark (8 ms)
      Get bookmarks
        โˆš should get bookmarks (5 ms)
      Get bookmark by id
        โˆš should get bookmark by id (5 ms)
      Edit bookmark by id
        โˆš should edit bookmark (10 ms)
      Delete bookmark by id
        โˆš should delete bookmark (9 ms)
        โˆš should get empty bookmarks (5 ms)

Test Suites: 1 passed, 1 total
ย