Construire une API REST Node.js/TypeScript, Partie 1 : Express.js

Publié: 2022-03-11

Comment écrire une API REST dans Node.js ?

Lors de la création d'un back-end pour une API REST, Express.js est souvent le premier choix parmi les frameworks Node.js. Bien qu'il prenne également en charge la création de HTML statique et de modèles, dans cette série, nous nous concentrerons sur le développement back-end à l'aide de TypeScript. L'API REST résultante sera celle que n'importe quel framework frontal ou service back-end externe pourra interroger.

Tu auras besoin:

  • Connaissance de base de JavaScript et TypeScript
  • Connaissance de base de Node.js
  • Connaissance de base de l'architecture REST (cf. cette section de mon précédent article sur l'API REST si besoin)
  • Une installation prête de Node.js (de préférence la version 14+)

Dans un terminal (ou une invite de commande), nous allons créer un dossier pour le projet. À partir de ce dossier, exécutez npm init . Cela créera certains des fichiers de projet Node.js de base dont nous avons besoin.

Ensuite, nous ajouterons le framework Express.js et quelques bibliothèques utiles :

 npm i express debug winston express-winston cors

Il y a de bonnes raisons pour lesquelles ces bibliothèques sont les préférées des développeurs Node.js :

  • debug est un module que nous allons utiliser pour éviter d'appeler console.log() lors du développement de notre application. De cette façon, nous pouvons facilement filtrer les instructions de débogage lors du dépannage. Ils peuvent également être entièrement désactivés en production au lieu de devoir être retirés manuellement.
  • winston est responsable de la journalisation des requêtes vers notre API et des réponses (et erreurs) renvoyées. express-winston s'intègre directement à Express.js, de sorte que tout le code de journalisation winston standard lié à l'API est déjà fait.
  • cors est un middleware Express.js qui nous permet d'activer le partage de ressources cross-origin. Sans cela, notre API ne serait utilisable qu'à partir des serveurs frontaux servis à partir du même sous-domaine que notre serveur principal.

Notre serveur principal utilise ces packages lorsqu'il est en cours d'exécution. Mais nous devons également installer certaines dépendances de développement pour notre configuration TypeScript. Pour cela, nous allons exécuter :

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

Ces dépendances sont nécessaires pour activer TypeScript pour le propre code de notre application, ainsi que les types utilisés par Express.js et d'autres dépendances. Cela peut faire gagner beaucoup de temps lorsque nous utilisons un IDE comme WebStorm ou VSCode en nous permettant de compléter automatiquement certaines méthodes de fonction lors du codage.

Les dépendances finales dans package.json devraient ressembler à ceci :

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

Maintenant que toutes nos dépendances requises sont installées, commençons à créer notre propre code !

Structure du projet de l'API REST TypeScript

Pour ce tutoriel, nous allons créer seulement trois fichiers :

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

L'idée derrière les deux dossiers de la structure du projet ( common et users ) est d'avoir des modules individuels qui ont leurs propres responsabilités. En ce sens, nous allons éventuellement avoir tout ou partie des éléments suivants pour chaque module :

  • Configuration de la route pour définir les requêtes que notre API peut gérer
  • Services pour des tâches telles que la connexion à nos modèles de base de données, l'exécution de requêtes ou la connexion à des services externes requis par la demande spécifique
  • Middleware pour exécuter des validations de requêtes spécifiques avant que le contrôleur final d'une route ne gère ses spécificités
  • Modèles pour définir des modèles de données correspondant à un schéma de base de données donné, pour faciliter le stockage et la récupération des données
  • Contrôleurs pour séparer la configuration de route du code qui traite finalement (après tout middleware) une demande de route, appelle les fonctions de service ci-dessus si nécessaire et donne une réponse au client

Cette structure de dossiers fournit une conception d'API REST de base, un point de départ précoce pour le reste de cette série de didacticiels et suffisamment pour commencer à s'entraîner.

Un fichier de routes communes dans TypeScript

Dans le dossier common , créons le fichier common.routes.config.ts pour qu'il ressemble à ceci :

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

La façon dont nous créons les itinéraires ici est facultative. Mais puisque nous travaillons avec TypeScript, notre scénario de routes est l'occasion de s'exercer à utiliser l'héritage avec le mot clé extends , comme nous le verrons bientôt. Dans ce projet, tous les fichiers de route ont le même comportement : ils ont un nom (que nous utiliserons à des fins de débogage) et un accès à l'objet Application Express.js principal.

