Erstellen einer Node.js/TypeScript-REST-API, Teil 1: Express.js

Veröffentlicht: 2022-03-11

Wie schreibe ich eine REST-API in Node.js?

Beim Aufbau eines Backends für eine REST-API ist Express.js oft die erste Wahl unter den Node.js-Frameworks. Während es auch das Erstellen von statischem HTML und Vorlagen unterstützt, konzentrieren wir uns in dieser Reihe auf die Back-End-Entwicklung mit TypeScript. Die resultierende REST-API ist eine, die jedes Front-End-Framework oder jeder externe Back-End-Dienst abfragen kann.

Du brauchst:

  • Grundkenntnisse in JavaScript und TypeScript
  • Grundkenntnisse von Node.js
  • Grundkenntnisse der REST-Architektur (vgl. bei Bedarf diesen Abschnitt meines vorherigen REST-API-Artikels)
  • Eine fertige Installation von Node.js (vorzugsweise Version 14+)

In einem Terminal (oder einer Eingabeaufforderung) erstellen wir einen Ordner für das Projekt. Führen Sie in diesem Ordner npm init aus. Dadurch werden einige der grundlegenden Node.js-Projektdateien erstellt, die wir benötigen.

Als Nächstes fügen wir das Express.js-Framework und einige hilfreiche Bibliotheken hinzu:

 npm i express debug winston express-winston cors

Es gibt gute Gründe, warum diese Bibliotheken die Favoriten der Node.js-Entwickler sind:

  • debug ist ein Modul, das wir verwenden werden, um den Aufruf console.log() während der Entwicklung unserer Anwendung zu vermeiden. Auf diese Weise können wir Debug-Anweisungen während der Fehlerbehebung einfach filtern. Sie können in der Produktion auch komplett abgeschaltet werden, anstatt manuell entfernt werden zu müssen.
  • winston ist für die Protokollierung von Anfragen an unsere API und die zurückgegebenen Antworten (und Fehler) verantwortlich. express-winston sich direkt in Express.js integrieren, sodass der gesamte Standard-API-bezogene winston -Protokollierungscode bereits fertig ist.
  • cors ist ein Teil der Express.js-Middleware, mit der wir die ursprungsübergreifende gemeinsame Nutzung von Ressourcen ermöglichen können. Ohne dies wäre unsere API nur von Frontends nutzbar, die von genau derselben Subdomain wie unser Backend bedient werden.

Unser Back-End verwendet diese Pakete, wenn es ausgeführt wird. Aber wir müssen auch einige Entwicklungsabhängigkeiten für unsere TypeScript-Konfiguration installieren. Dafür führen wir aus:

 npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript

Diese Abhängigkeiten sind erforderlich, um TypeScript für den eigenen Code unserer App zu aktivieren, zusammen mit den von Express.js und anderen Abhängigkeiten verwendeten Typen. Dies kann viel Zeit sparen, wenn wir eine IDE wie WebStorm oder VSCode verwenden, da wir einige Funktionsmethoden während des Codierens automatisch vervollständigen können.

Die endgültigen Abhängigkeiten in package.json sollten wie folgt aussehen:

 "dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }

Nachdem wir nun alle erforderlichen Abhängigkeiten installiert haben, können wir mit dem Erstellen unseres eigenen Codes beginnen!

TypeScript-REST-API-Projektstruktur

Für dieses Tutorial werden wir nur drei Dateien erstellen:

  1. ./app.ts
  2. ./common/common.routes.config.ts
  3. ./users/users.routes.config.ts

Die Idee hinter den beiden Ordnern der Projektstruktur ( common und users ) besteht darin, einzelne Module mit eigenen Verantwortlichkeiten zu haben. In diesem Sinne werden wir schließlich einige oder alle der folgenden Punkte für jedes Modul haben:

  • Routenkonfiguration , um die Anfragen zu definieren, die unsere API verarbeiten kann
  • Dienste für Aufgaben wie das Herstellen einer Verbindung zu unseren Datenbankmodellen, das Durchführen von Abfragen oder das Herstellen einer Verbindung zu externen Diensten, die für die jeweilige Anfrage erforderlich sind
  • Middleware zum Ausführen bestimmter Anforderungsvalidierungen, bevor der endgültige Controller einer Route ihre Besonderheiten behandelt
  • Modelle zum Definieren von Datenmodellen, die einem bestimmten Datenbankschema entsprechen, um das Speichern und Abrufen von Daten zu erleichtern
  • Controller zum Trennen der Routenkonfiguration vom Code, der schließlich (nach einer etwaigen Middleware) eine Routenanfrage verarbeitet, gegebenenfalls die oben genannten Dienstfunktionen aufruft und eine Antwort an den Client gibt

