Erstellen einer Node.js/TypeScript-REST-API, Teil 3: MongoDB, Authentifizierung und automatisierte Tests

Veröffentlicht: 2022-03-11

An diesem Punkt unserer Serie zum Erstellen einer Node.js-REST-API mit Express.js und TypeScript haben wir ein funktionierendes Back-End erstellt und unseren Code in Routenkonfiguration, Dienste, Middleware, Controller und Modelle unterteilt. Wenn Sie bereit sind, von dort aus weiterzumachen, klonen Sie das Beispiel-Repository und führen git checkout toptal-article-02 aus.

Eine REST-API mit Mongoose, Authentifizierung und automatisierten Tests

In diesem dritten und letzten Artikel werden wir unsere REST-API weiterentwickeln, indem wir Folgendes hinzufügen:

  • Mongoose , damit wir mit MongoDB arbeiten und unser In-Memory-DAO durch eine echte Datenbank ersetzen können.
  • Authentifizierungs- und Berechtigungsfunktionen, damit API-Konsumenten ein JSON Web Token (JWT) verwenden können, um sicher auf unsere Endpunkte zuzugreifen.
  • Automatisiertes Testen mit Mocha (einem Testframework), Chai (einer Assertionsbibliothek) und SuperTest (einem HTTP-Abstraktionsmodul), um bei der Überprüfung auf Regressionen zu helfen, wenn die Codebasis wächst und sich ändert.

Unterwegs fügen wir Validierungs- und Sicherheitsbibliotheken hinzu, sammeln Erfahrungen mit Docker und schlagen einige weitere Themen, Bibliotheken und Fähigkeiten vor, die Leser beim Erstellen und Erweitern ihrer eigenen REST-APIs gut erkunden können.

Installieren von MongoDB als Container

Beginnen wir damit, unsere In-Memory-Datenbank aus dem vorherigen Artikel durch eine echte zu ersetzen.

Um eine lokale Datenbank für die Entwicklung zu erstellen, könnten wir MongoDB lokal installieren. Unterschiede zwischen Umgebungen (z. B. Betriebssystemverteilungen und -versionen) können jedoch Probleme bereiten. Um dies zu vermeiden, nutzen wir diese Gelegenheit, um ein branchenübliches Tool zu nutzen: den Docker-Container.

Das einzige, was Leser tun müssen, ist Docker zu installieren und dann Docker Compose zu installieren. Nach der Installation sollte das Ausführen von docker -v in einem Terminal eine Docker-Versionsnummer ergeben.

Um nun MongoDB auszuführen, erstellen wir im Stammverzeichnis unseres Projekts eine YAML-Datei namens docker-compose.yml , die Folgendes enthält:

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

Docker Compose ermöglicht es uns, mehrere Container gleichzeitig mit einer Konfigurationsdatei auszuführen. Am Ende dieses Artikels sehen wir uns an, wie wir unser REST-API-Backend auch in Docker ausführen können, aber im Moment verwenden wir es nur, um MongoDB auszuführen, ohne es lokal installieren zu müssen:

 sudo docker-compose up -d

Der Befehl up startet den definierten Container und überwacht den Standard-MongoDB-Port 27017. Der Schalter -d trennt den Befehl vom Terminal. Wenn alles ohne Probleme läuft, sollten wir eine Meldung wie diese sehen:

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

Es wird auch ein neues data im Projektstamm erstellt, also sollten wir eine data in .gitignore .

Wenn wir jetzt unseren MongoDB-Docker-Container herunterfahren müssen, müssen wir nur sudo docker-compose down ausführen und sollten die folgende Ausgabe sehen:

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

Das ist alles, was wir wissen müssen, um unser Node.js/MongoDB-REST-API-Backend zu starten. Stellen wir sicher, dass wir sudo docker-compose up -d verwendet haben, damit MongoDB für unsere App bereit ist.

Verwenden von Mongoose für den Zugriff auf MongoDB

Um mit MongoDB zu kommunizieren, nutzt unser Back-End eine Objektdatenmodellierungsbibliothek (ODM) namens Mongoose. Während Mongoose recht einfach zu bedienen ist, lohnt es sich, die Dokumentation zu lesen, um alle erweiterten Möglichkeiten kennenzulernen, die es für Projekte in der realen Welt bietet.

Um Mongoose zu installieren, verwenden wir Folgendes:

 npm i mongoose

Lassen Sie uns einen Mongoose-Dienst konfigurieren, um die Verbindung zu unserer MongoDB-Instanz zu verwalten. Da dieser Dienst von mehreren Ressourcen gemeinsam genutzt werden kann, fügen wir ihn dem common Ordner unseres Projekts hinzu.

Die Konfiguration ist unkompliziert. Obwohl es nicht unbedingt erforderlich ist, haben wir ein mongooseOptions -Objekt, um die folgenden Mongoose-Verbindungsoptionen anzupassen:

  • useNewUrlParser : Ohne diese Einstellung auf true gibt Mongoose eine Verfallswarnung aus.
  • useUnifiedTopology : Die Mongoose-Dokumentation empfiehlt, dies auf „ true “ zu setzen, um eine neuere Verbindungsverwaltungs-Engine zu verwenden.
  • serverSelectionTimeoutMS : Für die Zwecke der UX dieses Demoprojekts bedeutet eine kürzere Zeit als der Standardwert von 30 Sekunden, dass alle Leser, die vergessen, MongoDB vor Node.js zu starten, früher hilfreiches Feedback dazu sehen, anstatt ein scheinbar nicht reagierendes Backend .
  • useFindAndModify : Wenn Sie dies auf „ false “ setzen, wird auch eine Verfallswarnung vermieden, aber es wird im Abschnitt „Verwerfungen“ der Dokumentation erwähnt und nicht unter den Mongoose-Verbindungsoptionen. Genauer gesagt, dies führt dazu, dass Mongoose eine neuere native MongoDB-Funktion anstelle eines älteren Mongoose-Shims verwendet.

Wenn Sie diese Optionen mit etwas Initialisierungs- und Wiederholungslogik kombinieren, ist hier die endgültige Datei 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();

Achten Sie darauf, den Unterschied zwischen der connect() Funktion von Mongoose und unserer eigenen connectWithRetry() -Dienstfunktion genau zu halten:

  • mongoose.connect() versucht, eine Verbindung zu unserem lokalen MongoDB-Dienst herzustellen (der mit docker-compose ausgeführt wird) und wird nach serverSelectionTimeoutMS Millisekunden ablaufen.
  • MongooseService.connectWithRetry() wiederholt das obige, falls unsere Anwendung gestartet wird, aber der MongoDB-Dienst noch nicht ausgeführt wird. Da es sich um einen Singleton-Konstruktor handelt, wird connectWithRetry() nur einmal ausgeführt, aber es wiederholt den Aufruf von connect() auf unbestimmte Zeit, mit einer Pause von retrySeconds Sekunden, wenn ein Timeout auftritt.

