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.0en 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
interfacesetenums(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/configpeut 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.tsprisma.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 đ
