[TypeScript] 노마드코더 - NestJS로 API 만들기

June·2021년 11월 2일
0

Architecture of NestJS

Overview

NestJs 설치

 npm i -g @nestjs/cli

원하는 폴더로 이동 후

nest new

프로젝트 명 설정해주면 된다. (hi-nest)

깃헙 리포지토리를 만들고 vscode에서 git remote add origin 해준다.

src폴더의 ~.sprc파일을 지워준다.

npm run start:dev

를하면 애플리케이션이 시작된다.
localhost:3000 을 가보면 hello world가 나온다.

main.ts가 무조건 있어야한다. 이름을 바꾸면 안된다.

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

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

async await 빼고는 특별한게 없다. 함수 명은 바꿔도 된다. AppModule에 가보자.

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

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

데코레이터가 사용되었다. 데이코레이터를 통해 클래스에 함수 기능을 추가할 수 있다.
AppController로 가보자.

AppController

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

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

AppService가 들어있다.

AppService

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

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

Controllers

앱 모듈은 모든 것의 루트 모듈 같은 것이다. 모듈은 애플리케이션의 일부다.

컨트롤러는 기본적으로 url을 가져오고 함수를 실행한다. express의 라우터와 같은 역할이다.

app.controller.ts

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

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @Get('/hello')
  sayHello(): string {
    return 'Hello everyone';
  }
}

Services

app.service.ts

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

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

  getHi(): string {
    return 'hi nest!';
  }
}

REST API

Movies Controller

nest g co

nest generate controller의 줄임말이다. 이 명령어를 터미널에서 치면 controller 이름을 묻는다. 이름을 입력하면 컨트롤러가 생성된다.

app.module.ts

import { Module } from '@nestjs/common';
import { MoviesController } from './movies/movies.controller';


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

movies.controllers.ts

import { Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';

@Controller('movies')
export class MoviesController {
    
    @Get()
    getAll() {
        return 'This will return all movies';
    }

    @Get("/:id")
    getOne(@Param('id') id: string) {
        return `This will return one movie with the id: ${id}`;
    }

    @Post()
    create() {
        return 'This will create a movie';
    }

    @Delete("/:id")
    remove(@Param('id') movieId:string) {
        return `This will delete a movie with the id: ${movieId}`;
    }

    @Patch(":/id")
    patch(@Param('id') movieId: string) {
        return `This will patch a movie with the id: ${movieId}`;
    }
}

More routes

import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';

@Controller('movies')
export class MoviesController {
    
    @Get()
    getAll() {
        return 'This will return all movies';
    }

    @Get("search")
    search(@Query("year") searchingYear: string) {
        return `We are seraching for a movie made after: ${searchingYear}: `;
    }

    @Get("/:id")
    getOne(@Param('id') id: string) {
        return `This will return one movie with the id: ${id}`;
    }

    @Post()
    create(@Body() movieData) {
        console.log(movieData);
        return movieData;
    }

    @Delete("/:id")
    remove(@Param('id') movieId:string) {
        return `This will delete a movie with the id: ${movieId}`;
    }

    @Patch("/:id")
    patch(@Param('id') movieId: string, @Body() updateData) {
        console.log(updateData);
        return {
            updateMovie: movieId,
            ...updateData,
        };
    }
}

search 부분이 get보다 밑에 있으면 NestJS는 search를 id로 판단한다.

    @Get("/:id")
    getOne(@Param('id') id: string) {
        return `This will return one movie with the id: ${id}`;
    }

이 부분이 위에 있으면 아래 부분이 제대로 작동안할 수도 있다. search를 id라 생각하는 것이다.

Movies Service part One

nest g s

서비스를 생성하는 명령어다.

app.module.ts

import { Module } from '@nestjs/common';
import { MoviesController } from './movies/movies.controller';
import { MoviesService } from './movies/movies.service';


@Module({
  imports: [],
  controllers: [MoviesController],
  providers: [MoviesService],
})
export class AppModule {}

movies.entity.ts

export class Movie {
    id: number;
    title: string;
    year: number;
    genres: string[];
}

movies.services.ts

import { Injectable } from '@nestjs/common';
import { Movie } from './movie.entity';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];
    
    getAll(): Movie[] {
        return this.movies;
    }

    getOne(id: string): Movie {
        return this.movies.find(movie => movie.id === +id);
    }

    deleteOne(id:string): boolean {
        this.movies.filter(movie => movie.id !== +id);
        return true;
    }

    create(movieData) {
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData,
        });
    }
}