Unser nächster Schritt ist, unsere bisherige In-Memory-Datenbank durch MongoDB zu ersetzen!

Entfernen unserer In-Memory-Datenbank und Hinzufügen von MongoDB

Früher haben wir eine In-Memory-Datenbank verwendet, damit wir uns auf die anderen Module konzentrieren konnten, die wir erstellten. Um stattdessen Mongoose zu verwenden, müssen wir users.dao.ts vollständig umgestalten. Wir brauchen eine weitere import Anweisung, um zu beginnen:

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

Entfernen wir nun alles aus der UsersDao -Klassendefinition außer dem Konstruktor. Wir können damit beginnen, es wieder auszufüllen, indem wir das Benutzerschema für Schema vor dem Konstruktor erstellen:

 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);

Dies definiert unsere MongoDB-Sammlung und fügt ein besonderes Feature hinzu, das unsere In-Memory-Datenbank nicht hatte: Das select: false im password wird dieses Feld ausblenden, wenn wir einen Benutzer erhalten oder alle Benutzer auflisten.

Unser Benutzerschema kommt Ihnen wahrscheinlich bekannt vor, da es unseren DTO-Entitäten ähnlich ist. Der Hauptunterschied besteht darin, dass wir definieren, welche Felder in unserer MongoDB-Sammlung namens Users vorhanden sein sollen, während die DTO-Entitäten definieren, welche Felder in einer HTTP-Anfrage akzeptiert werden sollen.

Dieser Teil unseres Ansatzes ändert sich nicht, daher importieren wir unsere drei DTOs immer noch oben in users.dao.ts . Aber bevor wir unsere CRUD-Methodenoperationen implementieren, werden wir unsere DTOs auf zwei Arten aktualisieren.

DTO-Änderung Nr. 1: id vs. _id

Da Mongoose automatisch ein _id -Feld zur Verfügung stellt, entfernen wir das id -Feld aus den DTOs. Es kommt ohnehin aus den Parametern der Routenanfrage.

Beachten Sie, dass Mongoose-Modelle standardmäßig einen virtuellen id -Getter bereitstellen, daher haben wir diese Option oben mit { id: false } deaktiviert, um Verwirrung zu vermeiden. Aber das hat unseren Verweis auf user.id in unserer Benutzer-Middleware validateSameEmailBelongToSameUser() zerstört – wir brauchen dort stattdessen user._id .

Einige Datenbanken verwenden die Konvention id , andere _id , also gibt es keine perfekte Schnittstelle. Für unser Beispielprojekt mit Mongoose haben wir einfach darauf geachtet, welches wir an welcher Stelle im Code verwenden, aber die Nichtübereinstimmung wird den API-Konsumenten dennoch offengelegt:

Die Pfade von fünf Anforderungstypen: 1. Eine nicht parametrisierte GET-Anforderung an /users durchläuft den listUsers()-Controller und gibt ein Array von Objekten zurück, von denen jedes einen _id-Schlüssel hat. 2. Eine nicht parametrisierte POST-Anforderung an /users geht durch den createUser()-Controller, der einen neu generierten ID-Wert verwendet und ihn in einem Objekt mit einem ID-Schlüssel zurückgibt. 3. Eine nicht parametrisierte Anfrage an /auth durchläuft die VerifyUserPassword()-Middleware, die eine MongoDB-Suche durchführt, um req.body.userId festzulegen; Von dort aus durchläuft die Anfrage den createJWT()-Controller, der req.body.userId verwendet, und gibt ein Objekt mit den Schlüsseln accessToken und refreshToken zurück. 4. Eine nicht parametrisierte Anfrage an /auth/refresh-token geht durch validJWTNeeded() Middleware, die res.locals.jwt.userId setzt, und validRefreshNeeded() Middleware, die res.locals.jwt.userId verwendet und auch a MongoDB-Suche zum Festlegen von req.body.userId; Von dort aus geht der Pfad durch denselben Controller und dieselbe Antwort wie im vorherigen Fall. 5. Eine parametrisierte Anfrage an /users durchläuft die UsersRoutes-Konfiguration, die req.params.userId über Express.js auffüllt, dann validJWTNeeded()-Middleware, die res.locals.jwt.userId festlegt, dann andere Middleware-Funktionen (die req. params.userId, res.locals.jwt.userId oder beides; und/oder führen Sie eine MongoDB-Suche durch und verwenden Sie result._id) und schließlich über eine UsersController-Funktion, die req.body.id verwendet und entweder no body.id oder an zurückgibt Objekt mit einem _id-Schlüssel.
Die Verwendung und Offenlegung von Benutzer-IDs während des gesamten REST-API-Projekts. Beachten Sie, dass die verschiedenen internen Konventionen unterschiedliche Quellen von Benutzer-ID-Daten implizieren: ein direkter Anforderungsparameter, JWT-codierte Daten oder ein frisch abgerufener Datenbankeintrag.

Wir überlassen es den Lesern als Übung, eine der vielen realen Lösungen zu implementieren, die am Ende des Projekts verfügbar sind.

DTO-Änderung Nr. 2: Vorbereitung auf Flags-basierte Berechtigungen

Wir werden auch permissionLevel in permissionFlags in den DTOs umbenennen, um das ausgefeiltere Berechtigungssystem widerzuspiegeln, das wir implementieren werden, sowie die obige Mongoose userSchema Definition.

DTOs: Was ist mit dem DRY-Prinzip?

Denken Sie daran, dass das DTO nur die Felder enthält, die wir zwischen dem API-Client und unserer Datenbank übergeben möchten. Dies mag unglücklich erscheinen, da es einige Überschneidungen zwischen dem Modell und den DTOs gibt, aber hüten Sie sich davor, zu viel für DRY auf Kosten der „Standardsicherheit“ zu treiben. Wenn das Hinzufügen eines Felds nur an einer Stelle erforderlich ist, könnten Entwickler es unwissentlich in der API anzeigen, wenn es nur intern gedacht war. Das liegt daran, dass der Prozess sie nicht dazu zwingt, Datenspeicherung und Datenübertragung als zwei getrennte Kontexte mit zwei potenziell unterschiedlichen Anforderungen zu betrachten.

Nachdem unsere DTO-Änderungen vorgenommen wurden, können wir unsere CRUD-Methodenoperationen (nach dem UsersDao Konstruktor) implementieren, beginnend mit create :

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

Beachten Sie, dass wir alles, was der API-Consumer für permissionFlags über userFields , mit dem Wert 1 überschreiben.

Als nächstes haben wir gelesen , die grundlegenden Funktionen zum Abrufen eines Benutzers nach ID, zum Abrufen eines Benutzers per E-Mail und zum Auflisten von Benutzern mit Paginierung:

 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(); }

Um einen Benutzer zu aktualisieren , reicht eine einzige DAO-Funktion aus, da die zugrunde liegende Mongoose-Funktion findOneAndUpdate() das gesamte Dokument oder nur einen Teil davon aktualisieren kann. Beachten Sie, dass unsere eigene Funktion userFields entweder als PatchUserDto oder als PutUserDto und einen TypeScript-Vereinigungstyp verwendet (gekennzeichnet durch | ):

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

