Ajouter les lignes dessus à docker-compose.dev.yml sous l'option services.
Ajouter le dossier dbms
Rebuilder votre DevContainer dans VSCode.
Le SGBDR est désormais disponible.
Docker gère automatiquement les connexions et ports pour nous :
Le nom d'hôte est le nom du service (dbms)
Le port est automatiquement mappé par docker, pas besoin de le préciser dans notre code
Pour plus facilement identifier notre container dans Docker, on précise un nom avec container_name
Dans le terminal VSCode, vous pouvez connecter à votre SGBDR avec :
mycli-hdbms-uroot
Ou bien, d'un terminal en dehors de VSCode (si vous êtes sur un serveur par exemple):
dockerexec-itmy_sql_databasemariadb-uroot-p
Ensuite, vous pouvez créer votre base de données, l'utilisateur pour notre api, et créer les premières tables :
/*Script de création de la base de données.A noter, on utilise uns stratégie avec DROP et IF NOT EXISTS afin de rendre notre script réutilisable dans le future, même si la base existe déjà*/createdatabaseIFNOTEXISTS school;/* Créer l'utilisateur API */createuserIFNOTEXISTS'api-dev'@'%.%.%.%' identified by'apipassword';grantselect, update, insert, deleteon school.*to'api-dev'@'%.%.%.%';flush privileges;/* La définition de la schéma */use school;/* user */createtableifnotexists user ( userId int auto_increment not null, email varchar(256) uniquenot null, familyName varchar(256), givenName varchar(256), primary key(userId));droptriggerifexists before_insert_user;createtriggerbefore_insert_userbeforeinserton user for each rowset new.email =lower(trim(new.email));/* ... */
Intégration NodeJS
Nous utilisons la librairie mysql2 pour communiquer avec notre base de données.
npminstallmysql2
Normalement, notre API va ouvrir une connexion unique auprès du SGBDR pour chaque requête en cours. Ceci peut être lourd et chronophage, donc le créateur de la librairie a prévu les connection pools. C'est-à-dire, on va essayer de réutiliser les connexions déjà ouvertes.
Moi, je préfère créer une classe qui enveloppe l'objet principal, pour ne pas répéter du code :
utility/DB.ts
import mysql, { Pool } from'mysql2/promise';/** Wrapper de la connexion à la SGBDR. * On stock une seule référence à la connexion-pool, et on va systématiquement * récupérer cette référence pour nos requêtes. */exportclassDB {// Variable "static": une seule instance pour toutes les instances de la classe DBprivatestatic POOL:Pool;/** * Récupérer ou créer la connexion-pool. */staticgetConnection():Pool {if (!this.POOL) {this.POOL=mysql.createPool({ host:process.env.DB_HOST||'dbms', user:process.env.DB_USER||'api-dev', database:process.env.DB_DATABASE||'school', password:process.env.DB_PASSWORD||'apipassword', }); }returnthis.POOL; }}
Ici, on crée une variable static, et on initialise notre pool avec les coordonnées tirées de l'environnement (ou des valeurs par défaut).
Opérations CRUD
Voici un exemple d'un set de endpoints pour la gestion de l'utilisateur :
routes/user.route.ts
import { NextFunction, Request, Response, Router } from"express";import { ResultSetHeader, RowDataPacket } from"mysql2";import { DB } from"../utility/DB";constrouter=Router({ mergeParams:true });// Créer un utilisateurrouter.put('/',async (request:Request, response:Response, next:NextFunction) => {console.log(request.body);try {constdb=DB.Connection;constresult=awaitdb.query<ResultSetHeader>('insert into user set ?',request.body );response.send({ id: result[0].insertId }); } catch (err:any) {next(err.message) } })// Lire les utilisateursrouter.get('/',async (request:Request, response:Response, next:NextFunction) => {constdb=DB.Connection;constlimit=parseInt(request.query.limit asstring) ||10;constoffset= (parseInt(request.query.page asstring) ||0) * limit;try { const data = await db.query("select userId, email, familyName, givenName from user limit ? offset ?", [limit, offset]);
constcount=awaitdb.query<{count:number}[] &RowDataPacket[]>("select count(*) as count from user");response.send({ total: count[0][0].count, rows: data[0] }); } catch (err:any) {next(err.message); } })// Lire un utilisateur avec ID userIdrouter.get('/:userId',async (request:Request, response:Response, next:NextFunction) => {console.log(`Le userId est: ${request.params.userId}`);constdb=DB.Connection;try { const data = await db.query<RowDataPacket[]>('select userId, familyName, givenName, email from user where userId = ?', [ request.params.userId ]);
response.send(data[0][0]); } catch (err:any) {next(err.message); } });// Mettre à jour un utilisteurrouter.patch('/:userId',async (request:Request, response:Response, next:NextFunction) => {console.log(`Le userId est: ${request.params.userId}`);try {constdb=DB.Connection;constresult=awaitdb.query<ResultSetHeader>('update user set ? where userId = ?', [ request.body,request.params.userId ] );response.send({ id: result[0].insertId }); } catch (err:any) {next(err.message) } });// Supprimer un utilisateurrouter.delete('/:userId',async (request:Request, response:Response, next:NextFunction) => {console.log(`Le userId est: ${request.params.userId}`);try {constdb=DB.Connection;constresult=awaitdb.query<ResultSetHeader>('delete from user where userId = ?', [ request.params.userId ] );response.send({ id: result[0].insertId }); } catch (err:any) {next(err.message) } })exportconstROUTES_USER= router;
Le formatage des requêtes SQL
Comme toutes les librairies, on évite l'injection SQL en utilisant des fonctionnalités pour échapper les données :
// On met les ?, puis on passe comme 2ème paramètre un tableau des données à injecter, dans l'ordreconst data = await db.query<RowDataPacket[]>("select userId, familyName, givenName, email from user limit ? offset ?", [limit, offset]);
Pour les inserts, il est pratique de passer plutôt un objet, et laisser la librairie formuler la requête :
constuser= { email:"kevin@nguni.fr", familyName:"Glass", givenName:"Kevin",}constdata=awaitdb.query<OkPacket>("insert into user set ?", user);
Accrocher les routes à la hiérarchie
On compose notre application principale par les routes qu'on vient de créer :
server.ts
import { json } from"body-parser";import Express from"express";import { join } from'path';import { ROUTES_USER } from"./routes/user.route";// Récupérer le port des variables d'environnement ou préciser une valeur par défautconstPORT=process.env.PORT||5050;// Créer l'objet Expressconstapp=Express();// Ajouter un 'middleware' lit du json dans le bodyapp.use(json());// Accrocher les routes 'user' à l'api qui se trouvent// dans routes/user.route.tsapp.use('/user',ROUTES_USER)// Server des fichiers statiquesapp.use('/public',Express.static(join('assets')));// Lancer le serveurapp.listen(PORT, () => {console.info("API Listening on port "+PORT); });
Notez bien la ligne app.use('/user', ROUTES_USER);.
Si vous ne l'avez pas encore fait, ajouter la ligne "forwardPorts": [ 5050 ] à votre fichier .devcontainer/devcontainer.json. Ceci permet à votre serveur d'être accessible en dehors de votre container VSCode.
Testez les deux endpoints avec PostMan :
GET http://localhost:5050/user
POST http://localhost:5050/user
Middleware
Il arrive que l'on veuille répéter une certaine logique dans un certain nombre routes différents.
Par exemple, l'authentification d'un utilisateur avant l'exécution d'une opération.
Pour ce faire, nous utilisons un "middleware", une fonction intermédiaire qui est appelée avant le endpoint final.
Ajoutez le suivant au debut de notre fichier user.route.ts :
routes/user.route.ts
constrouter=Router({ mergeParams:true });/// AJOUTEZ CETTE FONCTION ICIrouter.use( (request:Request, response:Response, next:NextFunction) => {console.log("this is a middleware");constauth=request.headers.authorization;console.log(auth);if (!auth || auth !=='Bearer 12345') {next("Unidentified user!");return; }next(); })....
Cette fonction sera appelé systématiquement avant tous les endpoints de ce router !
Middlewares doivent obligatoirement signaler quand ils ont finis :
en invoquant la fonction next(), sans paramètre quand tout s'est bien passé
en invoquant la fonction next('message'), avec un paramètre, quand il y a une erreur. Le paramètre contient de l'information sur l'erreur
Si on oublie d'appeler next() notre serveur va bloquer dans cette fonction et ne jamais avancer.
Mettez en commentaire votre middleware pour le moment, après l'avoir essayé. Pour l'instant, nous ne l'utiliserons pas.
Exercice (facultatif) : Erreurs
Essayez de rentrer des mauvaises informations via Postman :
Créer un utilisateur doublon
Essayer de passer un champ qui n'est pas une colonne dans la base
Passer du texte dans le query param de la requête index (pour limit et offset)
Afficher, mettre à jour, ou supprimer un utilisateur qui n'existe pas
Pour l'instant, on reçoit un message moche (pas en json) et pas très parlant dans Postman.
Ajoutez un middleware qui gère les erreurs :
Renvoyez plutôt du json
Avoir au moins le champ suivants :
code : le code HTTP approprié
400 : la requête est mauvaise (erreur dans les données entrantes)
401 : pas autorisé
404 : élément pas trouvé
500 : erreur interne (dernier recours)
structured : un champ plus libre, mais plus structuré qui permettrait de localiser l'erreur coté front. Exemple :
params-invalid
connection-error
auth/unknown-email
message: un message humain décrivant l'erreur
data: (optionnel) les données supplémentaires concernant l'erreur
Astuce : il faut dire à express d'utiliser votre handler d'erreur avec app.use(...). Votre handler doit avoir 4 paramètres dans le callback, le premier étant l'objet d'erreur.
D'abord, on crée une classe qui dérive de la classe générique de Javascript : Error
import { ErrorCode } from'./ErrorCode';import { StructuredErrors } from'./StructuredErrors';exportclassApiErrorextendsError { constructor(public httpCode: ErrorCode, public structuredError: StructuredErrors, public errMessage: string, public errDetails?: any) {
super(errMessage) } get json() {return { code:this.httpCode, structured:this.structuredError, message:this.errMessage, details:this.errDetails } }}
Cette classe contient notamment une fonction permettant d'exporter l'erreur en format JSON selon la description de ce problème. Les code et structured sont les énumérations des différentes possibilités :
// Les numéros de d'erreur standard de HTTPexportenumErrorCode { NotFound =404, Unauthorized =403, BadRequest =400, TooManyRequests =429, InternalError =500}
// Les types d'erreur connus par notre API, permettant au consommateur de plus facile comprend ce qui s'est passéexporttypeStructuredErrors=// SQL'sql/failed'|'sql/not-found'|// Crud'validation/failed'|// Authorization'auth/unknown-email'|// Default'internal/unknown';
Ensuite, nous allons rédiger un handler (middleware) qui prend 4 paramètres pour qu'Express l'utilise pour gérer des erreurs :
import { NextFunction, Request, Response } from'express';import { ApiError } from'../utility/Error/ApiError';import { ErrorCode } from'../utility/Error/ErrorCode';exportconstDefaultErrorHandler=async (error:any, req:Request, res:Response, next:NextFunction) => {console.log(error);console.log(error.constructor.name);let err =newApiError(ErrorCode.InternalError,'internal/unknown','An unknown internal error occurred');if (!!error) {if (error instanceofApiError) { err = error; } elseif (!!error.sql) {// Ceci est une erreur envoyé par la base de données. On va supposer une erreur de la part de l'utilisateur // A faire : il est peut-être recommandé d'avoir un handler dédié aux erreurs SQL pour mieux trier celles qui sont de notre faute, et celles la faute de l'utilisateur.
err =newApiError(ErrorCode.BadRequest,'sql/failed',error.message, { sqlState:error.sqlState, sqlCode:error.code }); // A noter : on ne renvoie pas le SQL pour ne pas divulger les informations secrets } else {if (error.message) {err.errMessage =error.message; } } }console.log(err.json);res.status(err.httpCode).json(err.json); }
Notez les paramètres de notre DefaultErrorHandler. On accepte comme premier paramètre une erreur inconnue. Ensuite, on construit l'erreur formatée à l'aide de notre classe ApiError. Enfin, on renvoie une réponse avec le code HTTP et le json représentant l'erreur.
Utilisez le DefaultErrorHandler comme middleware sur votre serveur :