string에 +를 붙이면 number형으로 캐스팅된다.

movies.controller.ts

import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Movie } from './movie.entity';
import { MoviesService } from './movies.service';

@Controller('movies')
export class MoviesController {
    
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[]{
        return this.moviesService.getAll();
    }

    @Get("/:id")
    getOne(@Param('id') id: string): Movie {
        return this.moviesService.getOne(id);
    }

    @Post()
    create(@Body() movieData) {
        return this.moviesService.create(movieData);
    }

    @Delete("/:id")
    remove(@Param('id') movieId:string) {
        return this.moviesService.deleteOne(movieId);
    }

    @Patch("/:id")
    patch(@Param('id') movieId: string, @Body() updateData) {
        console.log(updateData);
        return {
            updateMovie: movieId,
            ...updateData,
        };
    }
}

Movies Service part Two

movies.controllers.ts

import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Movie } from './movie.entity';
import { MoviesService } from './movies.service';

@Controller('movies')
export class MoviesController {
    
    constructor(private readonly moviesService: MoviesService) {}

    @Get()
    getAll(): Movie[]{
        return this.moviesService.getAll();
    }

    @Get("/:id")
    getOne(@Param('id') id: string): Movie {
        return this.moviesService.getOne(id);
    }

    @Post()
    create(@Body() movieData) {
        return this.moviesService.create(movieData);
    }

    @Delete("/:id")
    remove(@Param('id') movieId:string) {
        return this.moviesService.deleteOne(movieId);
    }

    @Patch("/:id")
    patch(@Param('id') movieId: string, @Body() updateData) {
        return this.moviesService.update(movieId, updateData);
    }
}

movies.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { Movie } from './movie.entity';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];
    
    getAll(): Movie[] {
        return this.movies;
    }

    getOne(id: string): Movie {
        const movie = this.movies.find(movie => movie.id === +id);
        if (!movie) {
            throw new NotFoundException(`Movie with ID ${id} not found.`);
        }
        return movie;
    }

    deleteOne(id:string) {
        this.getOne(id);
        this.movies = this.movies.filter(movie => movie.id !== +id);
    }

    create(movieData) {
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData,
        });
    }

    update(id:string, updateData) {
        const movie = this.getOne(id);
        this.deleteOne(id);
        this.movies.push({...movie, ...updateData});
    }
}

DTOs and Validation part One

create-movie.dto.ts

export class CreateMovieDto {
    readonly title: string;
    readonly year: number;
    readonly  genres: string[];
}

movies.controller.ts

import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { CreateMovieDto } from './dto/create-movie.dto';
import { Movie } from './movie.entity';
import { MoviesService } from './movies.service';

@Controller('movies')
export class MoviesController {
    
    constructor(private readonly moviesService: MoviesService) {}

	...

    @Post()
    create(@Body() movieData: CreateMovieDto) {
        return this.moviesService.create(movieData);
    }

	...
}

movies.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateMovieDto } from './dto/create-movie.dto';
import { Movie } from './movie.entity';

@Injectable()
export class MoviesService {
    private movies: Movie[] = [];
    
	...

    create(movieData: CreateMovieDto) {
        this.movies.push({
            id: this.movies.length + 1,
            ...movieData,
        });
    }

	...
}

main.ts

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

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

whitelist 옵션은 데코레이터가 없는 프로퍼티를 가진 것을 거르는 것이다.
forbidNonWhitelisted는 아예 요청을 못보내게 막는것이다.
transform은 자료형을 변환해주는 것이다. 처음에는 다 string이다.

유효성 검증을 위해 pipe를 main.ts에 둘 것이다. express에서 미들웨어와 같고, 스프링에서는 인터셉터와 같은 것 같다.

npm i class-validator class-transformer

create-movies.dto.ts

import { IsNumber, IsString } from "class-validator";

export class CreateMovieDto {

    @IsString()
    readonly title: string;
    @IsNumber()
    readonly year: number;
    @IsString({each: true})
    readonly  genres: string[];
}

DTOs and Validation part Two

npm i @nestjs/mapped-types