Die Option new: true weist Mongoose an, das Objekt so zurückzugeben, wie es nach der Aktualisierung ist, und nicht so, wie es ursprünglich war.

Löschen ist prägnant mit Mongoose:

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

Leser werden vielleicht bemerken, dass jeder der Aufrufe von User -Member-Funktionen mit einem exec() Aufruf verkettet ist. Dies ist optional, aber die Mongoose-Entwickler empfehlen es, weil es beim Debuggen bessere Stacktraces liefert.

Nachdem wir unser DAO codiert haben, müssen wir unsere users.service.ts aus unserem letzten Artikel leicht aktualisieren, um sie an die neuen Funktionen anzupassen. Es ist kein großes Refactoring erforderlich, nur drei Ausbesserungen:

 @@ -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); }

Die meisten Funktionsaufrufe bleiben genau gleich, da wir beim Refactoring von UsersDao die Struktur beibehalten haben, die wir im vorherigen Artikel erstellt haben. Aber warum die Ausnahmen?

  • Wir verwenden updateUserById() sowohl für PUT als auch für PATCH , wie wir oben angedeutet haben. (Wie in Teil 2 erwähnt, folgen wir typischen REST-API-Implementierungen, anstatt zu versuchen, bestimmte RFCs buchstabengetreu einzuhalten. Dies bedeutet unter anderem, dass PUT Anforderungen keine neuen Entitäten erstellen, wenn sie nicht vorhanden sind. Auf diese Weise unser Back-End übergibt die Kontrolle über die ID-Generierung nicht an API-Konsumenten.)
  • Wir übergeben die limit und Seitenparameter an page getUsers() , da unsere neue DAO-Implementierung sie verwenden wird.

Die Hauptstruktur hier ist ein ziemlich robustes Muster. Es kann beispielsweise wiederverwendet werden, wenn Entwickler Mongoose und MongoDB gegen etwas wie TypeORM und PostgreSQL austauschen möchten. Wie oben würde ein solcher Ersatz lediglich ein Refactoring der einzelnen Funktionen des DAO erfordern, während ihre Signaturen beibehalten werden, damit sie mit dem Rest des Codes übereinstimmen.

Testen unserer Mongoose-gestützten REST-API

Lassen Sie uns das API-Backend mit npm start . Wir werden dann versuchen, einen Benutzer zu erstellen:

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

Das Antwortobjekt enthält eine neue Benutzer-ID:

 { "id": "7WYQoVZ3E" }

Wie im vorherigen Artikel werden die verbleibenden manuellen Tests mithilfe von Umgebungsvariablen einfacher:

 REST_API_EXAMPLE_

Das Aktualisieren des Benutzers sieht folgendermaßen aus:

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

Die Antwort sollte mit HTTP/1.1 204 No Content beginnen. (Ohne den Schalter --include würde keine Antwort ausgegeben, was unserer Implementierung entspricht.)

Wenn wir jetzt den Benutzer dazu bringen, die obigen Updates zu überprüfen … :

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

… die Antwort zeigt die erwarteten Felder, einschließlich des oben besprochenen _id -Felds:

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

Es gibt auch ein spezielles Feld, __v , das von Mongoose für die Versionierung verwendet wird; er wird jedes Mal erhöht, wenn dieser Datensatz aktualisiert wird.

Als nächstes listen wir die Benutzer auf:

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

Die erwartete Antwort ist dieselbe, nur in [] .

Nachdem unser Passwort sicher gespeichert ist, stellen wir sicher, dass wir den Benutzer entfernen können:

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

Wir erwarten erneut eine 204-Antwort.

Leser fragen sich vielleicht, ob das Passwortfeld richtig funktioniert hat, da unser select: false in der Mongoose- Schema -Definition es wie beabsichtigt aus unserer GET -Ausgabe ausgeblendet hat. Wiederholen wir unseren anfänglichen POST , um erneut einen Benutzer zu erstellen, und überprüfen Sie ihn dann. (Vergessen Sie nicht, die neue ID für später zu speichern.)

Versteckte Passwörter und direktes Daten-Debugging mit MongoDB-Containern

Um zu überprüfen, ob Passwörter sicher gespeichert sind (dh gehasht und nicht im Klartext), können Entwickler MongoDB-Daten direkt überprüfen. Eine Möglichkeit besteht darin, vom laufenden Docker-Container aus auf den Standard- mongo -CLI-Client zuzugreifen:

 sudo docker exec -it toptal-rest-series_mongo_1 mongo

Von dort aus führt die Ausführung von use api-db gefolgt von db.users.find().pretty() alle Benutzerdaten auf, einschließlich Passwörter.

Wer eine GUI bevorzugt, kann einen separaten MongoDB-Client wie Robo 3T installieren:

Eine linke Seitenleiste zeigt Datenbankverbindungen, die jeweils eine Hierarchie von Dingen wie Datenbanken, Funktionen und Benutzern enthalten. Der Hauptbereich enthält Registerkarten zum Ausführen von Abfragen. Die aktuelle Registerkarte ist mit der api-db-Datenbank von localhost:27017 mit der Abfrage "db.getCollection('users').find({})" mit einem Ergebnis verbunden. Das Ergebnis hat vier Felder: _id, Passwort, E-Mail und __v. Das Passwortfeld beginnt mit „$argon2$i$v=19$m=4096,t=3,p=1$“ und endet mit einem Salt und einem Hash, getrennt durch ein Dollarzeichen und in Base 64 codiert.
Untersuchen von MongoDB-Daten direkt mit Robo 3T.

Das Passwort-Präfix ( $argon2... ) ist Teil des PHC-String-Formats und wird absichtlich unverändert gespeichert: Die Tatsache, dass Argon2 und seine allgemeinen Parameter erwähnt werden, würde einem Hacker nicht helfen, ursprüngliche Passwörter zu ermitteln, wenn es ihnen gelänge, diese zu stehlen Datenbank. Das gespeicherte Passwort kann durch Salting weiter gestärkt werden, eine Technik, die wir unten mit JWTs verwenden werden. Wir überlassen es dem Leser als Übung, obiges Salting anzuwenden und den Unterschied zwischen gespeicherten Werten zu untersuchen, wenn zwei Benutzer dasselbe Passwort eingeben.

Wir wissen jetzt, dass Mongoose erfolgreich Daten an unsere MongoDB-Datenbank sendet. Aber woher wissen wir, dass unsere API-Konsumenten entsprechende Daten in ihren Anfragen an unsere Benutzerrouten senden?

Express-Validator hinzufügen