Diese Ordnerstruktur bietet ein grundlegendes REST-API-Design, einen frühen Ausgangspunkt für den Rest dieser Tutorial-Reihe und genug, um mit dem Üben zu beginnen.

Eine allgemeine Routendatei in TypeScript

Lassen Sie uns im common Ordner die Datei common.routes.config.ts erstellen, die wie folgt aussieht:

 import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }

Die Art und Weise, wie wir die Routen hier erstellen, ist optional. Aber da wir mit TypeScript arbeiten, ist unser Routing-Szenario eine Gelegenheit, die Verwendung der Vererbung mit dem Schlüsselwort extends zu üben, wie wir gleich sehen werden. In diesem Projekt haben alle Routendateien das gleiche Verhalten: Sie haben einen Namen (den wir zu Debugging-Zwecken verwenden) und greifen auf das Hauptanwendungsobjekt von Application zu.

Jetzt können wir damit beginnen, die Routendatei des Benutzers zu erstellen. Lassen Sie uns im users users.routes.config.ts erstellen und beginnen, es wie folgt zu codieren:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }

Hier importieren wir die Klasse CommonRoutesConfig und erweitern sie auf unsere neue Klasse namens UsersRoutes . Mit dem Konstruktor senden wir die App (das Hauptobjekt express.Application ) und den Namen UsersRoutes an den Konstruktor von CommonRoutesConfig .

Dieses Beispiel ist ziemlich einfach, aber wenn wir skalieren, um mehrere Routendateien zu erstellen, hilft uns dies, doppelten Code zu vermeiden.

Angenommen, wir möchten dieser Datei neue Funktionen hinzufügen, z. B. die Protokollierung. Wir könnten das erforderliche Feld zur Klasse CommonRoutesConfig , und dann haben alle Routen, die CommonRoutesConfig erweitern, Zugriff darauf.

Verwenden von abstrakten TypeScript-Funktionen für ähnliche Funktionalität in allen Klassen

Was ist, wenn wir einige Funktionen haben möchten, die zwischen diesen Klassen ähnlich sind (wie das Konfigurieren der API-Endpunkte), aber für jede Klasse eine andere Implementierung benötigen? Eine Option ist die Verwendung einer TypeScript-Funktion namens abstraction .

Lassen Sie uns eine sehr einfache abstrakte Funktion erstellen, die die Klasse UsersRoutes (und zukünftige Routingklassen) von CommonRoutesConfig erben wird. Nehmen wir an, wir möchten zwingen, dass alle Routen eine Funktion namens configureRoutes() . Dort deklarieren wir die Endpunkte der Ressourcen jeder Routingklasse.

Dazu fügen wir drei schnelle Dinge zu common.routes.config.ts :

  1. Das Schlüsselwort abstract zu unserer class , um die Abstraktion für diese Klasse zu aktivieren.
  2. Eine neue Funktionsdeklaration am Ende unserer Klasse, abstract configureRoutes(): express.Application; . Dadurch wird jede Klasse, die CommonRoutesConfig erweitert, gezwungen, eine Implementierung bereitzustellen, die dieser Signatur entspricht – wenn dies nicht der Fall ist, gibt der TypeScript-Compiler einen Fehler aus.
  3. Ein Aufruf von this.configureRoutes(); am Ende des Konstruktors, da wir nun sicher sein können, dass diese Funktion existieren wird.

Das Ergebnis:

 import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }

Damit muss jede Klasse, die CommonRoutesConfig erweitert, eine Funktion namens configureRoutes() haben, die ein express.Application -Objekt zurückgibt. Das bedeutet, dass users.routes.config.ts aktualisiert werden muss:

 import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }

