Node.js/TypeScript REST API 빌드, 3부: MongoDB, 인증 및 자동 테스트

게시 됨: 2022-03-11

Express.js 및 TypeScript를 사용하여 Node.js REST API를 생성하는 방법에 대한 시리즈의 이 시점에서 작동 백엔드를 구축하고 코드를 경로 구성, 서비스, 미들웨어, 컨트롤러 및 모델로 분리했습니다. 여기에서 따라갈 준비가 되었다면 예제 리포지토리를 복제하고 git checkout toptal-article-02 를 실행하세요.

몽구스, 인증 및 자동화된 테스트가 포함된 REST API

이 세 번째이자 마지막 기사에서는 다음을 추가하여 REST API를 계속 개발할 것입니다.

  • Mongoose 를 사용하면 MongoDB로 작업하고 메모리 내 DAO를 실제 데이터베이스로 교체할 수 있습니다.
  • API 소비자가 JWT(JSON Web Token)를 사용하여 엔드포인트에 안전하게 액세스할 수 있도록 하는 인증 및 권한 기능.
  • Mocha(테스트 프레임워크), Chai(어설션 라이브러리) 및 SuperTest(HTTP 추상화 모듈)를 사용하여 테스트를 자동화 하여 코드 기반이 성장하고 변경됨에 따라 회귀를 확인합니다.

그 과정에서 유효성 검사 및 보안 라이브러리를 추가하고 Docker에 대한 약간의 경험을 얻으며 독자가 자신의 REST API를 구축 및 확장하는 데 탐색할 수 있는 몇 가지 추가 주제, 라이브러리 및 기술을 제안합니다.

MongoDB를 컨테이너로 설치

이전 기사의 메모리 내 데이터베이스를 실제 데이터베이스로 교체하는 것으로 시작하겠습니다.

개발을 위한 로컬 데이터베이스를 생성하기 위해 MongoDB를 로컬에 설치할 수 있습니다. 그러나 환경(예: OS 배포 및 버전) 간의 차이로 인해 문제가 발생할 수 있습니다. 이를 피하기 위해 업계 표준 도구인 Docker 컨테이너를 활용합니다.

독자는 Docker를 설치한 다음 Docker Compose를 설치하기만 하면 됩니다. 설치가 완료되면 터미널에서 docker -v 를 실행하면 Docker 버전 번호가 생성됩니다.

이제 MongoDB를 실행하기 위해 프로젝트 루트에서 다음을 포함하는 docker-compose.yml 이라는 YAML 파일을 생성합니다.

 version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"

Docker Compose를 사용하면 하나의 구성 파일로 여러 컨테이너를 한 번에 실행할 수 있습니다. 이 기사의 끝에서 Docker에서 REST API 백엔드를 실행하는 방법도 살펴보겠지만 지금은 로컬에 설치할 필요 없이 이 API를 사용하여 MongoDB를 실행할 것입니다.

 sudo docker-compose up -d

up 명령은 정의된 컨테이너를 시작하고 표준 MongoDB 포트 27017에서 수신 대기합니다. -d 스위치는 터미널에서 명령을 분리합니다. 모든 것이 문제 없이 실행되면 다음과 같은 메시지가 표시됩니다.

 Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done

또한 프로젝트 루트에 새 data 디렉토리를 생성하므로 .gitignoredata 라인을 추가해야 합니다.

이제 MongoDB Docker 컨테이너를 종료해야 하는 경우 sudo docker-compose down 을 실행하기만 하면 다음 출력이 표시됩니다.

 Stopping toptal-rest-series_mongo_1 ... done Removing toptal-rest-series_mongo_1 ... done Removing network toptal-rest-series_default

이것이 Node.js/MongoDB REST API 백엔드를 시작하기 위해 알아야 할 전부입니다. sudo docker-compose up -d 를 사용하여 MongoDB가 앱을 사용할 준비가 되었는지 확인합시다.

Mongoose를 사용하여 MongoDB에 액세스

MongoDB와 통신하기 위해 백엔드는 Mongoose라는 객체 데이터 모델링(ODM) 라이브러리를 활용합니다. Mongoose는 사용하기가 매우 쉽지만 실제 프로젝트에 제공되는 모든 고급 가능성에 대해 알아보려면 설명서를 확인하는 것이 좋습니다.

Mongoose를 설치하려면 다음을 사용합니다.

 npm i mongoose

MongoDB 인스턴스에 대한 연결을 관리하도록 Mongoose 서비스를 구성해 보겠습니다. 이 서비스는 여러 리소스 간에 공유할 수 있으므로 프로젝트의 common 폴더에 추가합니다.

구성은 간단합니다. 꼭 필요한 것은 아니지만 다음 Mongoose 연결 옵션을 사용자 지정하는 mongooseOptions 개체가 있습니다.

  • useNewUrlParser : true 로 설정하지 않으면 Mongoose는 사용 중단 경고를 출력합니다.
  • useUnifiedTopology : Mongoose 문서에서는 최신 연결 관리 엔진을 사용하기 위해 이것을 true 로 설정할 것을 권장합니다.
  • serverSelectionTimeoutMS : 이 데모 프로젝트의 UX 목적을 위해 기본값인 30초보다 짧은 시간은 Node.js보다 먼저 MongoDB를 시작하는 것을 잊은 독자가 분명히 응답하지 않는 백엔드 대신에 더 빨리 이에 대한 유용한 피드백을 볼 수 있음을 의미합니다. .
  • useFindAndModify : 이것을 false 로 설정하면 사용 중단 경고도 피할 수 있지만 Mongoose 연결 옵션이 아닌 설명서의 사용 중단 섹션에 언급되어 있습니다. 보다 구체적으로 말하면, 이로 인해 Mongoose는 이전 Mongoose shim 대신 새로운 기본 MongoDB 기능을 사용하게 됩니다.

이러한 옵션을 일부 초기화 및 재시도 논리와 결합하면 다음은 최종 common/services/mongoose.service.ts 파일입니다.

 import mongoose from 'mongoose'; import debug from 'debug'; const log: debug.IDebugger = debug('app:mongoose-service'); class MongooseService { private count = 0; private mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, serverSelectionTimeoutMS: 5000, useFindAndModify: false, }; constructor() { this.connectWithRetry(); } getMongoose() { return mongoose; } connectWithRetry = () => { log('Attempting MongoDB connection (will retry if needed)'); mongoose .connect('mongodb://localhost:27017/api-db', this.mongooseOptions) .then(() => { log('MongoDB is connected'); }) .catch((err) => { const retrySeconds = 5; log( `MongoDB connection unsuccessful (will retry #${++this .count} after ${retrySeconds} seconds):`, err ); setTimeout(this.connectWithRetry, retrySeconds * 1000); }); }; } export default new MongooseService();

Mongoose의 connect() 함수와 우리 고유의 connectWithRetry() 서비스 함수 간의 차이점을 명확히 하십시오.

  • mongoose.connect() 는 로컬 MongoDB 서비스( docker-compose 실행)에 연결을 시도하고 serverSelectionTimeoutMS 밀리초 후에 시간 초과됩니다.
  • MongooseService.connectWithRetry() 는 애플리케이션이 시작되지만 MongoDB 서비스가 아직 실행되지 않는 경우 위의 작업을 다시 시도합니다. 싱글톤 생성자에 connectWithRetry() 는 한 번만 실행되지만 시간 초과가 발생할 때마다 retrySeconds 초의 일시 중지와 함께 connect() 호출을 무기한 재시도합니다.

다음 단계는 이전의 인메모리 데이터베이스를 MongoDB로 교체하는 것입니다!

인메모리 데이터베이스 제거 및 MongoDB 추가

이전에는 인메모리 데이터베이스를 사용하여 구축 중인 다른 모듈에 집중할 수 있었습니다. 대신 Mongoose를 사용하려면 users.dao.ts 를 완전히 리팩토링해야 합니다. 시작하려면 import 문이 하나 더 필요합니다.

 import mongooseService from '../../common/services/mongoose.service';

이제 생성자를 제외한 UsersDao 클래스 정의에서 모든 것을 제거하겠습니다. 생성자 이전에 사용자 Schema for Mongoose를 생성하여 다시 채우기 시작할 수 있습니다.

 Schema = mongooseService.getMongoose().Schema; userSchema = new this.Schema({ _id: String, email: String, password: { type: String, select: false }, firstName: String, lastName: String, permissionFlags: Number, }, { id: false }); User = mongooseService.getMongoose().model('Users', this.userSchema);

이것은 MongoDB 컬렉션을 정의하고 메모리 내 데이터베이스에 없는 특수 기능을 추가합니다. password 필드의 select: false 는 사용자를 얻거나 모든 사용자를 나열할 때마다 이 필드를 숨깁니다.

사용자 스키마는 DTO 엔터티와 유사하기 때문에 친숙해 보일 것입니다. 주요 차이점은 Users 라는 MongoDB 컬렉션에 존재해야 하는 필드를 정의하는 반면 DTO 엔터티는 HTTP 요청에서 수락할 필드를 정의한다는 것입니다.

