DEV Community

Dylan 💻
Dylan 💻

Posted on • Updated on

Building A RESTful CRUD API with Nest.js, TypeORM & PostgreSQL: A Step-by-Step Guide

Embark on a personal exploration of building a robust REST API with Nest.js, TypeORM, and PostgreSQL. Throughout this series, I'll guide you through the essentials, from setting up a Nest.js project to connecting to PostgreSQL and implementing RESTful endpoints. We'll delve into the intricacies of database operations, whether you're an experienced developer or just starting, join me on this journey to master the powerful combination of Nest.js, TypeORM, and PostgreSQL, enhancing your backend development skills along the way!


RESTful API Standards

Routes Description
GET /api/v1/goals Get goals
GET /api/v1/goals/1 Get goal with id of 1
POST /api/v1/goals Add a goal
PUT /api/v1/goals/1 Update goal with id of 1
PATCH /api/v1/goals/1 Partially Update goal with id of 1
DELETE /api/v1/goals/1 Delete goal with id of 1

Setting Up Your Development Environment

Before we start building our API, we first scaffold the project with the Nest CLI.

  1. Install Nest.js globally:

    npm i -g @nestjs/cli
    
  2. Scaffold the project:

    nest new goal-tracker-nestjs
    
  3. Navigate to your directory that you just created with the following:

    cd goal-tracker-nestjs
    
  4. Start the server

    npm run start:dev
    

Setting A Global Prefix For Our API

Before we jump into creating our API, lets quickly add a prefix to it.

  1. Open main.ts in /src and add the following:

    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      app.setGlobalPrefix('api/v1'); // Adds prefix
      await app.listen(3000);
    }
    bootstrap();
    

Creating Our Controllers

Controllers are responsible for handling incoming requests and returning responses to the client.

  1. Let's create our controllers. To create a new controller type the following command:

    nest generate controller goals
    
  2. Now we need to update our goals.controller.ts file to the following:

    import { Controller, Delete, Get, Patch, Post } from '@nestjs/common';
    
    @Controller('goals')
    export class GoalsController {
        @Get()
        findAll() {}
    
        @Get()
        findOne() {}
    
        @Post()
        create() {}
    
        @Patch()
        update() {}
    
        @Delete()
        remove() {}
    }
    
  3. Now we need to register this controller with Nest.js. Add GoalsController to the controllers array.

    import { Module } from '@nestjs/common';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import { GoalsController } from './goals/goals.controller';
    
    @Module({
      imports: [],
      controllers: [AppController, GoalsController],
      providers: [AppService],
    })
    export class AppModule {}
    
  4. You are done, you will now get successful responses back from the API if you send a GET, POST, PATCH or DELETE to http://localhost:3000/api/v1/goals.


Adding Route Parameters To Our Controllers

