Build Restful Api With Nestjs the Right Way

Build Restful Api With Nestjs the Right Way

Post Date : 2022-10-30T06:00:00+07:00

Modified Date : 2022-10-30T06:00:00+07:00

Category: frameworks nestjs

Tags: nestjs

Foreword

If you are new with NestJS or you have been worked with it for several projects, this post is for you. In this article, I’ll share with you the real world process to build a Restful API from scratch with NestJS

Okie, let’s start. Imagination, we’ve already have detail specifications. This is our requirements in this sample:

Create Restful API for a image storage service that allow:

  1. Users can register to the system. They need to verify their account before using it (via email).
  2. Users can only use the system if they have an active account
  3. Users can upload images with png,jpg,jpeg format. The maximum size is 5MB
  4. User’s images won’t be public unless they created a shared link and share with other people.
  5. User’s images can be used on other websites
  6. A user has maximum 100 images.
  7. A system will delete unused image(unused more than 1 year) automatically every weekends.

Infrastructure requirements:

  1. Local : Local Docker
  2. Staging: Run on VM with Docker
  3. Production: Host everything on AWS

First of all, we need to write down what we have to do.

  • Install NestJS CLI
  • Setup your dev tools: vscode, config for debug, code linter
  • Design source code structure: seperate them into layers
  • Setup your local docker (database only)
  • Setup your application’s configuration
  • Setup data access layer and configuration
  • Business Layer: define business models: inputModels, outputModels
  • Presentation Layer: define controllers
  • Presentation Layer: integrate data validation from business models
  • Business Layer: define business service
  • Presentation Layer: integrate with business layer
  • Presentation Layer: create swagger - the api document
  • Persistence Layer: define database’s entities, create and run migration scripts, seeds
  • Persistence Layer: define repositories
  • Business Layer: integrate repository with business layer
  • Setup your staging docker: dockerfile, docker compose, setup scripts
  • Setup your CI/CD flow and setup on your staging
  • Design and setup AWS Infrastructure

And assume that we already have the high level designs

File Storage API

File Storage API Entities

File Storage API Endpoints

Step 1 : Install NestJS CLI

npm i -g @nestjs/cli
nest new your-project-name

Step 2: Setup your dev tools

  1. How to debug NestJS in VSCode?

.vscode/launch.json