접근 방식의 해당 부분은 변경되지 않으므로 여전히 users.dao.ts 상단에 3개의 DTO를 가져옵니다. 그러나 CRUD 메서드 작업을 구현하기 전에 두 가지 방법으로 DTO를 업데이트합니다.

DTO 변경 번호 1: id_id

Mongoose는 자동으로 _id 필드를 사용 가능하게 만들기 때문에 DTO에서 id 필드를 제거합니다. 어쨌든 경로 요청의 매개 변수에서 가져옵니다.

Mongoose 모델은 기본적으로 가상 id getter를 제공하므로 혼동을 피하기 위해 위의 { id: false } 옵션을 비활성화했습니다. 하지만 이는 사용자 미들웨어 validateSameEmailBelongToSameUser() 에서 user.id 에 대한 참조를 깨뜨렸습니다. 대신 user._id 가 필요합니다.

일부 데이터베이스는 규칙 id 를 사용하고 다른 데이터베이스는 _id 를 사용하므로 완벽한 인터페이스는 없습니다. Mongoose를 사용하는 예제 프로젝트의 경우 코드의 어느 지점에서 어느 것을 사용하고 있는지에 주목했지만 불일치는 여전히 API 소비자에게 노출됩니다.

5가지 요청 유형의 경로: 1. /users에 대한 매개변수화되지 않은 GET 요청은 listUsers() 컨트롤러를 거쳐 각 객체에 _id 키가 있는 배열을 반환합니다. 2. /users에 대한 매개변수화되지 않은 POST 요청은 새로 생성된 ID 값을 사용하는 createUser() 컨트롤러를 거쳐 ID 키가 있는 객체에 반환됩니다. 3. /auth에 대한 매개변수화되지 않은 요청은 req.body.userId를 설정하기 위해 MongoDB 조회를 수행하는 verifyUserPassword() 미들웨어를 거칩니다. 거기에서 요청은 req.body.userId를 사용하는 createJWT() 컨트롤러를 통과하고 accessToken 및 refreshToken 키가 있는 객체를 반환합니다. 4. /auth/refresh-token에 대한 매개변수화되지 않은 요청은 res.locals.jwt.userId를 설정하는 validJWTNeeded() 미들웨어와 res.locals.jwt.userId를 사용하는 validRefreshNeeded() 미들웨어를 거칩니다. req.body.userId를 설정하기 위한 MongoDB 조회; 거기에서 경로는 이전 경우와 동일한 컨트롤러 및 응답을 거칩니다. 5. /users에 대한 매개변수화된 요청은 Express.js를 통해 req.params.userId를 채우는 UsersRoutes 구성을 거친 다음 res.locals.jwt.userId를 설정하는 validJWTNeeded() 미들웨어, 그 다음 req를 사용하는 다른 미들웨어 기능을 거칩니다. params.userId, res.locals.jwt.userId 또는 둘 다; 및/또는 MongoDB 조회를 수행하고 result._id 사용), 마지막으로 req.body.id를 사용하고 본문이 없거나 반환하지 않는 UsersController 함수를 통해 _id 키가 있는 개체.
최종 REST API 프로젝트 전반에 걸친 사용자 ID의 사용 및 노출. 다양한 내부 규칙은 사용자 ID 데이터의 다른 소스(직접 요청 매개변수, JWT 인코딩 데이터 또는 새로 가져온 데이터베이스 레코드)를 의미합니다.

프로젝트가 끝날 때 사용할 수 있는 많은 실제 솔루션 중 하나를 구현하는 것은 독자에게 연습으로 남겨둡니다.

DTO 변경 번호 2: 플래그 기반 권한 준비

우리는 또한 우리가 구현할 보다 정교한 권한 시스템과 위의 Mongoose userSchema 정의를 반영하기 위해 DTO에서 permissionFlags 의 이름을 permissionLevel 로 바꿀 것입니다.

DTO: DRY 원칙은 어떻습니까?

DTO에는 API 클라이언트와 데이터베이스 간에 전달하려는 필드만 포함되어 있음을 기억하십시오. 모델과 DTO 사이에 일부 중복이 있기 때문에 불행하게 보일 수 있지만 "기본적으로 보안"을 희생하면서 DRY를 너무 많이 사용하지 않도록 주의하십시오. 필드를 추가할 때 한 곳에서만 필드를 추가해야 하는 경우 개발자는 내부용으로만 의도된 필드를 API에 무의식적으로 노출할 수 있습니다. 그 이유는 프로세스가 데이터 저장과 데이터 전송을 두 개의 잠재적으로 다른 요구 사항 집합이 있는 두 개의 개별 컨텍스트로 생각하도록 강요하지 않기 때문입니다.

DTO 변경이 완료되면 create 로 시작하는 CRUD 메서드 작업( UsersDao 생성자 이후)을 구현할 수 있습니다.

 async addUser(userFields: CreateUserDto) { const userId = shortid.generate(); const user = new this.User({ _id: userId, ...userFields, permissionFlags: 1, }); await user.save(); return userId; }

API 소비자가 userFields 를 통해 permissionFlags 에 대해 보내는 것이 무엇이든 간에 값 1 로 재정의합니다.

다음으로 우리는 ID로 사용자를 얻고, 이메일로 사용자를 얻고, 페이지 매김으로 사용자를 나열하는 기본 기능을 읽었습니다 .

 async getUserByEmail(email: string) { return this.User.findOne({ email: email }).exec(); } async getUserById(userId: string) { return this.User.findOne({ _id: userId }).populate('User').exec(); } async getUsers(limit = 25, page = 0) { return this.User.find() .limit(limit) .skip(limit * page) .exec(); }

사용자를 업데이트 하려면 기본 Mongoose findOneAndUpdate() 함수가 전체 문서 또는 일부만 업데이트할 수 있기 때문에 단일 DAO 함수로 충분합니다. 우리 고유의 함수는 TypeScript 공용체 유형( | 으로 표시됨)을 사용하여 PutUserDtoPatchUserDto 또는 userFields 로 사용합니다.

 async updateUserById( userId: string, userFields: PatchUserDto | PutUserDto ) { const existingUser = await this.User.findOneAndUpdate( { _id: userId }, { $set: userFields }, { new: true } ).exec(); return existingUser; }

new: true 옵션은 Mongoose가 개체를 원래 상태가 아니라 업데이트 후 있는 그대로 반환하도록 지시합니다.

삭제 는 Mongoose로 간결합니다.

 async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); }

독자는 User 멤버 함수에 대한 각 호출이 exec() 호출에 연결되어 있음을 알 수 있습니다. 이것은 선택 사항이지만 디버깅할 때 더 나은 스택 추적을 제공하기 때문에 Mongoose 개발자가 권장합니다.