Als Zusammenfassung dessen, was wir gemacht haben:

Wir importieren zuerst die Datei common.routes.config , dann das express Modul. Dann definieren wir die UserRoutes -Klasse und sagen, dass wir wollen, dass sie die CommonRoutesConfig -Basisklasse erweitert, was impliziert, dass wir versprechen, dass sie configureRoutes() implementiert.

Um Informationen an die Klasse CommonRoutesConfig zu senden, verwenden wir den constructor der Klasse. Es erwartet den Empfang des express.Application Objekts, das wir im nächsten Schritt genauer beschreiben werden. Mit super() übergeben wir die Anwendung und den Namen unserer Routen, in diesem Szenario CommonRoutesConfig , an den Konstruktor von CommonRoutesConfig . ( super() ruft wiederum unsere Implementierung von configureRoutes() auf.)

Konfigurieren der Express.js-Routen der Benutzerendpunkte

In der Funktion configureRoutes() erstellen wir die Endpunkte für Benutzer unserer REST-API. Dort verwenden wir die Anwendung und ihre Routenfunktionalitäten von Express.js.

Die Idee bei der Verwendung der Funktion app.route() besteht darin, Codeduplizierung zu vermeiden, was einfach ist, da wir eine REST-API mit klar definierten Ressourcen erstellen. Die Hauptressource für dieses Tutorial sind Benutzer . Wir haben zwei Fälle in diesem Szenario:

  • Wenn der API-Aufrufer einen neuen Benutzer erstellen oder alle vorhandenen Benutzer auflisten möchte, sollte der Endpunkt zunächst nur users am Ende des angeforderten Pfads haben. (Wir werden in diesem Artikel nicht auf Abfragefilterung, Paginierung oder ähnliche Abfragen eingehen.)
  • Wenn der Aufrufer etwas Bestimmtes für einen bestimmten Benutzerdatensatz tun möchte, folgt der Ressourcenpfad der Anforderung dem Muster users/:userId .

Die Funktionsweise .route() in Express.js ermöglicht es uns, HTTP-Verben mit einer eleganten Verkettung zu behandeln. Dies liegt daran, dass .get() , .post() usw. alle dieselbe Instanz von IRoute , die der erste Aufruf von .route() . Die endgültige Konfiguration wird wie folgt aussehen:

 configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }

Mit dem obigen Code kann jeder REST-API-Client den Endpunkt unseres users mit einer POST oder GET -Anfrage aufrufen. Auf ähnliche Weise kann ein Client unseren Endpunkt /users/:userId mit einer GET -, PUT -, PATCH - oder DELETE -Anfrage aufrufen.

Aber für /users/:userId haben wir auch generische Middleware mit der Funktion all() hinzugefügt, die vor jeder der Funktionen get() , put() , patch() oder delete() ausgeführt wird. Diese Funktion wird nützlich sein, wenn wir (später in der Serie) Routen erstellen, auf die nur authentifizierte Benutzer zugreifen sollen.

Sie haben vielleicht bemerkt, dass wir in unserer .all() -Funktion – wie bei jeder Middleware – drei Arten von Feldern haben: Request , Response und NextFunction .

  • Die Anforderung ist die Art und Weise, wie Express.js die zu verarbeitende HTTP-Anforderung darstellt. Dieser Typ aktualisiert und erweitert den nativen Node.js-Anforderungstyp.
  • Die Antwort stellt ebenfalls die HTTP-Antwort von Express.js dar, wobei wiederum der native Antworttyp von Node.js erweitert wird.
  • Nicht weniger wichtig, die NextFunction dient als Callback-Funktion, die es ermöglicht, dass die Steuerung alle anderen Middleware-Funktionen durchläuft. Unterwegs teilt sich die gesamte Middleware dieselben Anforderungs- und Antwortobjekte, bevor der Controller schließlich eine Antwort an den Anforderer zurücksendet.

Unsere Node.js-Einstiegspunktdatei, app.ts

Nachdem wir nun einige grundlegende Routenskelette konfiguriert haben, beginnen wir mit der Konfiguration des Einstiegspunkts der Anwendung. Lassen Sie uns die Datei app.ts im Stammverzeichnis unseres Projektordners erstellen und mit diesem Code beginnen:

 import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';