https://docs.nestjs.com/openapi/mapped-types

스프링에서 ObjectMapper 역할을 하는 것 같다?

update-movie.dto.ts

import { PartialType } from "@nestjs/mapped-types";
import { IsNumber, IsString } from "class-validator";
import { CreateMovieDto } from "./create-movie.dto";

export class UpdateMovieDto extends PartialType(CreateMovieDto) {
    
}

updateMovieDto는 CreateMovieDto와 비슷하다. 다만 차이점은 구성 요소가 필수가 아니다.

Modules and Dependency Injection

app.module은 사실 AppContrller랑 AppProvider만 가지고 있어야 한다. MovieService랑 MoviesController를 movies.module로 옮긴다.

nest g mo

module 생성. 이름은 movies로 했다.

app.module.ts

import { Module } from '@nestjs/common';
import { MoviesModule } from './movies/movies.module';


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

movies.module.ts

import { Module } from '@nestjs/common';
import { MoviesController } from './movies.controller';
import { MoviesService } from './movies.service';

@Module({
    controllers: [MoviesController],
    providers: [MoviesService],
})
export class MoviesModule {}

MovieController에서 MovieService를 사용하고 있는데 DI다. 스프링이랑 똑같다.

Express on NestJS

NestJS는 Express 위에서 돌아간다. 또한 Fastify라는 프레임워크 위에서 돌아가기도 하므로 Express에 의존적으로 코드를 짜면 안된다.

3. UNIT TESTING

Introduction to Testing in Nest

jest는 자스용 테스트 도구다. .spec.ts로 끝나는 파일은 테스트를 포함한 파일이다.
movies.controller.ts를 테스트하고 싶으면 movies.controller.spec.ts라는 파일이 있어야 한다. jest가 .spec.ts를 찾는다.

 npm run test:cov

현재 얼마나 테스트 코드가 이뤄졌나 보여준다.

npm run test:watch

유닛테스트는 서비스에서 분리된 유닛을 테스트하고, e2e 테스트는 이건 모든 시스템을 테스트하는 것이다. 이 페이지로 가면 특정 페이지가 나와야하는 경우 사용한다. 사용자 스토리랑 비슷하다.

movies.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { MoviesService } from './movies.service';

describe('MoviesService', () => {
  let service: MoviesService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [MoviesService],
    }).compile();

    service = module.get<MoviesService>(MoviesService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it("should be 4", () => {
    expect(2+2).toEqual(4);
  })
});

Testing getAll and getOne

movies.service.spec.ts

import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { MoviesService } from './movies.service';

describe('MoviesService', () => {
  let service: MoviesService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [MoviesService],
    }).compile();

    service = module.get<MoviesService>(MoviesService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe("getAll", () => {
    it("should return an array", () => {
      const result = service.getAll();
      expect(result).toBeInstanceOf(Array);
    })
  })

  describe("getOne", () => {
    it("should return a movie", () => {
      service.create({
        title: "Test Movie",
        genres: ["Test"],
        year: 2000,
      });
      const movie = service.getOne(1);
      expect(movie).toBeDefined();
      expect(movie.id).toEqual(1)
      });

      it("should throw 404 error", () => {
        try {
          service.getOne(999);
        } catch(e) {
          expect(e).toBeInstanceOf(NotFoundException);
          expect(e.message).toEqual("Movie with ID 999 not found.");
        }
      })
  });
});

Testing delete and create

movies.service.spec.ts

import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { MoviesService } from './movies.service';