DAO를 코딩한 후 새로운 기능과 일치하도록 지난 기사에서 users.service.ts 를 약간 업데이트해야 합니다. 주요 리팩토링이 필요하지 않으며 세 가지만 수정하면 됩니다.

 @@ -16,3 +16,3 @@ class UsersService implements CRUD { async list(limit: number, page: number) { - return UsersDao.getUsers(); + return UsersDao.getUsers(limit, page); } @@ -20,3 +20,3 @@ class UsersService implements CRUD { async patchById(id: string, resource: PatchUserDto): Promise<any> { - return UsersDao.patchUserById(id, resource); + return UsersDao.updateUserById(id, resource); } @@ -24,3 +24,3 @@ class UsersService implements CRUD { async putById(id: string, resource: PutUserDto): Promise<any> { - return UsersDao.putUserById(id, resource); + return UsersDao.updateUserById(id, resource); }

우리가 UsersDao 를 리팩토링할 때 이전 기사에서 생성한 구조를 유지했기 때문에 대부분의 함수 호출은 정확히 동일하게 유지됩니다. 그런데 왜 예외인가?

  • 위에서 암시한 것처럼 PUTPATCH 모두에 대해 updateUserById() 를 사용하고 있습니다. (2부에서 언급했듯이 우리는 특정 RFC를 편지에 고수하려고 하기 보다는 일반적인 REST API 구현을 따르고 있습니다. 무엇보다도 이는 PUT 요청이 존재하지 않는 경우 새 엔티티를 생성하지 않도록 하는 것을 의미합니다. 이런 식으로, 백엔드는 API 소비자에게 ID 생성 제어를 넘겨주지 않습니다.)
  • 새로운 DAO 구현에서 사용할 것이기 때문에 limitpage 매개변수를 getUsers() 에 전달합니다.

여기의 주요 구조는 상당히 견고한 패턴입니다. 예를 들어 개발자가 Mongoose와 MongoDB를 TypeORM 및 PostgreSQL과 같은 것으로 교체하려는 경우 재사용할 수 있습니다. 위와 같이 이러한 교체는 나머지 코드와 일치하도록 서명을 유지하면서 DAO의 개별 기능을 리팩토링하기만 하면 됩니다.

몽구스 지원 REST API 테스트

npm start 를 사용하여 API 백엔드를 시작하겠습니다. 그런 다음 사용자 생성을 시도합니다.

 curl --request POST 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

응답 개체에 새 사용자 ID가 포함되어 있습니다.

 { "id": "7WYQoVZ3E" }

이전 기사에서와 같이 나머지 수동 테스트는 환경 변수를 사용하면 더 쉬울 것입니다.

 REST_API_EXAMPLE_

사용자 업데이트는 다음과 같습니다.

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

응답은 HTTP/1.1 204 No Content 로 시작해야 합니다. ( --include 스위치가 없으면 구현과 일치하는 응답이 인쇄되지 않습니다.)

이제 사용자가 위의 업데이트를 확인하도록 하면 …

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --data-raw '{ "firstName": "Marcos", "lastName": "Silva" }'

... 응답은 위에서 논의한 _id 필드를 포함하여 예상 필드를 보여줍니다.

 { "_id": "7WYQoVZ3E", "email": "[email protected]", "permissionFlags": 1, "__v": 0, "firstName": "Marcos", "lastName": "Silva" }

Mongoose에서 버전 관리에 사용하는 특수 필드 __v 도 있습니다. 이 레코드가 업데이트될 때마다 증가합니다.

다음으로 사용자를 나열해 보겠습니다.

 curl --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

예상되는 응답은 동일하며 [] 로 래핑됩니다.

이제 비밀번호가 안전하게 저장되었으므로 사용자를 제거할 수 있는지 확인하겠습니다.

 curl --include --request DELETE "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json'

다시 204 응답을 기대합니다.

독자는 Mongoose Schema 정의의 select: false 가 의도한 대로 GET 출력에서 ​​암호 필드를 숨겼기 때문에 암호 필드가 제대로 작동했는지 궁금해할 수 있습니다. 초기 POST 를 반복하여 사용자를 다시 생성한 다음 확인합니다. (나중을 위해 새 ID를 저장하는 것을 잊지 마십시오.)

MongoDB 컨테이너를 사용한 숨겨진 암호 및 직접 데이터 디버깅

암호가 안전하게 저장되었는지 확인하기 위해(즉, 일반 텍스트가 아닌 해시) 개발자는 MongoDB 데이터를 직접 검사할 수 있습니다. 한 가지 방법은 실행 중인 Docker 컨테이너 내에서 표준 mongo CLI 클라이언트에 액세스하는 것입니다.

 sudo docker exec -it toptal-rest-series_mongo_1 mongo

거기에서 use api-db 다음에 db.users.find().pretty() 를 실행하면 비밀번호를 포함한 모든 사용자 데이터가 나열됩니다.

GUI를 선호하는 사용자는 Robo 3T와 같은 별도의 MongoDB 클라이언트를 설치할 수 있습니다.

왼쪽 사이드바는 데이터베이스 연결을 보여주며, 각 연결에는 데이터베이스, 기능 및 사용자와 같은 계층 구조가 포함되어 있습니다. 기본 창에는 쿼리 실행을 위한 탭이 있습니다. 현재 탭은 "db.getCollection('users').find({})" 쿼리를 사용하여 localhost:27017의 api-db 데이터베이스에 연결되어 하나의 결과가 있습니다. 결과에는 _id, password, email 및 __v의 네 가지 필드가 있습니다. 암호 필드는 "$argon2$i$v=19$m=4096,t=3,p=1$"로 시작하고 소금과 해시로 끝납니다. 달러 기호로 구분되고 64진수로 인코딩됩니다.
Robo 3T를 사용하여 MongoDB 데이터를 직접 검사합니다.

암호 접두사( $argon2... )는 PHC 문자열 형식의 일부이며 의도적으로 수정되지 않은 상태로 저장됩니다. Argon2 및 해당 일반 매개변수가 언급된다는 사실은 해커가 도용할 수 있는 경우 원래 암호를 확인하는 데 도움이 되지 않습니다. 데이터 베이스. 저장된 비밀번호는 아래에서 JWT와 함께 사용할 기술인 솔팅을 사용하여 더욱 강화할 수 있습니다. 위의 솔트링을 적용하고 두 사용자가 동일한 암호를 입력할 때 저장된 값의 차이를 조사하는 것은 독자가 연습 문제로 남겨둡니다.

이제 Mongoose가 MongoDB 데이터베이스에 데이터를 성공적으로 보냅니다. 그러나 API 소비자가 요청에 적절한 데이터를 사용자 경로로 보낼지 어떻게 알 수 있습니까?

익스프레스 유효성 검사기 추가

필드 유효성 검사를 수행하는 방법에는 여러 가지가 있습니다. 이 기사에서 우리는 매우 안정적이고 사용하기 쉬우며 적절하게 문서화된 express-validator를 사용할 것입니다. Mongoose와 함께 제공되는 유효성 검사 기능을 사용할 수 있지만 express-validator는 추가 기능을 제공합니다. 예를 들어, 이메일 주소에 대한 즉시 사용 가능한 유효성 검사기와 함께 제공되며, 몽구스에서는 사용자 지정 유효성 검사기를 코딩해야 합니다.

설치해 보겠습니다.

 npm i express-validator

유효성을 검사하려는 필드를 설정하려면 users.routes.config.ts 에서 가져올 body() 메서드를 사용합니다. body() 메서드는 필드의 유효성을 검사하고 오류가 발생한 경우 express.Request 객체에 저장된 오류 목록을 생성합니다.

그런 다음 오류 목록을 확인하고 사용하기 위해 자체 미들웨어가 필요합니다. 이 논리는 다른 경로에 대해 동일한 방식으로 작동할 가능성이 높으므로 다음을 사용하여 common/middleware/body.validation.middleware.ts 를 생성해 보겠습니다.

 import express from 'express'; import { validationResult } from 'express-validator'; class BodyValidationMiddleware { verifyBodyFieldsErrors( req: express.Request, res: express.Response, next: express.NextFunction ) { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).send({ errors: errors.array() }); } next(); } } export default new BodyValidationMiddleware();

이것으로 body() 함수에서 생성된 모든 오류를 처리할 준비가 되었습니다. 다음을 다시 users.routes.config.ts 에 추가해 보겠습니다.

 import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator';

이제 다음으로 경로를 업데이트할 수 있습니다.

 @@ -15,3 +17,6 @@ export class UsersRoutes extends CommonRoutesConfig { .post( - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailDoesntExist, @@ -28,3 +33,10 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.put(`/users/:userId`, [ - UsersMiddleware.validateRequiredUserBodyFields, + body('email').isEmail(), + body('password') + .isLength({ min: 5 }) + .withMessage('Must include password (5+ characters)'), + body('firstName').isString(), + body('lastName').isString(), + body('permissionFlags').isInt(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validateSameEmailBelongToSameUser, @@ -34,2 +46,11 @@ export class UsersRoutes extends CommonRoutesConfig { this.app.patch(`/users/:userId`, [ + body('email').isEmail().optional(), + body('password') + .isLength({ min: 5 }) + .withMessage('Password must be 5+ characters') + .optional(), + body('firstName').isString().optional(), + body('lastName').isString().optional(), + body('permissionFlags').isInt().optional(), + BodyValidationMiddleware.verifyBodyFieldsErrors, UsersMiddleware.validatePatchEmail,

존재하는 body() 행 뒤에 모든 경로에서 BodyValidationMiddleware.verifyBodyFieldsErrors 를 추가해야 합니다. 그렇지 않으면 아무 효과도 없습니다.

자체 제작한 validateRequiredUserBodyFields 함수 대신 express-validator를 사용하도록 POSTPUT 경로를 업데이트한 방법에 주목하세요. 이 경로는 이 기능을 사용하는 유일한 경로였으므로 해당 구현은 users.middleware.ts 에서 삭제할 수 있습니다.

그게 다야! 독자는 Node.js를 다시 시작하고 즐겨 사용하는 REST 클라이언트를 사용하여 결과를 시험해 보고 다양한 입력을 처리하는 방법을 확인할 수 있습니다. 추가 가능성을 위해 express-validator 문서를 탐색하는 것을 잊지 마십시오. 이 예는 요청 유효성 검사의 시작점일 뿐입니다.

유효한 데이터 는 보장해야 할 한 측면입니다. 유효한 사용자와 작업은 다릅니다.

인증 대 권한(또는 "권한 부여") 흐름

Node.js 앱은 완전한 users/ 엔드포인트 세트를 노출하여 API 소비자가 사용자를 생성, 업데이트 및 나열할 수 있도록 합니다. 그러나 모든 엔드포인트는 무제한 공개 액세스를 허용합니다. 사용자가 서로의 데이터를 변경하지 못하도록 하고 외부인이 공개하고 싶지 않은 엔드포인트에 액세스하지 못하도록 하는 일반적인 패턴입니다.

이러한 제한과 관련된 두 가지 주요 측면이 있으며 둘 다 "인증"으로 줄입니다. 인증 은 요청의 출처에 관한 것이고 권한 부여 는 요청한 것을 수행할 수 있는지 여부에 관한 것입니다. 어떤 것이 논의되고 있는지 파악하는 것이 중요합니다. 짧은 형식이 없어도 표준 HTTP 응답 코드는 문제를 혼란스럽게 만듭니다. 401 Unauthorized 는 인증에 관한 것이고 403 Forbidden 은 권한 부여에 관한 것입니다. 모듈 이름에서 "authentication"을 의미하는 "auth" 측면에서 오류를 범하고 권한 문제에 대해 "permissions"를 사용합니다.

짧은 형식이 없어도 표준 HTTP 응답 코드는 문제를 혼란스럽게 만듭니다. 401 Unauthorized 는 인증에 관한 것이고 403 Forbidden 은 권한 부여에 관한 것입니다.

트위터

Auth0과 같은 드롭인 타사 ID 공급자를 포함하여 탐색할 인증 접근 방식이 많이 있습니다. 이 기사에서는 기본적이지만 확장 가능한 구현을 선택했습니다. JWT를 기반으로 합니다.

JWT는 인증과 관련되지 않은 일부 메타데이터가 포함된 암호화된 JSON으로 구성됩니다. 이 메타데이터에는 사용자의 이메일 주소와 권한 플래그가 포함됩니다. JSON에는 메타데이터의 무결성을 확인하기 위한 비밀도 포함됩니다.

아이디어는 클라이언트가 각 비공개 요청 내에서 유효한 JWT를 보내도록 요구하는 것입니다. 이를 통해 각 요청과 함께 유선을 통해 자격 증명을 보낼 필요 없이 클라이언트가 최근에 사용하려는 끝점에 대해 유효한 자격 증명을 가지고 있는지 확인할 수 있습니다.

그러나 이것이 우리의 예제 API 코드베이스에 어디에 적합할까요? 간편함: 미들웨어를 사용하면 경로 구성에 사용할 수 있습니다!

인증 모듈 추가

먼저 JWT에 포함될 항목을 구성해 보겠습니다. 여기에서 사용자 리소스의 permissionFlags 필드를 사용하기 시작할 것이지만 JWT 내에서 암호화할 수 있는 편리한 메타데이터이기 때문입니다. JWT가 본질적으로 세분화된 권한 논리와 아무 관련이 없기 때문이 아닙니다.

JWT 생성 미들웨어를 생성하기 전에 일반적으로 검색하지 않도록 Mongoose를 설정했기 때문에 비밀번호 필드를 검색하기 위해 users.dao.ts 에 특수 기능을 추가해야 합니다.

 async getUserByEmailWithPassword(email: string) { return this.User.findOne({ email: email }) .select('_id email permissionFlags +password') .exec(); }

그리고 users.service.ts 에서 :

 async getUserByEmailWithPassword(email: string) { return UsersDao.getUserByEmailWithPassword(email); }

이제 프로젝트 루트에 auth 폴더를 생성하겠습니다. API 소비자가 JWT를 생성할 수 있도록 끝점을 추가할 것입니다. 먼저 AuthMiddleware 라는 싱글톤으로 auth/middleware/auth.middleware.ts 에 미들웨어를 생성해 보겠습니다.

import s가 필요합니다.

 import express from 'express'; import usersService from '../../users/services/users.service'; import * as argon2 from 'argon2';

AuthMiddleware 클래스에서 API 사용자가 요청에 유효한 로그인 자격 증명을 포함했는지 확인하는 미들웨어 함수를 생성합니다.

 async verifyUserPassword( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( req.body.email ); if (user) { const passwordHash = user.password; if (await argon2.verify(passwordHash, req.body.password)) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } } // Giving the same message in both cases // helps protect against cracking attempts: res.status(400).send({ errors: ['Invalid email and/or password'] }); }

emailpasswordreq.body 에 존재하는지 확인하는 미들웨어의 경우 나중에 위의 verifyUserPassword() 함수를 사용하도록 경로를 구성할 때 express-validator를 사용합니다.

JWT 비밀 저장

JWT를 생성하려면 생성된 JWT에 서명하고 클라이언트 요청에서 들어오는 JWT의 유효성을 검사하는 데 사용할 JWT 비밀이 필요합니다. TypeScript 파일 내에서 JWT 비밀 값을 하드 코딩하는 대신 별도의 "환경 변수" 파일인 .env 에 저장합니다. 이 파일 은 코드 저장소에 절대 푸시되어서는 안 됩니다.

일반적인 관행과 같이 .env.example 파일을 리포지토리에 추가하여 개발자가 실제 .env 를 생성할 때 어떤 변수가 필요한지 이해할 수 있도록 했습니다. 우리의 경우 JWT 비밀을 문자열로 저장하는 JWT_SECRET 이라는 변수가 필요합니다. 이 기사가 끝날 때까지 기다렸다가 repo의 마지막 분기를 사용하는 독자는 이러한 값을 로컬에서 변경해야 한다는 것을 기억 해야 합니다.

실제 프로젝트는 특히 환경(개발, 준비, 프로덕션 등)에 따라 JWT 비밀을 구분 하여 JWT 모범 사례를 따라야 합니다.

.env 파일(프로젝트 루트에 있음)은 다음 형식을 사용해야 하지만 동일한 비밀 값을 유지해서는 안 됩니다.

 JWT_SECRET=My!@!Se3cr8tH4sh3

이러한 변수를 앱에 로드하는 쉬운 방법은 dotenv라는 라이브러리를 사용하는 것입니다.

 npm i dotenv

필요한 유일한 구성은 애플리케이션을 시작하는 즉시 dotenv.config() 함수를 호출하는 것입니다. app.ts 맨 위에 다음을 추가합니다.

 import dotenv from 'dotenv'; const dotenvResult = dotenv.config(); if (dotenvResult.error) { throw dotenvResult.error; }

인증 컨트롤러

마지막 JWT 생성 전제 조건은 jsonwebtoken 라이브러리 및 해당 TypeScript 유형을 설치하는 것입니다.

 npm i jsonwebtoken npm i --save-dev @types/jsonwebtoken

이제 auth/controllers/auth.controller.ts/auth 컨트롤러를 생성해 보겠습니다. 여기서 dotenv 라이브러리를 가져올 필요는 없습니다. app.ts 에서 가져오면 process 라는 Node.js 전역 개체를 통해 앱 전체에서 .env 파일의 내용을 사용할 수 있기 때문입니다.

 import express from 'express'; import debug from 'debug'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; const log: debug.IDebugger = debug('app:auth-controller'); /** * This value is automatically populated from .env, a file which you will have * to create for yourself at the root of the project. * * See .env.example in the repo for the required format. */ // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; const tokenExpirationInSeconds = 36000; class AuthController { async createJWT(req: express.Request, res: express.Response) { try { const refreshId = req.body.userId + jwtSecret; const salt = crypto.createSecretKey(crypto.randomBytes(16)); const hash = crypto .createHmac('sha512', salt) .update(refreshId) .digest('base64'); req.body.refreshKey = salt.export(); const token = jwt.sign(req.body, jwtSecret, { expiresIn: tokenExpirationInSeconds, }); return res .status(201) .send({ accessToken: token, refreshToken: hash }); } catch (err) { log('createJWT error: %O', err); return res.status(500).send(); } } } export default new AuthController();

jsonwebtoken 라이브러리는 jwtSecret 으로 새 토큰에 서명합니다. 또한 Node.js 네이티브 crypto 모듈을 사용하여 솔트와 해시를 생성한 다음 이를 사용하여 API 소비자가 현재 JWT를 새로 고칠 수 있는 refreshToken 을 만들 것입니다. 확장할 수 있습니다.

refreshKey , refreshTokenaccessToken 의 차이점은 무엇입니까? *Token 은 일반 대중이 사용할 수 있는 것 이상의 모든 요청에 accessToken 을 사용하고 만료된 accessToken 에 대한 교체를 요청하는 데 refreshToken 을 사용한다는 아이디어로 API 소비자에게 전송됩니다. 반면에 refreshKeyrefreshToken 내에서 암호화된 salt 변수를 새로 고침 미들웨어로 다시 전달하는 데 사용되며, 이에 대해서는 아래에서 설명합니다.

우리의 구현에는 jsonwebtoken 핸들 토큰 만료가 있습니다. JWT가 만료되면 클라이언트는 다시 한 번 인증해야 합니다.

초기 Node.js REST API 인증 경로

이제 auth/auth.routes.config.ts 에서 엔드포인트를 구성해 보겠습니다.

 import { CommonRoutesConfig } from '../common/common.routes.config'; import authController from './controllers/auth.controller'; import authMiddleware from './middleware/auth.middleware'; import express from 'express'; import BodyValidationMiddleware from '../common/middleware/body.validation.middleware'; import { body } from 'express-validator'; export class AuthRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'AuthRoutes'); } configureRoutes(): express.Application { this.app.post(`/auth`, [ body('email').isEmail(), body('password').isString(), BodyValidationMiddleware.verifyBodyFieldsErrors, authMiddleware.verifyUserPassword, authController.createJWT, ]); return this.app; } }

그리고 app.ts 파일에 추가하는 것을 잊지 마십시오.

 // ... import { AuthRoutes } from './auth/auth.routes.config'; // ... routes.push(new AuthRoutes(app)); // independent: can go before or after UsersRoute // ...

Node.js를 다시 시작하고 지금 테스트할 준비가 되었습니다. 이전에 테스트 사용자를 만들 때 사용한 자격 증명과 일치하는지 확인합니다.

 curl --request POST 'localhost:3000/auth' \ --header 'Content-Type: application/json' \ --data-raw '{ "password":"secr3tPass!23", "email":"[email protected]" }'

응답은 다음과 같습니다.

 { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJVZGdzUTBYMXciLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicHJvdmlkZXIiOiJlbWFpbCIsInBlcm1pc3Npb25MZXZlbCI6MSwicmVmcmVzaEtleSI6ImtDN3JFdDFHUmNsWTVXM0N4dE9nSFE9PSIsImlhdCI6MTYxMTM0NTYzNiwiZXhwIjoxNjExMzgxNjM2fQ.cfI_Ey4RHKbOKFdVGsowePZlMeX3fku6WHFu0EMjFP8", "refreshToken": "cXBHZ2tJdUhucERaTVpMWVNTckhNenQwcy9Bd0VIQ2RXRnA4bVBJbTBuQVorcS9Qb2xOUDVFS2xEM1RyNm1vTGdoWWJqb2xtQ0NHcXhlWERUcG81d0E9PQ==" }

이전과 마찬가지로 위의 값을 사용하여 편의를 위해 몇 가지 환경 변수를 설정해 보겠습니다.

 REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"

엄청난! 액세스 토큰과 새로 고침 토큰이 있지만 이를 사용하여 유용한 작업을 수행할 수 있는 미들웨어가 필요합니다.

JWT 미들웨어

디코딩된 형식의 JWT 구조를 처리하려면 새로운 TypeScript 유형이 필요합니다. 다음과 같이 common/types/jwt.ts 를 만듭니다.

 export type Jwt = { refreshKey: string; userId: string; permissionFlags: string; };

새로 고침 토큰이 있는지 확인하고, 새로 고침 토큰을 확인하고, JWT를 확인하는 미들웨어 기능을 구현해 보겠습니다. 세 가지 모두 auth/middleware/jwt.middleware.ts 라는 새 파일에 들어갈 수 있습니다.

 import express from 'express'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; import { Jwt } from '../../common/types/jwt'; import usersService from '../../users/services/users.service'; // @ts-expect-error const jwtSecret: string = process.env.JWT_SECRET; class JwtMiddleware { verifyRefreshBodyField( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.body && req.body.refreshToken) { return next(); } else { return res .status(400) .send({ errors: ['Missing required field: refreshToken'] }); } } async validRefreshNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { const user: any = await usersService.getUserByEmailWithPassword( res.locals.jwt.email ); const salt = crypto.createSecretKey( Buffer.from(res.locals.jwt.refreshKey.data) ); const hash = crypto .createHmac('sha512', salt) .update(res.locals.jwt.userId + jwtSecret) .digest('base64'); if (hash === req.body.refreshToken) { req.body = { userId: user._id, email: user.email, permissionFlags: user.permissionFlags, }; return next(); } else { return res.status(400).send({ errors: ['Invalid refresh token'] }); } } validJWTNeeded( req: express.Request, res: express.Response, next: express.NextFunction ) { if (req.headers['authorization']) { try { const authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { res.locals.jwt = jwt.verify( authorization[1], jwtSecret ) as Jwt; next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } } } export default new JwtMiddleware();

The validRefreshNeeded() function also verifies if the refresh token is correct for a specific user ID. If it is, then below we'll reuse authController.createJWT to generate a new JWT for the user.

We also have validJWTNeeded() , which validates whether the API consumer sent a valid JWT in the HTTP headers respecting the convention Authorization: Bearer <token> . (Yes, that's another unfortunate “auth” conflation.)

Now to configure a new route for refreshing the token and the permission flags encoded within it.

JWT Refresh Route

In auth.routes.config.ts we'll import our new middleware:

 import jwtMiddleware from './middleware/jwt.middleware';

Then we'll add the following route:

 this.app.post(`/auth/refresh-token`, [ jwtMiddleware.validJWTNeeded, jwtMiddleware.verifyRefreshBodyField, jwtMiddleware.validRefreshNeeded, authController.createJWT, ]);

Now we can test if it is working properly with the accessToken and refreshToken we received earlier:

 curl --request POST 'localhost:3000/auth/refresh-token' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw "{ \"refreshToken\": \"$REST_API_EXAMPLE_REFRESH\" }"

We should expect to receive a new accessToken and a new refreshToken to be used later. We leave it as an exercise for the reader to ensure that the back end invalidates previous tokens and limits how often new ones can be requested.

Now our API consumers are able to create, validate, and refresh JWTs. Let's look at some permissions concepts, then implement one and integrate it with our JWT middleware in our user routes.

사용자 권한

Once we know who an API client is, we want to know whether they're allowed to use the resource they're requesting. It's quite common to manage combinations of permissions for each user. Without adding much complexity, this allows for more flexibility than a traditional “access level” strategy. Regardless of the business logic we use for each permission, it's quite straightforward to create a generic way to handle it.

Bitwise AND ( & ) and Powers of Two

To manage permissions, we'll leverage JavaScript's built-in bitwise AND operator, & . This approach lets us store a whole set of permissions information as a single, per-user number, with each of its binary digits representing whether the user has permission to do something. But there's no need to worry about the math behind it too much—the point is that it's easy to use.

All we need to do is define each kind of permission (a permission flag ) as a power of 2 (1, 2, 4, 8, 16, 32, …). Then we can attach business logic to each flag, up to a maximum of 31 flags. For example, an audio-accessible, international blog might have these permissions:

  • 1: Authors can edit text.
  • 2: Illustrators can replace illustrations.
  • 4: Narrators can replace the audio file corresponding to any paragraph.
  • 8: Translators can edit translations.

This approach allows for all sorts of permission flag combinations for users:

  • An author's (or editor's) permission flags value will be just the number 1.
  • An illustrator's permission flags will be the number 2. But some authors are also illustrators. In that case, we sum the relevant permissions values: 1 + 2 = 3.
  • A narrator's flags will be 4. In the case of an author who narrates their own work, it will be 1 + 4 = 5. If they also illustrate, it's 1 + 2 + 4 = 7.
  • A translator will have a permission value of 8. Multilingual authors would then have flags of 1 + 8 = 9. A translator who also narrates (but is not an author) would have 4 + 8 = 12.
  • If we want to have a sudo admin, having all combined permissions, we can simply use 2,147,483,647, which is the maximum safe value for a 32-bit integer.

Readers can test this logic as plain JavaScript:

  • User with permission 5 trying to edit text (permission flag 1):

Input: 5 & 1

Output: 1

  • User with permission 1 trying to narrate (permission flag 4):

Input: 1 & 4

Output: 0

  • User with permission 12 trying to narrate:

Input: 12 & 4

Output: 4

When the output is 0, we block the user; otherwise, we let them access what they are trying to access.

Permission Flag Implementation

We'll store permissions flags inside the common folder since the business logic can be shared with future modules. Let's start by adding an enum to hold some permission flags at common/middleware/common.permissionflag.enum.ts :

 export enum PermissionFlag { FREE_PERMISSION = 1, PAID_PERMISSION = 2, ANOTHER_PAID_PERMISSION = 4, ADMIN_PERMISSION = 8, ALL_PERMISSIONS = 2147483647, }

Note: Since this is an example project, we kept the flag names fairly generic.

Before we forget, now's a good time for a quick return to the addUser() function in our user DAO to replace our temporary magic number 1 with PermissionFlag.FREE_PERMISSION . We'll also need a corresponding import statement.

We can also import it into a new middleware file at common/middleware/common.permission.middleware.ts with a singleton class named CommonPermissionMiddleware :

 import express from 'express'; import { PermissionFlag } from './common.permissionflag.enum'; import debug from 'debug'; const log: debug.IDebugger = debug('app:common-permission-middleware');

Instead of creating several similar middleware functions, we'll use the factory pattern to create a special factory method (or factory function or simply factory ). Our factory function will allow us to generate—at the time of route configuration—middleware functions to check for any permission flag needed. With that, we avoid having to manually duplicate our middleware function whenever we add a new permission flag.

Here's the factory that will generate a middleware function that checks for whatever permission flag we pass it:

permissionFlagRequired(requiredPermissionFlag: PermissionFlag) { return ( req: express.Request, res: express.Response, next: express.NextFunction ) => { try { const userPermissionFlags = parseInt( res.locals.jwt.permissionFlags ); if (userPermissionFlags & requiredPermissionFlag) { next(); } else { res.status(403).send(); } } catch (e) { log(e); } }; }

보다 맞춤화된 경우는 특정 사용자 레코드에 액세스할 수 있어야 하는 유일한 사용자가 동일한 사용자 또는 관리자인 경우입니다.

 async onlySameUserOrAdminCanDoThisAction( req: express.Request, res: express.Response, next: express.NextFunction ) { const userPermissionFlags = parseInt(res.locals.jwt.permissionFlags); if ( req.params && req.params.userId && req.params.userId === res.locals.jwt.userId ) { return next(); } else { if (userPermissionFlags & PermissionFlag.ADMIN_PERMISSION) { return next(); } else { return res.status(403).send(); } } }

이번에는 users.middleware.ts 에 미들웨어의 마지막 부분을 추가합니다.

 async userCantChangePermission( req: express.Request, res: express.Response, next: express.NextFunction ) { if ( 'permissionFlags' in req.body && req.body.permissionFlags !== res.locals.user.permissionFlags ) { res.status(400).send({ errors: ['User cannot change permission flags'], }); } else { next(); } }

그리고 위의 함수는 res.locals.user 에 의존하기 때문에 next() 호출 전에 validateUserExists() 에 해당 값을 채울 수 있습니다.

 // ... if (user) { res.locals.user = user; next(); } else { // ...

사실, validateUserExists() 에서 이 작업을 수행하면 validateUserExists() validateSameEmailBelongToSameUser() 필요하지 않게 됩니다. 거기에서 데이터베이스 호출을 제거하고 res.locals 에 캐시될 수 있는 값으로 대체할 수 있습니다.

 - const user = await userService.getUserByEmail(req.body.email); - if (user && user.id === req.params.userId) { + if (res.locals.user._id === req.params.userId) {

이제 권한 로직을 users.routes.config.ts 에 통합할 준비가 되었습니다.

권한 필요

먼저 새로운 미들웨어와 enum 을 가져올 것입니다.

 import jwtMiddleware from '../auth/middleware/jwt.middleware'; import permissionMiddleware from '../common/middleware/common.permission.middleware'; import { PermissionFlag } from '../common/middleware/common.permissionflag.enum';

우리는 관리자 권한이 있는 사람의 요청에 의해서만 사용자 목록에 액세스할 수 있기를 원하지만 일반적인 UX 기대 흐름에 따라 새 사용자를 생성하는 기능은 여전히 ​​공개되기를 원합니다. 컨트롤러보다 먼저 공장 기능을 사용하여 사용자 목록을 제한합시다.

 this.app .route(`/users`) .get( jwtMiddleware.validJWTNeeded, permissionMiddleware.permissionFlagRequired( PermissionFlag.ADMIN_PERMISSION ), UsersController.listUsers ) // ...

여기에서 팩토리 호출( (...) ) 은 미들웨어 함수를 반환 하므로 모든 일반 비 팩토리 미들웨어는 호출( () ) 없이 참조됩니다.

또 다른 일반적인 제한 사항은 userId 를 포함하는 모든 경로에 대해 동일한 사용자 또는 관리자만 액세스할 수 있기를 원한다는 것입니다.

 .route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById)

또한 각 PUTPATCH 경로 끝에 UsersController 함수 참조 바로 앞에 UsersMiddleware.userCantChangePermission 을 추가하여 사용자가 권한을 상승시키는 것을 방지할 것입니다.

그러나 REST API 비즈니스 로직이 PAID_PERMISSION 이 있는 사용자만 정보를 업데이트할 수 있도록 허용한다고 가정해 보겠습니다. 이것은 다른 프로젝트의 비즈니스 요구 사항과 일치할 수도 있고 일치하지 않을 수도 있습니다. 단지 유료 및 무료 사용 권한 간의 차이를 테스트하기 위한 것입니다.

이것은 방금 추가한 각 userCantChangePermission 참조 뒤에 다른 생성기 호출을 추가하여 수행할 수 있습니다.

 permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),

이제 Node.js를 다시 시작하고 사용해 볼 준비가 되었습니다.

수동 권한 테스트

경로를 테스트하기 위해 액세스 토큰 없이 사용자 목록을 GET 해 보겠습니다.

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json'

유효한 JWT를 사용해야 하기 때문에 HTTP 401 응답을 받습니다. 이전 인증의 액세스 토큰을 사용해 보겠습니다.

 curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

이번에는 HTTP 403을 얻습니다. 토큰은 유효하지만 ADMIN_PERMISSION 이 없기 때문에 이 끝점을 사용할 수 없습니다.

하지만 다음과 같이 사용자 레코드를 GET 데 필요하지 않습니다.

 curl --request GET "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS"

응답:

 { "_id": "UdgsQ0X1w", "email": "[email protected]", "permissionFlags": 1, "__v": 0 }

대조적으로, 우리의 권한 값이 1( FREE_PERMISSION 만 해당)이기 때문에 자체 사용자 레코드를 업데이트 하려는 시도는 실패해야 합니다.

 curl --include --request PATCH "localhost:3000/users/$REST_API_EXAMPLE_ID" \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" \ --data-raw '{ "firstName": "Marcos" }'

예상대로 응답은 403입니다.

독자 연습으로 로컬 데이터베이스에서 사용자 permissionFlags 를 변경하고 /auth 에 대한 새 게시물 수행한 다음(새 permissionFlags 로 토큰을 생성하기 위해) 사용자를 다시 PATCH 하려고 합니다. 플래그를 PAID_PERMISSION 또는 ALL_PERMISSIONS 의 숫자 값으로 설정해야 한다는 점을 기억하십시오. 우리의 비즈니스 로직은 ADMIN_PERMISSION 자체가 다른 사용자나 자신을 패치할 수 없도록 지정하기 때문입니다.

/auth 에 대한 새 게시물에 대한 요구 사항은 염두에 둘 가치가 있는 보안 시나리오를 제공합니다. 사이트 소유자가 사용자의 권한을 변경하는 경우(예: 잘못된 행동을 하는 사용자를 잠그려고 시도하는 경우) 사용자는 다음 JWT 새로 고침 때까지 이것이 적용되는 것을 볼 수 없습니다. 권한 검사가 JWT 데이터 자체를 사용하여 추가 데이터베이스 적중을 피하기 때문입니다.

Auth0과 같은 서비스는 자동 토큰 순환을 제공하여 도움이 될 수 있지만 사용자는 순환 사이의 시간 동안 예기치 않은 앱 동작을 경험할 수 있습니다. 이를 완화하기 위해 개발자는 권한 변경에 대한 응답으로 새로 고침 토큰을 적극적으로 취소하도록 주의해야 합니다.


REST API에서 작업하는 동안 개발자는 주기적으로 cURL 명령 더미를 다시 실행하여 잠재적인 버그로부터 보호할 수 있습니다. 그러나 이는 느리고 오류가 발생하기 쉬우며 빠르게 지루해집니다.

자동화된 테스트

API가 성장함에 따라, 특히 자주 변경되는 비즈니스 로직으로 소프트웨어 품질을 유지하기가 어려워집니다. API 버그를 최대한 줄이고 새로운 변경 사항을 자신 있게 배포하기 위해 앱의 프런트 엔드 및/또는 백 엔드에 대한 테스트 제품군을 갖는 것이 매우 일반적입니다.

테스트 및 테스트 가능한 코드 작성에 뛰어들기보다는 몇 가지 기본 메커니즘을 보여주고 독자들이 구축할 수 있는 작동 테스트 제품군을 제공할 것입니다.

남은 테스트 데이터 다루기

자동화하기 전에 테스트 데이터에 어떤 일이 발생하는지 생각해 볼 가치가 있습니다.

우리는 Docker Compose를 사용하여 로컬 데이터베이스를 실행하고 있으며 라이브 프로덕션 데이터 소스가 아닌 개발용으로 이 데이터베이스를 사용할 것으로 예상합니다. 여기서 실행할 테스트는 실행할 때마다 새로운 테스트 데이터 세트를 남겨두어 로컬 데이터베이스에 영향을 미칩니다. 이것은 대부분의 경우 문제가 되지 않아야 하지만 만약 그렇다면 독자에게 테스트 목적으로 새 데이터베이스를 생성하기 위해 docker-compose.yml 을 변경하는 연습을 남겨둡니다.

현실 세계에서 개발자는 종종 지속적인 통합 파이프라인의 일부로 자동화된 테스트를 실행합니다. 그렇게 하려면 파이프라인 수준에서 각 테스트 실행에 대한 임시 데이터베이스를 만드는 방법을 구성하는 것이 좋습니다.

Mocha, Chai 및 SuperTest를 사용하여 테스트를 생성합니다.

 npm i --save-dev chai mocha supertest @types/chai @types/express @types/mocha @types/supertest ts-node

Mocha는 애플리케이션을 관리하고 테스트를 실행하고 Chai는 더 읽기 쉬운 테스트 표현을 허용하며 SuperTest는 REST 클라이언트가 하는 것처럼 API를 호출하여 종단 간(E2E) 테스트를 용이하게 합니다.

package.json 에서 스크립트를 업데이트해야 합니다.

 "scripts": { // ... "test": "mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict", "test-debug": "export DEBUG=* && npm test" },

그러면 test 라는 폴더에서 테스트를 실행할 수 있습니다.

메타 테스트

테스트 인프라를 시도하기 위해 test/app.test.ts 파일을 생성해 보겠습니다.

 import { expect } from 'chai'; describe('Index Test', function () { it('should always pass', function () { expect(true).to.equal(true); }); });

여기의 구문은 이상해 보일 수 있지만 정확합니다. 우리는 it() 블록 내에서 동작을 expect() 하여 테스트를 정의합니다. 이것은 우리가 it() 에 전달할 함수의 본문을 의미합니다. 이 블록은 describe() 블록 내에서 호출됩니다.

이제 터미널에서 다음을 실행합니다.

 npm run test

우리는 이것을 봐야 합니다:

 > mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)

엄청난! 테스트 라이브러리가 설치되어 사용할 준비가 되었습니다.

테스트 간소화

테스트 출력을 깨끗하게 유지하기 위해 일반 테스트 실행 중에 Winston 요청 로깅을 완전히 묵음 처리하려고 합니다. Mocha의 it() 함수가 있는지 여부를 감지하기 위해 app.ts 의 디버그가 아닌 else 분기를 빠르게 변경하는 것만큼 쉽습니다.

 if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, make terse + if (typeof global.it === 'function') { + loggerOptions.level = 'http'; // for non-debug test runs, squelch entirely + } }

추가해야 할 마지막 터치는 테스트에서 사용할 app.ts 를 내보내는 것입니다. app.ts 의 끝에서 listen() 은 Node.js http.Server 객체를 반환하기 때문에 server.listen() 바로 앞에 export default 을 추가합니다.

스택이 손상되지 않았는지 확인하기 위한 빠른 npm run test 를 통해 이제 API를 테스트할 준비가 되었습니다.

최초의 실제 REST API 자동화 테스트

사용자 테스트 구성을 시작하려면 필요한 가져오기 및 테스트 변수로 시작하여 test/users/users.test.ts 를 생성하겠습니다.

 import app from '../../app'; import supertest from 'supertest'; import { expect } from 'chai'; import shortid from 'shortid'; import mongoose from 'mongoose'; let firstUserIdTest = ''; // will later hold a value returned by our API const firstUserBody = { email: `marcos.henrique+${shortid.generate()}@toptal.com`, password: 'Sup3rSecret!23', }; let accessToken = ''; let refreshToken = ''; const newFirstName = 'Jose'; const newFirstName2 = 'Paulo'; const newLastName2 = 'Faraco';

다음으로 일부 설정 및 분해 정의를 사용하여 가장 바깥쪽의 describe() 블록을 생성합니다.

 describe('users and auth endpoints', function () { let request: supertest.SuperAgentTest; before(function () { request = supertest.agent(app); }); after(function (done) { // shut down the Express.js server, close our MongoDB connection, then // tell Mocha we're done: app.close(() => { mongoose.connection.close(done); }); }); });

before()after() 에 전달하는 함수는 동일한 describe() 블록 내에서 it() 을 호출하여 정의할 모든 테스트 전후에 호출됩니다. after() 에 전달된 함수는 done 이라는 콜백을 받아 앱과 해당 데이터베이스 연결을 모두 정리한 후에만 호출되도록 합니다.

참고: after() 전술이 없으면 테스트가 성공적으로 완료된 후에도 Mocha가 중단됩니다. 조언은 종종 이것을 피하기 위해 항상 Mocha를 --exit 로 호출하는 --exit (종종 언급되지 않는) 경고가 있습니다. 테스트 스위트가 테스트 스위트 또는 앱 자체에서 잘못 구성된 Promise와 같은 다른 이유로 중단되는 경우 --exit 를 사용하면 Mocha는 기다리지 않고 어쨌든 성공을 보고할 것이므로 디버깅에 미묘한 복잡성이 추가됩니다.

이제 describe() 블록 내에 개별 E2E 테스트를 추가할 준비가 되었습니다.

 it('should allow a POST to /users', async function () { const res = await request.post('/users').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.id).to.be.a('string'); firstUserIdTest = res.body.id; });

이 첫 번째 함수는 사용자 이메일이 이전에 shortid 를 사용하여 생성되었기 때문에 고유한 새 사용자를 생성합니다. request 변수에는 SuperTest 에이전트가 있어 API에 HTTP 요청을 할 수 있습니다. await 를 사용하여 만들었습니다. it() 에 전달하는 함수가 async 여야 하는 이유입니다. 그런 다음 Chai의 expect() 를 사용하여 결과의 ​​다양한 측면을 테스트합니다.

이 시점에서 npm run test 는 새로운 테스트가 작동하는 것을 보여야 합니다.

일련의 테스트

우리는 describe() 블록 안에 다음 it() 블록을 모두 추가할 것입니다. firstUserIdTest 와 같이 변경하는 변수와 함께 작동하도록 표시된 순서대로 추가해야 합니다.

 it('should allow a POST to /auth', async function () { const res = await request.post('/auth').send(firstUserBody); expect(res.status).to.equal(201); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body.accessToken).to.be.a('string'); accessToken = res.body.accessToken; refreshToken = res.body.refreshToken; });

여기에서 새로 생성된 사용자에 대한 새 액세스 및 새로 고침 토큰을 가져옵니다.

 it('should allow a GET from /users/:userId with an access token', async function () { const res = await request .get(`/users/${firstUserIdTest}`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(200); expect(res.body).not.to.be.empty; expect(res.body).to.be.an('object'); expect(res.body._id).to.be.a('string'); expect(res.body._id).to.equal(firstUserIdTest); expect(res.body.email).to.equal(firstUserBody.email); });

이는 사용자 데이터 응답이 우리가 처음에 보낸 것과 일치하는지 확인하기 위해 :userId 경로에 대한 토큰 베어링 GET 요청을 만듭니다.

테스트에서 중첩, 건너뛰기, 격리 및 차단

Mocha에서 it() 블록은 자체 describe() 블록도 포함할 수 있으므로 다른 describe() 블록 내에 다음 테스트를 중첩합니다. 그러면 마지막에 보여주겠지만 테스트 출력에서 ​​종속성의 계단식을 더 명확하게 만들 수 있습니다.

 describe('with a valid access token', function () { it('should allow a GET from /users', async function () { const res = await request .get(`/users`) .set({ Authorization: `Bearer ${accessToken}` }) .send(); expect(res.status).to.equal(403); }); });

효과적인 테스트는 우리가 기대하는 것뿐만 아니라 실패할 것으로 예상하는 것까지 다룹니다. 여기에서 모든 사용자를 나열하려고 시도하고 사용자(기본 권한이 있는)가 이 끝점을 사용할 수 없기 때문에 403 응답을 예상합니다.

이 새로운 describe() 블록 내에서 테스트를 계속 작성할 수 있습니다. 나머지 테스트 코드에서 사용되는 기능에 대해 이미 논의했으므로 repo의 이 줄에서 시작하여 찾을 수 있습니다.

Mocha는 테스트를 개발하고 디버깅하는 동안 편리하게 사용할 수 있는 몇 가지 기능을 제공합니다.

  1. .skip() 메서드는 단일 테스트 또는 전체 테스트 블록 실행을 방지하는 데 사용할 수 있습니다. it()it.skip() 으로 대체되면( describe() ()의 경우와 유사) 문제의 테스트가 실행되지 않지만 Mocha의 최종 출력에서 ​​"보류 중"으로 집계됩니다.
  2. 더 임시로 사용하기 위해 .only() 함수는 .only .only() 표시가 없는 모든 테스트를 완전히 무시하고 "보류 중"으로 표시되는 결과를 초래하지 않습니다.
  3. package.json 에 정의된 mocha 호출은 --bail 을 명령줄 매개변수로 사용할 수 있습니다. 이것이 설정되면 Mocha는 하나의 테스트가 실패하는 즉시 테스트 실행을 중지합니다. 이것은 테스트가 계단식으로 설정되어 있기 때문에 REST API 예제 프로젝트에서 특히 유용합니다. 첫 번째 테스트만 실패하면 Mocha는 현재 실패하고 있는 모든 종속(그러나 깨지지 않은) 테스트에 대해 불평하는 대신 정확히 보고합니다.

이 시점에서 npm run test 를 사용하여 전체 테스트 배터리를 실행하면 3개의 실패한 테스트가 표시됩니다. (만약 우리가 그들이 의존하는 기능을 당분간 구현하지 않은 채로 두려고 한다면, 이 세 가지 테스트는 .skip() 에 대한 좋은 후보가 될 것입니다.)

실패한 테스트는 현재 앱에서 누락된 두 부분에 의존합니다. 첫 번째는 users.routes.config.ts 에 있습니다.

 this.app.put(`/users/:userId/permissionFlags/:permissionFlags`, [ jwtMiddleware.validJWTNeeded, permissionMiddleware.onlySameUserOrAdminCanDoThisAction, // Note: The above two pieces of middleware are needed despite // the reference to them in the .all() call, because that only covers // /users/:userId, not anything beneath it in the hierarchy permissionMiddleware.permissionFlagRequired( PermissionFlag.FREE_PERMISSION ), UsersController.updatePermissionFlags, ]);

업데이트해야 하는 두 번째 파일은 users.controller.ts 입니다. 존재하지 않는 함수를 방금 참조했기 때문입니다. import { PatchUserDto } from '../dto/patch.user.dto'; 상단 근처 및 클래스에 대한 누락된 기능:

 async updatePermissionFlags(req: express.Request, res: express.Response) { const patchUserDto: PatchUserDto = { permissionFlags: parseInt(req.params.permissionFlags), }; log(await usersService.patchById(req.body.id, patchUserDto)); res.status(204).send(); }

이러한 권한 상승 기능을 추가하면 테스트에 유용하지만 대부분의 실제 요구 사항에는 맞지 않습니다. 여기에 독자를 위한 두 가지 연습 문제가 있습니다.

  1. 사용자가 자신의 permissionFlags 를 변경하지 못하도록 하는 동시에 권한이 제한된 끝점을 테스트할 수 있도록 하는 방법을 고려하십시오.
  2. API를 통해 permissionFlags 변경할 수 있는 방법에 대한 비즈니스 로직(및 해당 테스트)을 만들고 구현합니다. (여기에 닭과 계란 퍼즐이 있습니다. 특정 사용자가 처음에 권한을 변경할 수 있는 권한을 어떻게 얻습니까?)

이를 통해 npm run test 는 이제 다음과 같이 멋지게 형식화된 출력으로 성공적으로 완료되어야 합니다.

 Index Test ✓ should always pass users and auth endpoints ✓ should allow a POST to /users (76ms) ✓ should allow a POST to /auth ✓ should allow a GET from /users/:userId with an access token with a valid access token ✓ should allow a GET from /users ✓ should disallow a PATCH to /users/:userId ✓ should disallow a PUT to /users/:userId with an nonexistent ID ✓ should disallow a PUT to /users/:userId trying to change the permission flags ✓ should allow a PUT to /users/:userId/permissionFlags/2 for testing with a new permission level ✓ should allow a POST to /auth/refresh-token ✓ should allow a PUT to /users/:userId to change first and last names ✓ should allow a GET from /users/:userId and should have a new full name ✓ should allow a DELETE from /users/:userId 13 passing (231ms)

이제 REST API가 예상대로 작동하는지 빠르게 확인할 수 있습니다.

(함께) 테스트 디버깅

예상치 못한 테스트 실패에 직면한 개발자는 테스트 스위트를 실행할 때 Winston과 Node.js의 디버그 모듈을 모두 쉽게 활용할 수 있습니다.

예를 들어 DEBUG=mquery npm run test 를 호출하여 실행되는 Mongoose 쿼리에 집중하기 쉽습니다. (이 명령에는 중간에 export 접두사와 && 가 없기 때문에 이후 명령에 환경이 유지된다는 점에 유의하십시오.)

이전에 package.json 에 추가한 덕분에 npm run test-debug 를 사용하여 모든 디버그 출력을 표시할 수도 있습니다.

이를 통해 편리한 자동화 테스트 제품군과 함께 작동하고 확장 가능한 MongoDB 지원 REST API가 있습니다. 그러나 여전히 몇 가지 필수 요소가 빠져 있습니다.

보안(모든 프로젝트는 헬멧을 착용해야 함)

Express.js로 작업할 때 문서, 특히 보안 모범 사례를 반드시 읽어야 합니다. 최소한 다음을 추구할 가치가 있습니다.

  • TLS 지원 구성
  • 속도 제한 미들웨어 추가
  • npm 종속성이 안전한지 확인(독자는 npm audit 로 시작하거나 snyk로 더 깊이 들어갈 수 있음)
  • Helmet 라이브러리를 사용하여 일반적인 보안 취약점으로부터 보호

이 마지막 요점은 예제 프로젝트에 간단하게 추가할 수 있습니다.

 npm i --save helmet

그런 다음 app.ts 에서 가져오고 다른 app.use() 호출을 추가하기만 하면 됩니다.

 import helmet from 'helmet'; // ... app.use(helmet());

문서에서 지적한 것처럼 헬멧(모든 보안 추가 기능과 마찬가지로)은 만병통치약은 아니지만 모든 예방 조치가 도움이 됩니다.

Docker로 REST API 프로젝트 포함하기

이 시리즈에서는 Docker 컨테이너에 대해 자세히 다루지 않았지만 Docker Compose가 있는 컨테이너에서 MongoDB를 사용했습니다. Docker에 익숙하지 않지만 추가 단계를 시도하려는 독자는 프로젝트 루트에 Dockerfile (확장자 없음)이라는 파일을 만들 수 있습니다.

 FROM node:14-slim RUN mkdir -p /usr/src/app WORKDIR /usr/src/app COPY . . RUN npm install EXPOSE 3000 CMD ["node", "./dist/app.js"]

이 구성은 Docker의 node:14-slim 공식 이미지로 시작하여 컨테이너에서 예제 REST API를 빌드 및 실행합니다. 구성은 경우에 따라 변경될 수 있지만 이러한 일반적으로 보이는 기본값은 우리 프로젝트에서 작동합니다.

이미지를 빌드하려면 프로젝트 루트에서 이것을 실행하면 됩니다(원하는 대로 tag_your_image_here 대체).

 docker build . -t tag_your_image_here

그런 다음 정확히 동일한 텍스트 교체를 가정하여 백엔드를 실행하는 한 가지 방법은 다음과 같습니다.

 docker run -p 3000:3000 tag_your_image_here

이 시점에서 MongoDB와 Node.js는 모두 Docker를 사용할 수 있지만 두 가지 다른 방법으로 시작해야 합니다. 독자가 기본 Node.js 앱을 docker-compose.yml 에 추가하여 단일 docker-compose 명령으로 전체 앱을 시작할 수 있도록 하는 연습으로 남겨둡니다.

탐색할 추가 REST API 기술

이 기사에서는 REST API를 광범위하게 개선했습니다. 컨테이너화된 MongoDB를 추가하고, Mongoose 및 Express-Validator를 구성하고, JWT 기반 인증과 유연한 권한 시스템을 추가하고, 일련의 자동화 테스트를 작성했습니다.

이것은 신규 및 고급 백엔드 개발자 모두를 위한 견고한 출발점입니다. 그러나 어떤 면에서 우리 프로젝트는 프로덕션 사용, 확장 및 유지 관리에 이상적이지 않을 수 있습니다. 이 기사 전체에 걸쳐 뿌린 독자 연습 외에 무엇을 더 배울 수 있습니까?

API 수준에서 OpenAPI 호환 사양 생성에 대해 읽어보는 것이 좋습니다. 특히 기업 개발에 관심이 있는 독자는 NestJS도 사용해 보고 싶어할 것입니다. 이것은 Express.js 위에 구축된 또 다른 프레임워크이지만 더 강력하고 추상적입니다. 따라서 예제 프로젝트를 사용하여 먼저 Express.js 기본 사항에 익숙해지는 것이 좋습니다. 그만큼 API에 대한 GraphQL 접근 방식은 REST의 대안으로 널리 주목받고 있습니다.

권한과 관련하여 우리는 수동으로 정의된 플래그에 대한 미들웨어 생성기를 사용하여 비트 단위 플래그 접근 방식을 다뤘습니다. 확장 시 추가 편의를 위해 Mongoose와 통합되는 CASL 라이브러리를 살펴볼 가치가 있습니다. 이는 접근 방식의 유연성을 확장하여 can(['update', 'delete'], '(model name here)', { creator: 'me' }); 전체 사용자 정의 미들웨어 기능 대신.

우리는 이 프로젝트에서 실용적인 자동화 테스트 발판을 제공했지만 일부 중요한 주제는 우리 범위를 벗어났습니다. 독자들에게 다음을 권장합니다.

  1. 구성 요소를 개별적으로 테스트하기 위해 단위 테스트를 탐색합니다. Mocha와 Chai도 이를 위해 사용할 수 있습니다.
  2. 테스트 중에 실행되지 않는 코드 행을 표시하여 테스트 스위트의 격차를 식별하는 데 도움이 되는 코드 검사 도구를 살펴보십시오. 그런 다음 독자는 이러한 도구를 사용하여 필요에 따라 예제 테스트를 보완할 수 있지만 사용자가 PATCH 를 통해 /users/:userId 에 대한 권한을 수정할 수 있는지 여부와 같은 누락된 시나리오를 모두 드러내지는 않을 수 있습니다.
  3. 자동화된 테스트에 대한 다른 접근 방식을 시도합니다. 우리는 Chai의 BDD(behavior-driven development) 스타일의 expect 인터페이스를 사용했지만 should()assert 도 지원합니다. Jest와 같은 다른 테스트 라이브러리도 배울 가치가 있습니다.

이러한 주제 외에도 Node.js/TypeScript REST API를 구축할 준비가 되었습니다. 특히 독자는 표준 사용자 리소스에 대한 공통 비즈니스 논리를 적용하기 위해 더 많은 미들웨어를 구현하기를 원할 수 있습니다. 여기서 더 깊이 들어가지는 않겠지만 차단된 독자들에게 지침과 팁을 기꺼이 제공하겠습니다. 아래에 댓글을 남겨주세요.

이 프로젝트의 전체 코드는 오픈 소스 GitHub 리포지토리로 제공됩니다.


Toptal 엔지니어링 블로그에 대한 추가 정보:

  • Promise 기반 오류 처리를 위해 Express.js 경로 사용