Nur zwei dieser Importe sind an dieser Stelle des Artikels neu:

  • http ist ein Node.js-natives Modul. Es ist erforderlich, um unsere Express.js-Anwendung zu starten.
  • body-parser ist Middleware, die mit Express.js geliefert wird. Es parst die Anfrage (in unserem Fall als JSON), bevor die Kontrolle an unsere eigenen Request-Handler geht.

Nachdem wir die Dateien importiert haben, beginnen wir mit der Deklaration der Variablen, die wir verwenden möchten:

 const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');

Die express() Funktion gibt das Haupt-Express.js-Anwendungsobjekt zurück, das wir in unserem Code weitergeben, beginnend mit dem Hinzufügen zum http.Server Objekt. (Wir müssen den http.Server starten, nachdem wir unsere express.Application konfiguriert haben.)

Wir lauschen auf Port 3000 – was TypeScript automatisch als Number ableiten wird – anstelle der Standardports 80 (HTTP) oder 443 (HTTPS), da diese normalerweise für das Front-End einer App verwendet werden.

Warum Port 3000?

Es gibt keine Regel, dass der Port 3000 sein sollte – wenn nicht angegeben, wird ein beliebiger Port zugewiesen – aber 3000 wird in den Dokumentationsbeispielen sowohl für Node.js als auch für Express.js verwendet, also setzen wir die Tradition hier fort.

Kann Node.js Ports mit dem Frontend teilen?

Wir können immer noch lokal an einem benutzerdefinierten Port laufen, selbst wenn wir möchten, dass unser Backend auf Anfragen an Standardports antwortet. Dies würde erfordern, dass ein Reverse-Proxy Anfragen auf Port 80 oder 443 mit einer bestimmten Domain oder Subdomain entgegennimmt. Es würde sie dann auf unseren internen Port 3000 umleiten.

Das routes -Array verfolgt unsere Routendateien zu Debugging-Zwecken, wie wir weiter unten sehen werden.

Schließlich debugLog als eine ähnliche Funktion wie console.log , aber besser: Sie lässt sich einfacher feinabstimmen, da sie automatisch auf das beschränkt ist, was wir unseren Datei-/Modulkontext nennen möchten. (In diesem Fall haben wir es „app“ genannt, als wir es in einem String an den debug() Konstruktor übergeben haben.)

Jetzt können wir alle unsere Express.js-Middleware-Module und die Routen unserer API konfigurieren:

 // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });

Der expressWinston.logger hängt sich in Express.js ein und protokolliert automatisch Details – über dieselbe Infrastruktur wie debug – für jede abgeschlossene Anfrage. Die Optionen, die wir ihm übergeben haben, formatieren und färben die entsprechende Terminalausgabe ordentlich, mit ausführlicherer Protokollierung (Standardeinstellung), wenn wir uns im Debug-Modus befinden.

Beachten Sie, dass wir unsere Routen definieren müssen, nachdem wir expressWinston.logger eingerichtet haben.

Abschließend und am wichtigsten:

 server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });

Dies startet tatsächlich unseren Server. Sobald es gestartet ist, führt Node.js unsere Callback-Funktion aus, die im Debug-Modus die Namen aller von uns konfigurierten Routen meldet – bisher nur UsersRoutes . Danach benachrichtigt uns unser Callback, dass unser Backend bereit ist, Anfragen zu empfangen, auch wenn es im Produktionsmodus läuft.

Aktualisieren von package.json “, um TypeScript in JavaScript zu transpilieren und die App auszuführen