Es gibt mehrere Möglichkeiten, die Feldvalidierung durchzuführen. In diesem Artikel verwenden wir den Express-Validator, der ziemlich stabil, einfach zu bedienen und anständig dokumentiert ist. Während wir die mit Mongoose gelieferte Validierungsfunktion verwenden könnten, bietet express-validator zusätzliche Funktionen. Zum Beispiel wird es mit einem sofort einsatzbereiten Validator für E-Mail-Adressen geliefert, für den wir in Mongoose einen benutzerdefinierten Validator codieren müssten.

Installieren wir es:

 npm i express-validator

Um die zu validierenden Felder festzulegen, verwenden wir die body() -Methode, die wir in unsere users.routes.config.ts . Die Methode body() validiert Felder und generiert im Fehlerfall eine Fehlerliste, die im Objekt express.Request gespeichert wird.

Wir brauchen dann unsere eigene Middleware, um die Fehlerliste zu überprüfen und zu nutzen. Da diese Logik wahrscheinlich für verschiedene Routen gleich funktioniert, erstellen wir common/middleware/body.validation.middleware.ts mit dem Folgenden:

 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();

Damit sind wir bereit, alle Fehler zu behandeln, die von der Funktion body() generiert werden. Fügen wir Folgendes wieder in users.routes.config.ts :

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

Jetzt können wir unsere Routen wie folgt aktualisieren:

 @@ -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,

Achten Sie darauf, BodyValidationMiddleware.verifyBodyFieldsErrors in jeder Route nach allen vorhandenen body() Zeilen hinzuzufügen, da sonst keine davon eine Auswirkung hat.

Beachten Sie, wie wir unsere POST und PUT -Routen aktualisiert haben, um express-validator anstelle unserer selbst entwickelten Funktion validateRequiredUserBodyFields zu verwenden. Da diese Routen die einzigen waren, die diese Funktion verwendeten, kann ihre Implementierung aus users.middleware.ts gelöscht werden.

Das ist es! Leser können Node.js neu starten und das Ergebnis mit ihren bevorzugten REST-Clients ausprobieren, um zu sehen, wie es verschiedene Eingaben verarbeitet. Vergessen Sie nicht, die Express-Validator-Dokumentation nach weiteren Möglichkeiten zu durchsuchen; Unser Beispiel ist nur ein Ausgangspunkt für die Anforderungsvalidierung.

Gültige Daten sind ein Aspekt, der sichergestellt werden muss; gültige Benutzer und Aktionen sind eine andere.

Authentifizierung vs. Berechtigungen (oder „Autorisierung“) Flow

Unsere Node.js-App stellt einen vollständigen Satz von users/ Endpunkten bereit, sodass API-Konsumenten Benutzer erstellen, aktualisieren und auflisten können. Aber jeder Endpunkt erlaubt unbegrenzten öffentlichen Zugriff. Es ist ein gängiges Muster, um Benutzer daran zu hindern, die Daten anderer zu ändern, und Außenstehende daran zu hindern, auf Endpunkte zuzugreifen, die wir nicht öffentlich machen möchten.

Bei diesen Einschränkungen gibt es zwei Hauptaspekte, die beide mit „auth“ abgekürzt werden. Bei der Authentifizierung geht es darum, von wem die Anfrage stammt, und bei der Autorisierung geht es darum, ob sie das tun dürfen, was sie anfordern. Es ist wichtig, sich darüber im Klaren zu sein, welches diskutiert wird. Auch ohne Kurzformen schaffen es Standard-HTTP-Antwortcodes, das Problem zu verwirren: 401 Unauthorized sich auf die Authentifizierung und 403 Forbidden auf die Autorisierung. Wir irren uns auf der Seite von „auth“, das für „Authentifizierung“ in Modulnamen steht, und verwenden „Berechtigungen“ für Autorisierungsangelegenheiten.

Auch ohne Kurzformen schaffen es Standard-HTTP-Antwortcodes, das Problem zu verwirren: 401 Unauthorized sich auf die Authentifizierung und 403 Forbidden auf die Autorisierung.

Twittern

Es gibt viele Authentifizierungsansätze zu erkunden, einschließlich Drop-in-Drittanbieter-Identitätsanbietern wie Auth0. In diesem Artikel haben wir eine einfache, aber skalierbare Implementierung ausgewählt. Es basiert auf JWTs.

Ein JWT besteht aus verschlüsseltem JSON mit einigen nicht mit der Authentifizierung zusammenhängenden Metadaten, die in unserem Fall die E-Mail-Adresse des Benutzers und Berechtigungs-Flags enthalten. Der JSON enthält auch ein Geheimnis, um die Integrität der Metadaten zu überprüfen.

Die Idee ist, von Clients zu verlangen, dass sie in jeder nicht öffentlichen Anfrage ein gültiges JWT senden. Auf diese Weise können wir überprüfen, ob der Client kürzlich gültige Anmeldeinformationen für den Endpunkt hatte, den er verwenden möchte, ohne die Anmeldeinformationen bei jeder Anfrage selbst über das Kabel senden zu müssen.

Aber wo passt das in unsere Beispiel-API-Codebasis? Ganz einfach: Mit Middleware können wir unsere Routenkonfiguration verwenden!

Hinzufügen des Authentifizierungsmoduls

Lassen Sie uns zunächst konfigurieren, was in unseren JWTs enthalten sein wird. Hier beginnen wir mit der Verwendung des Felds permissionFlags aus unserer Benutzerressource, aber nur, weil es praktische Metadaten zum Verschlüsseln innerhalb von JWTs sind – nicht, weil JWTs von Natur aus irgendetwas mit feinkörniger Berechtigungslogik zu tun haben.

Bevor wir JWT-generierende Middleware erstellen, müssen wir users.dao.ts eine spezielle Funktion hinzufügen, um das Passwortfeld abzurufen, da wir Mongoose so einstellen, dass es normalerweise das Abrufen vermeidet:

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

Und in der users.service.ts :

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

Lassen Sie uns nun einen Authentifizierungsordner in unserem Projektstamm erstellen – wir fügen einen Endpunkt hinzu, damit API-Consumer auth generieren können. Lassen Sie uns zunächst eine Middleware dafür unter auth/middleware/auth.middleware.ts als Singleton namens AuthMiddleware .

Wir brauchen einige import s:

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

In der AuthMiddleware -Klasse erstellen wir eine Middleware-Funktion, um zu prüfen, ob ein API-Benutzer gültige Anmeldedaten in seine Anfrage eingefügt hat:

 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'] }); }

Was die Middleware betrifft, um sicherzustellen, dass E- email und password in req.body vorhanden sind, verwenden wir express-validator, wenn wir die Route später so konfigurieren, dass sie die obige Funktion verifyUserPassword() verwendet.

Speichern von JWT-Secrets

Um ein JWT zu generieren, benötigen wir ein JWT-Secret, mit dem wir unsere generierten JWTs signieren und auch eingehende JWTs von Clientanforderungen validieren. Anstatt den Wert des JWT-Geheimnisses in einer TypeScript-Datei fest zu codieren, speichern wir ihn in einer separaten „Umgebungsvariablen“-Datei, .env , die niemals in ein Code-Repository verschoben werden sollte .

