Dans cet article nous allons voir pourquoi et comment mettre en place l'injection de dépendance dans une API.
Nous allons mettre en place une API complÚte RESTfull pour gérer des utilisateurs avec les actions basiques (consultation, création, edition, suppression). Et tant qu'à faire, nous allons mettre des tests unitaires et fonctionnels.
Mais avant de commencer à tout mettre en place, je vais essayer ici de vous résumer ce qu'est l'injection de dépendance et à quoi ça sert.
Pourquoi utiliser l'injection de dépendance
Imaginons une classe User
qui a besoin d'une classe Database
pour ĂȘtre sauvegardĂ©. On serait tenter d'initialiser la connection Ă la base de donnĂ©e dans le constructeur de l'utilisateur :
class Logger {
log(message: string): void {
const time = new Date().toISOString();
console.log(`${time} -- ${message}`);
}
}
class Database {
constructor(connectionString: string) {
// do some stuff here
}
}
class User {
private database: Database;
constructor(public email: string, databaseString: string) {
this.database = new Database(databaseString);
}
}
const user = new User("john@doe.io", "./user.sqlite");
Cela pose plusieurs problĂšme:
- la classe
User
depends de la classeDatabase
. Si on change l'implémentation de la classeDatabase
, il faudra modifier la classeUser
- le code est beaucoup moins testable car pour tester un utilisateur, je dois connaĂźtre le fonctionnement de la classe user
Pour vous accentuer le problĂšme, rajoutons une classe Logger
qui permet de logger les événements dans l'appli. Imaginons que nous avons besoin de logger la connection à la base de donnée. Le code devient donc
class Logger {
log(message: string): void {
const time = new Date().toISOString();
console.log(`${time} -- ${message}`);
}
}
class Database {
constructor(connectionString: string) {
const logger = new Logger();
logger.log(`Connected to ${connectionString}`);
}
}
class User {
private database: Database;
constructor(public email: string, databaseString: string) {
this.database = new Database(databaseString);
}
}
const user = new User("john@doe.io", "./user.sqlite");
On voit bien que la situation se dégrade car toutes les classes deviennent dépendantes entre elles. Pour corriger cela, nous allons injecter directement la classe Database
dans le constructeur de User
:
class Logger {
/* ... */
}
class Database {
constructor(logger: Logger, connectionString: string) {
logger.log(`Connected to ${connectionString}`);
}
}
class User {
constructor(private database: Database) {}
}
const logger = new Logger();
const database = new Database(logger, "db.sqlite");
const user = new User(database);
Ce code devient plus solide car la classe User
, Database
et Logger
sont découplés.
OK, mais ça devient plus pénible d'instancier une
User
.
Effectivement. C'est pourquoi nous utilisons un Container
qui va enregistrer les classes qui peuvent ĂȘtre injectĂ©es et nous proposer de crĂ©er des instances facilement :
class Logger {
/* ... */
}
class Database {
/* ... */
}
class User {
/* ... */
}
class Container {
getLogger(): Logger {
return new Logger();
}
getDatabase(): Database {
return new Database(this.getLogger(), "db.sqlite");
}
getUser(): User {
return new User(this.getDatabase());
}
}
const container = new Container();
const user = container.getUser();
Le code est plus long mais tout devient découpé. Rassurez-vous, nous n'allons pas implémenter tout cela à la main. De trÚs bonne librairies existent. Celle que j'ai choisi est Inversify.
Initialisation de l'application avec Typescript
Maintenant que vous voyez un peu à quoi sert l'injection de dépendance, nous allons mettre en pratique dans un cas réel.
Nous allons utiliser TypeORM et Express.js. J'ai choisi ces librairies car je les connais bien mais il est possible d'utiliser Sequelize Ă la place de TypeORM, remplacer Express.js par Fastify ou autre chose.
Commençons par créer un projet Node.js versionné avec Git:
mkdir dependecy-injection-example
cd dependecy-injection-example
npm init
git init # Initialize Git repository (optional)
Installons maintenant Typescript:
npm add typescript @types/node --save-dev
Nous avons ajouté deux librairies :
typescript
qui va nous offrir les outils de transpilation@types/node
qui va ajouter la définition des types de Node.js
Ajoutons donc notre premier fichier Typescript :
// src/main.ts
function say(message: string): void {
console.log(`I said: ${message}`);
}
say("Hello");
Ce code est vraiment trÚs basique et va juste nous servir a vérifier que la transpilation fonctionne.
Afin d'utiliser la transpilation de Typescript, nous avons besoin de définir un fichier de configuration tsconfig.json
. En voici un basique:
// tsconfig.json
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"module": "commonjs",
"types": ["node"],
"target": "es6",
"esModuleInterop": true,
"lib": ["es6"],
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Cela fait beaucoup de code mais les deux directives a retenir ici sont: rootDir
et outDir
. Elles vont simplement spécifier ou sont les fichiers Typescript (rootDir
) et ou placer les fichiers Javascript résultants de la transpilation (outDir
).
Dans notre cas je place tous les fichiers Typescript dans le dossier src
et le résultat de la transpilation dans dist
.
On ajoute maintenant un script dans le package.json
pour compiler et executer notre application:
// package.json
{
// ...
"scripts": {
"start": "tsc && node dist/main.js"
}
// ...
}
On peut vérifier que tout fonctionne en executant le script:
npm start
> dependecy-injection-example@1.0.0 start /home/alexandre/github/madeindjs/dependecy-injection-example
> tsc && node dist/main.js
I said: Hello
Nous n'avons pas besoin d'aller plus loin pour le moment!
Mise en place du serveur web
Jusqu'ici nous avons mis en place un environnement qui va nous permettre d'éviter les erreurs de syntaxe et de typage automatiquement avec Typescript. Il est temps d'enfin faire une vrai fonctionnalité: le serveur web.
Il existe plusieurs bibliothÚque pour faire un serveur web avec Node.js. Dans mon cas je recommande Express.js tout simplement car c'est celle qui a une plus grosse communauté et elle offre des fonctionnalités basique. Elle vous laisse aussi la liberté d'organiser votre code comme vous le souhaitez tout en offrant une tonne de plugin pour rajouter des fonctionnalités par dessus.
Pour l'ajouter c'est trĂšs facile:
npm add express --save
On va aussi ajouter les typages Typescript qui vont aider un peu votre Ă©diteur de code :
npm add @types/express --save-dev
Et maintenant nous pouvons instancier notre serveur dans le fichier main.ts
// src/main.ts
import express, {Request, Response} from "express";
const app = express();
const port = 3000;
app.get("/", (req: Request, res: Response) => res.send("Hello World!"));
app.listen(port, () => console.log(`listen on http://localhost:${port}/`));
Vous pouvez lancer le serveur avec Nodemon (si ce n'est pas déjà fait) avec npm run start:watch
et vous allez avoir le résultat suivant :
Server listen on http://localhost:3000/
Vous pouvez donc ouvrir votre navigateur a l'adresse http://localhost:3000 et voir que tout fonctionne. Voici ici le résultat en utilisant curl
:
curl http://localhost:3000
Hello World!
Maintenant que tout fonctionne, commitons les changements:
git commit -am "Add express.js server"
Mise en place de Inversify
Dans cette section nous allons (enfin) mettre en place le systÚme d'injection de dépendance avec Inversify.
Nous allons commencer par mettre en place un Logger qui pourra ĂȘtre injectĂ© dans toutes les classes de notre application. Il nous permettra de les requĂȘtes HTTP par exemple mais aussi bien d'autres Ă©vĂ©nements.
Installons donc inversify
:
npm install inversify --save
Et créons une classe pour logger les événements toute simple:
NOTE: On pourrait utiliser une librairie comme Winston ou Morgan mais pour l'exemple je vais créer une classe assez basique :
// src/services/logger.service.ts
export class Logger {
public log(level: "DEBUG" | "INFO" | "ERROR", message: string): void {
const time = new Date().toISOString();
console.log(`${time} - ${level} - ${message}`);
}
}
Pour la rendre injectable, il faut lui ajouter un décorateur @injectable
. Ce dĂ©corateur va simplement ajouter une metadata a notre classe afin qu'elle puisse ĂȘtre injectĂ©e dans nos futures dĂ©pendances.
import {injectable} from "inversify";
@injectable()
export class Logger {
/* ... */
}
Et voilà . Il ne nous reste plus qu'à créer le container qui va enregistrer ce service. La documentation recommande de créer un objet TYPES
qui va simplement stocker les identifiants de nos services. Nous allons créer un dossier core
qui contiendra tout le code transverse Ă toute notre application.
// src/core/types.core.ts
export const TYPES = {Logger: Symbol.for("Logger")};
NOTE: Un Symbol
est un type primitif qui permet d'avoir une référence unique.
Maintenant nous pouvons utiliser ce symbole pour enregistrer notre logger dans un nouveau fichier container.core.ts
Il suffit d'instancier un Container
et d'ajouter notre service avec la méthode bind()
. On exporte ensuite cette instance pour l'utiliser dans l'application:
// src/core/container.core.ts
import {Container} from "inversify";
import {Logger} from "../services/logger.service";
import {TYPES} from "./types.core";
export const container = new Container();
container.bind(TYPES.Logger).to(Logger);
Et voilĂ .
Création d'un contrÎleur
Laissons de cĂŽtĂ© cette classe que nous allons utiliser plus tard dans notre premier contrĂŽleur. Les contrĂŽleurs font partis du design patern MVC: ModĂšle, Vue, ContrĂŽleur. Leur but est d'intercepter la requĂȘte et d'appeler les services dĂ©diĂ©s. Il existe une librairie officielle Inversify pour intĂ©grer l'injection de dĂ©pendance directement dans nos contrĂŽleurs: inverisfy-express-utils
.
On commence par installer la librairie. On va aussi ajouter body-parser
qui va nous permettre de traiter les paramĂštres de la requĂȘte HTTP (nous en reparlerons plus loins).
Pour l'installer, c'est trĂšs facile. Il suffit de suivre la https://github.com/inversify/inversify-express-utils[documentation officielle]. On commence donc par installer quelques librairies.
npm install inversify-express-utils reflect-metadata body-parse --save
reflet-metadata
permet Ă Inversify d'ajouter des metadata sur notre classe. Cet import doit ĂȘtre situĂ© au tout dĂ©bt du premier fichier.body-parse
va nous donner la possibilitĂ© d'extraire les paramĂštres des requĂȘtes HTTP (nous ren reparlerons plus tard)
Avant d'écrire notre premier contrÎleur, il est nécessaire de faire quelques modifications à la création de notre serveur HTTP. Créons un nouveau fichier core/server.core.ts
qui va simplement définir notre serveur HTTP avec inversify-express-utils
:
// src/core/server.ts
import * as bodyParser from "body-parser";
import {InversifyExpressServer} from "inversify-express-utils";
import {container} from "./container.core";
export const server = new InversifyExpressServer(container);
server.setConfig((app) => {
app.use(bodyParser.urlencoded({extended: true}));
app.use(bodyParser.json());
});
Comme vous pouvez le voir, nous utilisons maintenant une instance de InversifyExpressServer
. La méthode setConfig
permet d'ajouter des middleware (nous y reviendrons plus tard). Passons au fichier main.ts
que nous allons modifier un peu:
// src/main.ts
import "reflect-metadata";
import {container} from "./core/container.core";
import {server} from "./core/server";
import {TYPES} from "./core/types.core";
const port = 3000;
server
.build()
.listen(port, () => console.log(`Listen on http://localhost:${port}/`));
Et voilĂ . Nous pouvons maintenant nous attaquer Ă notre premier contrĂŽleur.
Le contrÎleur est une classe comme les autres. Elle va simplement le décorateur @controller
. Ce décorateur va lui aussi déclarer ce contrÎleur comme @injectable
mais aussi nos offrir des fonctionnalités spéciales.
Passons directement à l'implémentation afin que cela soit plus parlant:
// src/controllers/home.controller.ts
import {controller, httpGet} from "inversify-express-utils";
@controller("/")
export class HomeController {
@httpGet("")
public index(req: Request, res: Response) {
return res.send("Hello world");
}
}
Comme vous pouvez le voir, l'implémentation est trÚs claire grùce aux décorateurs:
- Le
@controller("/")
nous indique que toutes les routes de ce contrÎleur seront préfixées par/
- Le second décorateur
@httpGet("/")
définit que cette méthode sera accÚssible sur l'URL/
via le verbe HTTP POST.
Maintenant essayons d'injecter le Logger
afin d'afficher un message lorsque cette route est utilisée:
// src/controllers/home.controller.ts
// ...
import {TYPES} from "../core/types.core";
import {Logger} from "../services/logger.service";
@controller("/")
export class HomeController {
public constructor(@inject(TYPES.Logger) private readonly logger: Logger) {}
@httpGet("")
public index(req: Request, res: Response) {
this.logger.log("INFO", "Get Home.index");
return res.send("Hello world");
}
}
Et voilĂ !
Le décorateur @inject
s'occupe de tout, il suffit de spécifier le symbole. C'est magique.
La derniĂšre Ă©tape est d'importer manuellement ce contrĂŽleur dans le container. C'est vraiment trĂšs simple Ă faire :
// src/core/container.core.ts
import {Container} from 'inversify';
+ import '../controllers/home.controller';
import '../controllers/users.controller';
// ...
Vous pouvez maintenant démarrer le serveur avec npm run start
ou attendre que la transpilation se fasse automatiquement si vous n'avez pas arrĂȘtĂ© le prĂ©cĂ©dent serveur.
Mise en place de TypeORM
Ici nous allons mettre en place la couche Model du design patern MVC. Il s'agit de la couche relative à la base de données.
Afin d'accĂ©der a la base de donnĂ©es, nous allons utiliser un ORM (Object Relational Mapper). Le but d'un ORM est de dialoguer avec la base de donnĂ©es et de vous Ă©viter d'Ă©crire les requĂȘtes SQL Ă la main. Il nous permet aussi d'ajouter une couche d'abstraction au type de base de donnĂ©es et nous permet de ne pas nous soucier des diffĂ©rences entre PostgreSQL et SQLite par exemple.
Il existe plusieurs ORM pour Nodejs: Sequelize, Mongoose et TypeORM. J'ai choisis le dernier car c'est celui qui s'intÚgre le mieux avec Typescript. Il propose aussi une approche Active Record ET Data Mapper que j'apprécie beaucoup.
Pour l'installer c'est trÚs facile. Nous allons installer la librairie TypeORM mais aussi deux librairies supplémentaires :
sqlite3
qui va nous permettre de dialoguer avec notre base de données Sqlitedotenv
qui va nous permettre de commencer à définir des variables d'environnement comme la connexion à notre base de données.
C'est parti:
npm add typeorm sqlite3 dotenv --save
Nous allons maintenant générer notre fichier de configuration. Par défault, dotenv
va chercher un fichier nomé .env
. Créons le:
touch .env
Et commençons par définir les variables d'environnement de TypeORM pour une connexion basique à une base de donnée SQLite:
TYPEORM_CONNECTION=sqlite
TYPEORM_DATABASE=db/development.sqlite
TYPEORM_LOGGING=true
TYPEORM_SYNCHRONIZE=true
TYPEORM_ENTITIES=src/entities/*.entity.ts,dist/entities/*.entity.js
Comme vous pouvez le voir on définis que nous utiliserons Sqlite et que la base de données sera stockée dans le dossier db/
. TYPEORM_SYNCHRONIZE
permet d'éviter de ne pas se soucier des migrations et ainsi laisser TypeORM faire les modifications sur le schéma de notre base de données si nécessaire. Nous spécifions ensuite ou sont situé nos entités avec TYPEORM_ENTITIES
.
Il ne nous reste plus qu'a configurer dotenv
pour charger ce fichier. Pour faire cela, j'utilise le flag --require
de Node.js qui permet de pré-charger une librairie. Il suffit donc de modifier le package.json
:
{
// ...
"scripts": {
"start": "tsc && node dist/main.js -r dotenv/config",
"start:watch": "nodemon"
// ...
}
// ...
}
Nous allons maintenant créer un service DatabaseService
qu va s'occuper de connecter TypeORM à notre base de données. Comme nous avons mis en place l'injection de dépendance, ce service sera lui aussi injectable. Voici l'implémentation complÚte. Pas de panique, je vous détaille la logique ensuite.
// src/services/database.service.ts
import {inject, injectable} from "inversify";
import {Connection, createConnection, ObjectType} from "typeorm";
import {TYPES} from "../core/types.core";
import {Logger} from "./logger.service";
@injectable()
export class DatabaseService {
private static connection: Connection;
public constructor(@inject(TYPES.Logger) private readonly logger: Logger) {}
public async getConnection(): Promise<Connection> {
if (DatabaseService.connection instanceof Connection) {
return DatabaseService.connection;
}
try {
DatabaseService.connection = await createConnection();
this.logger.log("INFO", `Connection established`);
} catch (e) {
this.logger.log("ERROR", "Cannot establish database connection", e);
process.exit(1);
}
return DatabaseService.connection;
}
public async getRepository<T>(repository: ObjectType<T>): Promise<T> {
const connection = await this.getConnection();
return await connection.getCustomRepository<T>(repository);
}
}
Cette classe possÚde deux méthodes :
getConnection
: cette méthode va initialiser une nouvelle connection à la base de données. Celle-ci va appeler la méthodecreateConnection
qui va chercher un fichier de ormconfig (dans notre cas les variables d'environnement chargée pardotenv
) et établir une connection. Une fois la connection effectuée, elle est stoquée dans une propriété statique qui sera retournée directement la prochaine foisgetRepository
: cette méthode va nous permettre de manipuler nos modÚles via les repository. Nous en parlerons en détails plus loin
NOTE: C'est une bonne pratique de cacher la logique de la librairie par nos propres classe. Cela nous permettrai de moi dépendre de la librairie et de pouvoir migrer plus facilement si un jours nous souhaiterions changer.
Maintenant que notre service est créé, il faut l'ajouter à notre container :
// src/core/types.core.ts
export const TYPES = {
// ...
DatabaseService: Symbol.for("DatabaseService"),
};
// src/core/container.core.ts
import {Container} from "inversify";
import {DatabaseService} from "../services/database.service";
// ...
export const container = new Container();
// ...
container.bind(TYPES.DatabaseService).to(DatabaseService);
Et voilĂ .
Nous pouvons maintenant créer notre premier modÚle User
. En utilisant le patern Data Mapper il va falloir créer deux classe :
- l'entity : elle va définir les attributs des champs à sauvegarder dans la base de donnée. Dans notre cas, je vais simplement créer deux attributs:
email
etpassword
(le mot de passe sera chiffrée plus tards). - le repository : elle va ajouter certaines logiques pour sauvegarder nos entités.
Afin de simplifier l'exemple, je vais mettre ces deux classes dans le mĂȘme fichier mais vous pouvez trĂšs bien les sĂ©parer :
// src/entities/user.entity.ts
import {
Column,
Entity,
EntityRepository,
PrimaryGeneratedColumn,
Repository,
} from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({unique: true})
email: string;
@Column()
password: string;
}
@EntityRepository(User)
export class UserRepository extends Repository<User> {}
Et voilà . Le résultat est vraiment trÚs simple grà ce aux décorateurs @columns
proposées par TypeORM. Ceux-ci peuvent aussi définir le type d'information a stocker (Texte, date, etc..). L'implémentation de ce modÚle est suffisante pour le moment.
Pour l'instant notre travail n'est pas trÚs visible mais tenez bon car vous allez voir le résultat dans la prochaine section.
Nous pouvons commiter les changements effectuées jusqu'à maintenant:
git add .
git commit -m "Setup TypeORM"
Création du contrÎleur des utilisateurs
Il est maintenant temps d'entrer dans la partie concrÚte et de créer le contrÎleur qui va gérer les utiliseurs. Ce contrÎleur va respecter les normes REST et proposer les actions CRUD classiques. C'est à dire Create, Read, Update et Delete.
Lister les utilisateurs
Nous allons commencer par la méthode index
qui est la plus simple.
Comme nous l'avons vu plutĂŽt, les contrĂŽleurs peuvent injecter nos services. Nous allons donc injecter le DatabaseService
afin de pouvoir récupérer le UserRepository
. Il suffira ensuite d'appeler la méthode userRepository.find
afin de récupérer la liste de tous les utilisateur (qui est vide pour le moment).
Voici l'implémentation de notre contrÎleur:
// src/controllers/users.controller.ts
import {Request, Response} from "express";
import {inject} from "inversify";
import {controller, httpGet} from "inversify-express-utils";
import {TYPES} from "../core/types.core";
import {UserRepository} from "../entities/user.entity";
import {DatabaseService} from "../services/database.service";
@controller("/users")
export class UsersController {
public constructor(
@inject(TYPES.DatabaseService) private readonly database: DatabaseService
) {}
@httpGet("/")
public async index(req: Request, res: Response) {
const userRepository = await this.database.getRepository(UserRepository);
const users = await userRepository.find();
return res.json(users);
}
}
Et bien sûr, il ne faut pas oublier d'ajouter l'import de ce nouveau contrÎleur dans le container:
// src/core/container.core.ts
import {Container} from 'inversify';
import "../controllers/home.controller";
+ import "../controllers/users.controller";
import {DatabaseService} from '../services/database.service';
import {Logger} from '../services/logger.service';
// ...
Et voilĂ . Lancez la commande npm run start:watch
pour dĂ©marrer le serveur si vous l'avez arrĂȘtĂ© et testons la fonctionnalitĂ© avec cURL
:
curl http://localhost:3000/users
Le retour de la commande nous indique un tableau vide: c'est normal car il n'y a pas encore d'utilisateur. En revanche, le terminal du serveur nous indique qu'il s'est passé beaucoup de chose:
query: BEGIN TRANSACTION
query: SELECT _ FROM "sqlite_master" WHERE "type" = 'table' AND "name" IN ('user')
query: SELECT _ FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" IN ('user')
query: SELECT \* FROM "sqlite_master" WHERE "type" = 'table' AND "name" = 'typeorm_metadata'
query: CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password" varchar NOT NULL)
query: COMMIT
2020-11-15T22:09:25.476Z - INFO - Connection established - {}
query: SELECT "User"."id" AS "User_id", "User"."email" AS "User_email", "User"."password" AS "User_password" FROM "user" "User"
Il s'agit des logs de TypeORM. Ceux-ci nous indiquent que:
. TypeORM a essayé de voir s'il existait une table nommée user
. TypeORM a crée cette table puisqu'elle n'existait pas
. la connexion a la base de données été établie
. La requĂȘte SQL pour retrouver tous les utilisateurs a Ă©tĂ© exĂ©cutĂ©e
Cela nous indique que tout fonctionne parfaitement ! Mais je vous sent un peu déçu car nous n'avons pas encore d'utilisateur. Passons à la suite !
Create
Maintenant que toute notre structure a été mise en place, la suite va aller beaucoup plus vite. Passons directement à l'implémentation et je fous explique le code ensuite:
// src/controllers/home.controller.ts
// ...
import {
controller,
httpGet,
httpPost,
requestBody,
} from "inversify-express-utils";
// ...
interface CreateUserBody {
email: string;
password: string;
}
@controller("/users")
export class UsersController {
// ...
@httpPost("/")
public async create(
@requestBody() body: CreateUserBody,
req: Request,
res: Response
) {
const repository = await this.database.getRepository(UserRepository);
const user = new User();
user.email = body.email;
user.password = body.password;
repository.save(user);
return res.sendStatus(201);
}
}
Cela fait un peut de code mais pas de panique. CreateUserBody
est une interface qui dĂ©finie les paramĂštres HTTP qui peuvent ĂȘtre reçu. Nous prenons ces paramĂštres et nous les envoyons directement au repository
.
Testons que tout cela fonctionne:
curl -X POST -d "email=test@test.fr" -d "password=test" http://localhost:3000/users
Parfait. On voit que tout fonctionne correctement!
Passons à la suite pour récupérer les information de cet utilisateur.
Show
La méthode show
va s'occuper de retrouver les informations d'un utilisateur. Cette méthode va prendre l'identifiant de l'utilisateur. On va ensuite utiliser le repository
pour récupérer l'utilisateur.
Voici l'implémentation :
// src/controllers/home.controller.ts
// ...
@controller("/users")
export class UsersController {
// ...
@httpGet("/:userId")
public async show(@requestParam("userId") userId: number) {
const repository = await this.database.getRepository(UserRepository);
return repository.findOneOrFail(userId);
}
}
L'implémentation est vraiment trÚs simple. Il faut simplement retourner un objet et inversify-express-utils
va s'occuper de convertir l'objet JavaScript en JSON.
Essayons pour voir:
curl http://localhost:3000/users/1
{"id":1,"email":"test@test.fr","password":"test"}
Et voilĂ . Tous fonctionne correctement. Essayons maintenant de modifier cet utilisateur.
Update
La méthode update
va s'occuper de récupérer, modifier et enregistrer l'utilisateur. Comme pour la méthode précédente, TypeORM nous facilite beaucoup la tùche :
// src/controllers/home.controller.ts
// ...
interface UpdateUserBody {
email: string;
password: string;
}
@controller("/users")
export class UsersController {
// ...
@httpPut("/:userId")
public async update(
@requestBody() body: UpdateUserBody,
@requestParam("userId") userId: number,
req: Request,
res: Response
) {
const repository = await this.database.getRepository(UserRepository);
const user = await repository.findOneOrFail(userId);
user.email = body.email ?? user.email;
user.password = body.password ?? user.password;
await repository.save(user);
return res.sendStatus(204);
}
// ...
}
Et voilĂ . Comme tout Ă l'heure, essayons de voir si cela fonctionne :
curl -X PUT -d "email=foo@bar.com" http://localhost:3000/users/1
Parfait ! Vous pouvez mĂȘme voir, notre utilisateur a Ă©tĂ© mis Ă jour et il nous est renvoyĂ© sous format JSON. Vous pouvez mĂȘme voir la requĂȘte SQL que TypeORM a effectuĂ© dans les logs du terminal
query: SELECT "User"."id" AS "User_id", "User"."email" AS "User_email", "User"."password" AS "User_password" FROM "user" "User" WHERE "User"."id" IN (?) -- PARAMETERS: [1]
query: BEGIN TRANSACTION
query: UPDATE "user" SET "email" = ? WHERE "id" IN (?) -- PARAMETERS: ["foo@bar.com",1]
query: COMMIT
Passons maintenant à la derniÚre méthode du controlleur.
Delete
La méthode delete
est la plus facile. Il suffit de récupérer l'utilisateur et d'appeler la méthode repository.delete
. Allez c'est parti :
// src/controllers/home.controller.ts
// ...
@controller("/users")
export class UsersController {
// ...
@httpDelete("/:userId")
public async destroy(
@requestParam("userId") userId: number,
req: Request,
res: Response
) {
const repository = await this.database.getRepository(UserRepository);
const user = await repository.findOneOrFail(userId);
await repository.delete(user);
return res.sendStatus(204);
}
}
Et voilà . Nous pouvons aussi tester cette méthode :
curl -X DELETE http://localhost:3000/users/1
Ici encore, nous pouvons vérifier que l'utilisateur a bien été supprimé en regardant les logs de TypeORM :
query: SELECT "User"."id" AS "User_id", "User"."email" AS "User_email", "User"."password" AS "User_password" FROM "user" "User" WHERE "User"."id" IN (?) -- PARAMETERS: ["1"]
query: DELETE FROM "user" WHERE "id" = ? AND "email" = ? AND "password" = ? -- PARAMETERS: [1,"foo@bar.com","test"]
Et voilĂ . Maintenant que nous arrivons Ă la fin de de notre controlleur, nous pouvons commiter tous ces changements:
git commit -am "Implement CRUD actions on user"
Validation de nos utilisateurs
Tout semble fonctionner mais il rest une problÚme: nous ne validons pas les données que nous insérons en base. Ainsi, il est possible de créer un utilisateur avec un email faux :
curl -X POST -d "whatever" -d "password=test" http://localhost:3000/users
Encore une fois, nous allons avoir recours a une librairie toute faite: class-validator
. Cette librairie va nous offrir https://github.com/typestack/class-validator/#table-of-contents[une tonne de décorateurs] pour vérifier trÚs facilement notre instance User
.
Installons la avec NPM :
npm install class-validator --save
Et il suffit ensuite d'ajouter les décorateurs @IsEmail
et @IsDefined
comme ceci :
// src/entities/user.entity.ts
+ import {IsDefined, IsEmail, validateOrReject} from 'class-validator';
- import {/* ... */} from 'typeorm';
+ import {BeforeInsert, BeforeUpdate, /* ... */} from 'typeorm';
@Entity()
export class User {
// ...
+ @IsDefined()
+ @IsEmail()
@Column()
email: string;
+ @IsDefined()
@Column()
password: string;
+ @BeforeInsert()
+ @BeforeUpdate()
+ async validate() {
+ await validateOrReject(this);
+ }
}
// ...
Il n'a pas fallu beaucoup de code a ajouter. La partie la plus intéressante est la méthode validate
. Elle possÚde deux décorateurs BeforeInsert
et BeforeUpdate
qui vont permettre d'appeler automatiquement la méthode validate
lorsqu'on utilise la méthode save
d'un repository. C'est trĂšs pratique et il n'y a rien a faire. Essayons maintenant de crĂ©er le mĂȘme utilisateur avec l'email erronĂ© :
curl -X POST -d "whatever" -d "password=test" http://localhost:3000/users
...
<pre>An instance of User has failed the validation:<br> - property email has failed the following constraints: isDefined, isEmail <br></pre>
...
On voit que c'est beaucoup mieux. Cependant nous souhaiterions envoyer une erreur formatée en JSON avec le code d'erreur correspondant à la norme REST. Modifions donc le contrÎleur :
.Ajout de la validation des utilisateur dans le UserController
// src/controllers/home.controller.ts
// ...
@controller("/users")
export class UsersController {
// ...
@httpPost("/")
public async create(/* ... */): Promise<User | Response> {
// ...
const errors = await validate(user);
if (errors.length !== 0) {
return res.status(400).json({errors});
}
return repository.save(user);
}
@httpPut("/:id")
public async update(/* ... */): Promise<User | Response> {
// ...
const errors = await validate(user);
if (errors.length !== 0) {
return res.status(400).json({errors});
}
return repository.save(user);
}
// ...
}
Essayons maintenant :
curl -X POST -d "test@test.fr" -d "password=test" http://localhost:3000/users
{"errors":[{"target":{"password":"test"},"property":"email","children":[],"constraints":{"isDefined":"email should not be null or undefined","isEmail":"email must be an email"}}]}
Le résultat est vraiment complet et permettra a un utilisateur de l'API d'interpréter rapidement l'erreur.
Commitons ces changements:
git commit -am "Validate user"
Factorisation
Maintenant que nous avons un code qui fonctionne, il est temps de faire une passe pour factoriser tout ça.
Pendant la mise en place, vous avez sans doute remarqué que la méthode show
, update
et destroy
possédait un logique commune: elles récupÚrent toute l'utilisateur.
Pour factoriser ce code il y aurait deux solutions :
- déplacer le bout de code dans un méthode privée et l'appeler
- crĂ©er un Middleware qui va ĂȘtre exĂ©cutĂ© avant le contrĂŽleur
J'ai choisi la deuxiÚme option car elle permet de réduire le code et la responsabilité du contrÎleur. De plus, avec inversify-express-utils
c'est trĂšs facile. Laissez moi vous montrer :
import {NextFunction, Request, Response} from "express";
import {inject, injectable} from "inversify";
import {BaseMiddleware} from "inversify-express-utils";
import {TYPES} from "../core/types.core";
import {User, UserRepository} from "../entities/user.entity";
import {DatabaseService} from "../services/database.service";
@injectable()
export class FetchUserMiddleware extends BaseMiddleware {
constructor(
@inject(TYPES.DatabaseService) private readonly database: DatabaseService
) {
super();
}
public async handler(
req: Request & {user: User},
res: Response,
next: NextFunction
): Promise<void | Response> {
const userId = req.query.userId ?? req.params.userId;
const repository = await this.database.getRepository(UserRepository);
req.user = await repository.findOne(Number(userId));
if (!req.user) {
return res.status(404).send("User not found");
}
next();
}
}
Voici quelques explications sur ce code :
. inversify-express-utils
nous donne accĂšs a une classe abstraite BaseMiddleware
. Nous devons aussi ajouter le décorateur @injectable
pour l'utiliser plus tard dans notre contrĂŽleur
. un middleware est une simple méthode handle
qui prend en paramĂštre :
req
:: la requĂȘte envoyĂ©e par l'utilisateurres
:: la réponse HTTP à renvoyer.next
:: un callback a appeler une fois que notre traitement est finit . la méthodehandle
s'occupe de récupérer l'utilisateur et de l'ajouter à l'objetreq
pour qu'il soit utilisé plus tard . si l'utilisateur n'existe pas, nous utilisonsres
pour renvoyer directement une rĂ©ponse 404 sans mĂȘme passer par l'utilisateur
Vu que nous avons défini un nouvel injectable, il faut l'ajouter à notre container :
// src/core/types.core.ts
export const TYPES = {
Logger: Symbol.for("Logger"),
DatabaseService: Symbol.for("DatabaseService"),
+ // Middlewares
+ FetchUserMiddleware: Symbol.for("FetchUserMiddleware"),
};
// src/core/container.core.ts
// ...
+ import {FetchUserMiddleware} from '../middlewares/fetchUser.middleware';
export const container = new Container();
// services
container.bind(TYPES.Logger).to(Logger);
container.bind(TYPES.DatabaseService).to(DatabaseService);
+ // middlewares
+ container.bind(TYPES.FetchUserMiddleware).to(FetchUserMiddleware);
DĂ©sormais nous pouvons utiliser ce middleware dans notre contrĂŽleur en ajoutant TYPE.FetchUserMiddleware
au décorateur. Voici donc la modification :
// src/controllers/home.controller.ts
// ...
@controller("/users")
export class UsersController {
// ...
@httpGet("/:userId", TYPES.FetchUserMiddleware)
public async show(/* ... */) {
return req.user;
}
@httpPut("/:userId", TYPES.FetchUserMiddleware)
public async update(/* ... */) {
// ...
req.user.email = body.email ?? req.user.email;
req.user.password = body.password ?? req.user.password;
// ...
}
@httpDelete("/:userId", TYPES.FetchUserMiddleware)
public async destroy(/* ... */) {
// ...
await repository.delete(req.user);
// ...
}
}
Pas mal non ? Commitons les modifications avant d'aller plus loin :
git add .
git commit -m "Factorize user controller with middleware"
Hashage du mot de passe
La théorie
Nous allons utiliser la librairie de base de Node.js : https://nodejs.org/api/crypto.html[Crypto]. Voici un exemple d'une méthode pour hasher le mot de pass:
import {createHash} from "crypto";
function hashPassword(password: string): string {
return createHash("sha256").update(password).digest("hex");
}
console.log(hashPassword("$uper_u$er_p@ssw0rd"));
// => 51e649c92c8edfbbd8e1c17032...
Et voilà ! Pour savoir si le mot de passe correspond il suffit de vérifier si le hash correspond au précédent :
import {createHash} from "crypto";
function hashPassword(password: string): string {
return createHash("sha256").update(password).digest("hex");
}
function isPasswordMatch(hash: string, password: string): boolean {
return hash === hashPassword(password);
}
const hash = hashPassword("$uper_u$er_p@ssw0rd"); // => 51e649c92c8edfbbd8e1c17032...
isPasswordMatch(hash, "$uper_u$er_p@ssw0rd"); // => true
isPasswordMatch(hash, "wrong password"); // => false
Impeccable. Il y a néanmoins un petit problÚme avec ce type de méthode.
Si vos mots de passe fuite, il sera assez facile Ă retrouver le mot de passe correspondant en construisant un bibliothĂšque de hash. ConcrĂštement, le malveillant utiliserait les mots de passe courant, les hasherai un par avec le mĂȘme algorithme et les comparerait aux notre. Pour corriger cela, il faut utiliser un sel de hashage.
Le sel de hachage consiste a rajouter un texte définis à chaque mot de passe. Voici la modification :
import {createHash} from "crypto";
const salt = "my private salt";
function hashPassword(password: string, salt: string): string {
return createHash("sha256").update(`${password}_${salt}`).digest("hex");
}
function isPasswordMatch(hash: string, password: string): boolean {
return hash === hashPassword(password, salt);
}
const hash = hashPassword("$uper_u$er_p@ssw0rd", salt); // => 3fdd2b9c934cd34c3150a72fb4c98...
isPasswordMatch(hash, "$uper_u$er_p@ssw0rd"); // => true
isPasswordMatch(hash, "wrong password"); // => false
Et voilĂ ! Le fonctionnement est le mĂȘme mais notre application est plus sĂ©curisĂ©e. Si quelqu'un accedait Ă notre base de donnĂ©es, il faudrait qu'il ait en possession le sel de hachage pour retrouver les mots de passe correspondant.
L'implémentation
Maintenant que nous avons vu la thĂ©orie, passons Ă la pratique. Nous allons utiliser les mĂȘmes mĂ©thodes dans un fichier password.utils.ts
. C'est parti:
// src/utils/password.utils.ts
import {createHash} from "crypto";
const salt = "my private salt";
export function hashPassword(password: string, salt: string): string {
return createHash("sha256").update(`${password}_${salt}`).digest("hex");
}
export function isPasswordMatch(hash: string, password: string): boolean {
return hash === hashPassword(password, salt);
}
Nous allons maintenant utiliser la méthode hashPassword
dans l'entité User
. Avec TypeORM c'est trĂšs facile en utilisant les hooks comme nous l'avons fait avec la validation.
// src/entities/user.entity.ts
// ...
import {hashPassword} from "../utils/password.utils";
@Entity()
export class User {
// ...
@IsDefined()
@Column()
hashedPassword: string;
set password(password) {
if (password) {
this.hashedPassword = hashPassword(password);
}
} // ...
}
// ...
Quelques explications s'imposent :
- nous avons crée un attribut
hashedPassword
qui contient le mot de passe de l'utilisateur hashé. Cette valeur sera sauvegardée en base car nous avons ajouté le décorateur@column
. Nous en aurons besoin plus tard pour savoir si le mot de passe fournis par l'utilisateur correspond a celui qu'il avait définit - l'attribut
password
devient un setter. C'est comme un attribut virtuel qui va ĂȘtre appelĂ© lors de l'assignation. Ainsi en faisantuser.password = 'toto'
, cette méthode sera appelé. C'est parfait car nous ne voulons plus le stocker le mot de passe au cas ou notre base de données fuite.
Maintenant essayons de créer un utilisateur via l'API:
curl -X POST -d "email=test@test.fr" -d "password=test" http://localhost:3000/users
{"email":"test@test.fr","password":"test","hashedPassword":"8574a23599216d7752ef4a2f62d02b9efb24524a33d840f10ce6ceacda69777b","id":1}
Tout semble parfaitement fonctionner car on voit que l'utilisateur possÚde bien un mot de passe hashé. Si on change le mot de passe, le hash change correctement :
curl -X PUT -d "password=helloWorld" http://localhost:3000/users/4
{"id":4,"email":"test@test.fr","hashedPassword":"bdbe865951e5cd026bb82a299e3e1effb1e95ce8c8afe6814cecf8fa1e895d1f"}
Tout marche parfaitement bien. Faisons un commit avant d'aller plus loin.
git add .
git commit -m "Hash user password"
Mise en place d'un test unitaire
Nous avons un code qui fonctionne et c'est cool. Si nous pouvons nous assurer qu'il fonctionne comme cela Ă chaque Ă©volution c'est encore mieux. C'est donc ici qu'interviennent les tests unitaires.
Le rĂŽle du test unitaire est de s'assurer que notre mĂ©thode fonctionne toujours de la mĂȘme façon que nous l'avons dĂ©cidĂ©. Nous allons donc ici mettre en place un test simpliste pour s'assurer que tout fonctionne bien.
Il existe plusieurs librairie de tests en JavaScript. J'ai choisi Mocha
car c'est une des librairie les plus populaire et elle se met trĂšs facilement en place. Nous installons aussi ts-mocha
qui va transpiler le TypeScript à la volée :
npm install mocha ts-mocha @types/mocha --save-dev
Il faut aussi modifier un peut notre tsconfig.json
pour ajouter les déclaration de de Mocha et spécifier à Typescript de ne pas compiler ces fichier :
{
"compilerOptions": {
// ..
"types": [
"node",
+ "mocha"
],
// ...
},
+ "exclude": ["./**/*.spec.ts"]
}
Nous voici prĂȘt Ă crĂ©er notre premier test :
// src/entities/user.entity.spec.ts
import assert from "assert";
import {hashPassword} from "../utils/password.utils";
import {User} from "./user.entity";
describe("User", () => {
it("should hash password", () => {
const user = new User();
user.password = "toto";
const expected = hashPassword("toto");
assert.strictEqual(user.hashedPassword, expected);
});
});
Comme je vous le disait, c'est un test vraiment trĂšs simple. Ajoutons maintenant la commande qui va nous permettre de lancer ce test dans le fichier package.json
:
{
// ...
"scripts": {
"start": "tsc && node dist/main.js",
"start:watch": "nodemon",
+ "test": "DOTENV_CONFIG_PATH=.test.env ts-mocha -r reflect-metadata -r dotenv/config src/**/*.spec.ts",
"build": "tsc"
},
// ...
}
Quelques explications sur cette commande:
-r reflect-metadata
charge la librairiereflect-metadata
et nous Ă©vite de l'importer manuellement-r dotenv/config
charge la librairiedotenv
pour ainsi avoir les variables d'environnement de TypeORMDOTENV_CONFIG_PATH
va charger un fichier.env
particulier que nous allons créer juste aprÚs
Lorsque nous testons notre application, nous ne voulons pas polluer notre base de données avec des données que nous créons pendant les tests. C'est donc une bonne pratique de créer une base de donnée dédiée. Dans notre cas, nous allons utiliser une base SQLite in memory. C'est a dire qu'elle n'est pas stockée sur le disque dur mais directement dans la mémoire vive. Voici donc le fichier .test.env
:
TYPEORM_CONNECTION=sqlite
TYPEORM_DATABASE=:memory:
TYPEORM_LOGGING=true
TYPEORM_SYNCHRONIZE=true
TYPEORM_ENTITIES=src/entities/*.entity.ts
NOTE: La directive TYPEORM_ENTITIES
pointe aussi les fichier Typescript car ts-mocha
transpile et execute directement ces fichiers.
Et voilà . Nous pouvons maintenant exécuter ce test :
npm test
User
â should hash password
1 passing (5ms)
Et tant qu'à faire, nous pouvons aussi ajouter un autre test unitaire sur la méthode de comparaison du mot de passe isPasswordMatch
:
// src/utils/password.utils.spec.ts
import assert from "assert";
import {hashPassword, isPasswordMatch} from "./password.utils";
describe("isPasswordMatch", () => {
const hash = hashPassword("good");
it("should match", () => {
assert.strictEqual(isPasswordMatch(hash, "good"), true);
});
it("should not match", () => {
assert.strictEqual(isPasswordMatch(hash, "bad"), false);
});
});
Encore une fois, ce genre de test peut vous sembler simpliste mais ils sont trÚs rapide et permettent d'avoir une sécurité supplémentaire. Lançons les tests :
npm test
...
User
â should hash password
isPasswordMatch
â should match
â should not match
3 passing (6ms)
Maintenant que vous ĂȘtes Ă©chauffĂ©, commitons et passons Ă la suite :
git add .
git commit -m "Add unit test about password hash"
Ajout des tests fonctionnels
Maintenant que nous avons mis en place des tests unitaires, il est temps de mettre en place les tests fonctionnels. Ce type de test va tester des fonctionnalités plutÎt que des méthodes.
Une bonne pratique que j'ai appris en dĂ©veloppant avec le framework Ruby on Rails est de tester le comportement des contrĂŽleurs. C'est trĂšs facile car il suffit d'appeler un endpoint avec des paramĂštres et de vĂ©rifier le rĂ©sultat. Ainsi par exemple, si j'envoie une requĂȘte de Type GET
sur la route /users
je dois m'attendre Ă recevoir une liste d'utilisateur. La librairie https://www.npmjs.com/package/supertest[supertest] nous permet de faire cela sans mĂȘme dĂ©marrer le serveur.
Installons donc cette librairie:
npm install supertest @types/supertest --save-dev
Maintenant créons notre agent qui sera utilisé dans tous nos tests:
// src/tests/supertest.utils.ts
import supertest, {SuperTest, Test} from "supertest";
import {server} from "../core/server";
export const agent: SuperTest<Test> = supertest(server.build());
Et maintenant commençons pas créer notre premier test pour la méthode index
par exemple:
// src/controllers/users.controller.spec.ts
import {container} from "../core/container.core";
import {TYPES} from "../core/types.core";
import {UserRepository} from "../entities/user.entity";
import {agent} from "../tests/supertest.utils";
describe("UsersController", () => {
let userRepository: UserRepository;
describe("index", () => {
it("should respond 200", (done) => {
agent.get("/users").expect(200, done);
});
});
});
Le test est vraiment trĂšs simple et la syntaxe de supertest
rend le test trĂšs lisible. Ce test veut dire "envoie une requĂȘte HTTP de type Get
et attends toi à recevoir une réponse de type 200
". Essayons de lancer les tests
npm test
...
UsersController
index
â should respond 200
...
NOTE: les requĂȘtes SQL de TypeORM sont peut ĂȘtre loggĂ© chez vous car nous avons laissĂ© la directive TYPEORM_LOGGING=true
. Vous pouvez la passer Ă false
pour ne plus les voir.
Maintenant voici le mĂȘme tests pour create
. Celui-ci est différent car il envoie des paramÚtres HTTP.
// src/controllers/users.controller.spec.ts
// ...
describe("UsersController", () => {
let userRepository: UserRepository;
// ..
describe("create", () => {
it("should create user", (done) => {
const email = `${new Date().getTime()}@test.io`;
agent.post("/users").send({email, password: "toto"}).expect(201, done);
});
it("should not create user with missing email", (done) => {
const email = `${new Date().getTime()}@test.io`;
agent.post("/users").send({email}).expect(400, done);
});
});
});
NOTE: new Date().getTime()
renvoie un Number
du nombre de millisecondes écoulées depuis le 01/01/1970. Je l'utilise afin d'avoir un nombre unique. Nous verrons plus loins comment améliorer cela.
Ici nous testons deux choses:
- si l'on envoie les bonnes informations, on doit avoir un retour de type
200
- si l'on ne spécifie pas de mot de passe, on doit avoir un retour de type
400
Ce test est trĂšs simple et vous pouvez en rajouter d'autres comme "should not create user with invalid email" par exemple. Ces tests sont faciles Ă mettre en place et valident un comportement global.
Vous pouvez maintenant commiter les changements:
git add && git commit -m "Add functional tests"
Conclusion
Et voilĂ , ce tutoriel touche Ă sa fin.
J'espÚre que cet article aura permit de démystifier un peu l'injection de dépendance et/ou que vous aurez appris des choses ici.