Maintenant, nous pouvons commencer à créer le fichier de routage des utilisateurs. Dans le dossier des users , créons users.routes.config.ts et commençons à le coder comme ceci :

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

Ici, nous importons la classe CommonRoutesConfig et l'étendons à notre nouvelle classe, appelée UsersRoutes . Avec le constructeur, nous envoyons l'application (l'objet principal express.Application ) et le nom UsersRoutes au constructeur de CommonRoutesConfig .

Cet exemple est assez simple, mais lors de la mise à l'échelle pour créer plusieurs fichiers de route, cela nous aidera à éviter le code en double.

Supposons que nous souhaitions ajouter de nouvelles fonctionnalités dans ce fichier, telles que la journalisation. Nous pourrions ajouter le champ nécessaire à la classe CommonRoutesConfig , puis toutes les routes qui étendent CommonRoutesConfig y auront accès.

Utilisation des fonctions abstraites TypeScript pour des fonctionnalités similaires dans toutes les classes

Que se passe-t-il si nous souhaitons avoir des fonctionnalités similaires entre ces classes (comme la configuration des points de terminaison de l'API), mais qui nécessitent une implémentation différente pour chaque classe ? Une option consiste à utiliser une fonctionnalité TypeScript appelée abstraction .

Créons une fonction abstraite très simple dont la classe UsersRoutes (et les futures classes de routage) hériteront de CommonRoutesConfig . Disons que nous voulons forcer toutes les routes à avoir une fonction (afin que nous puissions l'appeler depuis notre constructeur commun) nommée configureRoutes() . C'est là que nous déclarerons les points de terminaison de chaque ressource de classe de routage.

Pour ce faire, nous allons ajouter trois choses rapides à common.routes.config.ts :

  1. Le mot clé abstract à notre ligne de class , pour activer l'abstraction pour cette classe.
  2. Une nouvelle déclaration de fonction à la fin de notre classe, abstract configureRoutes(): express.Application; . Cela oblige toute classe étendant CommonRoutesConfig à fournir une implémentation correspondant à cette signature. Si ce n'est pas le cas, le compilateur TypeScript génère une erreur.
  3. Un appel à this.configureRoutes(); à la fin du constructeur, puisque nous pouvons maintenant être sûrs que cette fonction existera.

Le résultat:

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

Avec cela, toute classe étendant CommonRoutesConfig doit avoir une fonction appelée configureRoutes() qui renvoie un objet express.Application . Cela signifie que users.routes.config.ts doit être mis à jour :

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

En guise de récapitulatif de ce que nous avons fait :

Nous importons d'abord le fichier common.routes.config , puis le module express . Nous définissons ensuite la classe UserRoutes , en disant que nous voulons qu'elle étende la classe de base CommonRoutesConfig , ce qui implique que nous promettons qu'elle implémentera configureRoutes() .

Pour envoyer des informations à la classe CommonRoutesConfig , nous utilisons le constructor de la classe. Il s'attend à recevoir l'objet express.Application , que nous décrirons plus en détail à l'étape suivante. Avec super() , nous transmettons au constructeur de CommonRoutesConfig l'application et le nom de nos routes, qui dans ce scénario est UsersRoutes. ( super() , à son tour, appellera notre implémentation de configureRoutes() .)

Configuration des routes Express.js des points de terminaison des utilisateurs

La fonction configureRoutes() est l'endroit où nous allons créer les points de terminaison pour les utilisateurs de notre API REST. Là, nous utiliserons l'application et ses fonctionnalités de routage depuis Express.js.

L'idée d'utiliser la fonction app.route() est d'éviter la duplication de code, ce qui est facile puisque nous créons une API REST avec des ressources bien définies. La ressource principale de ce didacticiel est les utilisateurs . Nous avons deux cas dans ce scénario :

  • Lorsque l'appelant de l'API souhaite créer un nouvel utilisateur ou répertorier tous les utilisateurs existants, le point de terminaison doit initialement n'avoir que des users à la fin du chemin demandé. (Nous n'aborderons pas le filtrage des requêtes, la pagination ou d'autres requêtes de ce type dans cet article.)
  • Lorsque l'appelant veut faire quelque chose de spécifique à un enregistrement d'utilisateur spécifique, le chemin de ressource de la demande suivra le modèle users/:userId .

La façon dont .route() fonctionne dans Express.js nous permet de gérer les verbes HTTP avec un chaînage élégant. C'est parce que .get() , .post() , etc., renvoient tous la même instance de l' IRoute que le premier appel .route() . La configuration finale ressemblera à ceci :

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

Le code ci-dessus permet à tout client API REST d'appeler le point de terminaison de nos users avec une POST ou GET . De même, il permet à un client d'appeler notre point de terminaison /users/:userId avec une requête GET , PUT , PATCH ou DELETE .

Mais pour /users/:userId , nous avons également ajouté un middleware générique utilisant la fonction all() , qui sera exécutée avant les fonctions get() , put() , patch() ou delete() . Cette fonction sera bénéfique lorsque (plus tard dans la série) nous créerons des routes destinées à être accessibles uniquement par des utilisateurs authentifiés.

Vous avez peut-être remarqué que dans notre fonction .all() , comme dans tout middleware, nous avons trois types de champs : Request , Response et NextFunction .

  • La requête est la manière dont Express.js représente la requête HTTP à gérer. Ce type met à niveau et étend le type de requête natif Node.js.
  • La réponse est également la façon dont Express.js représente la réponse HTTP, étendant à nouveau le type de réponse natif Node.js.
  • Non moins important, la NextFunction sert de fonction de rappel, permettant au contrôle de passer par toutes les autres fonctions du middleware. En cours de route, tous les intergiciels partageront les mêmes objets de demande et de réponse avant que le contrôleur ne renvoie finalement une réponse au demandeur.

Notre fichier de point d'entrée Node.js, app.ts

Maintenant que nous avons configuré quelques squelettes de route de base, nous allons commencer à configurer le point d'entrée de l'application. Créons le fichier app.ts à la racine de notre dossier de projet et commençons-le avec ce code :

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

Seules deux de ces importations sont nouvelles à ce stade de l'article :

  • http est un module natif Node.js. Il est nécessaire pour démarrer notre application Express.js.
  • body-parser est un middleware fourni avec Express.js. Il analyse la requête (dans notre cas, en tant que JSON) avant que le contrôle ne passe à nos propres gestionnaires de requêtes.

Maintenant que nous avons importé les fichiers, nous allons commencer à déclarer les variables que nous voulons utiliser :

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

La fonction express() renvoie l'objet principal de l'application Express.js que nous transmettrons tout au long de notre code, en commençant par l'ajouter à l'objet http.Server . (Nous devrons démarrer le http.Server après avoir configuré notre express.Application .)

Nous écouterons sur le port 3000 - dont TypeScript déduira automatiquement qu'il s'agit d'un Number - au lieu des ports standard 80 (HTTP) ou 443 (HTTPS) car ceux-ci seraient généralement utilisés pour le front-end d'une application.

Pourquoi Port 3000 ?

Il n'y a pas de règle selon laquelle le port doit être 3000 - s'il n'est pas spécifié, un port arbitraire sera attribué - mais 3000 est utilisé dans les exemples de documentation pour Node.js et Express.js, nous poursuivons donc la tradition ici.

Node.js peut-il partager des ports avec le frontal ?

Nous pouvons toujours exécuter localement sur un port personnalisé, même lorsque nous voulons que notre back-end réponde aux demandes sur les ports standard. Cela nécessiterait un proxy inverse pour recevoir les requêtes sur le port 80 ou 443 avec un domaine ou un sous-domaine spécifique. Il les redirigerait ensuite vers notre port interne 3000.

Le tableau routes gardera une trace de nos fichiers de routes à des fins de débogage, comme nous le verrons ci-dessous.

Enfin, debugLog se retrouvera comme une fonction similaire à console.log , mais en mieux : il est plus facile à affiner car il est automatiquement limité à tout ce que nous voulons appeler notre contexte de fichier/module. (Dans ce cas, nous l'avons appelé "app" lorsque nous l'avons transmis dans une chaîne au constructeur debug() .)

Nous sommes maintenant prêts à configurer tous nos modules middleware Express.js et les routes de notre API :

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

expressWinston.logger s'accroche à Express.js, enregistrant automatiquement les détails, via la même infrastructure que le debug , pour chaque requête terminée. Les options que nous lui avons transmises formateront et coloriseront soigneusement la sortie du terminal correspondante, avec une journalisation plus détaillée (par défaut) lorsque nous sommes en mode débogage.

Notez que nous devons définir nos itinéraires après avoir configuré expressWinston.logger .

Enfin et surtout :

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

Cela démarre réellement notre serveur. Une fois démarré, Node.js exécutera notre fonction de rappel qui, en mode débogage, rapporte les noms de toutes les routes que nous avons configurées, jusqu'à présent, uniquement UsersRoutes . Après cela, notre rappel nous informe que notre back-end est prêt à recevoir des requêtes, même en mode production.

Mise à jour de package.json pour transpiler TypeScript en JavaScript et exécuter l'application

Maintenant que notre squelette est prêt à fonctionner, nous avons d'abord besoin d'une configuration passe-partout pour activer la transpilation TypeScript. Ajoutons le fichier tsconfig.json à la racine du projet :

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

Ensuite, il nous suffit d'ajouter la touche finale à package.json sous la forme des scripts suivants :

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

Le script de test est un espace réservé que nous remplacerons plus tard dans la série.

Le tsc dans le script de start appartient à TypeScript. Il est responsable de la transpilation de notre code TypeScript en JavaScript, qu'il affichera dans le dossier dist . Ensuite, nous exécutons simplement la version construite avec le node ./dist/app.js .

Nous passons --unhandled-rejections=strict à Node.js (même avec Node.js v16+) parce qu'en pratique, le débogage à l'aide d'une approche directe "planter et afficher la pile" est plus simple qu'une journalisation plus sophistiquée avec un objet expressWinston.errorLogger . Cela est le plus souvent vrai même en production, où laisser Node.js continuer à s'exécuter malgré un rejet non géré est susceptible de laisser le serveur dans un état inattendu, permettant à d'autres bogues (et plus compliqués) de se produire.

Le script de debug appelle le script de start mais définit d'abord une variable d'environnement DEBUG . Cela a pour effet de permettre à toutes nos debugLog() (ainsi que celles similaires d'Express.js lui-même, qui utilise le même module de debug que nous) de fournir des détails utiles au terminal - des détails qui sont (commodément) cachés lors de l'exécution le serveur en mode production avec un npm start standard.

Essayez d'exécuter npm run debug vous-même, puis comparez cela avec npm start pour voir comment la sortie de la console change.

Conseil : vous pouvez limiter la sortie de débogage aux propres debugLog() de notre fichier app.ts en utilisant DEBUG=app au lieu de DEBUG=* . Le module de debug est généralement assez flexible, et cette fonctionnalité ne fait pas exception.

Les utilisateurs de Windows devront probablement changer l' export vers SET car export est la façon dont cela fonctionne sur Mac et Linux. Si votre projet doit prendre en charge plusieurs environnements de développement, le package cross-env fournit ici une solution simple.

Test du back-end Live Express.js

Avec npm run debug ou npm start toujours en cours, notre API REST sera prête à traiter les demandes sur le port 3000. À ce stade, nous pouvons utiliser cURL, Postman, Insomnia, etc. pour tester le back-end.

Comme nous n'avons créé qu'un squelette pour la ressource utilisateurs, nous pouvons simplement envoyer des requêtes sans corps pour voir que tout fonctionne comme prévu. Par exemple:

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

Notre serveur principal devrait renvoyer la réponse GET requested for id 12345 .

En ce qui concerne POST ing:

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

Ceci et tous les autres types de requêtes pour lesquels nous avons construit des squelettes seront assez similaires.

Prêt pour le développement rapide de l'API REST Node.js avec TypeScript

Dans cet article, nous avons commencé à créer une API REST en configurant le projet à partir de zéro et en plongeant dans les bases du framework Express.js. Ensuite, nous avons fait notre premier pas vers la maîtrise de TypeScript en créant un modèle avec UsersRoutesConfig étendant CommonRoutesConfig , un modèle que nous réutiliserons pour le prochain article de cette série. Nous avons terminé en configurant notre point d'entrée app.ts pour utiliser nos nouvelles routes et package.json avec des scripts pour construire et exécuter notre application.

Mais même les bases d'une API REST réalisée avec Express.js et TypeScript sont assez compliquées. Dans la prochaine partie de cette série, nous nous concentrons sur la création de contrôleurs appropriés pour la ressource des utilisateurs et approfondissons certains modèles utiles pour les services, les intergiciels, les contrôleurs et les modèles.

Le projet complet est disponible sur GitHub, et le code à la fin de cet article se trouve dans la toptal-article-01 .