Wie üblich haben wir dem Repository eine .env.example -Datei hinzugefügt, um Entwicklern zu helfen, zu verstehen, welche Variablen beim Erstellen der echten .env erforderlich sind. In unserem Fall möchten wir, dass eine Variable namens JWT_SECRET unser JWT-Geheimnis als Zeichenfolge speichert. Leser, die bis zum Ende dieses Artikels warten und den letzten Zweig des Repos verwenden, müssen daran denken, diese Werte lokal zu ändern .

Projekte in der realen Welt müssen insbesondere den Best Practices von JWT folgen, indem sie JWT-Secrets je nach Umgebung (Entwicklung, Staging, Produktion usw.) unterscheiden.

Unsere .env -Datei (im Stammverzeichnis des Projekts) muss das folgende Format verwenden, sollte aber nicht denselben geheimen Wert behalten:

 JWT_SECRET=My!@!Se3cr8tH4sh3

Eine einfache Möglichkeit, diese Variablen in unsere App zu laden, ist die Verwendung einer Bibliothek namens dotenv:

 npm i dotenv

Die einzige erforderliche Konfiguration besteht darin, die Funktion dotenv.config() , sobald wir unsere Anwendung starten. Ganz oben in app.ts fügen wir hinzu:

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

Der Authentifizierungscontroller

Die letzte JWT-Generierungsvoraussetzung ist die Installation der jsonwebtoken-Bibliothek und ihrer TypeScript-Typen:

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

Lassen Sie uns nun den /auth -Controller unter auth/controllers/auth.controller.ts . Wir müssen die dotenv-Bibliothek hier nicht importieren, da der Import in app.ts den Inhalt der .env -Datei über das globale Node.js-Objekt namens process in der gesamten App verfügbar macht:

 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();

Die Bibliothek jsonwebtoken signiert ein neues Token mit unserem jwtSecret . Wir generieren auch ein Salt und einen Hash mit dem Node.js-nativen crypto und verwenden sie dann, um ein refreshToken zu erstellen, mit dem API-Verbraucher das aktuelle JWT aktualisieren können – ein Setup, das für eine App besonders gut geeignet ist skalieren können.

Was ist der Unterschied zwischen refreshKey , refreshToken und accessToken ? Die *Token s werden an unsere API-Konsumenten gesendet, mit der Idee, dass das accessToken für alle Anfragen verwendet wird, die über das hinausgehen, was der allgemeinen Öffentlichkeit zur Verfügung steht, und refreshToken wird verwendet, um einen Ersatz für ein abgelaufenes accessToken . Der refreshKey hingegen wird verwendet, um die salt -Variable – verschlüsselt innerhalb des refreshToken – zurück an unsere Refresh-Middleware zu übergeben, auf die wir weiter unten eingehen werden.

Beachten Sie, dass unsere Implementierung für uns den jsonwebtoken-Handle-Token-Ablauf hat. Wenn das JWT abgelaufen ist, muss sich der Client erneut authentifizieren.

Anfängliche Node.js-REST-API-Authentifizierungsroute

Lassen Sie uns jetzt den Endpunkt unter 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; } }

Und vergessen Sie nicht, es zu unserer app.ts -Datei hinzuzufügen:

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

Wir sind bereit, Node.js neu zu starten und jetzt zu testen, und stellen sicher, dass wir mit den Anmeldeinformationen übereinstimmen, die wir zuvor zum Erstellen unseres Testbenutzers verwendet haben:

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

Die Antwort wird ungefähr so ​​​​aussehen:

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

Lassen Sie uns wie zuvor einige Umgebungsvariablen der Einfachheit halber mit den obigen Werten festlegen:

 REST_API_EXAMPLE_ACCESS="put_your_access_token_here" REST_API_EXAMPLE_REFRESH="put_your_refresh_token_here"

Toll! Wir haben unser Zugriffstoken und ein Aktualisierungstoken, aber wir brauchen eine Middleware, die etwas Nützliches damit machen kann.

JWT-Middleware

Wir benötigen einen neuen TypeScript-Typ, um die JWT-Struktur in ihrer decodierten Form zu verarbeiten. Erstellen Sie common/types/jwt.ts mit folgendem Inhalt:

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

Lassen Sie uns Middleware-Funktionen implementieren, um das Vorhandensein eines Aktualisierungstokens zu prüfen, ein Aktualisierungstoken zu verifizieren und ein JWT zu verifizieren. Alle drei können in eine neue Datei gehen, 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.

User Permissions

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); } }; }

Ein individuellerer Fall ist, dass die einzigen Benutzer, die auf einen bestimmten Benutzerdatensatz zugreifen können sollten, derselbe Benutzer oder ein Administrator sind:

 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(); } } }

Wir fügen ein letztes Stück Middleware hinzu, diesmal in 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(); } }

Und da die obige Funktion von res.locals.user abhängt, können wir diesen Wert in validateUserExists() vor dem Aufruf von next() füllen:

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

Wenn Sie dies in validateUserExists() tun, wird es in validateSameEmailBelongToSameUser() unnötig. Wir können unseren Datenbankaufruf dort eliminieren und ihn durch den Wert ersetzen, auf den wir zählen können, dass er in res.locals zwischengespeichert wird:

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

Jetzt können wir unsere Berechtigungslogik in users.routes.config.ts integrieren.

Berechtigungen erforderlich

Zuerst importieren wir unsere neue Middleware und enum :

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

Wir möchten, dass auf die Benutzerliste nur durch Anfragen von jemandem mit Administratorberechtigungen zugegriffen werden kann, aber wir möchten dennoch, dass die Möglichkeit besteht, einen neuen Benutzer öffentlich zu erstellen, da die normalen UX-Erwartungen fließen. Lassen Sie uns zuerst die Benutzerliste mit unserer Factory-Funktion vor unserem Controller einschränken:

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

Denken Sie daran, dass der Factory-Aufruf hier ( (...) ) eine Middleware-Funktion zurückgibt – daher wird auf die gesamte normale, nicht werksseitige Middleware ohne Aufruf ( () ) verwiesen.

Eine weitere häufige Einschränkung ist, dass für alle Routen, die eine userId , nur derselbe Benutzer oder ein Administrator Zugriff haben soll:

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

Wir verhindern auch, dass Benutzer ihre Berechtigungen eskalieren, indem UsersMiddleware.userCantChangePermission direkt vor der UsersController Funktionsreferenz am Ende jeder der PUT und PATCH Routen hinzufügen.

Aber nehmen wir weiter an, dass unsere REST-API-Geschäftslogik nur Benutzern mit PAID_PERMISSION erlaubt, ihre Informationen überhaupt zu aktualisieren. Dies kann mit den geschäftlichen Anforderungen anderer Projekte übereinstimmen oder auch nicht: Es dient nur dazu, den Unterschied zwischen bezahlter und kostenloser Genehmigung zu testen.

