Search

NestJS로 API 만들기

Date
2024/07/05
Category
WEB
Tag
Typescript
NestJS
목차

main.ts

NestJS는 main.ts 파일을 가지며 무조건 main이라는 이름이어야 한다

Decorator

클래스에 함수 기능을 추가하는 것
클래스 위의 함수이며, 클래스를 위해 움직임

Architecture

main.ts에서 NestJS 애플리케이션이 시작함
NestJS에서는 컨트롤러와 비즈니스 로직을 구분함
컨트롤러는 url을 가져오는 역할, function을 실행하는 역할
서비스는 비즈니스 로직을 처리함
실제 function을 가지는 부분

Module

하나의 모듈에서 애플리케이션을 생성
앱 모듈은 모든 모듈의 루트
모듈: 애플리케이션의 일부분, 한 가지의 역할을 하는 앱
ex) Users module - 인증을 담당하는 애플리케이션

Controller

컨트롤러가 하는 일은 기본적으로 url을 가져오고 함수를 실행함
함수가 놓이는 곳은 Service
express의 router와 같음
@Get("/hello") sayHello(): string { return "Hello everyone" }
TypeScript
복사
위와 같이 사용해도 브라우저에서 작동하는데 왜 서비스를 사용해야 하는가?

Service

비즈니스 로직을 처리하며, 실제 function을 가지는 부분
import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHi(): string { return 'Hello everyone'; } }
TypeScript
복사
@Get("/hello") sayHello(): string { return this.appService.getHi(); }
TypeScript
복사

Rest API 만들기

Controller 생성

nest g co = nest generate controller
컨트롤러를 생성하면 모듈에도 자동 참조됨

CRUD

import { Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; @Controller('movies') // url의 Entry Point를 컨트롤 함 export class MoviesController { @Get() getAll() { return "This will return all movies"; } @Get("/:id") getOne(@Param("id") movieId: string) { return `This will return one movie with the id: ${movieId}`; } @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') // Put은 모든 리소스를 업데이트. patch는 리소스의 일부분만 업데이트 path(@Param('id') movieId: string) { return `This will patch a movie with the id: ${movieId}`; } }
TypeScript
복사

Service 생성

nest g s = nest generate service

Service

import { Injectable, NotFoundException } from '@nestjs/common'; import { Movie } from './entities/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 === parseInt(id)); // parseInt(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}) } }
TypeScript
복사

Service에 맞게 Controller 수정

import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { MoviesService } from './movies.service'; import { Movie } from './entities/movie.entity'; @Controller('movies') // url의 Entry Point를 컨트롤 함 export class MoviesController { constructor(private readonly moviesService: MoviesService) {} @Get() getAll(): Movie[] { return this.moviesService.getAll(); } @Get("/:id") getOne(@Param("id") movieId: string): Movie { return this.moviesService.getOne(movieId); } @Post() create(@Body() movieData) { return this.moviesService.create(movieData); } @Delete(":id") remove(@Param("id") movieId:string) { return this.moviesService.deleteOne(movieId); } @Patch('/:id') // Put은 모든 리소스를 업데이트. patch는 리소스의 일부분만 업데이트 patch(@Param('id') movieId: string, @Body() updateData) { return this.moviesService.update(movieId, updateData) } }
TypeScript
복사

DTO

Data Transfer Object ( 데이터 전송 객체 )
DTO를 통해서 유효성 검사를 함

Create DTO

import { IsNumber, IsString } from "class-validator"; export class CreateMovieDto { @IsString() readonly title: string; @IsNumber() readonly year: number; @IsString({ each: true }) readonly genres: string[]; }
TypeScript
복사
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, // 데코레이터가 없는 속성은 제거함 forbidNonWhitelisted: true, // 화이트리스트에 존재하지 않는 데이터가 있을 시 HttpException. whitelist: true가 선행되어야 함 transform: true, // transform은 사용자의 값을 실제 타입으로 변환시켜줌 -> url은 전부 string이기 때문에 기존에는 id를 string으로 받아 int 타입으로 변환시켰음 })); await app.listen(3000); } bootstrap();
TypeScript
복사

Update DTO

모든 필드가 필수가 아니기 때문에 {변수}? 로 사용할 수 있음
다른 방법으로는 PartialType을 사용할 수 있음
import { IsNumber, IsString } from "class-validator"; import { PartialType } from "@nestjs/mapped-types" import { CreateMovieDto } from "./create-movie.dto"; // export class UpdateMovieDto { // @IsString() // readonly title?: string; // @IsNumber() // readonly year?: number; // @IsString({ each: true }) // readonly genres?: string[]; // } export class UpdateMovieDto extends PartialType(CreateMovieDto) {}
TypeScript
복사

Module

app.module

app.moduleAppServiceAppController만 가져야 함
여러 모듈을 app.module로 불러와 사용하는 것임
import { Module } from '@nestjs/common'; import { MoviesModule } from './movies/movies.module'; import { AppController } from './app.controller'; @Module({ imports: [MoviesModule], controllers: [AppController], // app.module은 AppController와 AppService만 가짐 providers: [], }) export class AppModule {}
TypeScript
복사
import { Module } from '@nestjs/common'; import { MoviesController } from './movies.controller'; import { MoviesService } from './movies.service'; @Module({ controllers: [MoviesController], providers: [MoviesService] }) export class MoviesModule {}
TypeScript
복사

Testing

NestJS Test

jest

자바스크립트 테스팅 프레임워크
JS를 테스팅하는 npm 패키지
NestJS에서는 jest.spec.ts 파일을 찾아 볼 수 있도록 설정됨

.spec.ts

테스트를 포함한 파일

cov

코드가 얼마나 테스팅되었는지 또는 안 됐는지 알려줌
모든 .spec.ts 파일을 찾아 몇 줄이 테스팅되었는지 알려줌

Unit Test

모든 function을 따로 테스트하는 기법
서비스에서 분리된 유닛을 테스트
import { Test, TestingModule } from '@nestjs/testing'; import { MoviesService } from './movies.service'; import { NotFoundException } from '@nestjs/common'; 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", year: 2000, genres: ['test'], }); 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", year: 2000, genres: ['test'], }); 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); } }); }); describe('create', () => { it('should create a movie', () => { const beforeCreate = service.getAll().length service.create({ title: "Test Movie", year: 2000, genres: ['test'], }); const afterCreate = service.getAll().length expect(afterCreate).toBeGreaterThan(beforeCreate) }); }); describe('update', () => { it('should update a movie', () => { service.create({ title: "Test Movie", year: 2000, genres: ['test'], }); 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); } }); }); });
TypeScript
복사