Nachdem wir unser Skelett nun betriebsbereit haben, benötigen wir zunächst einige Boilerplate-Konfigurationen, um die TypeScript-Transpilation zu aktivieren. Fügen wir die Datei tsconfig.json im Projektstamm hinzu:

 { "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }

Dann müssen wir der package.json nur noch den letzten Schliff in Form der folgenden Skripte geben:

 "scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },

Das test ist ein Platzhalter, den wir später in der Serie ersetzen werden.

Das tsc im start gehört zu TypeScript. Es ist dafür verantwortlich, unseren TypeScript-Code in JavaScript zu transpilieren, das es in den dist -Ordner ausgibt. Dann führen wir einfach die gebaute Version mit node ./dist/app.js .

Wir übergeben --unhandled-rejections=strict an Node.js (selbst mit Node.js v16+), weil in der Praxis das Debugging mit einem direkten „crash and show the stack“-Ansatz unkomplizierter ist als eine schickere Protokollierung mit einem expressWinston.errorLogger -Objekt. Dies gilt meistens sogar in der Produktion, wo es wahrscheinlich ist, dass das Weiterlaufen von Node.js trotz einer unbehandelten Ablehnung den Server in einen unerwarteten Zustand versetzt, wodurch weitere (und kompliziertere) Fehler auftreten können.

Das debug Skript ruft das start -Skript auf, definiert aber zuerst eine DEBUG Umgebungsvariable. Dies hat den Effekt, dass alle unsere debugLog() Anweisungen (plus ähnliche aus Express.js selbst, das dasselbe debug -Modul wie wir verwendet) aktiviert werden, um nützliche Details an das Terminal auszugeben – Details, die ansonsten (bequemerweise) während der Ausführung verborgen sind den Server im Produktionsmodus mit einem Standard- npm start .

Versuchen Sie, npm run debug selbst auszuführen, und vergleichen Sie das anschließend mit npm start , um zu sehen, wie sich die Konsolenausgabe ändert.

Tipp: Sie können die Debug-Ausgabe auf die eigenen debugLog() Anweisungen unserer app.ts -Datei beschränken, indem Sie DEBUG=app anstelle von DEBUG=* verwenden. Das debug -Modul ist im Allgemeinen ziemlich flexibel, und diese Funktion ist keine Ausnahme.

Windows-Benutzer müssen wahrscheinlich den export auf SET ändern, da der export auf Mac und Linux funktioniert. Wenn Ihr Projekt mehrere Entwicklungsumgebungen unterstützen muss, bietet das cross-env-Paket hier eine unkomplizierte Lösung.

Testen des Back-Ends von Live Express.js

npm run debug oder npm start noch läuft, ist unsere REST-API bereit, Anfragen auf Port 3000 zu bedienen. An diesem Punkt können wir cURL, Postman, Insomnia usw. verwenden, um das Backend zu testen.

Da wir nur ein Skelett für die Benutzerressource erstellt haben, können wir einfach Anfragen ohne Text senden, um zu sehen, ob alles wie erwartet funktioniert. Zum Beispiel:

 curl --request GET 'localhost:3000/users/12345'

Unser Back-End sollte die Antwort GET requested for id 12345 .

POST :

 curl --request POST 'localhost:3000/users' \ --data-raw ''

Diese und alle anderen Arten von Anfragen, für die wir Skelette gebaut haben, werden ziemlich ähnlich aussehen.

Bereit für die schnelle Node.js-REST-API-Entwicklung mit TypeScript

In diesem Artikel haben wir mit der Erstellung einer REST-API begonnen, indem wir das Projekt von Grund auf neu konfiguriert und uns mit den Grundlagen des Express.js-Frameworks befasst haben. Dann haben wir unseren ersten Schritt zur Beherrschung von TypeScript gemacht, indem wir ein Muster mit UsersRoutesConfig erstellt haben, das CommonRoutesConfig erweitert, ein Muster, das wir für den nächsten Artikel dieser Reihe wiederverwenden werden. Abschließend haben wir unseren Einstiegspunkt app.ts so konfiguriert, dass er unsere neuen Routen und package.json mit Skripts zum Erstellen und Ausführen unserer Anwendung verwendet.

Aber selbst die Grundlagen einer mit Express.js und TypeScript erstellten REST-API sind ziemlich kompliziert. Im nächsten Teil dieser Serie konzentrieren wir uns auf die Erstellung geeigneter Controller für die Benutzerressource und graben uns in einige nützliche Muster für Dienste, Middleware, Controller und Modelle ein.

Das vollständige Projekt ist auf GitHub verfügbar, und der Code am Ende dieses Artikels befindet sich im Zweig toptal-article-01 .