Mise en place de l'injection de dépendance dans une API avec Inversify

  • expressjs
  • inversify
  • node.js
  • typeorm
  • typescript

Publié le 2021-06-10

IntĂ©ressĂ© pour crĂ©er un API avec Typescript / Node.js? Jette un coup d'Ɠil Ă  mon livre: REST-API.ts. Tu peux tĂ©lĂ©charger une version gratuite au format PDF sur Github. Si tu aimes mon travail tu peux acheter un version payante sur Leanpub.

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:

  1. la classe User depends de la classe Database. Si on change l'implémentation de la classe Database, il faudra modifier la classe User
  2. 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 Sqlite
  • dotenv 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Ă©thode createConnection qui va chercher un fichier de ormconfig (dans notre cas les variables d'environnement chargĂ©e par dotenv) 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 fois
  • getRepository : 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 et password (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'utilisateur res:: la rĂ©ponse HTTP Ă  renvoyer. next:: un callback a appeler une fois que notre traitement est finit . la mĂ©thode handle s'occupe de rĂ©cupĂ©rer l'utilisateur et de l'ajouter Ă  l'objet req pour qu'il soit utilisĂ© plus tard . si l'utilisateur n'existe pas, nous utilisons res 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 faisant user.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 librairie reflect-metadata et nous Ă©vite de l'importer manuellement
  • -r dotenv/config charge la librairie dotenv pour ainsi avoir les variables d'environnement de TypeORM
  • DOTENV_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:

  1. si l'on envoie les bonnes informations, on doit avoir un retour de type 200
  2. 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.

Articles liés