Dies kann durch Hinzufügen eines weiteren Generatoraufrufs nach jeder der gerade hinzugefügten userCantChangePermission Referenzen erfolgen:

 permissionMiddleware.permissionFlagRequired( PermissionFlag.PAID_PERMISSION ),

Damit sind wir bereit, Node.js neu zu starten und es auszuprobieren.

Manuelle Berechtigungsprüfung

Um die Routen zu testen, versuchen GET , die Benutzerliste ohne Zugriffstoken abzurufen:

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

Wir erhalten eine HTTP 401-Antwort, weil wir ein gültiges JWT verwenden müssen. Versuchen wir es mit einem Zugriffstoken aus unserer früheren Authentifizierung:

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

Dieses Mal erhalten wir ein HTTP 403. Unser Token ist gültig, aber wir dürfen diesen Endpunkt nicht verwenden, da wir nicht über ADMIN_PERMISSION verfügen.

Wir sollten es jedoch nicht brauchen, um unseren eigenen Benutzerdatensatz zu GET :

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

Die Antwort:

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

Im Gegensatz dazu sollte der Versuch, unseren eigenen Benutzerdatensatz zu aktualisieren , fehlschlagen, da unser Berechtigungswert 1 ist (nur 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" }'

Die Antwort ist wie erwartet 403.

Als Leserübung empfehle ich, die Benutzer- permissionFlags in der lokalen Datenbank zu ändern und einen neuen Beitrag zu /auth zu erstellen (um ein Token mit den neuen permissionFlags zu generieren), und dann erneut zu versuchen, den Benutzer zu PATCH . Denken Sie daran, dass Sie die Flags auf den numerischen Wert von entweder PAID_PERMISSION oder ALL_PERMISSIONS setzen müssen, da unsere Geschäftslogik angibt, dass ADMIN_PERMISSION allein es Ihnen nicht erlaubt, andere Benutzer oder sogar sich selbst zu patchen.

Das Erfordernis eines neuen Beitrags an /auth bringt ein Sicherheitsszenario hervor, das es wert ist, im Hinterkopf behalten zu werden. Wenn ein Websitebesitzer die Berechtigungen eines Benutzers ändert – beispielsweise um zu versuchen, einen sich schlecht benehmenden Benutzer zu sperren – wird dies für den Benutzer erst bei der nächsten JWT-Aktualisierung wirksam. Das liegt daran, dass die Berechtigungsprüfung die JWT-Daten selbst verwendet, um einen zusätzlichen Datenbanktreffer zu vermeiden.

Dienste wie Auth0 können helfen, indem sie eine automatische Token-Rotation anbieten, aber Benutzer werden in der Zeit zwischen den Rotationen immer noch unerwartetes App-Verhalten erleben, wie kurz diese normalerweise auch sein mag. Um dies abzumildern, müssen Entwickler darauf achten, Aktualisierungstoken als Reaktion auf Berechtigungsänderungen aktiv zu widerrufen.


Während der Arbeit an einer REST-API können Entwickler sich vor potenziellen Fehlern schützen, indem sie regelmäßig einen Stapel von cURL-Befehlen erneut ausführen. Das ist aber langsam und fehleranfällig und wird schnell mühsam.

Automatisiertes Testen

Wenn eine API wächst, wird es schwierig, die Softwarequalität aufrechtzuerhalten, insbesondere bei sich häufig ändernder Geschäftslogik. Um API-Fehler so weit wie möglich zu reduzieren und neue Änderungen vertrauensvoll bereitzustellen, ist es üblich, eine Testsuite für das Front-End und/oder Back-End einer App zu haben.

Anstatt sich mit dem Schreiben von Tests und testbarem Code zu beschäftigen, zeigen wir einige grundlegende Mechanismen und stellen eine funktionierende Testsuite zur Verfügung, auf der die Leser aufbauen können.

Umgang mit Testdatenresten

Bevor wir automatisieren, lohnt es sich, darüber nachzudenken, was mit Testdaten passiert.

Wir verwenden Docker Compose, um unsere lokale Datenbank auszuführen, und erwarten, diese Datenbank für die Entwicklung und nicht als Live-Produktionsdatenquelle zu verwenden. Die Tests, die wir hier ausführen, wirken sich auf die lokale Datenbank aus, indem sie jedes Mal, wenn wir sie ausführen, einen neuen Satz von Testdaten hinterlassen. Dies sollte in den meisten Fällen kein Problem sein, aber wenn doch, überlassen wir es den Lesern, docker-compose.yml zu ändern, um zu Testzwecken eine neue Datenbank zu erstellen.

In der realen Welt führen Entwickler oft automatisierte Tests als Teil einer Continuous-Integration-Pipeline durch. Dazu wäre es sinnvoll, auf Pipelineebene eine Methode zum Erstellen einer temporären Datenbank für jeden Testlauf zu konfigurieren.

Wir verwenden Mocha, Chai und SuperTest, um unsere Tests zu erstellen:

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

Mocha wird unsere Anwendung verwalten und die Tests ausführen, Chai wird einen besser lesbaren Testausdruck ermöglichen und SuperTest wird End-to-End (E2E)-Tests erleichtern, indem es unsere API so aufruft, wie es ein REST-Client tun würde.

Wir müssen unsere Skripte unter package.json :

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

Dadurch können wir Tests in einem von uns erstellten Ordner mit dem Namen test ausführen.

Ein Metatest

Um unsere Testinfrastruktur auszuprobieren, erstellen wir eine Datei, test/app.test.ts :

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

Die Syntax hier mag ungewöhnlich erscheinen, ist aber korrekt. Wir definieren Tests, indem wir Verhalten innerhalb von it() - Blöcken expect() – womit wir den Rumpf einer Funktion meinen, die wir an it() übergeben –, die innerhalb describe() -Blöcken aufgerufen werden.

Jetzt führen wir am Terminal Folgendes aus:

 npm run test

Das sollten wir sehen:

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

Toll! Unsere Testbibliotheken sind installiert und einsatzbereit.

Testen optimieren

Um die Testausgabe sauber zu halten, möchten wir die Winston-Anforderungsprotokollierung während normaler Testläufe vollständig stummschalten. Das ist so einfach wie ein schneller Wechsel zu unserem Nicht-Debug-Verzweigung else in app.ts , um zu erkennen, ob die Funktion it() von Mocha vorhanden ist:

 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 + } }

Ein letzter Schliff, den wir hinzufügen müssen, ist der Export unserer app.ts , die von unseren Tests verwendet werden sollen. Am Ende von app.ts fügen wir export default kurz vor server.listen() , da listen() unser Node.js http.Server Objekt zurückgibt.

Mit einem schnellen npm run test zu überprüfen, ob wir den Stack nicht beschädigt haben, sind wir jetzt bereit, unsere API zu testen.

Unser erster echter automatisierter REST-API-Test

Um mit der Konfiguration unserer Benutzertests zu beginnen, erstellen wir test/users/users.test.ts , beginnend mit den erforderlichen Importen und Testvariablen:

 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';

