Node.js / TypeScript REST APIの構築、パート3:MongoDB、認証、および自動テスト
公開: 2022-03-11Express.jsとTypeScriptを使用してNode.jsRESTAPIを作成する方法に関するシリーズのこの時点で、動作するバックエンドを構築し、コードをルート構成、サービス、ミドルウェア、コントローラー、モデルに分離しました。 そこからフォローする準備ができている場合は、サンプルリポジトリのクローンを作成し、 git checkout toptal-article-02を実行します。
マングース、認証、自動テストを備えたREST API
この3番目の最後の記事では、以下を追加してRESTAPIの開発を続けます。
- Mongooseを使用すると、MongoDBを操作して、メモリ内のDAOを実際のデータベースに置き換えることができます。
- APIコンシューマーがJSONWebToken(JWT)を使用してエンドポイントに安全にアクセスできるようにする認証およびアクセス許可機能。
- Mocha(テストフレームワーク)、Chai(アサーションライブラリ)、およびSuperTest(HTTP抽象化モジュール)を使用した自動テスト。コードベースの拡大と変更に伴うリグレッションのチェックに役立ちます。
その過程で、検証ライブラリとセキュリティライブラリを追加し、Dockerの経験を積み、さらにいくつかのトピック、ライブラリ、スキルの読者が独自のRESTAPIを構築および拡張するために役立つことを提案します。
コンテナとしてのMongoDBのインストール
前回の記事のインメモリデータベースを実際のデータベースに置き換えることから始めましょう。
開発用のローカルデータベースを作成するために、MongoDBをローカルにインストールできます。 ただし、環境(OSのディストリビューションやバージョンなど)の違いにより、問題が発生する可能性があります。 これを回避するために、この機会を利用して、業界標準のツールであるDockerコンテナーを活用します。
読者が行う必要があるのは、DockerをインストールしてからDockerComposeをインストールすることだけです。 インストールしたら、ターミナルでdocker -vを実行すると、Dockerのバージョン番号が生成されます。
ここで、MongoDBを実行するために、プロジェクトのルートに、以下を含むdocker-compose.ymlというYAMLファイルを作成します。
version: '3' services: mongo: image: mongo volumes: - ./data:/data/db ports: - "27017:27017"Docker Composeを使用すると、1つの構成ファイルで一度に複数のコンテナーを実行できます。 この記事の最後では、DockerでREST APIバックエンドを実行する方法についても説明しますが、今のところは、ローカルにインストールせずに、MongoDBを実行するために使用します。
sudo docker-compose up -d upコマンドは、定義されたコンテナーを起動し、27017の標準MongoDBポートでリッスンします。 -dスイッチは、コマンドをターミナルから切り離します。 すべてが問題なく実行される場合、次のようなメッセージが表示されます。
Creating network "toptal-rest-series_default" with the default driver Creating toptal-rest-series_mongo_1 ... done また、プロジェクトルートに新しいdataディレクトリが作成されるため、 .gitignoreにdata行を追加する必要があります。
ここで、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 / MongoDBRESTAPIバックエンドを起動するために知っておく必要があるのはこれだけです。 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:新しい接続管理エンジンを使用するには、これをtrueに設定することをMongooseのドキュメントで推奨しています。 -
serverSelectionTimeoutMS:このデモプロジェクトのUXの目的で、デフォルトの30秒よりも短い時間は、Node.jsの前にMongoDBを起動するのを忘れた読者は、明らかに応答しないバックエンドではなく、MongoDBに関する有益なフィードバックをすぐに見ることができることを意味します。 -
useFindAndModify:これをfalseに設定すると、非推奨の警告も回避されますが、Mongoose接続オプションではなく、ドキュメントの非推奨のセクションに記載されています。 具体的には、これにより、Mongooseは古いMongooseシムの代わりに新しいネイティブ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サービス(dockerdocker-composeで実行)への接続を試み、serverSelectionTimeoutMSミリ秒後にタイムアウトします。 -
MongooseService.connectWithRetry()は、アプリケーションが起動したがMongoDBサービスがまだ実行されていない場合に、上記を再試行します。 シングルトンコンストラクター内にあるため、connectWithRetry()は1回だけ実行されますが、タイムアウトが発生するたびにretrySeconds秒の一時停止を使用して、connect()呼び出しを無期限に再試行します。
次のステップは、以前のインメモリデータベースをMongoDBに置き換えることです。
インメモリデータベースの削除とMongoDBの追加
以前は、インメモリデータベースを使用して、構築中の他のモジュールに集中できるようにしました。 代わりにMongooseを使用するには、 users.dao.tsを完全にリファクタリングする必要があります。 開始するには、もう1つのimportステートメントが必要です。
import mongooseService from '../../common/services/mongoose.service'; 次に、コンストラクターを除くすべてをUsersDaoクラス定義から削除しましょう。 コンストラクターの前にMongooseのユーザーSchemaを作成することで、入力を開始できます。
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メソッド操作を実装する前に、2つの方法でDTOを更新します。
DTO変更番号1: idと_id
Mongooseは自動的に_idフィールドを使用可能にするため、DTOからidフィールドを削除します。 それはとにかくルートリクエストからのパラメータから来ます。
Mongooseモデルはデフォルトで仮想idゲッターを提供することに注意してください。混乱を避けるために、上記のオプションを{ id: false }で無効にしました。 しかし、それはユーザーミドルウェアvalidateSameEmailBelongToSameUser()のuser.idへの参照を壊しました—代わりにuser._idが必要です。
一部のデータベースは規則idを使用し、他のデータベースは_idを使用するため、完全なインターフェースはありません。 Mongooseを使用したサンプルプロジェクトでは、コードのどの時点でどのプロジェクトを使用しているかに注意を払っただけですが、不一致は引き続きAPIコンシューマーに公開されます。
プロジェクトの最後に利用できる多くの実際のソリューションの1つを実装するための演習として、読者に任せます。
DTO変更2:フラグベースのアクセス許可の準備
また、DTOでpermissionFlagsの名前をpermissionLevelに変更して、実装するより高度なアクセス許可システムと、上記のuserSchema定義を反映します。
DTO:DRYの原則はどうですか?
DTOには、APIクライアントとデータベースの間で渡したいフィールドだけが含まれていることを忘れないでください。 モデルとDTOの間にいくつかの重複があるため、これは不幸に思えるかもしれませんが、「デフォルトのセキュリティ」を犠牲にしてDRYを押しすぎないように注意してください。 フィールドの追加に1か所での追加のみが必要な場合、開発者は、フィールドが内部のみであることが意図されていたときに、無意識のうちにAPIでフィールドを公開する可能性があります。 これは、プロセスによって、データストレージとデータ転送を2つの潜在的に異なる要件のセットを持つ2つの別個のコンテキストとして考えるように強制されないためです。
DTOの変更が完了したら、CRUDメソッド操作を( UsersDaoコンストラクターの後に) createから始めて実装できます。
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共用体型( |で示される)を使用して、 PutUserDtoをPatchUserDtoまたは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に、元のオブジェクトではなく、更新後のオブジェクトをそのまま返すように指示します。
削除はマングースと簡潔です:
async removeUserById(userId: string) { return this.User.deleteOne({ _id: userId }).exec(); } 読者は、 Userメンバー関数への各呼び出しがexec()呼び出しにチェーンされていることに気付くかもしれません。 これはオプションですが、デバッグ時にスタックトレースが向上するため、Mongoose開発者はこれをお勧めします。
DAOをコーディングした後、新しい関数に一致するように、前回の記事からusers.service.tsを少し更新する必要があります。 大規模なリファクタリングは必要ありません。3回の修正が必要です。
@@ -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をリファクタリングしたときに、前の記事で作成した構造を維持したため、ほとんどの関数呼び出しはまったく同じままです。 しかし、なぜ例外なのか?
- 上記で示唆したように、
PUTとPATCHの両方にupdateUserById()を使用しています。 (パート2で説明したように、レターの特定のRFCに準拠しようとするのではなく、通常のREST API実装に従います。これは、PUTリクエストが存在しない場合に、新しいエンティティを作成しないことを意味します。このように、バックエンドは、ID生成の制御をAPIコンシューマーに引き渡しません。) - 新しいDAO実装はそれらを利用するため、
limitとpageのパラメーターをgetUsers()に渡します。
ここでの主な構造は、かなり堅牢なパターンです。 たとえば、開発者がMongooseとMongoDBをTypeORMやPostgreSQLなどに交換したい場合に再利用できます。 上記のように、このような置換では、DAOの個々の機能をリファクタリングするだけで、残りのコードと一致するように署名を維持する必要があります。
マングースが支援するRESTAPIのテスト
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データを直接検査できます。 1つの方法は、実行中のDockerコンテナ内から標準のmongoクライアントにアクセスすることです。
sudo docker exec -it toptal-rest-series_mongo_1 mongo そこから、 use api-dbを実行してからdb.users.find().pretty()を実行すると、パスワードを含むすべてのユーザーデータが一覧表示されます。
GUIを好む人は、Robo3Tのような別のMongoDBクライアントをインストールできます。
パスワードプレフィックス( $argon2... )はPHC文字列形式の一部であり、意図的に変更されずに保存されます。Argon2とその一般的なパラメータが言及されているという事実は、ハッカーがなんとか盗んだ場合、元のパスワードを判断するのに役立ちません。データベース。 保存されたパスワードは、ソルティングを使用してさらに強化できます。これは、以下でJWTで使用する手法です。 読者が上記のソルティングを適用し、2人のユーザーが同じパスワードを入力したときに保存された値の違いを調べるための演習として残しておきます。
これで、MongooseがMongoDBデータベースにデータを正常に送信することがわかりました。 しかし、APIコンシューマーが、リクエストで適切なデータをユーザールートに送信することをどのようにして知ることができますか?
エクスプレスバリデーターの追加
フィールド検証を実行するには、いくつかの方法があります。 この記事では、非常に安定していて使いやすく、適切に文書化されているexpress-validatorを使用します。 Mongooseに付属する検証機能を使用することもできますが、express-validatorは追加の機能を提供します。 たとえば、Eメールアドレス用のすぐに使えるバリデーターが付属しています。これは、Mongooseではカスタムバリデーターをコーディングする必要があります。
それをインストールしましょう:
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を使用するようにPOSTルートとPUTルートを更新したことに注目してください。 この関数を使用しているのはこれらのルートだけであるため、その実装はusers.middleware.tsから削除できます。
それでおしまい! 読者はNode.jsを再起動し、お気に入りのRESTクライアントを使用して結果を試して、さまざまな入力をどのように処理するかを確認できます。 さらなる可能性については、エクスプレスバリデーターのドキュメントを調べることを忘れないでください。 この例は、リクエスト検証の開始点にすぎません。
有効なデータは確実にするための1つの側面です。 有効なユーザーとアクションは別のものです。
認証と許可(または「承認」)フロー
Node.jsアプリは、 users/エンドポイントの完全なセットを公開し、APIコンシューマーがユーザーを作成、更新、リストできるようにします。 ただし、すべてのエンドポイントで無制限のパブリックアクセスが許可されます。 これは、ユーザーが互いのデータを変更したり、部外者が公開したくないエンドポイントにアクセスしたりするのを防ぐための一般的なパターンです。
これらの制限には2つの主要な側面があり、どちらも「認証」に短縮されます。 認証とは、要求の送信者に関するものであり、承認とは、要求していることを実行できるかどうかに関するものです。 どちらが議論されているかを常に把握しておくことが重要です。 短い形式がなくても、標準の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を生成するミドルウェアを作成する前に、 users.dao.tsに特別な関数を追加して、パスワードフィールドを取得する必要があります。これは、通常は取得を回避するようにMongooseを設定しているためです。
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を生成できるようにエンドポイントを追加します。 まず、 auth/middleware/auth.middleware.tsに、 AuthMiddlewareと呼ばれるシングルトンとしてミドルウェアを作成しましょう。
いくつかのimportが必要になります:
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'] }); } emailとpasswordがreq.bodyに存在することを確認するミドルウェアについては、後で上記のverifyUserPassword()関数を使用するようにルートを構成するときにexpress-validatorを使用します。
JWTシークレットの保存
JWTを生成するには、JWTシークレットが必要です。これを使用して、生成されたJWTに署名し、クライアントリクエストからの着信JWTを検証します。 JWTシークレットの値をTypeScriptファイル内にハードコーディングするのではなく、別の「環境変数」ファイル.envに保存します。このファイルは、コードリポジトリにプッシュしないでください。
一般的な方法として、リポジトリに.env.exampleファイルを追加して、開発者が実際の.envを作成するときに必要な変数を理解できるようにしました。 この場合、JWTシークレットを文字列として格納するJWT_SECRETという変数が必要です。 この記事の終わりまで待ってリポジトリの最後のブランチを使用する読者は、これらの値をローカルで変更することを忘れないでください。
実際のプロジェクトでは、環境(開発、ステージング、本番など)に応じて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ライブラリをインポートする必要はありません。これは、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 、 refreshToken 、およびaccessTokenの違いは何ですか? *TokenはAPIコンシューマーに送信され、 accessTokenは一般の人々が利用できるもの以外のすべてのリクエストに使用され、 refreshTokenは期限切れのaccessTokenの置き換えをリクエストするために使用されます。 一方、 refreshKeyは、 refreshToken内で暗号化されたsalt変数をrefreshミドルウェアに戻すために使用されます。これについては以下で説明します。
私たちの実装では、jsonwebtokenがトークンの有効期限を処理することに注意してください。 JWTの有効期限が切れた場合、クライアントはもう一度認証する必要があります。
初期Node.jsRESTAPI認証ルート
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を確認しましょう。 3つすべてを新しいファイル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()でこれを行うと、 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 ) // ... ここでのファクトリ呼び出し( (...) )はミドルウェア関数を返すことを忘れないでください。したがって、呼び出しなしで参照されるすべての通常の非ファクトリミドルウェア( () )。
もう1つの一般的な制限は、 userIdを含むすべてのルートについて、同じユーザーまたは管理者のみがアクセスできるようにすることです。
.route(`/users/:userId`) - .all(UsersMiddleware.validateUserExists) + .all( + UsersMiddleware.validateUserExists, + jwtMiddleware.validJWTNeeded, + permissionMiddleware.onlySameUserOrAdminCanDoThisAction + ) .get(UsersController.getUserById) また、各PUTルートとPATCHルートの最後にある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を使用する必要があるため、HTTP401応答を受け取ります。 以前の認証のアクセストークンを試してみましょう。
curl --include --request GET 'localhost:3000/users' \ --header 'Content-Type: application/json' \ --header "Authorization: Bearer $REST_API_EXAMPLE_ACCESS" 今回はHTTP403を取得します。トークンは有効ですが、 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することをお勧めします。 ビジネスロジックでは、 ADMIN_PERMISSION自体では他のユーザーや自分自身にパッチを適用できないと指定されているため、フラグをPAID_PERMISSIONまたはALL_PERMISSIONSのいずれかの数値に設定する必要があることに注意してください。
/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-nodeMochaがアプリケーションを管理してテストを実行し、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()動作によって定義されます。これは、 describe()ブロック内で呼び出されるit()に渡す関数の本体を意味します。
次に、ターミナルで次のコマンドを実行します。
npm run testこれを見る必要があります:
> mocha -r ts-node/register 'test/**/*.test.ts' --unhandled-rejections=strict Index Test ✓ should always pass 1 passing (6ms)すごい! テストライブラリがインストールされ、すぐに使用できます。
テストの合理化
テスト出力をクリーンに保つために、通常のテスト実行中はWinstonリクエストのログを完全に停止する必要があります。 これは、 app.tsの非デバッグelseブランチにすばやく変更して、Mochaのit()関数が存在するかどうかを検出するのと同じくらい簡単です。
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の最後に、 server.listen()の直前にexport defaultを追加します。これは、 listen()がhttp.Serverオブジェクトを返すためです。
スタックを壊していないことを確認するための簡単なnpm run testで、APIをテストする準備が整いました。
私たちの最初のRealRESTAPI自動テスト
ユーザーテストの構成を開始するには、必要なインポートとテスト変数から始めて、 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はハングします。 多くの場合、これを回避するために、常に--exitを--exitしてMochaを呼び出すことをお勧めしますが、(多くの場合、言及されていない)警告があります。 テストスイートが他の理由でハングする場合(テストスイート内の誤って構築された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は、新しいテストが機能していることを示しているはずです。
一連のテスト
次のすべてのit()ブロックをdescribe()ブロック内に追加します。 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); }); これにより、トークンを含むGETリクエストが:userIdルートに送信され、ユーザーデータの応答が最初に送信されたものと一致することが確認されます。
テストでのネスト、スキップ、分離、およびベイリング
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()ブロック内で、テストの作成を続行できます。 残りのテストコードで使用される機能についてはすでに説明したので、リポジトリのこの行から見つけることができます。
Mochaは、テストの開発およびデバッグ中に使用するのに便利ないくつかの機能を提供します。
-
.skip()メソッドを使用すると、単一のテストまたはテストのブロック全体の実行を回避できます。it()がit.skip()に置き換えられると(describedescribe())の場合も同様)、問題の1つまたは複数のテストは実行されませんが、Mochaの最終出力で「保留中」として集計されます。 - さらに一時的に使用する場合、
.only()関数を使用すると、.only(.only()でマークされていないすべてのテストが完全に無視され、「保留中」とマークされることはありません。 -
package.jsonで定義されているmochaの呼び出しでは、コマンドラインパラメーターとして--bailを使用できます。 これが設定されている場合、Mochaは1つのテストが失敗するとすぐにテストの実行を停止します。 テストはカスケードするように設定されているため、これはRESTAPIサンプルプロジェクトで特に役立ちます。 最初のテストだけが壊れた場合、Mochaは、それが原因で現在失敗しているすべての依存(ただし壊れていない)テストについて不平を言う代わりに、正確にそれを報告します。
この時点でnpm run testと、3つの失敗したテストが表示されます。 (当面の間、依存している関数を実装しないままにしておく場合、これらの3つのテストは.skip()の適切な候補になります。)
失敗したテストは、現在アプリから欠落している2つの部分に依存しています。 1つ目は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, ]); 更新する必要のある2番目のファイルは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(); }このような特権昇格機能を追加することはテストには役立ちますが、実際のほとんどの要件には適合しません。 ここで読者のための2つの演習があります:
- 権限が制限されたエンドポイントのテストを許可しながら、ユーザーが自分の
permissionFlagsを変更することをコードで再度禁止する方法を検討してください。 - 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)これで、RESTAPIが期待どおりに機能していることをすばやく確認する方法ができました。
テストのデバッグ(あり)
予期しないテストの失敗に直面している開発者は、テストスイートを実行するときにWinstonとNode.jsの両方のデバッグモジュールを簡単に活用できます。
たとえば、 DEBUG=mquery npm run testを呼び出すことで、どのMongooseクエリが実行されるかに焦点を当てるのは簡単です。 (そのコマンドの途中にexportプレフィックスと&&がないことに注意してください。これにより、環境が後のコマンドに保持されます。)
package.jsonに以前に追加したおかげで、 npm run test-debugを使用してすべてのデバッグ出力を表示することもできます。
これにより、便利な自動テストスイートを備えた、機能し、スケーラブルな、MongoDBでサポートされたRESTAPIができました。 しかし、それでもいくつかの必需品が欠けています。
セキュリティ(すべてのプロジェクトはヘルメットを着用する必要があります)
Express.jsを使用する場合、ドキュメント、特にセキュリティのベストプラクティスは必読です。 少なくとも、追求する価値があります。
- TLSサポートの構成
- レート制限ミドルウェアの追加
- npmの依存関係が安全であることを確認します(読者は
npm auditから始めるか、snykでさらに深く掘り下げたいと思うかもしれません) - ヘルメットライブラリを使用して、一般的なセキュリティの脆弱性から保護します
この最後のポイントは、サンプルプロジェクトに追加するのは簡単です。
npm i --save helmet 次に、 app.tsで、それをインポートして別のapp.use()呼び出しを追加するだけです。
import helmet from 'helmet'; // ... app.use(helmet());そのドキュメントが指摘しているように、ヘルメットは(他のセキュリティ追加のように)特効薬ではありませんが、あらゆる予防策が役立ちます。
Dockerを使用したRESTAPIプロジェクトを含む
このシリーズでは、Dockerコンテナーについて詳しくは説明しませんでしたが、DockerComposeを使用するコンテナーで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公式イメージから始まり、コンテナーでサンプルのRESTAPIをビルドして実行します。 構成はケースごとに変わる可能性がありますが、これらの一般的なデフォルトはプロジェクトで機能します。
イメージをビルドするには、プロジェクトルートでこれを実行します(必要に応じてtag_your_image_hereを置き換えます)。
docker build . -t tag_your_image_here次に、バックエンドを実行する1つの方法(まったく同じテキスト置換を想定)は次のとおりです。
docker run -p 3000:3000 tag_your_image_here この時点で、MongoDBとNode.jsはどちらもDockerを使用できますが、2つの異なる方法で開始する必要があります。 読者がメインのNode.jsアプリをdocker-compose.ymlに追加して、アプリ全体を1つのdocker-composeコマンドで起動できるようにするための演習として残しておきます。
探索するさらなるRESTAPIスキル
この記事では、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' });ように、特定のフラグで許可される能力を簡潔に定義できます。 カスタムミドルウェア機能全体の代わりに。
このプロジェクトでは実用的な自動テストの出発点を提供しましたが、いくつかの重要なトピックは私たちの範囲を超えていました。 読者に次のことをお勧めします。
- ユニットテストを調べて、コンポーネントを個別にテストします。これには、MochaとChaiも使用できます。
- テスト中に実行されないコード行を表示することで、テストスイートのギャップを特定するのに役立つコードカバレッジツールを調べます。 このようなツールを使用すると、読者は必要に応じてサンプルテストを補足できますが、ユーザーが
PATCHを介して/users/:userIdへのアクセス許可を変更できるかどうかなど、欠落しているすべてのシナリオが明らかになるとは限りません。 - 自動テストへの他のアプローチを試してください。 Chaiのビヘイビア駆動開発(BDD)スタイルの
expectインターフェイスを使用しましたが、should()とassertもサポートしています。 Jestのような他のテストライブラリを学ぶことも価値があります。
これらのトピックとは別に、Node.js / TypeScriptRESTAPIを構築する準備ができています。 特に、読者は、標準のユーザーリソースに共通のビジネスロジックを適用するために、より多くのミドルウェアを実装することをお勧めします。 ここでは詳しく説明しませんが、ブロックされていると感じた読者にガイダンスとヒントを提供させていただきます。以下にコメントを残してください。
このプロジェクトの完全なコードは、オープンソースのGitHubリポジトリとして入手できます。
Toptal Engineeringブログでさらに読む:
- Promiseベースのエラー処理のためのExpress.jsルートの使用
