Construire Un Serveur Backend Robuste Avec NestJS 🚧
25 octobre 2021 | 15 mins de lecturePublié sur dev.to et medium.com
Il est temps d'en parler... Pas
Next, niNuxt, mais Nest !
P r o l o g u e
Récemment, je me suis challengé pour mettre en œuvre une solution backend robuste en JavaScript. Beaucoup de développeurs ont pour préjugé de croire que le JavaScript n'est pas assez sécurisé, performant ou tout simplement organisé pour gérer des données via des APIs. Je vais vous persuader du contraire...
Pour ce nouveau projet, j'ai choisi des technologies qui ont le vent en poupe, notamment PostgreSQL mais surtout NestJS. Ainsi, j'ai abandonné ma base de données de toujours qu'est MongoDB, au profit d'une base de données plus traditionnelle.
NB : J'étais un peu rouillé au début, mais le retour à la base de données relationnelle était une vraie bonne idée ; ne serait-ce que pour récupérer des données dans plusieurs tables en un appel, au contraire du NoSQL qui nécessite autant d'appels qu'il y a de documents à interroger... Finalement, le SQL c'est comme le vélo, cela ne s'oublie pas 😉
NestJS est un framework permettant de créer des applications serveurs (propulsées par NodeJS) à haute scalabilité. Ce dernier s'appuie notamment sur TypeScript et combine trois paradigmes de développement :
- La Programmation Orientée Objet #POO
- La Programmation Fonctionnelle #PF
- La Programmation Réactive Fonctionnelle #PRF
NestJS part du constat que les frameworks Web (notamment ceux orientés composants), ne sont pas assez structurés... Sauf un : Angular ! Il s'inspire donc de celui-ci afin de fournir une librairie complète pour les développements backend.
NB : En effet, Angular dispose d'une organisation similaire, mais contrairement à NestJS, la structure de celui-ci est (à mon avis) plus contraignante qu'autre chose... Je préfère ses homologues React et Vue (et Svelte) qui offrent plus de flexibilité dans la conception d'une application frontend. Cependant, je suis persuadé que cette "lourde" architecture peut être une aide significative à ceux qui débutent en développement frontend.
Enfin, NestJS encapsule (au choix) le moteur ExpressJS ou celui de Fastify, pour livrer un serveur HTTP robuste et efficace. On peut donc parler d'un framework de frameworks (meta-framework !? 🤔) Pour ce projet, j'ai choisi d'utiliser Fastify qui semble plus que prometteur...
I n i t i a l i z a t i o n
Il est temps de coder ! 🧑💻 Commençons par installer la dépendance principale de NestJS, puis initialisons un nouveau projet : hello-community
npm i -g @nestjs/cli
nest new hello-community
Alors, vous la voyez la ressemblance avec Angular ? 🙃 En utilisant l'interface en ligne de commande de NestJS, on se retrouve avec une architecture "modules - controllers - services" :
- Les controllers se chargent d'exposer les routes de l'application
- Les services s'occupent de la communication avec la / les source(s) de données
- Les modules mettent en relation les services avec les controllers
NB : En plus de l'initialisation du projet, l'outil CLI de NestJS va nous permettre de "scaffolder" individuellement ces mêmes fichiers (
nest generate <module|controller|service>
). Plutôt pratique !
Maintenant, intéressons-nous davantage au fichier main.ts
, point d'entrée de l'application. Par défaut, NestJS va monter un serveur HTTP en se basant sur ExpressJS. Or, dans mon cas, je vais avoir besoin de Fastify, qui (pour rappel) est censé être plus rapide et léger que son homologue.
Pour cela, il est nécessaire d'ajouter la dépendance @nestjs/platform-fastify
au projet, puis d'adapter le code présent dans le fichier main.ts
.
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter());
await app.listen(3000);
}
bootstrap();
main.ts
Et voilà ! Il vous suffit de vous rendre à l'adresse http://localhost:3000
pour admirer votre premier Hello World!
(ou bien Hello Community!
si vous avez été curieux) 👏
Et voilà ! Just go to http://localhost:3000
, to see your first Hello World!
(or Hello Community!
if you've been curious) 👏
NB : En précisant la chaîne de caractères
0.0.0.0
en tant que deuxième paramètre de la fonctionlisten()
, vous pourrez accéder à vos endpoints dans un même réseau local.
Difficile de voir une vraie différence entre ExpressJS et Fastify avec un simple appel. Quoique... J'ai remarqué une légère différence au moment du chargement. D'après Google Chrome (confirmé par Mozilla Firefox), ce premier Hello World!
charge 239 octets avec ExpressJS, contre 176 octets via Fastify. Force est de constater que ce framework est (au moins) plus léger que son prédécesseur.
C R U D
Rendons les choses plus intéressantes / plus concrètes en mettant en œuvre notre première API REST(ful) avec NestJS. Pour cela, on peut utiliser l'outil CLI pour créer des fichiers indépendamment (module / controller / service), ou bien générer tout l'ensemble directement (c'est-à-dire une nouvelle ressource CRUD).
La commande nest g resource
fait cela pour nous. En renseignant seulement le nom de la ressource (users
) et en précisant qu'il s'agit d'une API REST, on obtient rapidement un controller et un service prêts à l'emploi.
L'un des avantages de ce mode de fonctionnement, c'est que NestJS génère également certains patterns de développement, notamment le DTO (Data Transfer Object) bien connu des développeurs. #POO
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './interfaces/user.interface';
import { generateUuid } from '../utils';
@Injectable()
export class UsersService {
users: User[] = [];
findAll(): User[] {
return this.users;
}
findOne(id: string): User {
const user = this.users.find(user => user.id === id);
if (user) return user;
throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
}
findOneByEmail(email: string): User {
const user = this.users.find(user => user.email === email);
if (user) return user;
throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
}
create(createUserDto: CreateUserDto): { createdId: string } {
const uuid = generateUuid();
this.users = [
...this.users,
{
id: uuid,
...createUserDto
}
];
return { createdId: uuid };
}
update(id: string, updateUserDto: UpdateUserDto): { updatedId: string } {
let founded = false;
this.users = this.users.map(user => {
if (user.id === id) {
founded = true;
return { ...user, ...updateUserDto };
}
return user;
});
if (founded) return { updatedId: id };
throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
}
remove(id: string): { removedId: string } {
const length = this.users.length;
this.users = this.users.filter(user => user.id !== id);
if (length !== this.users.length) return { removedId: id };
throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
}
}
users.controller.ts
Pour cette première ressource, je vous propose de conserver le controller en l'état (users.controller.ts
), mais d'adapter le code du service (users.service.ts
), afin de simuler une source de données.
NB : Pour des soucis pratiques (réduction de la taille de cet article), j'ai choisi de faire apparaitre le type de mes données. Dans un cas réel, je vous conseille de ranger cela aux bons endroits, à savoir dans des dossiers
interfaces
etenums
(ou tout simplementmodels
).
Assurez-vous d'avoir enregistré votre module users.module.ts au niveau du module principal (app.module.ts
), puis lancer l'application (npm run start
). Vous devriez pouvoir commencer à jouer avec votre API via le protocole REST (et Postman bien sûr 😎).
S é c u r i t é
Les choses sérieuses débutent ici... Si on reviens un peu en arrière, on remarque que nos endpoints sont tous accessibles avec le même niveau de sécurité (c'est-à-dire aucun). De plus, le mot de passe de l'utilisateur est sauvegardé en clair en base de données... Il convient donc de sécuriser tout cela !
Première étape : l'offuscation du mot de passe. NestJS supporte le chiffrement de données (grâce au module crypto de NodeJS), mais aussi le hachage de données. Ici, j'ai choisi d'utiliser la librairie bcrypt pour "hasher" mes chaines de caractères, afin de garantir une sécurité unidirectionnelle (et optimale) ! 👌
Installons les dépendances (npm i --save bcrypt && npm i --save-dev @types/bcrypt
) et modifions le service de création d'utilisateur pour sécuriser le mot de passe.
import * as bcrypt from 'bcrypt';
@Injectable()
export class UsersService {
// ...
async create(createUserDto: CreateUserDto): Promise<{ createdId: string }> {
const uuid = generateUuid();
const salt = await bcrypt.genSalt();
const hash = await bcrypt.hash(password, salt);
this.users = [
...this.users,
{
id: uuid,
password: hash,
...createUserDto
}
];
return { createdId: uuid };
}
// ...
}
users.service.ts
Deuxième étape : sécuriser les appels en utilisant un protocole d'authentification. Cette seconde tâche est plus complexe puisqu'elle nécessite de mettre en place une ou plusieurs stratégies d'authentification. Fort heureusement, NestJS dispose déjà d'un concept de "guard" permettant "de valider / d'invalider" les routes de l'application. C'est partie !
npm install --save @nestjs/passport passport passport-local passport-jwt
npm install --save-dev @types/passport-local @types/passport-jwt
Le meilleur moyen d'établir ce genre de fonctionnement (avec NodeJS) est d'utiliser Passport. Côté conception, je vais devoir créer une nouvelle route d'authentification qui me fournira un jeton permettant de valider mes endpoints /users/:id
(GET, PATCH et DELETE). Voilà pourquoi, je récupère (ci-dessus) deux stratégies :
- La stratégie LOCAL, permet une authentification simple (basé sur le couple
username
/password
) - La stratégie JWT, s'appuie sur la validité d'un jeton dans le temps
À nouveau, il va falloir utiliser l'interface en ligne de commande pour instancier un module, un controller ainsi qu'un service (nest g <module|controller|service> auth
). De plus, nous allons devoir créer deux nouveaux fichiers correspondants respectivement aux stratégies de Passport.
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { User } from '../users/interfaces/user.interface';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
usernameField: 'email'
});
}
async validate(email: string, password: string): Promise<User> {
const user = await this.authService.validateUser(email, password);
if (user) return user;
throw new UnauthorizedException();
}
}
local.strategy.ts
NB : Vous noterez ici que je surcharge le comportement par défaut de la stratégie LOCAL, pour utiliser le couple
password
.
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
interface Payload {
id: string;
email: string;
iat: string;
exp: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET
});
}
async validate(payload: Payload) {
return { userId: payload.id, userEmail: payload.email };
}
}
jwt.strategy.ts
NB : Dans un cas réel, je vous déconseille d'exposer le secret JWT ! Il est préférable de le récupérer autrement, via le fichier
.env
(le module@nestjs/config
peut alors être utile) ou un autre procédé...
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { User } from '../users/interfaces/user.interface';
import { UsersService } from '../users/users.service';
type UserOrNull = User | null;
@Injectable()
export class AuthService {
constructor(
private usersService: UsersService,
private jwtService: JwtService
) {}
async validateUser(email: string, password: string): Promise<UserOrNull> {
const user = await this.usersService.findOneByEmail(email);
const isMatch = await bcrypt.compare(password, user.password);
if (user && isMatch) {
return user;
}
return null;
}
async login(user: { id: string; email: string }) {
const payload = { userId: user.id, userEmail: user.email };
return {
access_token: this.jwtService.sign(payload)
};
}
}
auth.service.ts
import { Controller, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(AuthGuard('local'))
@Post('login')
async login(@Request() req) {
return this.authService.login(req.user);
}
}
auth.controller.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: 'Hello_Community',
signOptions: { expiresIn: '300s' }
}),
UsersModule
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService]
})
export class AuthModule {}
auth.module.ts
Après tout cela, quelques explications s'imposent. Commençons avec le controller...
Lors de l'appel du endpoint /login
, l'utilisateur va "POSTer" ses identifiants au format email
/ password
.
NestJS va alors protéger la route par la "guard" LOCAL qui s'occupera de vérifier la présence de l'utilisateur en base. Si l'utilisateur portant l'adresse email unique est trouvé, le mot de passe va être vérifié à son tour par bcrypt (auth.service.ts
).
Si tout se passe bien, la fonction login()
va être jouée avec les informations relatives à l'utilisateur (notamment l'identifiant unique et l'email) qui seront nécessaires à la génération d'un jeton d'une validité de 5 minutes (300s
).
Enfin, au retour du endpoint, nous devrions récupérer un jeton JWT (access_token
) qui servira à authentifier les autres appels de notre API REST. Pour ce faire, deux dernières choses :
- Ajouter la "guard" JWT sur les endpoints
/users/:id
(users.controller.ts
) - Ajouter le
Bearer <token>
dans le header de(s) l'appel(s)
P R I S M A
Évoquons maintenant la partie concernant les données. À partir de maintenant il va falloir se connecter à notre base de données (préalablement installé sur notre machine : sudo apt install postgresql
), puis l'interroger de manière REST(ful), à savoir en GET, POST, PATCH, DELETE, etc...
Une dernière fois, un choix cornélien s'impose, entre :
- Le driver de la base de données (requêtage de bas niveau)
- Le "Query Builder" (tel que KnexJS)
- L'ORM - Object Relational Mapping (requêtage de haut niveau)
NB : À titre d'exemple, avec MongoDB, j'ai l'habitude de développer avec Mongoose (qui est un ODM - Object Document Mapper) pour requêter ma source de données NoSQL. Ayant fait le choix du SQL avec Postgres, il va être indispensable d'utiliser une nouvelle librairie...
Pas de panique ! NestJS est là 🎉 Et il dispose déjà de "connecteurs" pour certaines librairies, dont : Sequelize, TypeORM ou encore Prisma. J'ai préféré écarter Sequelize (malgré sa popularité) de la liste des challengers, puisque je souhaite pouvoir ré-utiliser "l'élu" pour un développement futur avec NoSQL (je ne peux vivre sans Mongo 😅). De ce fait, le seul choix possible n'est autre que Prisma. D'ailleurs, j'ai été agréablement surpris par ses concepts et son API intuitive ! Il est grand temps d'installer cette nouvelle dépendance, puis d'initialiser le schéma de la base de données.
npm install prisma --save-dev
npx prisma init
En exécutant la commande init
de Prisma, on devrait voir apparaitre un nouveau dossier à la racine de notre projet, avec un premier brouillon du schéma. Je vous invite donc à le compléter de la sorte :
datasource db {
provider = "postgresql"
url = "postgresql://<username>:<password>@<host>:<post>/hello-community?schema=public"
}
generator client {
provider = "prisma-client-js"
}
enum Gender {
X
Y
}
model User {
id String @id @default(uuid())
email String @unique
password String
firstName String?
lastName String?
gender Gender
}
schema.prisma
Aussi explicite soit-il, le schéma de base de données va nous permettre d'initialiser notre base PostgreSQL. Clairement, en exécutant ce dernier (avec la commande migrate
), Prisma va transcrire les informations ci-dessus en script SQL, afin de créer nos tables (avec les bons champs et les bons types) et ajouter nos relations (clés primaires et étrangères). Il finira par exécuter la requête directement en base.
npx prisma migrate dev --name init
NB : À partir de là, notre base est créée. Vous pouvez vous y connecter et constater le résultat ; ou bien aller jeter un coup d'oeil à la requête SQL présent dans le fichier
migration.sql
(dans le dossierprisma
).
npm i @prisma/client
nest g module prisma
nest g service prisma
La base de données et donc correctement créée, mais il nous reste encore à faire le lien entre PostgreSQL (via Prisma) et NestJS. En utilisant l'outil CLI de NestJS (ci-dessus), j'ai généré deux nouveaux fichiers :
prisma.module.ts
prisma.service.ts
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}
prisma.module.ts
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
prisma.service.ts
Une dernière étape : l'utilisation ! Vous vous souvenez de notre CRUD ci-dessus ? Faisons le évoluer ! Dorénavant, plus question de simuler notre base de données, requêtons là directement. Pour cela, l'API de Prisma est tout simplement magique ! Elle génère (grâce au schéma de la base de données) les types correspondant aux models
, et nous fournit des fonctions intuitives pour interroger notre base SQL. Voyez par vous-même le résultat avec les fonctions de création et de récupération d'un utilisateur.
NB : N'oubliez pas d'enregistrer votre nouveau module (
prisma.module.ts
) dans les imports du moduleusers
, sinon impossible de requêter votre source de données...
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { User, Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
// ...
async findOne(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (user) return user;
throw new HttpException('No User Found', HttpStatus.NOT_FOUND);
}
// ...
async create({ password, ...userCreateInput }: Prisma.UserCreateInput): Promise<{ createdId: string }> {
const salt = await bcrypt.genSalt();
const hash = await bcrypt.hash(password, salt);
const user = await this.prisma.user.create({
data: {
password: hash,
...userCreateInput
}
});
return { createdId: user.id };
}
// ...
}
users.service.ts
NB : Pour des soucis pratiques (réduction de la taille de cet article), je vous présente ici seulement deux des fonctions présentes dans le service, mais vous pouvez consulter le reste du code sur GitHub.
E p i l o g u e
NestJS est une pépite du développement Web ! Je me suis éclaté à développer cette nouvelle solution backend en TypeScript. Tout est très bien structuré ! Par ailleurs, la CLI est d'une grande aide lorsqu'il s'agit de "scaffolder" tout ou partie d'une ressource REST.
Globalement, j'ai été agréablement surpris par son organisation qui n'est pas sans rappeler les frameworks que sont SpringBoot (en Java) ou encore Django (en Python). Les fichiers sont rangés à leur place et on s'y retrouve. Comme quoi, le pattern MVC est toujours une valeur sûre...
NB : De manière plus objective, j'ai même été jusqu'à demander l'avis d'un confrère développeur spécialisé en Java, et je vous assure il n'a eu aucun problème à se plonger dans le code (merci TypeScript).
À propos de la sécurité, NestJS respecte sa part du contrat sans pour autant réinventer les choses, puisqu'il se repose majoritairement sur la dépendance Passport pour consolider ses endpoints. Il peut aussi définir la sécurité des headers HTTP en intégrant simplement des middlewares fournis par la librairie Helmet. De même pour le chiffrement et le hachage des données, ces concepts ne sont pas une nouveauté et existent également dans d'autres frameworks backend.
Je n'ai pas évoqué les tests unitaires précédemment, mais ils sont bien présents dans ce projet. NestJS fait (là encore) un bon choix en "abandonnant" le couple Karma / Jasmine au profit de Jest. Il est très simple de mocker les sources de données Prisma, pour se concentrer sur les appels d'un controller, ou bien sur la fonction d'un service.
En disposant de concepts puissants et d'une documentation bien faite, NestJS s'impose comme une référence dans le développement backend en JavaScript. C'est un framework personnalisable (en acceptant le moteur ExpressJS, ou en changeant pour celui de Fastify), et pourtant très complet avec son lot de plugins : tel que la possibilité d'ajouter de la documentation OpenAPI via Swagger, ou encore d'ajouter l'abstraction GraphQL à nos appels REST(ful), etc...
À l'avenir, je n'hésiterai plus dans mon choix de socle technique pour NodeJS, j'opterai simplement pour NestJS 👍