Als Nächstes erstellen wir einen äußersten describe() -Block mit einigen Setup- und Teardown-Definitionen:

 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); }); }); });

Die Funktionen, die wir an before() und after() übergeben, werden vor und nach all den Tests aufgerufen, die wir definieren werden, indem wir it() innerhalb desselben describe() -Blocks aufrufen. Die an after() übergebene Funktion nimmt einen Rückruf, done , von dem wir sicherstellen, dass er nur aufgerufen wird, wenn wir sowohl die App als auch ihre Datenbankverbindung bereinigt haben.

Hinweis: Ohne unsere after() -Taktik bleibt Mocha auch nach erfolgreichem Testabschluss hängen. Der Rat lautet oft, Mocha einfach immer mit --exit , um dies zu vermeiden, aber es gibt eine (oft unerwähnte) Einschränkung. Wenn die Testsuite aus anderen Gründen hängenbleibt – wie ein falsch konstruiertes Promise in der Testsuite oder der App selbst – dann wartet Mocha mit --exit nicht und meldet trotzdem Erfolg, was das Debuggen um eine subtile Komplikation erweitert.

Jetzt sind wir bereit, einzelne E2E-Tests innerhalb des describe() -Blocks hinzuzufügen:

 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; });

Diese erste Funktion erstellt einen neuen Benutzer für uns – einen eindeutigen, da unsere Benutzer-E-Mail zuvor mit shortid generiert wurde. Die request enthält einen SuperTest-Agenten, mit dem wir HTTP-Anforderungen an unsere API stellen können. Wir machen sie mit await , weshalb die Funktion, die wir an it() übergeben, async sein muss. Wir verwenden dann expect() von Chai, um verschiedene Aspekte des Ergebnisses zu testen.

Ein npm run test an dieser Stelle sollte zeigen, dass unser neuer Test funktioniert.

Eine Kette von Tests

Wir fügen alle folgenden it() Blöcke in unseren describe() -Block ein. Wir müssen sie in der angegebenen Reihenfolge hinzufügen, damit sie mit Variablen funktionieren, die wir mutieren, wie zum Beispiel 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; });

Hier holen wir ein neues Zugriffs- und Aktualisierungstoken für unseren neu erstellten Benutzer.

 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); });

Dadurch wird eine tokentragende GET -Anforderung an die Route :userId , um zu überprüfen, ob die Benutzerdatenantwort mit dem übereinstimmt, was wir ursprünglich gesendet haben.

Verschachteln, Überspringen, Isolieren und Bailing von Tests

In Mocha können it() Blöcke auch ihre eigenen describe() -Blöcke enthalten, also verschachteln wir unseren nächsten Test in einem anderen describe() -Block. Dadurch wird unsere Kaskade von Abhängigkeiten in der Testausgabe deutlicher, wie wir am Ende zeigen werden.

 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); }); });

Effektives Testen umfasst nicht nur das, was wir als funktionierend erwarten, sondern auch das, wovon wir erwarten, dass es fehlschlägt. Hier versuchen wir, alle Benutzer aufzulisten und erwarten eine 403-Antwort, da unser Benutzer (mit Standardberechtigungen) diesen Endpunkt nicht verwenden darf.

Innerhalb dieses neuen describe() -Blocks können wir mit dem Schreiben von Tests fortfahren. Da wir die im Rest des Testcodes verwendeten Funktionen bereits besprochen haben, finden Sie ihn ab dieser Zeile im Repo.

Mocha bietet einige Funktionen, die beim Entwickeln und Debuggen von Tests bequem zu verwenden sind:

  1. Die Methode .skip() kann verwendet werden, um die Ausführung eines einzelnen Tests oder eines ganzen Testblocks zu vermeiden. Wenn it() durch it.skip() ersetzt wird (ebenso für describe() ), werden der oder die betreffenden Tests nicht ausgeführt, sondern in der endgültigen Ausgabe von Mocha als „ausstehend“ aufgeführt.
  2. Für eine noch vorübergehendere Verwendung bewirkt die Funktion .only() , dass alle nicht .only() markierten Tests vollständig ignoriert werden, und führt nicht dazu, dass irgendetwas als „ausstehend“ markiert wird.
  3. Der Aufruf von mocha , wie in package.json definiert, kann --bail als Befehlszeilenparameter verwenden. Wenn dies eingestellt ist, stoppt Mocha die Ausführung von Tests, sobald ein Test fehlschlägt. Dies ist in unserem REST-API-Beispielprojekt besonders nützlich, da die Tests kaskadierend eingerichtet sind; wenn nur der erste Test kaputt ist, meldet Mocha genau das, anstatt sich über alle abhängigen (aber nicht kaputten) Tests zu beschweren, die jetzt deswegen fehlschlagen.

Wenn wir zu diesem Zeitpunkt unsere gesamte Testbatterie mit npm run test , sehen wir drei fehlgeschlagene Tests. (Wenn wir die Funktionen, auf die sie sich verlassen, vorerst nicht implementiert lassen würden, wären diese drei Tests gute Kandidaten für .skip() .)

Die fehlgeschlagenen Tests beruhen auf zwei Teilen, die derzeit in unserer App fehlen. Die erste befindet sich in 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, ]);

Die zweite Datei, die wir aktualisieren müssen, ist users.controller.ts , da wir gerade auf eine Funktion verwiesen haben, die dort nicht vorhanden ist. Wir müssen import { PatchUserDto } from '../dto/patch.user.dto'; ganz oben und die fehlende Funktion für die Klasse:

 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(); }

Das Hinzufügen solcher Rechteausweitungsfähigkeiten ist zum Testen nützlich, wird aber den meisten realen Anforderungen nicht gerecht. Hier gibt es zwei Übungen für den Leser:

  1. Erwägen Sie Möglichkeiten, den Code erneut zu veranlassen, Benutzern das Ändern ihrer eigenen permissionFlags zu verbieten, während weiterhin das Testen von Endpunkten mit eingeschränkten Berechtigungen zugelassen wird.
  2. Erstellen und implementieren Sie Geschäftslogik (und entsprechende Tests) dafür, wie sich permissionFlags über die API ändern können sollen . (Hier gibt es ein Henne-Ei-Puzzle: Wie erhält ein bestimmter Benutzer überhaupt die Erlaubnis, Berechtigungen zu ändern?)

Damit sollte npm run test nun erfolgreich mit einer gut formatierten Ausgabe wie dieser abgeschlossen werden:

 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)

Wir haben jetzt eine Möglichkeit, schnell zu überprüfen, ob unsere REST-API wie erwartet funktioniert.

Debuggen (mit) Tests

Entwickler, die mit unerwarteten Testfehlern konfrontiert sind, können beim Ausführen der Testsuite problemlos sowohl das Debug-Modul von Winston als auch von Node.js nutzen.