{
  "configurations": [
    {
      "name": "Debug API",
      "type": "node",
      "program": "${workspaceFolder}/src/application/api/main.ts",
      "runtimeArgs": [
        "-r",
        "ts-node/register",
        "-r",
        "tsconfig-paths/register"
      ],
      "console": "integratedTerminal",
      "request": "launch",
      "skipFiles": ["<node_internals>/**"],
      "outFiles": ["${workspaceFolder}/**/*.js", "!**/node_modules/**"]
    },
    {
      "name": "Debug remote api",
      "type": "node",
      "request": "attach",
      "remoteRoot": "/app",
      "localRoot": "${workspaceFolder}",
      "port": 9229,
      "restart": true,
      "address": "0.0.0.0",
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

Debug main app (file storage api)

F5 -> Select Debug API

Make sure your program path is your api’s main file

Eg: “program”: “${workspaceFolder}/src/application/api/main.ts”,

Debug remote

Example: Start app in debug mode - debugger port 9229

F5 -> Select Debug remote API

npm run start:debug

Step 3: Design source code structure

Overview layers

File Storage API clean architecture layer

  1. solutions
    • deployment -> deployment scripts, dockerfiles, k8s, …
  2. src
  • src/application -> applications: file-storage-api, web, queues, …
  • src/domain -> Tables mapping
  • src/business -> Business logic
  • src/share -> Share/Common projects
  • src/persistence -> TypeORM Core
  • src/config -> config: log,environment, configuration
  1. test -> test configuration

domain layer File Storage API business layer

application layer File Storage API application layer

business layer File Storage API business layer

persistence layer File Storage API persistence layer

config layer File Storage API config layer

Example domain layer

// src/domain/base.entity.ts
export abstract class BaseEntity {
  id: number;
  createdAt: Date;
  updatedAt: Date;
}

// src/domain/user/user.entity.ts
import { UserSns } from "./user-sns.entity";
import { BaseEntity } from "../base.entity";
export enum UserType {
  admin = "admin",
  user = "user",
}

export class UserEntity extends BaseEntity {
  fullname: string;
  username: string;
  email: string;
  password: string;
  isActive: boolean;
  userType: UserType;
  snsAccounts?: UserSns[] = [];
  avatar: string;
  maximumFiles: number;
  totalFiles: number;
}

// src/domain/user/user-sns.entity.ts
import { UserEntity } from "./user.entity";
import { BaseEntity } from "../base.entity";

export enum SnsType {
  FACEBOOK = "facebook",
  GOOGLE = "google",
  LINKEDIN = "linkedin",
  TWITTER = "twitter",
  INSTAGRAM = "instagram",
}

export class UserSns extends BaseEntity {
  snsType: SnsType;
  snsId: string;
  authorizedDate: Date;
  // relationship
  user: UserEntity;
}

Example application layer

// src/application/api/api.module.ts
import { Module } from "@nestjs/common";

import { ExampleModule } from "./example/example.module";
import { UserModule } from "./user/user.module";
import { FileModule } from "./file/file.module";
import { AuthModule } from "./auth/auth.module";

@Module({
  imports: [ExampleModule, UserModule, FileModule, AuthModule],
  controllers: [],
  providers: [],
})
export class ApiModule {}

// src/application/api/example/example.module.ts
import { Module } from "@nestjs/common";
import { ExampleController } from "./controllers/example.controller";

@Module({
  controllers: [ExampleController],
})
export class ExampleModule {}

// src/application/api/example/controllers/example.controller.ts
import { Controller } from "@nestjs/common";

@Controller("examples")
export class ExampleController {}

Example persistence layer

In this example, i will use typeorm as our main ORM and MYSQL as main database

npm install --save @nestjs/typeorm typeorm mysql2

Install and use typeorm cli

npm install ts-node --save-dev
// src/persistence/database/entities/Example.ts
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity()
export class Example {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column()
  age: number;
}

// src/config/configuration/index.ts
import { MYSQL_DB_URI } from "@config/environment";
import { TypeOrmModule } from "@nestjs/typeorm";

export const getTypeORMConfiguration = (entities) => {
  return TypeOrmModule.forRoot({
    type: "mysql",
    url: MYSQL_DB_URI,
    entities,
    synchronize: false,
  });
};

// src/persistence/database/database.module.ts
import { DynamicModule, Module } from "@nestjs/common";
import { getTypeORMConfiguration } from "@config/configuration";
import entities from "./entities";

@Module({})
export class DatabaseModule {
  static forRoot(): DynamicModule {
    return getTypeORMConfiguration([entities]);
  }
}

Data Migration

// src/persistence/database/data-source.ts
import { MYSQL_DB_URI } from "@config/environment";
import { DataSource } from "typeorm";
import entities from "./entities";

export const AppDataSource = new DataSource({
  type: "mysql",
  url: MYSQL_DB_URI,
  synchronize: false,
  logging: false,
  entities,
  migrations: ["./src/persistence/database/migrations/*.ts"],
  subscribers: [],
});
  "scripts": {
    "typeorm": "typeorm-ts-node-commonjs --dataSource ./src/persistence/database/data-source.ts",
    "typeorm:migration:generate": "typeorm-ts-node-commonjs migration:generate --dataSource ./src/persistence/database/data-source.ts",
    "typeorm:migration:create": "typeorm-ts-node-commonjs migration:create"
  },

generate migration script

npm run typeorm:migration:generate ./src/persistence/database/migrations/migration-script-name

create migration script

npm run typeorm:migration:create ./src/persistence/database/migrations/migration-script-name

show migration

npm run typeorm migration:show
npm run typeorm migration:run
npm run typeorm migration:revert

Example config layer

// src/config/environment/index.ts
import * as dotenv from "dotenv";
// LOAD process's enviroment variables
dotenv.config();
// REQUIRED VARIABLES
const REQUIRED_VARIABLES = ["NODE_ENV", "MYSQL_DB_URI"];
/*
Log level:
- fatal
- error
- warn
- info
- verbose
- debug
*/
export const LOG_LEVEL = process.env.LOG_LEVEL;
export const ERROR_LOG_PATH =
  process.env.ERROR_LOG_PATH || "logs/server-errors.log";
export const LOG_PATH = process.env.LOG_PATH || "logs/server.log";

export const validateRequiredEnviromentVariables = () => {
  const missingKeys = [];
  REQUIRED_VARIABLES.map((requiredKey) => {
    if (!process.env[requiredKey]) {
      missingKeys.push(requiredKey);
    }
  });
  if (missingKeys.length > 0) {
    console.log(missingKeys);
    missingKeys.map((key, index) => {
      console.error(`${index + 1}. Missing ${key} !!!`);
    });
    process.exit(1);
  }
};

export const NODE_ENV = process.env.NODE_ENV;
export const MYSQL_DB_URI = process.env.MYSQL_DB_URI;

validateRequiredEnviromentVariables();

// src/config/configuration/index.ts
import { MYSQL_DB_URI } from "@config/environment";
import { TypeOrmModule } from "@nestjs/typeorm";

export const getTypeORMConfiguration = (entities) => {
  return TypeOrmModule.forRoot({
    type: "mysql",
    url: MYSQL_DB_URI,
    entities,
    synchronize: false,
  });
};

// src/config/log/index.ts
import { ConsoleLogger, Optional } from "@nestjs/common";
import * as dayjs from "dayjs";
import * as utc from "dayjs/plugin/utc";
import {
  configure,
  getLogger,
  Layout,
  Logger as Log4js,
  LoggingEvent,
} from "log4js";
import { ERROR_LOG_PATH, LOG_LEVEL, LOG_PATH } from "@config/environment";

// Fix timezone
dayjs.extend(utc);

export const log4jLayout = (isConsole = false): Layout => {
  return {
    type: "pattern",
    pattern: isConsole
      ? "%[[%x{startTime}] [%p] [%c] -%] %m"
      : "[%x{startTime}] [%p] [%c] - %m",
    tokens: {
      startTime: (logEvent: LoggingEvent) =>
        dayjs.utc(logEvent.startTime).format(),
    },
  };
};

configure({
  appenders: {
    Server: {
      type: "dateFile",
      maxLogSize: 52428800,
      numBackups: 20,
      filename: LOG_PATH,
      layout: log4jLayout(),
      pattern: "yyyy-MM-dd",
      compress: true,
    },
    serverError: {
      type: "dateFile",
      maxLogSize: 52428800,
      numBackups: 20,
      filename: ERROR_LOG_PATH,
      layout: log4jLayout(),
      pattern: "yyyy-MM-dd",
      compress: true,
    },
    serverLogFilter: {
      type: "logLevelFilter",
      appender: `serverError`,
      level: "error",
    },
    console: {
      type: "console",
      layout: log4jLayout(true),
    },
  },
  categories: {
    default: {
      appenders: ["Server", "serverLogFilter", "console"],
      level: LOG_LEVEL,
    },
  },
});

export class Logger extends ConsoleLogger {
  logger: Log4js;
  constructor(
    @Optional() protected context?: string,
    @Optional() protected options: { timestamp?: boolean } = {}
  ) {
    super();
    this.logger = getLogger("Server");
  }

  log(message: any, ...optionalParams: any[]): void {
    optionalParams = this.context
      ? optionalParams.concat(this.context)
      : optionalParams;
    this.logger.info(message, ...optionalParams);
  }

  error(message: any, ...optionalParams: any[]): void {
    optionalParams = this.context
      ? optionalParams.concat(this.context)
      : optionalParams;
    this.logger.error(message, ...optionalParams);
  }

  warn(message: any, ...optionalParams: any[]): void {
    optionalParams = this.context
      ? optionalParams.concat(this.context)
      : optionalParams;
    this.logger.warn(message, ...optionalParams);
  }

  debug(message: any, ...optionalParams: any[]): void {
    optionalParams = this.context
      ? optionalParams.concat(this.context)
      : optionalParams;
    this.logger.debug(message, ...optionalParams);
  }
}

// src/application/api/main.ts
import { Logger } from "@config/log";
import { NestFactory } from "@nestjs/core";
import { ApiModule } from "./api.module";

async function bootstrap() {
  const app = await NestFactory.create(ApiModule, {
    logger: new Logger(), // overwrite default Logger
  });

  await app.listen(3000);
}
bootstrap();

// usage
import { Controller, Get, Logger } from "@nestjs/common";

@Controller("auth")
export class AuthController {
  @Get("")
  index() {
    Logger.error("test auth log");
  }
}

Step 4 : Application Layer Implementation

Let’s assume that we already have the below high level design diagrams

Response & Response

Response & Response

Create Example

Create Example

List Example

List Example

Update Example

List Example

Delete Example

List Example