Search
moon
sun

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.module은 AppService와 AppController만 가져야 함
•
여러 모듈을 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
복사