Beispielsweise ist es einfach, sich darauf zu konzentrieren, welche Mongoose-Abfragen ausgeführt werden, indem DEBUG=mquery npm run test . (Beachten Sie, dass diesem Befehl das export und && in der Mitte fehlen, wodurch die Umgebung für spätere Befehle bestehen bleiben würde.)

Dank unserer früheren Ergänzung zu package.json ist es auch möglich, die gesamte Debug-Ausgabe mit npm run test-debug anzuzeigen.

Damit haben wir eine funktionierende, skalierbare, von MongoDB unterstützte REST-API mit einer praktischen automatisierten Testsuite. Aber es fehlen noch einige wesentliche Dinge.

Sicherheit (Alle Projekte sollten einen Helm tragen)

Wenn Sie mit Express.js arbeiten, ist die Dokumentation ein Muss, insbesondere die Best Practices für die Sicherheit. Es lohnt sich mindestens, Folgendes zu verfolgen:

  • Konfigurieren der TLS-Unterstützung
  • Hinzufügen von ratenbegrenzender Middleware
  • Sicherstellen, dass npm-Abhängigkeiten sicher sind (Leser möchten vielleicht mit npm audit beginnen oder mit snyk tiefer gehen)
  • Verwenden der Helmbibliothek zum Schutz vor häufigen Sicherheitslücken

Dieser letzte Punkt lässt sich einfach zu unserem Beispielprojekt hinzufügen:

 npm i --save helmet

Dann müssen wir es in app.ts nur importieren und einen weiteren app.use() hinzufügen:

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

Wie die Dokumentation zeigt, ist Helm (wie jede Sicherheitsergänzung) keine Wunderwaffe, aber jedes bisschen Prävention hilft.

Enthält unser REST-API-Projekt mit Docker

In dieser Serie haben wir uns nicht eingehend mit Docker-Containern befasst, aber wir haben MongoDB in einem Container mit Docker Compose verwendet. Leser, die mit Docker nicht vertraut sind, aber einen weiteren Schritt ausprobieren möchten, können eine Datei namens Dockerfile (ohne Erweiterung) im Projektstammverzeichnis erstellen:

 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"]

Diese Konfiguration beginnt mit dem offiziellen Image node:14-slim von Docker und erstellt und führt unsere Beispiel-REST-API in einem Container aus. Die Konfiguration kann sich von Fall zu Fall ändern, aber diese generisch aussehenden Standardeinstellungen funktionieren für unser Projekt.

Um das Image zu erstellen, führen wir dies einfach im Projektstammverzeichnis aus (ersetzen tag_your_image_here wie gewünscht):

 docker build . -t tag_your_image_here

Dann ist eine Möglichkeit, unser Backend auszuführen – unter der Annahme, dass genau derselbe Text ersetzt wird –:

 docker run -p 3000:3000 tag_your_image_here

An diesem Punkt können sowohl MongoDB als auch Node.js Docker verwenden, aber wir müssen sie auf zwei verschiedene Arten starten. Wir überlassen es dem Leser als Übung, die Haupt-Node.js-App zu docker-compose.yml , damit die gesamte App mit einem einzigen docker-compose Befehl gestartet werden kann.

Weitere REST-API-Fähigkeiten zum Erkunden

In diesem Artikel haben wir umfangreiche Verbesserungen an unserer REST-API vorgenommen: Wir haben eine containerisierte MongoDB hinzugefügt, Mongoose und Express-Validator konfiguriert, JWT-basierte Authentifizierung und ein flexibles Berechtigungssystem hinzugefügt und eine Reihe automatisierter Tests geschrieben.

Dies ist ein solider Ausgangspunkt für neue und fortgeschrittene Back-End-Entwickler. In gewisser Weise ist unser Projekt jedoch möglicherweise nicht ideal für den Einsatz in der Produktion, Skalierung und Wartung. Abgesehen von den Leseübungen, die wir in diesem Artikel verteilt haben, was gibt es sonst noch zu lernen?

Auf API-Ebene empfehlen wir, sich über die Erstellung einer OpenAPI-kompatiblen Spezifikation zu informieren. Leser, die besonders an der Unternehmensentwicklung interessiert sind, werden auch NestJS ausprobieren wollen. Es ist ein weiteres Framework, das auf Express.js aufbaut, aber robuster und abstrakter ist – deshalb ist es gut, unser Beispielprojekt zu verwenden, um sich zuerst mit den Grundlagen von Express.js vertraut zu machen. Nicht weniger wichtig ist, dass der GraphQL-Ansatz für APIs als Alternative zu REST weit verbreitet ist.

In Bezug auf Berechtigungen haben wir einen bitweisen Flags-Ansatz mit einem Middleware-Generator für manuell definierte Flags abgedeckt. Für weitere Bequemlichkeit bei der Skalierung lohnt es sich, einen Blick in die CASL-Bibliothek zu werfen, die sich in Mongoose integriert. Es erweitert die Flexibilität unseres Ansatzes und ermöglicht prägnante Definitionen von Fähigkeiten, die ein bestimmtes Flag zulassen sollte, wie etwa can(['update', 'delete'], '(model name here)', { creator: 'me' }); anstelle einer ganzen benutzerdefinierten Middleware-Funktion.

Wir haben in diesem Projekt ein praktisches Sprungbrett für automatisierte Tests bereitgestellt, aber einige wichtige Themen lagen außerhalb unseres Rahmens. Wir empfehlen den Lesern:

  1. Untersuchen Sie Unit-Tests, um Komponenten separat zu testen – Mocha und Chai können auch dafür verwendet werden.
  2. Sehen Sie sich Code-Coverage-Tools an, die helfen, Lücken in Testsuiten zu identifizieren, indem sie Codezeilen anzeigen, die während des Tests nicht ausgeführt werden. Mit solchen Tools können Leser dann die Beispieltests nach Bedarf ergänzen – aber sie zeigen möglicherweise nicht alle fehlenden Szenarien auf, z. B. ob Benutzer ihre Berechtigungen über einen PATCH auf /users/:userId können.
  3. Probieren Sie andere Ansätze für automatisiertes Testen aus. Wir haben die expect -Schnittstelle im Stil der verhaltensgesteuerten Entwicklung (BDD) von Chai verwendet, aber sie unterstützt auch should() und assert . Es lohnt sich auch, andere Testbibliotheken wie Jest zu lernen.

Abgesehen von diesen Themen ist unsere Node.js/TypeScript-REST-API bereit, darauf aufzubauen. Insbesondere möchten Leser möglicherweise mehr Middleware implementieren, um eine gemeinsame Geschäftslogik um die Standardbenutzerressource herum zu erzwingen. Ich werde hier nicht näher darauf eingehen, aber ich würde Lesern, die sich blockiert fühlen, gerne Anleitungen und Tipps geben – hinterlassen Sie einfach unten einen Kommentar.

Der vollständige Code für dieses Projekt ist als Open-Source-GitHub-Repository verfügbar.


Weiterführende Literatur im Toptal Engineering Blog:

  • Verwenden von Express.js-Routen für Promise-basierte Fehlerbehandlung