We would want to add Route Parameters if we want to have dynamic routes and to extract values from the URL.

  1. Update our goals.controller.ts file to the following:

    import { Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
    
    @Controller('goals')
    export class GoalsController {
      @Get()
      findAll() {}
    
      // Example #1 - Value
      @Get(':id')
      findOne(@Param('id') id) {
        return id; 
        // 1
      }
    
      // Example #2 - JSON Object
      @Get(':id')
      findOne(@Param() id) {
        return id;
        // {
        //    "id": "1"
        // }
      }
    
      @Post()
      create() {}
    
      @Patch(':id')
      update(@Param('id') id) {}
    
      @Delete(':id')
      remove(@Param('id') id) {}
    }
    

Adding The Request Body

We need to create our Goal Model/Schema in order to describe how our data is going to look like, there are also some other use cases such as data validation, security, etc.

  1. Update our goals.controller.ts file to the following:

    import {
      Body,
      Controller,
      Delete,
      Get,
      Param,
      Patch,
      Post,
    } from '@nestjs/common';
    
    @Controller('goals')
    export class GoalsController {
      // ...
    
      @Post()
      create(@Body() input) {
        return input;
      }
    
      @Patch(':id')
      update(@Param('id') id, @Body() input) {}
    
      // ...
    }
    

Adding The Responses & Status Codes

By default Nest.js adds the HTTP Codes for you but if you want to give a specific Controller a HTTP Code you can do so with the decorator @HttpCode(204). We are going to update the current API with the following:

  1. Update our goals.controller.ts file to the following:

    import {
      Body,
      Controller,
      Delete,
      Get,
      HttpCode,
      Param,
      Patch,
      Post,
    } from '@nestjs/common';
    
    @Controller('goals')
    export class GoalsController {
      @Get()
      findAll() {
        return [
          { id: 1, name: 'Goal 1' },
          { id: 2, name: 'Goal 2' },
          { id: 3, name: 'Goal 3' },
        ];
      }
    
      @Get(':id')
      findOne(@Param('id') id) {
        return {
          id: 1,
          name: 'Goal 1',
        };
      }
    
      @Post()
      create(@Body() input) {
        return input;
      }
    
      @Patch(':id')
      update(@Param('id') id, @Body() input) {
        return input;
      }
    
      @Delete(':id')
      @HttpCode(204)
      remove(@Param('id') id) {}
    }
    

Creating Our Data Transfer Objects (DTO)

We use Data Transfer Objects (DTO) for defining the input properties and their types upfront.

Request Payload (Create)

  1. Let's create our Create DTO. Create a new file called create-goal.dto.ts in the directory /src/goals/dtos

  2. Now we need to update our create-goal.dto.ts file to the following:

    export class CreateGoalDto {
      name: string;
      priority: string;
      status: string;
      createdAt: string;
      updatedAt: string;
    }
    
  3. Now we need to update our goals.controller.ts file to the following:

    import {
      Body,
      Controller,
      Delete,
      Get,
      HttpCode,
      Param,
      Patch,
      Post,
    } from '@nestjs/common';
    import { CreateGoalDto } from './dtos/create-goal.dto';
    
    @Controller('goals')
    export class GoalsController {
      // ...
    
      @Post()
      create(@Body() input: CreateGoalDto) {
        return input;
      }
    
      // ...
    }
    

 

Update Payload (Update)

  1. Install @nestjs/mapped-types:

    npm i @nestjs/mapped-types
    
  2. Let's create our Update DTO. Create a new file called update-goal.dto.ts in the directory /src/goals/dtos

  3. Now we need to update our update-goal.dto.ts file to the following:

    import { PartialType } from '@nestjs/mapped-types';
    import { CreateGoalDto } from './create-goal.dto';
    
    // Pulls types from CreateGoalDto into UpdateGoalDto
    export class UpdateGoalDto extends PartialType(CreateGoalDto) {}
    
  4. To make our code a bit more cleaner, lets combine the Classes together into a single file with the following:

    // index.ts
    import { CreateGoalDto } from './create-goal.dto';
    import { UpdateGoalDto } from './update-goal.dto';
    
    export { CreateGoalDto, UpdateGoalDto };
    
  5. Now we need to update our goals.controller.ts file to the following:

    import {
      Body,
      Controller,
      Delete,
      Get,
      HttpCode,
      Param,
      Patch,
      Post,
    } from '@nestjs/common';
    import { CreateGoalDto, UpdateGoalDto } from './dtos/index';
    
    @Controller('goals')
    export class GoalsController {
      // ...
    
      @Patch(':id')
      update(@Param('id') id, @Body() input: UpdateGoalDto) {
        return input;
      }
    
      // ...
    }
    

Example of Our API Working In A Session (Without Being Connected To A Database)

  1. Before we create our entity let's create Enums for our priority and status type. Create a new file called priority.enum.ts and another called status.enum.ts in the directory /src/goals/enums

  2. Now we need to update our priority.enum.ts to the following:

    // priority.enum.ts
    export enum Priority {
      LOW = 'Low',
      MEDIUM = 'Medium',
      HIGH = 'High',
    }
    
  3. Now we need to update our status.enum.ts to the following:

    // status.enum.ts
    export enum Status {
      PENDING = 'Pending',
      IN_PROGRESS = 'In Progress',
      COMPLETED = 'Completed',
    }
    
  4. To make our code a bit more cleaner, lets combine the Enums together into a single file with the following:

    // index.ts
    import { Priority } from './priority.enum';
    import { Status } from './status.enum';
    
    export { Priority, Status };
    
  5. Now we need to update our create-goal.dto.ts file to the following:

    import { Priority, Status } from '../enums';
    
    export class CreateGoalDto {
      name: string;
      priority: Priority;
      status: Status;
      createdAt: string;
      updatedAt: string;
    }
    
  6. Let's create our Entity. Create a new file called goal.entity.ts in the directory /src/goals/entities

  7. Now we need to update our goal.entity.ts file to the following:

    import { Priority, Status } from '../enums';
    
    export class Goal {
      id: number;
      name: string;
      priority: Priority;
      status: Status;
      createdAt: Date;
      updatedAt: Date;
    }
    
  8. Now we need to update our goals.controller.ts file to the following:

    import {
      Body,
      Controller,
      Delete,
      Get,
      HttpCode,
      Param,
      Patch,
      Post,
    } from '@nestjs/common';
    import { CreateGoalDto, UpdateGoalDto } from './dtos/index';
    import { Goal } from './entities/goal.entity';
    import { Priority, Status } from './enums/index';
    
    @Controller('goals')
    export class GoalsController {
      private goals: Goal[] = [
        {
          id: 1,
          name: 'Learn tRPC',
          priority: Priority.LOW,
          status: Status.PENDING,
          createdAt: new Date(),
          updatedAt: new Date(),
        },
        {
          id: 2,
          name: 'Learn Nest.js',
          priority: Priority.HIGH,
          status: Status.IN_PROGRESS,
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ];
    
      // GET /api/v1/goals
      @Get()
      findAll() {
        return this.goals;
      }
    
      // GET /api/v1/goals/:id
      @Get(':id')
      findOne(@Param('id') id) {
        const goal = this.goals.find((goal) => goal.id === parseInt(id));
    
        return goal;
      }
    
      // POST /api/v1/goals
      @Post()
      create(@Body() input: CreateGoalDto) {
        const goal = {
          ...input,
          createdAt: new Date(input.createdAt),
          updatedAt: new Date(input.updatedAt),
          id: this.goals.length + 1,
        };
    
        this.goals.push(goal);
      }
    
      // PATCH /api/v1/goals/:id
      @Patch(':id')
      update(@Param('id') id, @Body() input: UpdateGoalDto) {
        const index = this.goals.findIndex((goal) => goal.id === parseInt(id));
    
        this.goals[index] = {
          ...this.goals[index],
          ...input,
          createdAt: input.createdAt
            ? new Date(input.createdAt)
            : this.goals[index].createdAt,
          updatedAt: input.updatedAt
            ? new Date(input.updatedAt)
            : this.goals[index].updatedAt,
        };
    
        return this.goals[index];
      }
    
      // DELETE /api/v1/goals/:id
      @Delete(':id')
      @HttpCode(204)
      remove(@Param('id') id) {
        this.goals = this.goals.filter((goal) => goal.id !== parseInt(id));
      }
    }
    
  9. You can make the following requests to each endpoint and the responses will be cached in each session to simulate a real working API.


Setting Up & Connecting To Our Database

Installing TypeORM & PostgreSQL

  1. Install TypeORM and the database you want to use, in this case we will use PostgreSQL (pg):

    npm install @nestjs/typeorm typeorm pg
    

 

Creating Our Database

  1. Open your favourite database management tool, in this case I will be using pgAdmin.

  2. Right-Click Databases > Create > Database...

  3. Name you database whatever you like, we will be using goaltracker-db

  4. Now, Click Save

 

Connecting To Our Database

  1. Let's create two database configurations, one for production called orm.config.prod.ts and the other for development called orm.config.ts in the directory /src/config

  2. Now we need to update orm.config.prod.ts to the following:

    // orm.config.prod.ts
    import { registerAs } from '@nestjs/config';
    import { TypeOrmModuleOptions } from '@nestjs/typeorm';
    import { Goal } from 'src/goals/entities/goal.entity';
    
    export default registerAs(
      'orm.config',
      (): TypeOrmModuleOptions => ({
        type: 'postgres',
        host: '<YOUR_HOST>',
        port: 5432,
        username: '<YOUR_PRODUCTION_DATABASE_USERNAME>',
        password: '<YOUR_PRODUCTION_DATABASE_PASSWORD>',
        database: 'goaltracker-db',
        entities: [Goal],
        synchronize: false, // Disable this always in production
      }),
    );
    
  3. Now we need to update orm.config.ts to the following:

    // orm.config.ts
    import { registerAs } from '@nestjs/config';
    import { TypeOrmModuleOptions } from '@nestjs/typeorm';
    import { Goal } from 'src/goals/entities/goal.entity';
    
    export default registerAs(
      'orm.config',
      (): TypeOrmModuleOptions => ({
        type: 'postgres',
        host: 'localhost',
        port: 5432,
        username: 'postgres',
        password: '<YOUR_DATABASE_PASSWORD>',
        database: 'goaltracker-db',
        entities: [Goal],
        synchronize: true,
      }),
    );
    
  4. Install @nestjs/config since we will be needing it next:

    npm install @nestjs/config
    
  5. Now, in our app.module.ts we need to update our file to the following to use our configuration:

    // app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { AppController } from './app.controller';
    import { AppService } from './app.service';
    import ormConfig from './config/orm.config';
    import ormConfigProd from './config/orm.config.prod';
    import { Goal } from './goals/entities/goal.entity';
    import { GoalsController } from './goals/goals.controller';
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true,
          load: [ormConfig],
          expandVariables: true,
        }),
        TypeOrmModule.forRootAsync({
          useFactory:
            process.env.NODE_ENV !== 'production' ? ormConfig : ormConfigProd,
        }),
        TypeOrmModule.forFeature([Goal]),
      ],
      controllers: [AppController, GoalsController],
      providers: [AppService],
    })
    export class AppModule {}
    
  6. Finally, we will need to mark our Goal Class as an Entity and the properties as Columns so that we can define the structure of our data. Update goal.entity.ts to the following:

    // goal.entity.ts
    import {
      Column,
      CreateDateColumn,
      Entity,
      PrimaryGeneratedColumn,
      UpdateDateColumn,
    } from 'typeorm';
    import { Priority, Status } from '../enums';
    
    @Entity()
    export class Goal {
      @PrimaryGeneratedColumn('uuid')
      id: number;
    
      @Column()
      name: string;
    
      @Column({
        type: 'enum',
        enum: Priority,
        default: Priority.LOW,
      })
      priority: Priority;
    
      @Column({
        type: 'enum',
        enum: Status,
        default: Status.PENDING,
      })
      status: Status;
    
      @CreateDateColumn()
      createdAt: Date;
    
      @UpdateDateColumn()
      updatedAt: Date;
    }
    
  7. Restart the server and you should see your table appear in your database with all of the columns.