E2E Test (end-to-end)

모든 시스템을 테스팅
사용자 관점 테스트
테스트를 위해 test 폴더가 필요
NestJS는 테스트마다 애플리케이션을 생성함
브라우저에서 확인하는 애플리케이션과는 다른 것임
beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); });
TypeScript
복사
테스트 할때마다 애플리케이션을 생성하면 데이터베이스가 빈 상태가 됨
따라서 테스팅을 시작하기 전에 애플리케이션을 새로 만들기 위해 beforeAll 사용
테스트에서도 실제 애플리케이션의 환경을 그대로 적용시켜줘야 함
main.ts에서 transform을 통해 url에서의 숫자를 string이 아닌 number로 변환함
테스트에서는 적용되지 않기 때문에 따로 적용시켜줘야 함
beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); app.useGlobalPipes( new ValidationPipe({ whitelist: true, // 데코레이터가 없는 속성은 제거함 forbidNonWhitelisted: true, // 화이트리스트에 존재하지 않는 데이터가 있을 시 HttpException. whitelist: true가 선행되어야 함 transform: true, // transform은 사용자의 값을 실제 타입으로 변환시켜줌 -> url은 전부 string이기 때문에 기존에는 id를 string으로 받아 int 타입으로 변환시켰음 })); await app.init(); });
TypeScript
복사
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, // 화이트리스트에 존재하지 않는 데이터가 있을 시 HttpException. whitelist: true가 선행되어야 함 transform: true, // transform은 사용자의 값을 실제 타입으로 변환시켜줌 -> url은 전부 string이기 때문에 기존에는 id를 string으로 받아 int 타입으로 변환시켰음 })); await app.init(); }); it('/ (GET)', () => { return request(app.getHttpServer()) .get('/') .expect(200) .expect('Welcome 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 200", () => { return request(app.getHttpServer()) .delete('/movies/1') .expect(200) }); }) });
TypeScript
복사