describe('MoviesService', () => {
  let service: MoviesService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [MoviesService],
    }).compile();

    service = module.get<MoviesService>(MoviesService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe("getAll", () => {
    it("should return an array", () => {
      const result = service.getAll();
      expect(result).toBeInstanceOf(Array);
    })
  })

  describe("getOne", () => {
    it("should return a movie", () => {
      service.create({
        title: "Test Movie",
        genres: ["Test"],
        year: 2000,
      });
      const movie = service.getOne(1);
      expect(movie).toBeDefined();
      expect(movie.id).toEqual(1)
      });

      it("should throw 404 error", () => {
        try {
          service.getOne(999);
        } catch(e) {
          expect(e).toBeInstanceOf(NotFoundException);
          expect(e.message).toEqual("Movie with ID 999 not found.");
        }
      })
  });

  describe("deleteOne", () => {
    it("deletes a movie", () => {
      service.create({
        title: "Test Movie",
        genres: ["Test"],
        year: 2000,
      });
      const beforeDelete = service.getAll();
      service.deleteOne(1)
      const afterDelete = service.getAll();
      expect(afterDelete.length).toBeLessThan(beforeDelete.length);
    });
    it("should return a 404", () => {
      try {
        service.deleteOne(999)
      } catch(e) {
        expect(e).toBeInstanceOf(NotFoundException);
        expect(e.message).toEqual("Movie with ID 999 not found.");
      }
    })
  })

  describe("create", () => {
    it("should create a movie", () => {
      const beforeCreate = service.getAll().length;
      service.create({
        title: "Test Movie",
        genres: ["Test"],
        year: 2000,
      });
      const afterCreate = service.getAll().length;
      expect(afterCreate).toBeGreaterThan(beforeCreate);
    });
  })
});

Testing update

movies.service.spec.ts

	...

  describe("update", () => {
    it("should update a movie", () => {
      service.create({
        title: "Test Movie",
        genres: ["Test"],
        year: 2000,
      });
      service.update(1, {title: "Updated Test"});
      const movie = service.getOne(1);
      expect(movie.title).toEqual("Updated Test");
    })
    it("should throw a NotFoundException", () => {
      try {
        service.update(999, {});
      } catch(e) {
        expect(e).toBeInstanceOf(NotFoundException);
        expect(e.message).toEqual("Movie with ID 999 not found.");
      }
    })
  })
});
npm run test:cov

를 통해서 얼마나 진행됐는지 확인 가능

~.spec.ts는 유닛테스트용 파일이다.

4. E2E TESTING

Testing movies

npm run test:e2e

movies.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Welcom to my Movie API');
  });

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
        .get('/movies')
        .expect(200)
        .expect([]);
    });
    it('POST', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
        })
        .expect(201);
    });
    it('DELETE', () => {
      return request(app.getHttpServer())
        .delete('/movies')
        .expect(404);
    });
  });
});

Testing GET movies id

위의 코드로는 매 테스트를 진행할때마다 새로운 어플리케이션을 만드는 것이다. 그래서 데이터베이스가 계속 비어있다. 그래서 beforeAll로 바꿔준다.

테스트 서버에서는 get해서 id의 타입을 찍어보면 string이다. transform을 테스트가 아닌 실제 애플리케이션에만 걸어줬다. 테스트에도 실제 어플리케이션 환경을 그대로 적용시켜줘야 한다.

app.e2e-spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
	...
    app.useGlobalPipes(new ValidationPipe({
      whitelist:true,
      forbidNonWhitelisted: true,
      transform: true
    }),
    );
    await app.init();
  });

	...

  describe('/movies/:id', () => {
    it('GET 200', () => {
      return request(app.getHttpServer()).get("/movies/1").expect(200);
    });
  })
});

Testing PATCH and DELETE movies id

movies.service.spec.ts

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist:true,
      forbidNonWhitelisted: true,
      transform: true
    }),
    );
    await app.init();
  });

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Welcom to my Movie API');
  });

  describe('/movies', () => {
    it('GET', () => {
      return request(app.getHttpServer())
        .get('/movies')
        .expect(200)
        .expect([]);
    });
    it('POST 201', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
        })
        .expect(201);
    });
    it('POST 400', () => {
      return request(app.getHttpServer())
        .post('/movies')
        .send({
          title: 'Test',
          year: 2000,
          genres: ['test'],
          other: "thing"
        })
        .expect(400);
    });
    it('DELETE', () => {
      return request(app.getHttpServer())
        .delete('/movies')
        .expect(404);
    });
  });

  describe('/movies/:id', () => {
    it('GET 200', () => {
      return request(app.getHttpServer()).get("/movies/1").expect(200);
    });
    it('GET 404', () => {
      return request(app.getHttpServer())
        .get('/movies/999')
        .expect(404);
    });
    it('PATCH 200', () => {
      return request(app.getHttpServer())
        .patch('/movies/1')
        .send({title: "Updated Test"})
        .expect(200);
    })
    it('DELETE', () => {
      return request(app.getHttpServer())
        .delete('/movies/1')
        .expect(200);
    });
  })
});

0개의 댓글