Example of Our API Working (While Connected To A Database)

  1. Update our goals.controller.ts file to the following:

    import {
      Body,
      Controller,
      Delete,
      Get,
      HttpCode,
      NotFoundException,
      Param,
      Patch,
      Post,
    } from '@nestjs/common';
    import { InjectRepository } from '@nestjs/typeorm';
    import { Repository } from 'typeorm';
    import { CreateGoalDto, UpdateGoalDto } from './dtos/index';
    import { Goal } from './entities/goal.entity';
    
    @Controller('goals')
    export class GoalsController {
      // Dependency Injection
      constructor(
        @InjectRepository(Goal) private readonly repository: Repository<Goal>,
      ) {}
    
      // GET /api/v1/goals
      @Get()
      async findAll() {
        const goals = await this.repository.find();
    
        return { success: true, count: goals.length, data: goals };
      }
    
      // GET /api/v1/goals/:id
      @Get(':id')
      async findOne(@Param('id') id) {
        const goal = await this.repository.findOneBy({ id });
    
        if (!goal) {
          throw new NotFoundException();
        }
    
        return { success: true, data: goal };
      }
    
      // POST /api/v1/goals
      @Post()
      async create(@Body() input: CreateGoalDto) {
        const goal = await this.repository.save({
          ...input,
          createdAt: input.createdAt,
          updatedAt: input.updatedAt,
        });
    
        return { success: true, data: goal };
      }
    
      // PATCH /api/v1/goals/:id
      @Patch(':id')
      async update(@Param('id') id, @Body() input: UpdateGoalDto) {
        const goal = await this.repository.findOneBy({ id });
    
        if (!goal) {
          throw new NotFoundException();
        }
    
        const data = await this.repository.save({
          ...goal,
          ...input,
          createdAt: input.createdAt ?? goal.createdAt,
          updatedAt: input.updatedAt ?? goal.updatedAt,
        });
    
        return { success: true, data };
      }
    
      // DELETE /api/v1/goals/:id
      @Delete(':id')
      @HttpCode(204)
      async remove(@Param('id') id) {
        const goal = await this.repository.findOneBy({ id });
    
        if (!goal) {
          throw new NotFoundException();
        }
    
        await this.repository.remove(goal);
      }
    }
    
  2. You can make the following requests to each endpoint and your database will populate with data.


In summary, the use of Nest.js, TypeORM, and PostgreSQL facilitates the creation of a robust and scalable REST API. Nest.js provides a modular structure, TypeORM streamlines database interactions, and PostgreSQL ensures efficiency. This powerful combination enables developers to build high-performance APIs that adhere to best practices, fostering reliable and maintainable backend systems.

 

Code

If you want to refer to the code you can do so here.

 

Thanks for reading!

Have a question? Connect with me via Twitter or send me a message at hello@dylansleith.com

Top comments (0)