Avant-propos

"REST-API.ts" est une adaptation de mon précédent livre API on Rails dans lequel je construisait une API avec le framework Ruby on Rails. Ce livre est une de mes belle expérience Open-Source puisque le livre est très actif et compte compte plusieurs contributeurs (que je remercie encore ici). J’ai aussi eu beaucoup de retours positifs. J’ai donc décidé de retenter l’expérience avec des technologies différentes que Ruby on Rails que j’apprécie beaucoup.

Je souhaite donc que ce livre soit vivant et évolue en suivant les meilleurs pratiques du moment. Je souhaite aussi qu’il soit un point d’entrée aux débutant comme d’autres ressources l’ont été pour moi lorsque j’ai débuté dans le développement.

Ce livre est donc tout naturellement Open-Source et en accès libre sur Github. Vous y trouverez les version PDF, EPUB et aussi les sources au format Asciidoctor. Une version payante est disponible sur leanpub si vous souhaitez contribuer au projet. Bien sûr, ce n’est pas la seule façon de le faire, vous pouvez aussi proposer des améliorations en forkant le projet, parler de ce livre autour de vous ou simplement me remercier par mail à contact@rousseau-alexandre.fr.

A propos de l’auteur original

Je m’appelle Alexandre Rousseau et je suis un développeur passionné. J’aime partager mon expérience à travers mon blog] et certains livre comme API on Rails ou même celui-ci.

Je suis actuellement associé chez iSignif] où je construis et maintiens un produit de type SAAS en utilisant Ruby on Rails. Je contribue aussi à la communauté Ruby en produisant et maintenant quelques gemmes que vous pouvez consulter sur mon profil Rubygem. La plupart de mes projets sont sur GitHub donc n’hésitez pas à me suivre.

Droits d’auteur et licence

"REST-API.ts" de Alexandre Rousseau] est mis à disposition selon les termes de la licence Creative Commons Attribution - Partage dans les Mêmes Conditions 4.0 International.

Merci

Introduction

Bienvenue sur API-REST.ts, un tutoriel sous stéroïdes pour apprendre la meilleure façon de construire votre prochaine application avec Typescript. Le but de ce livre est de vous fournir une méthodologie complète pour développer une API RESTful en suivant les meilleures pratiques.

Lorsque vous en aurez fini avec ce livre, vous serez en mesure de créer votre propre API et de l’intégrer à n’importe quel client comme un navigateur Web ou une application mobile. Le code généré est construit avec Typescript 4 qui est la version actuelle.

L’intention de ce livre n’est pas seulement de vous apprendre à construire une API mais plutôt de vous apprendre comment construire une API évolutive et maintenable avec Typescript. Dans ce voyage, vous allez apprendre à:

  • Utiliser Git pour le contrôle de version

  • Construire des réponses JSON

  • Tester vos points d’entrées avec des tests unitaires et fonctionnels

  • Mettre en place une authentification avec des JSON Web Tokens (JWT)

  • Utiliser les spécifications JSON:API

  • Optimiser et mettre en cache l’API

Je vous recommande fortement de suivre toutes les étapes de ce livre. Essayez de ne pas sauter des chapitres car je vais vous proposer des conseils et des astuces pour vous améliorer tout au long du livre. Vous pouvez vous considérer comme le personnage principal d’un jeu vidéo qui obtient un niveau supérieur à chaque chapitre.

Dans ce premier chapitre je vous expliquerai comment configurer votre environnement (au cas où vous ne l’auriez pas déjà fait). Nous allons ensuite créer une application appelée =market_place=. Je veillerai à vous enseigner les meilleures pratiques que j’ai pu apprendre au cours de mon expérience. Cela signifie qu’après avoir initialisé le projet, nous commencerons à utiliser Git.

Dans les prochains chapitres, nous allons construire l’application en suivant une méthode de travail simple que j’utilise quotidiennement. Nous développerons toute l’application en utilisant le développement dirigé par les tests (TDD). Je vous expliquerai aussi l’intérêt d’utiliser une API pour votre prochain projet et de choisir un format de réponse adapté comme le JSON ou le XML. Plus loin, nous mettrons les mains dans le code et nous compléterons les bases de l’application en construisant toutes les routes nécessaires. Nous sécuriserons aussi l’accès à l’API en construisant une authentification par échange d’en-têtes HTTP. Enfin, dans le dernier chapitre, nous ajouterons quelques techniques d’optimisation pour améliorer la structure et les temps de réponse du serveur.

L’application finale sera une application de place de marché qui permettra à des vendeurs de mettre en place leur propre boutique en ligne. Les utilisateurs seront en mesure de passer des commandes, télécharger des produits et plus encore. Il existe de nombreuses options pour créer une boutique en ligne comme Shopify, Spree ou Magento.

Tout au long de ce voyage (cela dépend de votre expertise), vous allez vous améliorer et être en mesure de mieux comprendre le fonctionnement des frameworks modernes. J’ai aussi pris certaines des pratiques de mon expérience de développeur.

Conventions sur ce livre

Les conventions de ce livre sont basées sur celles du Tutoriel Ruby on Rails. Dans cette section, je vais en mentionner quelques-unes que vous ne connaissez peut-être pas.

Je vais utiliser de nombreux exemples en utilisant des ligne de commande. Je ne vais pas traiter avec Windows cmd (désolé les gars). Je vais baser tous les exemples en utilisant l’invite de ligne de commande de style Unix. Voici un exemple:

$ echo "A command-line command"
A command-line command

J’éssaierai de raccourcir au maximum les morceaux de code en ne gardant que lignes intéressantes en fonction de contexte. Je masquerai les lignes non pertinentes avec le symbole commentaire // …​ ou /* …​ */. Je spécifierai aussi, au début de chaque morceau de code, le chemin et le nom du fichier concerné. Voici un exemple:

// user.ts
// ...
class User {
  constructor(/* ... */) {
      doInterestingStuff(message);
  }
  // ...
  doInterestingStuff(message: string) {
    console.log(message)
  }
}

J’utiliserai quelques principes comme:

  • "Éviter" signifie que vous n’êtes pas censé le faire.

  • "Préférer" indique que parmi les deux options, la première est la plus appropriée.

  • "Utiliser" signifie que vous êtes en mesure d’utiliser la ressource.

Si vous rencontrez une erreur quelconque lors de l’exécution d’une commande, je vous recommande d’utiliser votre moteur de recherche pour trouver votre solution. Malheureusement, je ne peux pas couvrir toutes les erreurs possibles. Si vous rencontrez des problèmes avec ce tutoriel, vous pouvez toujours m’envoyer un email.

Environnements de développement

Pour presque tous les développeurs, l’une des parties les plus douloureuses est de mettre en place un environnement de développement confortable. Si vous le faites correctement, les prochaines étapes devraient être un jeu d’enfant. Je vais vous guider dans cette étape afin de vous faciliter la tâche et de vous motiver.

Éditeurs de texte et Terminal

Il existe deux catégories d’éditeurs de code :

Les environnement de développement sont plus complets et offre plus de fonctionnalités mais sont souvent beaucoup plus lourds.

Il n’y a pas de mauvais choix et c’est vraiment une question de gout.

Pour ma part j’utilise Visual Studio Code de Microsoft que se situe à mis chemin entre un éditeur de texte et un environnement de développement. Son auto-complétion est vraiment très performante lorsqu’on utilise Typescript]. Si vous ne savez pas quoi utiliser, vous ne pouvez pas vous tromper en utilisant cet éditeur.

Navigateur web

Quand au navigateur, je conseillerai directement Firefox. Mais d’autres développeurs utilisent Chrome ou même Safari. N’importe lequel d’entre eux vous aidera à construire l’application que vous voulez. Ils proposent tous un bon inspecteur pour le DOM, un analyseur de réseau et de nombreuses autres fonctionnalités que vous connaissez peut-être déjà.

Je vous conseille néanmoins d’utiliser au moins deux navigateurs web. Il y a quelques nuances sur l’intérprétation du Javascript ou du CSS. En utilisant deux navigateurs vous vous assurez que vos développements fonctionnent correctement pour la majorité de vos utilisateurs.

Personnellement j’utilise Firefox dans la vie de tous les jours et je vérifie le bon fonctionnement de mes fonctionnalités sur Chromium, un dérivé de Google Chrome.

Gestionnaire de paquets

  • Mac OS: Il existe de nombreuses options pour gérer la façon dont vous installez les paquets sur votre Mac, comme Mac Ports ou Homebrew. Les deux sont de bonnes options, mais je choisirais la dernière. J’ai rencontré moins de problèmes lors de l’installation de logiciels avec Homebrew. Pour installer brew il suffit d’exécuter la commande ci-dessous:

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
  • Linux: Vous êtes déjà prêts! Peu importe si vous utilisez apt, pacman, yum tant que vous vous sentez à l’aise et que vous savez comment installer des paquets.

Git

Nous utiliserons beaucoup Git et vous devriez aussi l’utiliser (non seulement pour ce tutoriel mais aussi pour tous vos projets). Pour l’installer, c’est très facile:

  • sous Mac OS: $ brew install git

  • sous Linux: $ sudo apt-get install git

Node.js

Il existe de nombreuses façons d’installer et de gérer Node.js. Vous avez peut être même déjà avoir une version installée sur votre système. Pour le savoir, tapez simplement:

$ node -v

Si vous ne l’avez pas installé, vous pouvez le faire simplement avec votre gestionnaire de paquet. Je vous recommande néanmoins d’utiliser Node Version Manager (NVM). Le principe de cet outil est de permettre d’installer plusieurs versions de Node.js sur une même machine, dans un environnement hermétique à une éventuelle version installée sur votre système d’exploitation et de pouvoir basculer de l’une à l’autre facilement.

Pour l’installer, il suffit de suivre la documentation officielle. Il suffit donc de lancer le script suivant :

$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.0/install.sh | bash

L’URL du script peut varier en fonction de la version actuelle.

Une fois l’installation terminée, vous pouvez installer la dernière version de Node.js avec la commande suivante :

$ nvm install node

Bases de données

Je vous recommande fortement d’installer Postgresql pour gérer vos bases de données. Mais ici, pour plus de simplicité, nous allons utiliser SQLite. Si vous utilisez Mac OS vous n’avez pas de bibliothèques supplémentaires à installer. Si vous êtes sous Linux, ne vous inquiétez pas, je vous guide:

$ sudo apt-get install libxslt-dev libxml2-dev libsqlite3-dev

ou

$ sudo yum install libxslt-devel libxml2-devel libsqlite3-devel

Initialisation de l’application

Maintenant que votre poste de travail est prêt, nous sommes maintenant en mesure de créer notre projet !

Dans cette section, nous allons poser l’architecture de notre application. Cela veut dire :

  • mise en place de l’ORM avec le connexion à la base de données

  • mise en place des contrôleur

C’est à mons avis une des partie les plus intéressante car vous allez découvrir une manière de faire certainement différente de la vôtre.

Il existe une tonne de framework complets comme Nest.js qui est vraiment très bien. Mais ici nous allons partir de zéro en utilisant des librairies très populaires afin de maîtriser complètement notre application.

Cette méthode vous permettra aussi d’adapter et de construire l’architecture qui vous convient le mieux. Gardez à l’esprit que l’architecture que je vais vous présenter est celle que j’apprécie. Elle est totalement personnelle et je ne prétends pas que c’est la meilleure. Gardez toujours un esprit critique.

Vous êtes prêt ? C’est partit !

Placez vous donc dans le dossier de votre choix et créez un nouveau dossier :

$ mkdir node_market_place
$ cd node_market_place

Contrôle de version

Rappelez-vous que Git vous aide à suivre et à maintenir l’historique de votre code. Versionnez tous vos projets. Même si c’est un petit projet.

Initialiser Git dans votre projet ce résume à la commande suivante :

$ git init

Il faut néanmoins configurer les informations de l’auteur des commits. Si ce n’est pas déjà fait, placez vous dans le répertoire et lancez les commandes suivantes:

$ git config user.name "John Doe"
$ git config user.email "john@doe.io"

Et voilà. Passons à la suite.

Initialisation de NPM

NPM est le gestionnaire de paquets officiel de Node.js. Depuis la version 0.6.3 de Node.js, npm fait partie de l’environnement et est donc automatiquement installé par défaut

Initialiser votre projet avec Node.js signifie que vous serez en mesure d’installer n’importe quelle librairie publiée sur npmjs.com.

Initialisons donc NPM dans notre projet :

$ npm init

Plusieurs questions vous serons posées et à la fin vous verrez un nouveau fichier package.json. Ce fichier détaille les informations de votre projet et les dépendances de celui-ci.

Mise en place de Typescript

Maintenant que nous avons créée nos dossiers, nous somme prêts à mettre en place Typescript.

Typescript va nous apporter un typage fort et va effectuer des vérification avant de transpiler le Code Typescript vers du Javascript :

Note
On parle de compilateur pour une compilation d’un programme vers un éxecutable et d’une transpilation pour la conversion d’un programme dans un language vers un autre language.

Nous installons donc Typescript en tant que dépendance de développement car il va uniquement nous servir à transpiler notre code. Ce sera Node.js qui va éxecuter le Javascript plus tard :

$ 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:

{
  "compilerOptions": {
    "rootDir": "./",
    "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.

A partir d’ici vous pouvez tester que tout fonctionne en executant la commande suivante :

$ ./node_modules/.bin/tsc

Vous allez voir apparaître un fichier dist/main.js de cette forme

// dist/main.js
function say(message) {
  console.log(`I said: ${message}`);
}
say("Hello");

Il s’agit de la version transpilé de notre fichier Typescript.

Maintenant que nous avons vu que tout fonctionne, nous pouvons automatiser un peu cela en ajoutant les commandes directement dans le fichier package.json:

{
  // ...
  "scripts": {
    "start": "tsc && node dist/main.js"
  },
  // ...
}

Et vous pouvez donc maintenant executer le script avec la commande suivante:

$ npm run start

Maintenant que tout fonctionne il est temps de versionner nos changement. N’ajoutez pas tous les fichiers crées, il est important de ne versionner certains dossier uniquement :

  • le dossier node_modules car il contient les librairies récupérées à l’aide de NPM et il est amené a changer lors de la mise a jours de ces librairies

  • le dossier dist car il résulte de la transpilation de notre code

Afin de les ignorer, il suffit juste de créer un fichier .gitignore avec le contenu suivant :

node_modules
dist

Nous pouvons maintenant mettre ajouter tous nos fichiers avec Git et commiter :

$ git add .
$ git commit -m "Setup Typescript for backend"

Mise en place du Hot Reload avec Nodemon

Il est sympa d’avoir une fonctionnalité de Hot Reload lors de la phase de développement. Cela signifie que notre programme se transpilera à nouveau et s’exécutera a chaque fois que notre code change.

La librairie Nodemon va nous offrir cette fonctionnalité. Ajoutons la :

$ npm add nodemon --save-dev

Il suffit maintenant de définir un fichier nodemon.json :

{
  "watch": ["src"],
  "ext": "ts",
  "ignore": ["src/**/*.spec.ts"],
  "exec": "npm run start"
}

Quelques explications s’imposent :

  • watch spécifie le dossier dans lequel Nodemon surveillera les changement de fichier

  • ignore permet d’éviter le Hot Reload pour certains types de fichiers (ici ce sont les tests que nous verrons plus tard)

  • exec, la commande a executer a chaque changement

Vérifions que tous fonctionne en lançant Nodemon à la main :

./node_modules/.bin/nodemon
[nodemon] 2.0.6
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): src/**/*
[nodemon] watching extensions: ts
[nodemon] starting `npm run start`
I said: Hello
[nodemon] clean exit - waiting for changes before restart

Notre code a été transpilé et executé et on voit que Nodemon continue de s’éxecuter et attends un changement. Modifions donc notre fichier main.ts :

// src/main.ts
function say(message: string): void {
-   console.log(`I said: ${message}`);
+   console.log(`Nodemon said: ${message}`);
}
say("Hello");

Lorsque vous allez sauvegarder ce fichier, vous allez voir le travail de Nodemon dans le terminal

[nodemon] restarting due to changes...
[nodemon] starting `npm run start`
Nodemon said: Hello
[nodemon] clean exit - waiting for changes before restart

Maintenant que tout fonctionne, nous pouvons modifier le fichier package.json et ajouter la commande nodemon:

{
  // ...
  "scripts": {
    "start": "tsc && node dist/main.js",
    "start:watch": "nodemon"
  },
  // ...
}

Nous pouvons maintenant commiter les changements :

$ git add .
$ git commit -m "Setup Nodemon"

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 :

[nodemon] restarting due to changes...
[nodemon] starting `npm run start`
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"

Conclusion

Cela a été un chapitre assez long. Si vous êtes arrivés ici, permettez-moi de vous féliciter. Les choses vont s’améliorer à partir de ce point. Commençons à mettre les mains dans le code!

L’API

Dans ce chapitre, je vais vous donner les grandes lignes de l’application. Vous devriez avoir lu le chapitre précédent. Si ce n’est pas le cas, je vous recommande de le faire.

Pour résumer, nous avons simplement généré notre application Rails et réalisé notre premier commit.

Planification de l’application

Notre application sera assez simple. Elle se composera de cinq modèles. Ne vous inquiétez pas si vous ne comprenez pas bien ce qui se passe, nous reverrons et développerons chacune de ces ressources au fur et à mesure que nous avancerons avec le tutoriel.

+---------+     +---------+
| User    +---->+Product  |
+---------+     +---------+
     |               |
     v               v
+---------+     +---------+
|Order    +---->+Placement|
+---------+     +---------+

En bref, nous avons l’utilisateur (User) qui sera en mesure de créer des commandes (Order). Il pourra aussi passer de nombreuses commandes (Order) qui vont contenir des éléments (Placement) qui désignes des produits.

Nous n’allons pas construire d’interface pour l’interaction avec l’API afin de ne pas surcharger le tutoriel. Si vous voulez construire des vues, il existe de nombreuses options comme des frameworks JavaScript (Angular, Vue.JS, React) ou des librairies mobiles (AFNetworking).

À ce stade, vous devriez vous poser cette question:

D’accord, mais j’ai besoin d’explorer et de visualiser l’API que je vais construire, non?

C’est juste. Si vous googlez quelque chose lié à l’exploration d’une API, vous allez trouver pas mal de résultats. Vous pouvez par exemple utiliser Postman qui est devenu incontournable. Mais nous n’allons pas l’utiliser. Dans notre cas nous allons utiliser cURL qui est un outil en ligne de commande disponible presque partout. Cela permet d’avoir des exemples reproductibles quelque soit votre environnement de développement.

Mettre en place l’API

Une API est définie par wikipedia comme une interface de programmation d’application (API) qui est un ensemble normalisé de composants qui sert de façade par laquelle un logiciel offre des services à d’autres logiciels. En d’autres termes, il s’agit d’une façon dont les systèmes interagissent les uns avec les autres via une interface (dans notre cas un service web construit avec JSON). Il existe d’autres types de protocoles de communication comme SOAP, mais nous n’en parlons pas ici.

JSON est devenu incontournable en tant que format de fichier pour Internet en raison de sa lisibilité, de son extensibilité et de sa facilité à mettre en œuvre. Beaucoup de frameworks JavaScript l’utilisent comme protocole par défaut comme Angular ou EmberJS. D’autres grandes bibliothèques en Objective-C l’utilisent comme AFNetworking ou RESTKit. Il existe probablement de bonnes solutions pour Android mais en raison de mon manque d’expérience sur cette plate-forme de développement je ne peux pas vous recommander quelque chose.

Nous allons donc utiliser le format JSON pour construire notre API. La première idée qui pourrait vous venir à l’esprit serait de commencer à créer des routes en vrac. Le problème est qu’elles ne seraient pas normalisées. Un utilisateur ne pourrait pas deviner quelle ressource est renvoyée par une route.

C’est pourquoi une norme existe: REST (Representational State Transfer). REST impose une norme pour les routes qui créent, lisent, mettent à jour ou suppriment des informations sur un serveur en utilisant de simples appels HTTP. C’est une alternative aux mécanismes plus complexes comme SOAP, CORBA et RPC. Un appel REST est simplement une requête GET HTTP vers le serveur.

aService.getUser("1")

Et avec REST, vous pouvez appeler une URL avec une requête HTTP spécifique. Dans ce cas avec une requête GET:

http://domain.com/resources_name/uri_pattern

Les API RESTful doivent suivre au minimum trois règles:

  • Une URI de base comme http://example.com/resources/

  • Un type de média Internet pour représenter les données, il est communément JSON et est communément défini par l’échange d’en-têtes.

  • Suivez les méthodes HTTP standard telles que GET, POST, PUT, PUT, DELETE.

    • GET: Lit la ou les ressources définies par le modèle URI

    • POST: Crée une nouvelle entrée dans la collection de ressources

    • PUT: Met à jour une collection ou un membre des ressources

    • DELETE: Détruit une collection ou un membre des ressources

Cela peut sembler compliqué mais au fur et à mesure que nous avancerons dans le tutoriel cela deviendra beaucoup plus facile à comprendre.

Initialisation de l’application

Maintenant que nous savons quel conventions nous allons suivre, nous pouvons commencer à construire l’architecture de notre application. Ici nous allons donc continuer la mise en place de Typescript et de certaines librairies qui vont nous aider à respecter les meilleurs pratiques en terme de développement logiciel.

L’injection de dépendance

Dans cette section nous allons mettre en place le système d'injection de dépendance. Si vous n’en avez jamais entendu parler, il s’agit très certainement de la partie la plus abstraite de ce chapitre.

Je vais essayer ici de vous résumer ce qu’est l’injection de dépendance et à quoi ça sert. 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 :

Une mauvaise implémentation n’utilisant pas l’injection de dépendance
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 :

La classe Database est maintenant injectée dans le constructeur
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.

Dans cette section nous allons mettre en place concrètement un système d’injection de dépendance complet.

Nous allons 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.

ajout du décorateur @injectable
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 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’extraires 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:

La définition de 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:

  1. Création du premier contrôleur avec une unique route

// 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 arreté le précédent serveur.

Si tout fonctionne comme avant, vous pouvez commiter les changements :

$ git add .
$ git commit -m "Add inversify"

Conclusion

Ça a été un peu long, je sais, mais vous avez réussi! N’abandonnez pas, c’est juste notre petite fondation pour quelque chose de grand, alors continuez comme ça.

Présentation des utilisateurs

Dans le chapitre précédent, nous avons réussi à mettre en place les bases de la configuration de notre application. Ce chapitre va perfectionner cette base et ajouter la couche Model qui sera en charge de stocker les données et aussi d’ajouter les premiers tests.

Dans les prochains chapitres, nous traiterons l’authentification des utilisateurs à l’aide de jetons d’authentification ainsi que la définition de permissions pour limiter l’accès aux utilisateurs connectés. Nous relierons ensuite les produits aux utilisateurs et leur donnerons la possibilité de passer des commandes.

Comme vous pouvez déjà l’imaginer, il existe de nombreuses solutions d’authentification pour Node.js comme Passport.js, Permit et Devise. Ces solutions sont des librairies clé en main, c’est à dire qu’elles permettent de gérer tout un tas de choses comme l’authentification, la fonctionnalité d’oubli de mot de passe, la validation, etc..

Nous ne les utiliserons pas afin de mieux appréhender le mécanisme d’authentification. Cela vous permettra de découvrir qu’il n’y a rien de magique derrière le chiffrement des mots de passe et la création des jetons d’authentifications.

Ce chapitre sera complet. Il sera peut-être long mais je vais essayer d’aborder autant de sujets que possible. N’hésitez pas à vous prendre un café et allons-y. A la fin de ce chapitre, vous aurez construit toute la logique des utilisateurs ainsi que la validation et la gestion des erreurs.

Mise en place de TypeORM

Ici nous allons mettre e 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:

Ajout des librairies pour installer TypeORM
$ 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:

La configuration de base de TypeORM pour une connexion à 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:

La configuration de base de TypeORM pour une connexion à SQLite
{
  // ...
  "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.

Implémentation du service d’initialisation de TypeORM
// 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 :

Ajout du Symbol lié au service DatabaseService
// src/core/types.core.ts
export const TYPES = {
  // ...
  DatabaseService: Symbol.for('DatabaseService'),
};
Enregistrement du service DatabaseService dans le container Inversify
// 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 :

Première implémentation de la classe User
// 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 (Tex te, 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:

Création du UserController avec la méthode index
// 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:

  1. TypeORM a essayé de voir s’il existait une table nommée user

  2. TypeORM a crée cette table puisqu’elle n’existait pas

  3. la connexion a la base de données été établie

  4. 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:

Ajout de la méthode create à la classe UserRepository
// 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:

Création d’un utilisateur avec cURL
$ 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 :

Ajout de la méthode create à la classe UserRepository
// 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 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 :

  1. déplacer le bout de code dans un méthode privée et l’appeler

  2. 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 :

  1. 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

  2. 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

  3. 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

  4. 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 : 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 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

Oh vous êtes là! Bien joué! Je sais que c’était probablement le chapitre le plus long mais n’abandonnez pas!

Si vous n’avez pas l’habitude d’utiliser des tests, nous verrons dans le chapitre comment les utiliser pour définir à l’avance le comportement que nous souhaitons avant même de coder les fonctionnalité. Nous mettrons donc en place les tests pour les méthodes show, update et destroy qui auront besoin d’une authentification. En d’autres termes, nous commencerons à faire du développement dirigé par les tests Test Driven Development. Il s’agit très certainement de la partie la plus importante du livre!

Authentification des utilisateurs

Dans ce chapitre, les choses vont devenir plus intéressantes. Nous allons mettre en place notre mécanisme d’authentification. À mon avis, ce sera l’un des chapitres les plus intéressants car nous allons introduire beaucoup de nouveaux concepts. A la fin, vous aurez un système d’authentification simple mais puissante. Ne paniquez pas, nous y arriverons.

Sessions sans état

Avant d’aller plus loin, quelque chose doit être clair: une API ne gère pas les sessions. Cela peut paraître un peu fou si vous n’avez pas d’expérience dans la création de ce genre d’applications. Une API doit être sans état. Ce qui signifie, par définition, qu’une API qui fournit une réponse après votre demande ne nécessite aucune autre attention. Cela a pour conséquence qu’aucun état antérieur ou futur n’est nécessaire pour que le système fonctionne.

Le processus d’authentification de l’utilisateur via une API est très simple:

  1. Le client demande une ressource de sessions avec les informations d’identification correspondantes (généralement un e-mail et un mot de passe).

  2. Le serveur renvoie la ressource utilisateur avec son jeton d’authentification correspondant.

  3. Pour chaque page qui nécessite une authentification, le client doit envoyer ce jeton d’authentification.

Dans cette section et la suivante, nous nous concentrerons sur la construction d’un contrôleur de sessions avec ses actions correspondantes. Nous compléterons ensuite le flux de demandes en ajoutant l’accès d’autorisation nécessaire.

Présentation de JSON Web Token

Lorsqu’on parle de jeton d’authentification, un standard existe: le JSON Web Token (JWT).

JWT est un standard ouvert défini dans la RFC 75191. Il permet l’échange sécurisé de jetons (tokens) entre plusieurs parties. – Wikipédia

Globalement, un jeton JWT est composé de trois parties :

  • un en-tête structuré en JSON qui contiendra par exemple la date de validité du jeton.

  • un payload structuré en JSON qui peut contenir n’importe quelle donnée. Dans notre cas, il contiendra l’identifiant de l’utilisateur "connecté".

  • une signature qui nous permettra de vérifier que le jeton a bien été chiffré par notre application et donc qu’il est valide.

Ces trois parties sont chacune encodées en base64 puis concaténées en utilisant des points (.). Ce qui nous donne quelque chose comme ça:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Une fois décodé, ce jeton nous donne les informations suivantes:

  • l’en tête

{ "alg": "HS256", "typ": "JWT" }
  • le payload

{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }

Pour plus d’information à propos des jetons JWT je vous invite à consulter jxt.io.

Cela possède beaucoup d’avantages comme par exemple le fait d’envoyer des informations au consommateur de l’API directement dans le token. On pourra par exemple choisir d’intégrer les informations de l’utilisateur dans le payload.

Mise en place du jeton d’authentification

La norme JWT possède beaucoup d’implémentation dans des langages et des librairies diverses. Bien entendu, il existe une librairie Nodejs à ce sujet: node-jsonwebtoken.

Commençons donc par l’installer:

$ npm install jsonwebtoken
$ npm install --save-dev @types/jsonwebtoken

La libraire s’utilise très facilement avec la méthode jwt.sign et jwt.verify. Voici un exemple :

import {sign, verify} from 'jsonwebtoken';

const JWT_PRIVATE_KEY = "123456789";
const payload = { userId: 1 };
const token = sign(payload, JWT_PRIVATE_KEY, { expiresIn: "1 day" });

console.log(verify(token, JWT_PRIVATE_KEY));
// => { userId: 1, iat: 1605730716, exp: 1605817116 }

Lors de la première ligne nous avons encodé un payload avec la clé secrète JWT_PRIVATE_KEY. Nous obtenons donc un jeton que nous pouvons décoder, tout simplement. La deuxième ligne s’occupe de décoder le jeton et nous voyons que nous retrouvons bien notre payload.

Nous allons maintenant englober toute cette logique dans une classe JsonWebTokenService. Cela nous permettra d’éviter de dupliquer le code. Cette classe s’occupera juste d’encoder et de décoder les jetons JWT. Voici donc l’implémentation :

// src/services/jsonWebToken.service.ts
import {injectable} from 'inversify';
import {sign, verify} from 'jsonwebtoken';

@injectable()
export class JsonWebTokenService {
  private readonly JWT_PRIVATE_KEY = "123456789";

  encode(payload: Object): string {
    return sign(payload, this.JWT_PRIVATE_KEY, { expiresIn: "1 day" });
  }

  decode(token: string): Object {
    return verify(token, this.JWT_PRIVATE_KEY);
  }
}

L’implémentation est très simple. Une méthode encode un payload, l’autre le décode. Comme ce service est injecatble, nous devont l’enregistrer dans le container.

Ajout du Symbol pour le service JsonWebTokenService
// src/core/types.core.ts
export const TYPES = {
  // ...
  JsonWebTokenService: Symbol.for("JsonWebTokenService"),
};
Ajout du service JsonWebTokenService dans le container
// src/core/container.core.ts
// ...
import {JsonWebTokenService} from '../services/jsonWebToken.service';

export const container = new Container();
// ...
container.bind(TYPES.JsonWebTokenService).to(JsonWebTokenService);

Et voilà. Nous pouvons même ajouter un petit test rapide qui va encoder et décoder un payload et vérifier que nous retrouvons bien le contenu:

// src/services/jsonWebToken.service.spec.ts
import assert from 'assert';
import {container} from '../core/container.core';
import {TYPES} from '../core/types.core';
import {JsonWebTokenService} from './jsonWebToken.service';

describe("JsonWebTokenService", () => {
  let jsonWebTokenService: JsonWebTokenService;

  before(() => {
    jsonWebTokenService = container.get(TYPES.JsonWebTokenService);
  });

  it("should encode and decode payload", () => {
    const token = jsonWebTokenService.encode({ userId: 1 });
    const payload = jsonWebTokenService.decode(token);
    assert.strictEqual(payload.userId, 1);
  });
});

Ce test est un peu plus long que les autres car nous devons récupérer une instance de JsonWebTokenService via la container. Pour ce faire, nous utiliser la méthode before qui va être exécutée avant notre batterie de test.

Voyons maintenant si tous nos tests passent :

$ npm test
...
  JsonWebTokenService
    ✓ should encode and decode payload
...

C’est parfait. Commitons et passons à la suite :

$ git add .
$ git commit -m "Create JsonWebTokenService"

Le contrôleur de jetons

Nous avons donc mis en place le système de génération d’un jeton JWT. Il est maintenant temps de créer une route qui va générer ce jeton. Les actions que nous allons implémenter seront gérées en tant que services RESTful: la connexion sera gérée par une demande POST à l’action create.

Avant de passer à l’implémentation, nous allons essayer d’écrire un test complet.

Mise en place du tests fonctionnel

Ici nous allons tester l'endpoint que nous alloons créer juste après. Cet endpoint prendra en paramètre l’email et le mot de passe de l’utilisateur. Nous pouvons donc tester trois choses:

  1. l’utilisateur a envoyé les bonnes informations donc on renvoie un token

  2. le mot de passe est erroné donc on renvoie une erreur 400 - Bad request

  3. l’utilisateur n’existe pas donc on renvoie une erreur 400 - Bad request

Note
Nous renvoyons un code 400 sans donner plus d’explications. En effet, nous ne voulons pas indiquer à l’utilisateur que cet email n’est pas présent en base. C’est une bonne pratique qui compliquerai un peu plus une attaque par force-brute sur un utilisateur.

Forcément, le test va commencer par créer un utilisateur. C’est ce qu’on va faire dans la méthode before

Création d’une partie du test fonctionnel de TokensController
// src/controllers/tokens.controller.spec.ts
import {container} from '../core/container.core';
import {TYPES} from '../core/types.core';
import {User, UserRepository} from '../entities/user.entity';
import {DatabaseService} from '../services/database.service';

describe("TokensController", () => {
  let user: User;

  before(async () => {
    const databaseService = container.get<DatabaseService>(TYPES.DatabaseService);
    const userRepository = await databaseService.getRepository(UserRepository);

    const newUser = new User();
    newUser.email = `${new Date().getTime()}@test.io`;
    newUser.password = "p@ssw0rd";
    user = await userRepository.save(newUser);
  });
});
Note
on stock la variable user en dehors de la méthode before afin de pouvoir l’utiliser plus tard.

Maintenant nous n’avons plus qu’a écrire nos tests

Création du test fonctionnel de TokensController
// src/controllers/tokens.controller.spec.ts
import {container} from '../core/container.core';
import {TYPES} from '../core/types.core';
import {User, UserRepository} from '../entities/user.entity';
import {DatabaseService} from '../services/database.service';
import {agent} from '../tests/supertest.utils';

describe("TokensController", () => {
  // ...
  describe("create", () => {
    it("should get token", (done) => {
      agent
        .post("/tokens")
        .send({ email: user.email, password: "p@ssw0rd" })
        .expect(200, done);
    });

    it("should not get token user with bad password", (done) => {
      agent
        .post("/tokens")
        .send({ email: user.email, password: "bad password" })
        .expect(400, done);
    });

    it("should not create token with nonexisting email", (done) => {
      agent
        .post("/tokens")
        .send({ email: user.email, password: "bad password" })
        .expect(400, done);
    });
  });
});

Et voilà. Comme nous travaillons en développement dirigé par les tests, a ce moment nos tests ne passent pas :

$ npm test
...
  1) TokensController
       create
         should get token:
     Error: expected 200 "OK", got 404 "Not Found"
...
  2) TokensController
       create
         should not get token user with bad password:
     Error: expected 400 "Bad Request", got 404 "Not Found"
...
  3) TokensController
       create
         should not create token with nonexisting email:
     Error: expected 400 "Bad Request", got 404 "Not Found"
...

Notre but dans la prochaine section sera de faire passer ces tests.

Implémentation

Nous allons donc créer le contrôleur TokenController. Commençons par créer le contôleur avec les dépendances nécessaire:

  1. DatabaseService pour récupérer l’utilisateur qui correspond à l’email

  2. JsonWebTokenService pour créer un jeton JWT

Création du contrôleur TokensController avec les dépendances nécessaire
// src/controllers/tokens.controller.ts
import {inject} from 'inversify';
import {controller} from 'inversify-express-utils';
import {TYPES} from '../core/types.core';
import {UserRepository} from '../entities/user.entity';
import {DatabaseService} from '../services/database.service';
import {JsonWebTokenService} from '../services/jsonWebToken.service';

@controller("/tokens")
export class TokensController {
  public constructor(
    @inject(TYPES.JsonWebTokenService) private readonly jsonWebTokenService: JsonWebTokenService,
    @inject(TYPES.DatabaseService) private readonly database: DatabaseService
  ) {}
}

Et on ajoute maintenant ce contrôleur à container afin qu’il soit chargé:

// src/core/container.core.ts
// ...
import "../controllers/tokens.controller";
// ...

Maintenant il ne nous reste plus qu’à ce concentrer sur la méthode create de notre contrôleur

// src/controllers/tokens.controller.ts
// ...
import {Request, Response} from 'express';
import {controller, httpPost, requestBody} from 'inversify-express-utils';
import {isPasswordMatch} from '../utils/password.utils';

@controller("/tokens")
export class TokensController {
  // ...

  @httpPost("")
  public async create(
    @requestBody() body: { email: string; password: string },
    req: Request,
    res: Response
  ) {
    const repository = await this.databaseService.getRepository(UserRepository);
    const user = await repository.findOne({ email: body.email });

    if (!user) {
      return res.sendStatus(400);
    }

    if (isPasswordMatch(user.hashedPassword, body.password)) {
      const token = this.jsonWebTokenService.encode({
        userId: user.id,
        email: user.email,
      });
      return res.json({ token });
    }

    return res.sendStatus(400);
  }
}

Oula! Ce ce code à l’air compliqué mais il est en fait très simple :

  1. on crée une méthode create dans le contrôleur qui va s’occuper de créer un token pour l’utilisateur demandé

  2. cette méthode utilise le userRepository pour récupérer l’utilisateur à partir de l’email donné. Si nous ne trouvons pas l’utilisateur, nous renvoyons une erreur 400 - Bad request

  3. nos utilisons la méthode isPasswordMatch pour vérifier si le mot de passe correspond au hash que nous avons stoqué. Si c’est le cas, nous créons et renvoyons un jeton avec la méthode jsonWebTokenService.encode

Toujours là ? Essayons de lancer les tests pour voir si notre code fonctionne:

$ npm test
...
  TokensController
    create
      ✓ should get token (41ms)
      ✓ should not get token user with bad password
      ✓ should not create token with nonexisting email

Essayons la logique dans le terminal. Créons un utilisateur (si ce n’est pas déja fait) :

$ curl -X POST -d "email=test@test.fr" -d "password=test" http://localhost:3000/users
{"email":"test@test.fr","hashedPassword":"8574a23599216d7752ef4a2f62d02b9efb24524a33d840f10ce6ceacda69777b","id":1}

Ensuite demandons le jeton pour celui-ci :

$ curl -X POST -d "email=test@test.fr" -d "password=test" http://localhost:3000/tokens
{"token":"eyJhbGciOiJIUzI1NiI..."}

Oura! Essayons avec un mot de passe erroné :

$ curl -X POST -d "email=test@test.fr" -d "password=azerty" http://localhost:3000/tokens
Bad Request

C’est parfait !

Comittons et passons à la suite :

$ git add .
$ git commit -m "Create token controller"

Utilisateur connecté

Nous avons donc mis en place la logique suivante: l’API retourne un jeton d’authentification si les paramètres passés d’authentification sont corrects.

Nous allons maintenant implémenter la logique suivante: A chaque fois que ce client demandera une page protégée, nous devrons retrouver l’utilisateur à partir de ce jeton d’authentification que l’utilisateur aura passé dans l’en-tête HTTP.

Dans notre cas, nous utiliserons l’en-tête HTTP Authorization qui est souvent utilisé pour ça. Personnellement, je trouve que c’est la meilleure manière parce que cela donne un contexte à la requête sans polluer l’URL avec des paramètres supplémentaires.

Cette action sera centrale à notre application et sera utilisée un peu partout. Il est donc assez logique de créer un middleware dédié. Comme nous l’avons plus tôt. Mais avant de passer au code, nous allons définir le comportement que nous souhaitons.

Mise en place du test fonctionnel

Le fonctionnement que nous souhaitons mettre en place est le suivant:

  • il n’y a pas besoin de jeton pour créer un utilisateur car c’est l’étape d’inscription

  • il faut un jeton d’authentification pour consulter ou modifier un utilisateur

Maintenant qu nous avons définis cela, nous pouvons créer notre test fonctionnel.

Nous reprenons le test users.controller.spec.ts et nous allons efin implémenter les tests pour show, update et destroy.

Ces trois tests nécessitent qu’on ai déjà un utilisateur en base. Nous allons créer un méthode utils qui va générer un utilisateur aléatoire:

// src/utils/faker.utils.ts
import {randomBytes} from 'crypto';
import {User} from '../entities/user.entity';

export function randomString(size: number = 8): string {
  return randomBytes(size).toString("hex");
}

export function generateUser(user?: User): User {
  const newUser = new User();
  newUser.email = user?.email ?? `${randomString()}@random.io`;
  newUser.password = newUser.email;

  return newUser;
}

Cette méthode est très simple et va juste s’appuyer sur randomBytes du module crypto pour génerer une adresse email totalement aléatoire.

Note
il existe des librairies comme Faker.js qui permettent de faire ça mais ici je préfère m’en passer pour simplifier l’exemple.

Maintenant nous pouvons revenir à notre test et créer un utilisateur dans la méthode before:

Création d’un user pour le test show
// src/controllers/users.controller.spec.ts
// ...
describe("UsersController", () => {
  let userRepository: UserRepository;
  before(async () => {
    const databaseService = container.get<DatabaseService>(TYPES.DatabaseService);
    userRepository = await databaseService.getRepository(UserRepository);
  });
  // ...
  describe("show", () => {
    let user: User;

    before(async () => {
      user = await userRepository.save(generateUser());
    });
  });
});

Maintenant il ne nous reste plus qu’à essayer d’accéder à cette utilisateur via GET /users/1 avec et sans jeton JWT:

Tests fonctionnels de la méthode UsersController.show
// src/controllers/users.controller.spec.ts
// ...
describe("UsersController", () => {
  let jsonWebTokenService: JsonWebTokenService;
  before(async () => {
    // ...
    jsonWebTokenService = container.get(TYPES.JsonWebTokenService);
  });
  // ...
  describe("show", () => {
    let user: User;
    // ...
    it("should not show user other user", (done) => {
      agent.get(`/users/${user.id}`).expect(403, done);
    });

    it("should show my profile", (done) => {
      const jwt = jsonWebTokenService.encode({ userId: user.id });
      agent
        .get(`/users/${user.id}`)
        .set("Authorization", jwt)
        .expect(200, done);
    });
  });
});

Comme vous le voyez les tests restent vraiment très simple. On vérifie simplement le code du status HTTP de la réponse.

Le principe est exactement le même pour la méthode update et destroy:

Tests fonctionnels de la méthode UsersController.show
// src/controllers/users.controller.spec.ts
// ...
describe("UsersController", () => {
  // ...
  describe("update", () => {
    // ... create user on `before`
    it("should not update other user", (done) => {
      agent.put(`/users/${user.id}`)
        .send({ password: "test" })
        .expect(403, done);
    });

    it("should update my profile", (done) => {
      const jwt = jsonWebTokenService.encode({ userId: user.id });
      agent.put(`/users/${user.id}`)
        .set("Authorization", jwt)
        .send({ password: "test" })
        .expect(200, done);
    });
  });

  describe("destroy", () => {
    // ... create user on `before`
    it("should not destroy other user", (done) => {
      agent.delete(`/users/${user.id}`).expect(403, done);
    });

    it("should delete my profile", (done) => {
      const jwt = jsonWebTokenService.encode({ userId: user.id });
      agent.delete(`/users/${user.id}`)
        .set("Authorization", jwt)
        .expect(204, done);
    });
  });
});

Et voilà. SI vous executez les tests à ce moment précis vous allez avoir un paquet d’erreurs:

$ npm test
// ...
UsersController
    index
      ✓ should respond 200
    show
      1) should not show user other user
      2) should show my profile
    create
      ✓ should create user
      ✓ should not create user with missing email
    update
      3) should not update other user
      4) should update my profile
    destroy
      5) should not destroy other user
      6) should delete my profile
// ...
  10 passing (226ms)
  6 failing

C’est tout à fait normal car nous n’avons pas encore implémenté la suite. Passons maintenant à l’implémentation.

Création du middleware

Nous allons donc créer un Middleware FetchLoggerUserMiddleware pour répondre à nos besoins. C’est-à-dire retrouver l’utilisateur grâce à son jeton d’authentification qui est envoyé sur chaque requête.

Le principe est assez identique au précédent middleware que nous avons crée plus tôt donc je passe directement à l’implémentation. De la même manière que le TokenController, on lui injecte

  • le jsonWebTokenService pour décoder le jeton JWT

  • le databaseService pour récupérer l’utilisateur associé au token

// src/middlewares/fetchLoggedUser.middleware.ts
import {inject, injectable} from 'inversify';
import {BaseMiddleware} from 'inversify-express-utils';
import {TYPES} from '../core/types.core';
import {DatabaseService} from '../services/database.service';
import {JsonWebTokenService} from '../services/jsonWebToken.service';

@injectable()
export class FetchLoggedUserMiddleware extends BaseMiddleware {
  constructor(
    @inject(TYPES.DatabaseService)
    private readonly databaseService: DatabaseService,
    @inject(TYPES.JsonWebTokenService)
    private readonly jsonWebTokenService: JsonWebTokenService
  ) {
    super();
  }
}

Et maintenant voici l’implémentation de la méthode handler

Implémentation de la méthode handle du FetchLoggedUserMiddleware
// src/middlewares/fetchLoggedUser.middleware.ts
// ...
import {NextFunction, Request, Response} from 'express';
import {User, UserRepository} from '../entities/user.entity';

@injectable()
export class FetchLoggedUserMiddleware extends BaseMiddleware {
  // ...
  public async handler(
    req: Request & { user: User },
    res: Response,
    next: NextFunction
  ): Promise<void | Response> {
    const repository = await this.databaseService.getRepository(UserRepository);
    const token = req.headers.authorization?.replace("bearer", "");

    if (token === undefined) {
      return res.status(403).send("You must provide an `Authorization` header");
    }

    try {
      const payload = this.jsonWebTokenService.decode(token);
      req.user = await repository.findOneOrFail(payload.userId);
    } catch (e) {
      return res.status(403).send("Invalid token");
    }

    next();
  }
}

Encore une fois le code paraît long mais il est en fait très simple :

  1. on extrais le jeton JWT dans le header Authorization. S’il n’est pas définis, on renvoie une erreur 403 - Forbidden avec une brève explication

  2. on décode le jeton JWT et on récupère l’utilisateur associé. Si une erreur survient (le jeton ne peut pas être décodé ou l’utilisateur n’existe pas), on renvoie une erreur 403 aussi

  3. on injecte l’utilisateur dans la requête afin qu’on puisse l’utiliser dans le contrôleur

Bien entendu, nous n’oublions pas d’ajouter ce middleware à notre conatiner :

Ajout du symbole FetchLoggedUserMiddleware
// src/core/types.core.ts
export const TYPES = {
  // ...
  FetchLoggedUserMiddleware: Symbol.for("FetchLoggedUserMiddleware"),
};
Ajout du middleware FetchLoggedUserMiddleware dans le container
// src/core/container.core.ts
// ...
import {FetchLoggedUserMiddleware} from '../middlewares/fetchLoggedUser.middleware';

export const container = new Container();
// ...
container.bind(TYPES.FetchLoggedUserMiddleware).to(FetchLoggedUserMiddleware);

ET voilà notre middleware est prêt à être utilisé.

Utilisation du middleware

Et maintenant il ne nous reste plus qu’à utiliser le middleware dans le UsersController . Voici par exemple pour la méthode show :

// src/controllers/home.controller.ts
// ...
@controller('/users')
export class UsersController {
  // ...
-   @httpGet('/:userId', TYPES.FetchUserMiddleware)
+   @httpGet('/:userId', TYPES.FetchLoggedUserMiddleware)
  public async show(/* ... */) {
+    if (Number(userId) !== req.user.id) {
+      return res.sendStatus(403);
+    }
    return req.user;
  }
  // ...
}

Comme vous pouvez le voir, les modifications reste minimes car une partie de la logique est déportée dans le middleware. Vous pouvez aussi voir que j’ai mis une vérification très simple pour empêcher un utilisateur de consulter les informations d’un autre.

Le middleware nous a permis de garder une logique très simple dans notre contrôleur.

Le principe est exactement le même pour la méthode update et destroy.

// src/controllers/home.controller.ts
// ...
@controller('/users')
export class UsersController {
  // ...
-  @httpPut('/:userId', TYPES.FetchUserMiddleware)
+  @httpPut('/:userId', TYPES.FetchLoggedUserMiddleware)
  public async update(/* ... */)> {
+    if (Number(userId) !== req.user.id) {
+      return res.sendStatus(403);
+    }
    // ...
    return repository.save(req.user);
  }

-  @httpDelete('/:userId', TYPES.FetchUserMiddleware)
+  @httpDelete('/:userId', TYPES.FetchLoggedUserMiddleware)
  public async destroy(/* ... */) {
+    if (Number(userId) !== req.user.id) {
+      return res.sendStatus(403);
+    }
    const repository = await this.databaseService.getRepository(UserRepository);
    await repository.delete(req.user);
  }
}

Si tout ce passe bien. Nos tests devraient passer:

$ npm test

  TokensController
    create
      ✓ should get token (41ms)
      ✓ should not get token user with bad password
      ✓ should not create token with nonexisting email

  UsersController
    index
      ✓ should respond 200
    show
      ✓ should not show user other user
      ✓ should show my profile
    create
      ✓ should create user
      ✓ should not create user with missing email
    update
      ✓ should not update other user
      ✓ should update my profile
    destroy
      ✓ should not destroy other user
      ✓ should delete my profile

  User
    ✓ should hash password

  JsonWebTokenService
    ✓ should encode and decode payload

  isPasswordMatch
    ✓ should match
    ✓ should not match


  16 passing (201ms)

C’est beau tout ce vert n’est-ce pas?

Essayons de faire la même chose avec cURL:

$ curl -X POST -d "email=test@test.fr" -d "password=test" http://localhost:3000/tokens
{"token":"eyJhbGciOiJIUzI1NiI..."}
$ curl -H "Authorization: eyJhbGciOiJIUzI1NiI..." http://localhost:3000/users/1
{"id":1,"email":"test@test.fr","hashedPassword":"8574a23599216d7752ef4a2f62..."}

Parfait ! et que se passe t’il si nous essayons d’accéder à cette route sans autorisation ?

$ curl http://localhost:3000/users/1
You must provide an `Authorization` header

Et voilà. L’accès nous a été interdit comme prévu.

Il est temps de commiter tous nos changement:

$ git add .
$ git commit -m "Add JWT middleware"

Conclusion

Vous l’avez fait! Vous êtes à mi-chemin! Ce chapitre a été long et difficile, mais c’est un grand pas en avant sur la mise en place d’un mécanisme solide pour gérer l’authentification utilisateur et nous commençons même à gratter la surface pour de simples règles d’autorisation.

Dans le prochain chapitre, nous nous concentrerons sur la personnalisation de la sortie JSON pour l’utilisateur et l’ajout d’un modèle de produit en donnant à l’utilisateur la possibilité de créer un produit et le publier pour la vente.

Produits des utilisateurs

Dans le chapitre précédent, nous avons implémenté le mécanisme d’authentification que nous allons utiliser tout au long de l’application.

Pour l’instant nous avons une implémentation très simple du modèle User mais le moment de vérité est venu. Nous allons personnaliser la sortie JSON et ajouter une deuxième ressource: les produits de l’utilisateur. Ce sont les éléments que l’utilisateur vendra dans l’application.

Si vous êtes familier avec un ORM, vous savez peut-être déjà de quoi je parle. Mais pour ceux qui ne le savent pas, nous allons associer le modèle User au modèle Product en utilisant les décorateurs @ManyToOne et @OneToMany de TypeORM.

Dans ce chapitre, nous allons construire le modèle de Product à partir de zéro, l’associer à l’utilisateur et créer les entrées nécessaires pour que tout client puisse accéder aux informations.

Vous pouvez cloner le projet jusqu’à ce point:

$ git checkout tags/checkpoint_chapter05

Avant de début, et comme d’habitude quand nous commençons de nouvelles fonctionnalités, nous créons une nouvelle branche:

$ git checkout -b chapter05

Le modèle du produit

Nous commencerons d’abord par créer un modèle de Product puis nous y ajouterons quelques validations et enfin nous l’associerons au modèle User. Comme le modèle User, le Product sera entièrement testé et sera automatiquement supprimé si l’utilisateur est supprimé.

Les fondements du produit

Le modèle Product aura besoin de plusieurs champs: un attribut price pour le prix du produit, un booléen published pour savoir si le produit est prêt à être vendu ou non, un title pour définir un titre de produit sexy, et enfin et surtout un userId pour associer ce produit particulier à un utilisateur:

Passons directement à l’implémentation

Création de l’entité Product
// src/entities/product.entity.ts
import {validateOrReject} from 'class-validator';
import {BeforeInsert, BeforeUpdate, Column, CreateDateColumn, Entity, EntityRepository, ManyToOne, PrimaryGeneratedColumn, Repository, UpdateDateColumn,} from 'typeorm"; import {User} from "./user.entity';

@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: "text" })
  title: string;

  @Column({ type: "float" })
  price: number;

  @Column({ type: "boolean" })
  published: boolean;

  @Index()
  @ManyToOne(() => User, (user) => user.products, { onDelete: "CASCADE" })
  user: User;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @BeforeInsert()
  @BeforeUpdate()
  async validate() {
    await validateOrReject(this);
  }
}

@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {}

Comme vous le voyez cela est très lisible. La seule chose nouvelle ici est l’apparition de la relation ManyToOne. Ceci est un décorateur qui va créer une colonne userId de type int. Il prend trois paramètres:

  1. une fonction qui renvoie la classe correspondant à l’association

  2. une fonction qui définie comment est spécifié la liaison dans l’autre sens

  3. un object contenant diverses paramètres

Note
J’ai aussi rajouté un décorateur @Index pour que cette colonne soit indexée. C’est une bonne pratique pour les clés d’associations car cela optimise les requêtes de la base de données. Ce n’est pas obligatoire, mais je vous le recommande vivement.

Avant de passer à la suite, nous devons aussi définir l’association OneToMany dans l’entité User

Le fichier de migration devrait ressembler à ceci:

// src/entities/user.entity.ts
// ...
@Entity()
export class User {
  // ...
  @OneToMany(() => Product, (product) => product.user)
  products: Product[];
  // ...
}
// ...

Et voilà. Notre association est faite et si vous démarrez le serveur avec les logs des requêtes de TypeORM vous devriez voir la requête SQL qui crée la table:

Logs du serveur dans le terminal
...
query: BEGIN TRANSACTION
...
query: CREATE TABLE "product" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" text NOT NULL, "price" float NOT NULL, "published" boolean NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), 'userId' integer)
...
query: CREATE INDEX "IDX_329b8ae12068b23da547d3b479" ON "product" ('userId')
query: COMMIT

Et voilà. Faisons un commit:

$ git add .
$ git commit -m "Generate product model"

Validations des produits

Comme nous l’avons vu avec l’utilisateur, les validations sont une partie importante lors de la construction de tout type d’application. Cela nous permet d’empêcher toute donnée indésirable d’être enregistrée dans la base de données. Pour le produit, nous devons nous assurer, par exemple, que le prix est un nombre et qu’il n’est pas négatif.

Pour cette partie là, nous n’avons pas besoin de mettre en place des tests car tout est déjà disponible et testé par la librairie class-validator. Il nous suffit de simplement ajouter les décorateurs correspondants. Voici donc le résultat:

// src/entities/product.entity.ts
import {IsDefined, IsPositive, validateOrReject} from 'class-validator';
// ...
@Entity()
export class Product {
  // ...
  @IsDefined()
  @Column({ type: "text", nullable: false })
  title: string;

  @IsPositive()
  @IsDefined()
  @Column({ type: "float", nullable: false })
  price: number;

  @Column({ type: "boolean", default: false })
  published: boolean;

  @Index()
  @ManyToOne(() => User, (user) => user.products, { onDelete: "CASCADE" })
  user: User;
  // ...
}
// ...

Les décorateurs documente le code et il n’y a pas grand chose à ajouter ici. Notez simplement que j’ai ajouté la propriété nullable: false qui va modifier le schéma de la base de donnée et ajouter une contrainte NOT NULL.

Commitons ces changements et continuons d’avancer:

$ git commit -am "Adds some validations to products"

Point d’entrée pour nos produits

Il est maintenant temps de commencer à construire les points d’entrée des produits. Pour l’instant, nous allons juste construire cinq actions REST.

Nous devons d’abord créer le ProductsController. En guise d’échauffement, nous allons commencer par construire l’action du show pour le produit.

Action d’affichage d’un produit

Tests

Comme d’habitude, nous commençons par ajouter quelques test du contrôleur des produits. Le but ici est très simple. Il suffit d’afficher un seul produit et de s’assurer que la réponse du serveur est celle que nous attendons.

Mais pour cela, nous allons tout d’abord créer un produit et un utilisateur dans la méthode before. Nous allons donc peaufiner notre utilitaire pour créer des modèles en ajoutant generateProduct:

Création de la méthode generateProduct
// src/utils/faker.utils.ts
// ...
import {Product} from '../entities/product.entity';

export function randomString(size: number = 8): string {
  return randomBytes(size).toString("hex");
}
// ...
export function generateProduct(product?: Partial<Product>): Product {
  const newProduct = new Product();
  newProduct.price = product?.price ?? Math.random() * 100;
  newProduct.published = product?.published ?? randomBoolean();
  newProduct.title = product?.title ?? randomString();
  newProduct.user = product?.user ?? generateUser();

  return newProduct;
}

Nous allons donc maintenant utiliser cette méthode dans le before du nouveau tests ci-dessous:

// src/controllers/products.controller.spec.ts
import {container} from '../core/container.core';
import {TYPES} from '../core/types.core';
import {Product, ProductRepository} from '../entities/product.entity';
import {User, UserRepository} from '../entities/user.entity';
import {DatabaseService} from '../services/database.service';
import {JsonWebTokenService} from '../services/jsonWebToken.service';
import {generateProduct, generateUser} from '../tests/faker.utils';
import {agent} from '../tests/supertest.utils';

describe("ProductsController", () => {
  let productRepository: ProductRepository;
  let product: Product;

  before(async () => {
    const databaseService = container.get<DatabaseService>( TYPES.DatabaseService);
    productRepository = await databaseService.getRepository(ProductRepository);
  });

  beforeEach(async () => {
    product = await productRepository.save(generateProduct({ user }));
  });
});

Et maintenant nous pouvons utiliser ce produit et pour tester s’il est consultable:

Test fonctionnel de la méthode ProductsController.show
// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("show", () => {
    it("should show product", (done) => {
      agent.get(`/products/${product.id}`).expect(200, done);
    });
  });
  // ...
});

Implémentation

Maintenant que notre test est en place, il est temps de faire passer le test.

Tout comme nous l’avons fait avec les utilisateurs, nous allons créer un middleware FetchProductMiddleware. Il s’occupera juste de récupérer le produit en fonction du paramètre productId et de l’injecter dans la requête:

// src/middlewares/fetchUser.middleware.ts
// ...
@injectable()
export class FetchProductMiddleware extends BaseMiddleware {
  constructor(@inject(TYPES.DatabaseService) private readonly databaseService: DatabaseService) {
    super();
  }

  public async handler(
    req: Request & { product: Product },
    res: Response,
    next: NextFunction
  ): Promise<void | Response> {
    const productId = req.query.productId ?? req.params.productId;
    const repository = await this.databaseService.getRepository(ProductRepository);
    req.product = await repository.findOne(Number(productId), { relations: ["user"] });

    if (!req.product) {
      return res.status(404).send("product not found");
    }

    next();
  }
}

La petite nouveauté ici est l’apparition du paramètre relation de la méthode findOne. Ce paramètre permet de récupérer aussi l’utilisateur associé au produit et de remplir la propriété product.user qui nous servira un peu plus loin.

Nous pouvons maintenant passer au contrôleur:

Implémentaion de la méthode ProductController.show
// src/controllers/home.controller.ts
// ...
@controller("/products")
export class ProductController {
  public constructor(
    @inject(TYPES.DatabaseService) private readonly databaseService: DatabaseService
  ) {}

  // ...

  @httpGet("/:productId", TYPES.FetchProductMiddleware)
  public async show(req: Request & { product: Product }) {
    return req.product;
  }
}

Attendez! N’exécutez pas encore les tests. N’oubliez pas que nous devons ajouter la route au container:

// src/core/types.core.ts
export const TYPES = {
  // ...
  FetchProductMiddleware: Symbol.for("FetchProductMiddleware"),
};
// src/core/container.core.ts
import "../controllers/products.controller";
// ...

export const container = new Container();
// ...
container.bind(TYPES.FetchProductMiddleware).to(FetchProductMiddleware);

Maintenant, nous nous assurons que les tests passent:

$ npm test
...
  ProductsController
    show
      ✓ should show product
...

Parfait, nous pouvons maintenant passer à la suite.

$ git add .
$ git commit -m "Add logic to show product"

Liste des produits

Il est maintenant temps de créer une entrée pour une liste de produits qui pourrait permettre d’afficher le catalogue de produits d’un marché par exemple. Pour ce point d’accès, nous n’exigeons pas que l’utilisateur soit connecté. Comme d’habitude, nous allons commencer à écrire quelques tests:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("index", () => {
    it("should respond 200", (done) => {
      agent.get("/products").expect(200, done);
    });
  });
});

Passons maintenant à la mise en œuvre, qui, pour l’instant, va être une petite méthode:

// src/controllers/home.controller.ts
// ...

@controller("/products")
export class ProductController {
  // ...

  @httpGet("/")
  public async index() {
    const repository = await this.databaseService.getRepository(ProductRepository);
    return repository.find();
  }
}

Dans les chapitres suivants, nous allons améliorer ce point d’entré et donner la possibilité de recevoir des paramètres pour les filtrer. Commitons ces changements et continuons d’avancer:

$ git add .
$ git commit -m "Add logic to list product"

Création des produits

Créer des produits est un peu plus délicat parce que nous aurons besoin d’une configuration supplémentaire. La stratégie que nous suivrons est d’attribuer le produit créé à l’utilisateur propriétaire du jeton JWT fourni d’en l’en-tête HTTP Authorization.

Tests

Notre premier arrêt sera donc le fichier products.controller.spec.ts. Nous allons tout d’abord créer un utilisateur spécifique dans le before et récupérer son jeton JWT:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  let userRepository: UserRepository;
  let productRepository: ProductRepository;
  let jsonWebTokenService: JsonWebTokenService;
  let user: User;
  let jwt: string;
  let product: Product;

  before(async () => {
    jsonWebTokenService = container.get(TYPES.JsonWebTokenService);

    const databaseService = container.get<DatabaseService>(TYPES.DatabaseService);
    userRepository = await databaseService.getRepository(UserRepository);
    productRepository = await databaseService.getRepository(ProductRepository);
  });

  beforeEach(async () => {
    user = await userRepository.save(generateUser());
    product = await productRepository.save(generateProduct({ user }));
    jwt = jsonWebTokenService.encode({ userId: user.id });
  });
  // ...
});

Nous allons maintenant créer trois tests:

  1. le cas ou on crée un produit avec un utilisateur

  2. le cas ou on ne peut pas créer de produit car il est incomplet

  3. le cas ou on ne fournis pas de jeton JWT et nous ne pouvons créer le produit

C’est partit:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("create", () => {
    it("should create product", (done) => {
      const { title, price, published } = generateProduct();
      agent
        .post("/products")
        .set("Authorization", jwt)
        .send({ title, price, published })
        .expect(201, done);
    });

    it("should not create product without auth", (done) => {
      const { title, price, published } = generateProduct();
      agent
        .post("/products")
        .send({ title, price, published })
        .expect(403, done);
    });

    it("should not create user with missing title", (done) => {
      const { price, published } = generateProduct();
      agent
        .post("/products")
        .set("Authorization", jwt)
        .send({ price, published })
        .expect(400, done);
    });
  });
  // ...
});

Wow! Nous avons ajouté beaucoup de code. Si vous vous souvenez, les tests sont en fait les mêmes que ceux de la création de l’utilisateur excepté quelques changements mineurs.

Implémentation

Il est donc temps de faire passer le test. L’implémentation est encore une fois très similaire à celle précédente dans le contrôleur des utilisateur. A la différence près que ici nous allons récupérer l’utilisateur associé au jeton JWT et l’assigner au produit que nous somme en train de créer:

// src/controllers/home.controller.ts
// ...
@controller("/products")
export class ProductController {
  // ...
  @httpPost("/", TYPES.FetchLoggedUserMiddleware)
  public async create(
    @requestBody() body: Partial<Product>,
    req: Request & { user: User },
    res: Response
  ) {
    const repository = await this.databaseService.getRepository(ProductRepository);
    const product = new Product();
    product.title = body.title;
    product.published = body.published;
    product.price = body.price;
    product.user = req.user;

    const errors = await validate(product);

    if (errors.length !== 0) {
      return res.status(400).json({ errors });
    }

    await repository.save(product);
    return res.sendStatus(201);
  }
}

Et voilà. Si vous faites les tests maintenant, ils devraient tous passer:

$ npm test
...
  ProductsController
    index
      ✓ should respond 200
    show
      ✓ should show product
    create
      ✓ should create product
      ✓ should not create product without auth
      ✓ should not create user with missing title
...

Mise à jour des produits

J’espère que maintenant vous comprenez la logique pour construire les actions à venir. Dans cette section, nous nous concentrerons sur l’action de mise à jour qui fonctionnera de manière similaire à celle de création. Nous avons juste besoin d’aller chercher le produit dans la base de données et de le mettre à jour.

Avant de commencer à coder certains tests je veux juste préciser que, de la même manière que pour l’action create, nous allons délimiter le produit à l’utilisateur courant. Nous voulons nous assurer que le produit que nous mettons à jour appartient bien à l’utilisateur. Nous allons donc chercher ce produit dans l’association product.user.

Tests

Tout d’abord, nous ajoutons quelques tests. Ici nous allons tester trois choses:

  1. le cas ou l’utilisateur possède effectivement le produit

  2. le cas ou l’utilisateur ne possède pas le produit et reçoit donc une réponse 403 - Forbidden

  3. le cas sans authentification

Afin de mettre en place ces tests, nous allons créer un product, un user qui possède le produit et un utilisateur stranger qui sera un utilisateur qui n’est pas associé au produit:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  let user: User;
  let stranger: User;
  let jwt: string;
  let strangerJwt: string;
  let product: Product;

  before(async () => {
    // ...
    stranger = await userRepository.save(generateUser());
    strangerJwt = jsonWebTokenService.encode({ userId: stranger.id });
  });

  beforeEach(async () => {
    user = await userRepository.save(generateUser());
    product = await productRepository.save(generateProduct({ user }));
    jwt = jsonWebTokenService.encode({ userId: user.id });
  });

  // ...
});

Cela peut paraître abstrait mais regardez l’implémentation des tests qui vont utiliser ces variables:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("update", () => {
    it("should update product", (done) => {
      const { title, price, published } = generateProduct();
      agent
        .put(`/products/${product.id}`)
        .set("Authorization", jwt)
        .send({ title, price, published })
        .expect(204, done);
    });

    it("should not update product of other users", (done) => {
      const { price, published } = generateProduct();
      agent
        .put(`/products/${product.id}`)
        .set("Authorization", strangerJwt)
        .send({ price, published })
        .expect(403, done);
    });

    it("should not update product without auth", (done) => {
      const { price, published } = generateProduct();
      agent
        .put(`/products/${product.id}`)
        .send({ price, published })
        .expect(403, done);
    });
  });
});

Les tests peuvent paraître complexes, mais en jetant un coup d’œil, ils sont presque identiques à ceux des utilisateurs.

Implémentation

Maintenant implémentons le code pour faire passer nos tests avec succès:

Implémentation de la méthode update
// src/controllers/home.controller.ts
// ...
@controller("/products")
export class ProductController {
  // ...

  @httpPut("/:productId", TYPES.FetchLoggedUserMiddleware, TYPES.FetchProductMiddleware)
  public async update(
    @requestBody() body: Partial<Product>,
    req: Request & { user: User; product: Product },
    res: Response
  ) {
    if (!this.canEditProduct(req.user, req.product)) {
      return res.sendStatus(403);
    }

    req.product.title = body.title;
    req.product.published = body.published;
    req.product.price = body.price;

    const errors = await validate(req.product);

    if (errors.length !== 0) {
      return res.status(400).json({ errors });
    }
    const repository = await this.databaseService.getRepository(ProductRepository);
    await repository.save(req.product);
    return res.sendStatus(204);
  }

  private canEditProduct(user: User, product: Product): boolean {
    return user.id === product.user.id;
  }
}

Comme vous pouvez le constater, l’implémentation est assez simple. Les Middleware vont automatiquement récupérer le produit et l’utilisateur lié au jeton JWT. Il ne nous reste plus qu’à vérifier que l’utilisateur possède bien le produit. C’est ce que nous faisons avec la méthode canEditProduct. Ensuite nous mettons à jour le produit et nous le sauvegardons après avoir vérifié qu’il est valide bien sûr.

Si nous lançons les tests, ils devraient passer:

$ npm test
...
  ProductsController
    index
      ✓ should respond 200
    show
      ✓ should show product
    create
      ✓ should create product
      ✓ should not create product without auth
      ✓ should not create user with missing title
    update
      ✓ should update product
      ✓ should not update product of other users
      ✓ should not update product without auth
...

Suppression des produits

Notre dernier arrêt pour les route des produits, sera l’action destroy. Vous pouvez maintenant imaginer à quoi cela ressemblerait. La stratégie ici sera assez similaire à l’action de create et update. Ce qui signifie que nous allons récupérer l’utilisateur connecté puis vérifier que l’utilisateur possède bien le produit et enfin le supprimer en retournant un code 204.

Commençons par ajouter quelques tests:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("destroy", () => {
    it("should destroy product", (done) => {
      const jwt = jsonWebTokenService.encode({ userId: user.id });
      agent
        .delete(`/products/${product.id}`)
        .set("Authorization", jwt)
        .expect(204, done);
    });

    it("should not destroy product without auth", (done) => {
      agent.delete(`/products/${product.id}`).expect(403, done);
    });

    it("should not destroy of other users", (done) => {
      agent
        .delete(`/products/${product.id}`)
        .set("Authorization", strangerJwt)
        .expect(403, done);
    });
  });
});

Maintenant, ajoutons simplement le code nécessaire pour faire passer les tests:

// src/controllers/home.controller.ts
// ...
@controller("/products")
export class ProductController {
  // ...
  @httpDelete("/:productId", TYPES.FetchLoggedUserMiddleware, TYPES.FetchProductMiddleware)
  public async destroy(
    req: Request & { user: User; product: Product },
    res: Response
  ) {
    if (!this.canEditProduct(req.user, req.product)) {
      return res.sendStatus(403);
    }
    const repository = await this.databaseService.getRepository(
      ProductRepository
    );
    await repository.delete(req.product);
    return res.sendStatus(204);
  }
  // ...
}

Comme vous pouvez le voir, l’implémentation fait le travail en trois lignes. Nous pouvons lancer les tests pour nous assurer que tout est bon.

$ npm test
...
  ProductsController
...
    destroy
      ✓ should destroy product
      ✓ should not destroy product without auth
      ✓ should not destroy of other users
...
  27 passing (344ms)

Après cela, nous commitons les changements.

$ git commit -am "Adds the products create, update and destroy action"

Essai avec cURL

Nos tests nous indiquent que tout va bien mais c’est toujours bien de s’en assurer. Nous allons donc créer un utilisateur puis un produit le mettre à jour et enfin le supprimer. C’est partit.

Démarrez votre serveur avec npm start si ce n’est déjà fait et commençons par créer un utilisateur:

Création de l’utilisateur
$ curl -X POST -d "email=test@test.io" -d "password=test" http://localhost:3000/users
{
  "email": "test@test.io",
  "hashedPassword": "8574a23599216d7752ef4a2f62d02b9efb24524a33d840f10ce6ceacda69777b",
  "id": 1,
  "createdAt": "2020-11-25T20:37:20.000Z",
  "updatedAt": "2020-11-25T20:37:20.000Z"
}

Et maintenant obtenons un jeton JWT valide:

Obtention d’un jeton d’authentification
$ curl -X POST -d "email=test@test.io" -d "password=test" http://localhost:3000/tokens
{
  "token": "eyJhbGciOiJ..."
}

Notez ce jeton et sauvegardons le dans une variable Bash:

$ export JWT="eyJhbGciOiJ..."

Maintenant utilisons ce jetons pour créer un produit:

$ curl -X POST  -H "Authorization: $JWT" -d "title=my first product" -d "price=1"  http://localhost:3000/products
{
  "id": 1,
  "title": "my first product",
  "price": 1,
...
}

Nous pouvons le mettre à jour facilement avec le requête PUT:

$ curl -X PUT  -H "Authorization: $BASH" -d "title=my first product undated" -d "price=66"  http://localhost:3000/products/1

Et enfin supprimer ce produit:

$ curl -X DELETE -H "Authorization: $JWT" http://localhost:3000/products/1

C’est parfait.

Il est donc temps de clôturer ce chapitre et de passer à la suite.

$ git checkout master
$ git merge chapter05

Conclusion

J’espère que vous avez apprécié ce chapitre. C’est un long travail, mais le code que nous avons créé est une excellente base pour l’application principale.

Dans le chapitre suivant, nous allons nous concentrer sur la personnalisation de la sortie des modèles utilisateur et produits à l’aide de la librairie jsonapi-serializer. Elle nous permettra de filtrer facilement les attributs à afficher et à gérer les associations comme des objets embarqués par exemple.

Modélisation du JSON

Dans le chapitre précédent, nous avons ajouté les produits à l’application et construit toutes les routes nécessaires. Nous avons également associé un produit à un utilisateur et restreint certaines des actions de ProductsController.

Maintenant, vous devriez être satisfaits de tout ce travail. Mais nous avons encore du pain sur la planche. Actuellement, nous avons une sortie JSON qui n’est pas parfaite. La sortie JSON ressemble à celle-ci:

[
  {
    "id": 1,
    "title": "Tag Case",
    "price": 98.77,
    "published": false,
    "userId": 1,
    "createdAt": "2018-12-20T12:47:26.686Z",
    "updatedAt": "2018-12-20T12:47:26.686Z"
  },
]

Or nous voulons une sortie qui ne contienne pas les champs userId, createdAt et updatedAt.

De plus, une partie importante et difficile lors de la création de votre API est de décider le format de sortie. Heureusement, certaines organisations ont déjà fait face à ce genre de problème et elles ont ainsi établi certaines conventions que vous allez découvrir dans ce chapitre.

Vous pouvez clôner le projet jusqu’à ce point avec:

$ git checkout tags/checkpoint_chapter06

Commençons une nouvelle branche pour ce chapitre:

$ git checkout -b chapter06

Présentation de JSON:API

Comme je le disais plus tôt, une partie importante et difficile lors de la création de votre API est de décider le format de sortie. Heureusement, certaines conventions existent déjà.

L’une d’elles, certainement la plus utilisée est JSON:API. La documentation de JSON:API nous donne quelques règles à suivre concernant le formatage du document JSON.

Ainsi, notre document doit contenir ces clefs:

  • data qui doit contenir les données que nous renvoyons

  • errors qui doit contenir un tableau des erreurs qui sont survenues.

  • meta qui contient un objet meta

Le contenu de la clé data est lui aussi assez strict:

  • il doit posséder une clé `type`qui décrit le type du modèle JSON (si c’est un article, un utilisateur, etc..)

  • les propriétés de l’objet doivent être placées dans une clé attributes et les undescore (_) sont remplacés par des tirets (-)

  • les liaisons de l’objets doivent être placées dans une clé relationships

Dans ce chapitre, nous allons personnaliser la sortie JSON en utilisant la librairie jsonapi-serializer qui respecte toutes les normes JSON:API.

Installons donc cette dépendance:

$ npm install jsonapi-serializer
$ npm install @types/jsonapi-serializer --save-dev

Vous devriez être prêts à continuer avec ce tutoriel.

Sérialiser l’utilisateur

jsonapi-serializer utilise des sérialiseurs. Les sérialiseurs représentent des méthodes qui seront responsables de convertir un objet en un autre objet respectant la norme JSON:API.

Nous devons d’abord ajouter un fichier serializers.utils.ts qui contiendra tous les sérialseurs. Et dans la foulée, je commence directement par l’implémentation de userSerializer:

Création du sérialseur userSerializer
// src/utils/serializers.utils.ts
import {Serializer} from 'jsonapi-serializer';

export const userSerializer = new Serializer("users", {
  attributes: ["email"],
  dataLinks: {
    self: (user) => `/users/${user.id}`,
  },
});

Ce serializer va nous permettre de convertir notre objet User en JSON qui implémente correctement la norme JSON:API. Nous avons spécifié l’attribut email afin qu’il soit présent dans le tableau data. En listant les champs que nous voulons voir apparaître, cette librairie nous corrige le problème de l’attribut hashedPassword qui était envoyé par notre API.

Il ne nous reste plus qu’à utiliser cette instance dans notre contrôleur:

// src/controllers/home.controller.ts
// ...
import {userSerializer} from '../utils/serializers.utils';

@controller('/users')
export class UsersController {
  // ...
  @httpGet("/")
  public async index() {
    // ...
    return userSerializer.serialize(users);
  }
  // ...
  @httpGet('/:userId', TYPES.FetchLoggedUserMiddleware)
  public async show(/* ... */) {
    // ...
    return userSerializer.serialize(req.user);
  }
  // ...
}

Comme vous pouvez le constater, cela ne change pas grand chose! Nous important simplement notre sérialiseur et nous utilisons sa méthode serialize.

Essayons tout cela avec cURL:

$ curl http://localhost:3000/users
Sortie JSON des utilisateurs
{
  "data": [
    {
      "type": "users",
      "id": "1",
      "attributes": {
        "email": "test@test.io"
      }
    }
  ]
}

Commitons ces changements et continuons d’avancer:

$ git add .
$ git commit -am "Adds user serializer for customizing the json output"

Sérialiser les produits

Maintenant que nous comprenons comment fonctionne la gemme de sérialisation, il est temps de personnaliser la sortie des produits. La première étape est identique à celle pour l’utilisateur, nous avons besoin d’un sérialiseur de produit, alors faisons-le:

// src/utils/serializers.utils.ts
// ...
export const productsSerializer = new Serializer("products", {
  attributes: ["title", "price", "published", "user"],
});

Et voilà. Ce n’est pas plus compliqué que cela. Modifions un petit peu notre contrôleur.

// src/controllers/home.controller.ts
// ...
import {productsSerializer} from '../utils/serializers.utils';

@controller("/products")
export class ProductController {
  // ...
  @httpGet("/")
  public async index() {
    // ...
    return productsSerializer.serialize(products);
  }
  // ...
  @httpGet("/:productId", TYPES.FetchProductMiddleware)
  public async show(req: Request & { product: Product }) {
    return productsSerializer.serialize(req.product);
  }
  // ...
}

Vous pouvez lancer les tests pour vérifier mais ils devraient encore être bons. Commitons ces petits changements:

$ git add .
$ git commit -m "Adds product serializer for custom json output"

Sérialiser les associations

Nous avons travaillé avec des sérialiseurs et vous remarquerez peut-être que c’est très simple. Dans certains cas, la décision difficile est de savoir comment nommer vos routes ou comment structurer la sortie JSON afin que votre solution soit pérenne. Lorsque vous travaillez avec des associations entre les modèles sur une API, il existe de nombreuses approches que vous pouvez prendre.

Nous n’avons pas à nous soucier de ce problème dans notre cas, la norme JSON:API l’a fait pour nous!

Pour résumer, nous avons une association de type has_many entre l’utilisateur et le modèle de produit.

// src/entities/user.entity.ts
// ...
@Entity()
export class User {
  // ...
  @OneToMany(() => Product, (product) => product.user)
  products: Product[];
  // ...
}
// ...
// src/entities/product.entity.ts
// ...
@Entity()
export class Product {
  // ...
  @ManyToOne(() => User, (user) => user.products, { onDelete: "CASCADE" })
  user: User;
  // ...
}
// ...

C’est une bonne idée d’intégrer les utilisateurs dans les sortie JSON des produits. Cela rendra la sortie plus lourde mais ça évitera au client de l’API d’éxecuter d’autres requêtes pour récupérer les informations des utilisateurs liées aux produits. Cette méthode peut vraiment vous éviter un énorme goulet d’étranglement.

Théorie de l’injection des relations

Imaginez un scénario où vous allez chercher les produits dans l’API, mais dans ce cas, vous devez afficher une partie des informations de l’utilisateur.

Une solution possible serait d’ajouter l’attribut user_id au product_serializer pour que nous puissions récupérer l’utilisateur correspondant plus tard. Cela peut sembler être une bonne idée, mais si vous vous souciez de la performance, ou si les transactions de votre base de données ne sont pas assez rapides, vous devriez reconsidérer cette approche. Vous devez comprendre que pour chaque produit que vous récupérez, vous allez devoir récupérer son utilisateur correspondant.

Face à ce problème, il y a plusieurs alternatives possibles.

Intégrer dans un attribut meta

Une bonne solution à mon avis est d’intégrer les identifiants des utilisateurs liés aux produits dans un attribut meta, donc nous aurions une sortie JSON comme:

{
  "meta": { "user_ids": [1,2,3] },
  "data": [

  ]
}

Cela peut nécessiter une configuration supplémentaire sur le terminal de l’utilisateur, afin que le client puisse récupérer ses utilisateurs à partir de ces user_ids.

Incorporer l’objet dans l’attribut

Une autre solution, est d’incorporer l’objet user dans l’objet product. Ce qui peut rendre la première requête un peu plus lente, mais de cette façon le client n’a pas besoin de faire une autre requête supplémentaire. Un exemple des résultats escomptés est présenté ci-dessous:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false,
          "user": {
            "id": 2,
            "attributes": {
              "email": "stephany@lind.co.uk",
              "created_at": "2014-07-29T03:52:07.432Z",
              "updated_at": "2014-07-29T03:52:07.432Z",
              "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
            }
          }
        }
    }
  ]
}

Le problème de cette approche est que nous devons dupliquer les objets User pour tous les produits qui appartiennent au même utilisateur:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          // ...
          "user": {
            "id": 2,
            "type": "user",
            "attributes": {
              "email": "stephany@lind.co.uk",
              // ...
            }
          }
        }
    },
    {
        "id": 2,
        "type": "product",
        "attributes": {
          "title": "Second product",
          // ...
          "user": {
            "id": 2,
            "type": "user",
            "attributes": {
              "email": "stephany@lind.co.uk",
              // ...
            }
          }
        }
    }
  ]
}

Incorporer les relation dans include

La troisième solution, choisie par la norme JSON:API, est un mélange des deux premières.

Nous allons inclure toutes les relations dans une clé include qui contiendra tous les relations des objets précédemment cités. Aussi, chaque objet inclura une clé relationships définissant la relation et qu’il faudra retrouver dans la clé include.

Un JSON vaut mille mots:

{
  "data":
  [
    {
        "id": 1,
        "type": "product",
        "attributes": {
          "title": "First product",
          "price": "25.02",
          "published": false
        },
        "relationships": {
          "user": {
            "id": 1,
            "type": "user"
          }
        }
    },
    {
        "id": 2,
        "type": "product",
        "attributes": {
          "title": "Second product",
          "price": "25.02",
          "published": false
        },
        "relationships": {
          "user": {
            "id": 1,
            "type": "user"
          }
        }
    }
  ],
  "include": [
    {
      "id": 2,
      "type": "user",
      "attributes": {
        "email": "stephany@lind.co.uk",
        "created_at": "2014-07-29T03:52:07.432Z",
        "updated_at": "2014-07-29T03:52:07.432Z",
        "auth_token": "Xbnzbf3YkquUrF_1bNkZ"
      }
    }
  ]
}

Vous voyez la différence? Cette solution réduit drastiquement la taille du JSON et donc la bande passante utilisée.

Application de l’injection des relations

Nous allons donc incorporer l’objet utilisateur dans le produit. Commençons par ajouter quelques tests.

Nous allons simplement modifier le test UsersControllers.show afin de vérifier que nous récupérons:

Ajout d’un test pour controller l’ajout du include dans la sortie JSON
// src/controllers/users.controller.spec.ts
// ...
describe("UsersController", () => {
  // ...
  let productRepository: ProductRepository;

  before(async () => {
    // ...
    productRepository = await databaseService.getRepository(ProductRepository);
  });

  beforeEach(async () => {
    user = await userRepository.save(generateUser());
    const product = await productRepository.save(generateProduct({ user }));
    user.products = [product];
    // ...
  });

  // ...

  describe("show", () => {
    // ...
    it("should show my profile", () => {
      return agent
        .get(`/users/${user.id}`)
        .set("Authorization", jwt)
        .expect(200)
        .then((response) => {
          assert.strictEqual(response.body.data.attributes.email, user.email);
          assert.strictEqual(response.body.included[0].attributes.title, user.products[0].title);
        });
    });
  });
// ...
});

Nous vérifions maintenant deux choses sur le JSON qui est retourné:

  1. il contient le titre du produit

  2. les données de l’utilisateur sont incluses dans la clé include

Vous pouvez aussi remarquer que j’ai créer et lier un produit à l’utilisateur sauvegardé dans la méthode beforeEach.

Pour faire passer ce test nous allons commencer par inclure la relation dans le serializer:

// src/utils/serializers.utils.ts
// ...
export const userSerializer = new Serializer("users", {
  attributes: ["email", "products"],
  included: true,
  products: {
    ref: "id",
    attributes: ["title", "price", "published"],
    included: true,
  },
} as any);
// ...
Note
à l’heure ou j’écris ces lignes, je n’ai pas trouvé d’autres moyens que le as any pour contourner l’erreur de typing de TypeScript. Peut être que la librairie sera mse à jour prochainement.

Cet ajout aura pour effet de rajouter une clé relationship contenant l’identifiant de l’utilisateur mais aussi ajouter un clé include qui va contenir la relation. Voici un exemple:

Exemple de sortie JSON avec un utilisateur possédant un produit
{
  data: {
    type: 'users',
    id: '16',
    attributes: {
      email: 'ddf1bbe99c3a7ee8@random.io'
    },
    relationships: {
      products: {
        data: [
          { type: 'products', id: '15' }
        ]
      }
    }
  },
  included: [
    {
      type: 'products',
      id: '15',
      attributes: {
        title: 'adc643eaa6bc1748',
        price: 72.45882186217555,
        published: false
      }
    }
  ],
}

L’implémentation est très simple: il suffit d’ajouter une ligne au sérialiseur du produit:

$ npm test

  ProductsController
...
    show
      ✓ should show product
...

Faisons un commit pour fêter ça:

$ git commit -am "Add user relationship to product"

Récupérer l’utilisateur d’un produit

Vous avez compris le principe? Nous avons inclus les informations de l’utilisateur dans le JSON des produits.

Commençons par le test:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("show", () => {
    it("should show product", () => {
      agent
        .get(`/products/${product.id}`)
        .expect(200)
        .then((response) => {
          assert.strictEqual(response.body.data.attributes.title, product.title);
          assert.strictEqual(response.body.included[0].attributes.email, product.user.email);
        });
    });
  });
  // ...
});

Ensuite le serializer:

// src/utils/serializers.utils.ts
// ...
export const productsSerializer = new Serializer("products", {
  attributes: ["title", "price", "published", "user"],
  included: true,
  user: {
    ref: "id",
    included: true,
    attributes: ["email"],
  },
} as any);

Et pour terminer le contrôleur:

// src/controllers/home.controller.ts
// ...
@controller("/products")
export class ProductController {
  // ...
  @httpGet("/")
  public async index() {
    // ...
    return productsSerializer.serialize(products);
  }
  // ...
  @httpGet("/:productId", TYPES.FetchProductMiddleware)
  public async show(/* ... */) {
    return productsSerializer.serialize(req.product);
  }
  // ...
}

Et voilà. Nous obtenons un JSON de cette forme:

{
  data: {
    type: 'products',
    id: '2',
    attributes: {
      title: 'd358a5c96b94a562',
      price: 56.85800753546402,
      published: false
    },
    relationships: {
      user: {
        data: {
          type: 'users',
          id: '3'
        }
      }
    }
  },
  included: [
    {
      type: 'users',
      id: '3',
      attributes: {
        email: 'ddaf230c3d15a057@random.io'
      }
    }
  ]
}

C’était vraiment facile. Faisons un commit:

$ git commit -am "Add user relationship to ProductsController.show"

Rechercher les produits

Dans cette dernière section, nous continuerons à renforcer l’action Products#index en mettant en place un mécanisme de recherche très simple pour permettre à n’importe quel client de filtrer les résultats. Cette section est facultative car elle n’aura aucun impact sur les modules de l’application. Mais si vous voulez pratiquer davantage avec le TDD, je vous recommande de compléter cette dernière étape.

Il existe des librairies pour construire des formulaires de recherche avancée extrêmement rapidement. Mais ici, comme le but est d’apprendre et que la recherche que nous allons effectuer est très simple, je pense que nous pouvons construire un moteur de recherche à partir de zéro. Nous devons simplement considérer les critères par lesquels nous allons filtrer les attributs. Accrochez-vous bien à vos sièges, ça va être un voyage difficile.

Nous filtrerons donc les produits selon les critères suivants:

  • Par titre

  • Par prix

  • Trier par date de création

Cela peut sembler court et facile, mais croyez-moi, cela vous donnera mal à la tête si vous ne le planifiez pas.

Nous allons donc ajouter une méthode search au ProductRepository qui prendra en paramètre les filtres que je viens d’énumérer plus haut:

// src/entities/product.entity.ts
// ...
interface ProductSearchFilters {
  // need to be implemented
}

@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
  public search(filters: ProductSearchFilters): SelectQueryBuilder<Product> {
    // need to be implemented
  }
}

Vous voyez un peu comment nous allons nous y prendre? Commençons par le premier filtre.

Les produits publiés

Comme depuis le début de ce livre, nous allons commencer par écrire le test qui va tester notre nouvelle méthode. Voici la structure de base de notre test qui doit vous sembler familière:

// src/entities/product.entity.spec.ts
import {container} from '../core/container.core';
import {TYPES} from '../core/types.core';
import {ProductRepository} from '../entities/product.entity';
import {DatabaseService} from '../services/database.service';

describe("ProductRepository", () => {
  let productRepository: ProductRepository;

  before(async () => {
    const databaseService = container.get<DatabaseService>(TYPES.DatabaseService);
    productRepository = await databaseService.getRepository(ProductRepository);
  });

  describe("search", () => {
    // will be implemented
  });
});

Ce tests aura besoin de plusieurs produits déja existants en base de données que nous allons créer à la main. Voici donc la structure de notre test:

// src/entities/product.entity.spec.ts
// ...
import {Product, ProductRepository} from '../entities/product.entity';
import {generateProduct} from '../tests/faker.utils';

describe("ProductRepository", () => {
  // ...
  describe("search", () => {
    let tvPlosmo: Product;
    let computer: Product;
    let tvCheap: Product;
    let unpublishedProduct: Product;

    before(async () => {
      tvPlosmo = await productRepository.save(generateProduct({
        title: "TV Plosmo Philopp",
        price: 9999.99,
        published: true,
      }));
      computer = await productRepository.save(generateProduct({
        title: "Azos Zeenbok",
        price: 499.99,
        published: true,
      }));
      tvCheap = await productRepository.save(generateProduct({
        title: "Cheap TV",
        price: 99.99,
        published: true,
      }));
      unpublishedProduct = await productRepository.save(generateProduct({
        published: false,
      }));
    });
    // ...
  });
});

Comme vous pouvez le voir, nous avons inséré en base 4 produits diverses. Dans notre premier test nous allons appeler notre méthode ProductReposiroty.search sans paramètre et nous allons vérifier que aucun produit non publié ne nous est retourné. Voici le test:

// src/entities/product.entity.spec.ts
// ...
describe("ProductRepository", () => {
  // ...
  describe("search", () => {
    // ...
    it("should not include unpublished products", async () => {
      const products = await productRepository.search({}).getMany();
      assert.ok(products.every((p) => p.published));
    });
  });
});

Et commençons donc par définir notre méthode pour faire passer ce test:

// src/entities/product.entity.ts
// ...
interface ProductSearchFilters { }

@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
  public search(filters: ProductSearchFilters): SelectQueryBuilder<Product> {
    const query = this.createQueryBuilder()
                      .where("published = TRUE")
                      .orderBy("updatedAt", "DESC");

    return query;
  }
}

Et voilà. Le test devrait passer. Passons à notre premier filtre.j

Par titre

Maintenant que la structure de nos tests et de l’implémentation est en place, tout va aller plus vite. Voici le test pour le filtre qui ressemble beaucoup au précédent:

// src/entities/product.entity.spec.ts
// ...
describe("ProductRepository", () => {
  // ...
  describe("search", () => {
    // ...
    it("should filter products by title", async () => {
      const products = await productRepository.search({ title: "tv" }).getMany();
      assert.ok(products.some((p) => p.id === tvPlosmo.id));
      assert.ok(products.some((p) => p.id === computer.id) === false);
    });
  });
});

Les tests suivants s’assurent que la méthode va rechercher correctement les produits en fonction de leurs titres. Nous utilisons le terme tv en minuscule afin de s’assurer que notre recherche ne sera pas sensible à la casse.

L’implémentation est très simple:

// src/entities/product.entity.ts
// ...
interface ProductSearchFilters {
  title?: string;
}

@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
  public search(filters: ProductSearchFilters): SelectQueryBuilder<Product> {
    // ...
    if (filters.title !== undefined) {
      query.andWhere("lower(title) LIKE :title", { title: `%${filters.title}%` });
    }

    return query;
  }
}

L’implémentation est suffisante pour que nos tests passent:

$ npm test
....
  ProductRepository
    search
      ✓ should not include unpublished products
      ✓ should filter products by title
....

Par prix

Pour filtrer par prix, les choses peuvent devenir un peu plus délicates. Nous allons séparer la logique de filtrer par prix en deux méthodes différentes: l’une qui va chercher les produits plus grands que le prix reçu et l’autre qui va chercher ceux qui sont sous ce prix. De cette façon, nous garderons une certaine flexibilité et nous pouvons facilement tester les scope.

Commençons par construire les tests:

// src/entities/product.entity.spec.ts
// ...
describe("ProductRepository", () => {
  // ...
  describe("search", () => {
    // ...
    it("should filter products by priceMax", async () => {
      const products = await productRepository
        .search({priceMax: 100})
        .getMany();
      assert.ok(products.some((p) => p.id === tvCheap.id));
      assert.ok(products.some((p) => p.id === tvPlosmo.id) === false);
    });

    it("should filter products by priceMin", async () => {
      const products = await productRepository
        .search({priceMin: 500})
        .getMany();
      assert.ok(products.some((p) => p.id === tvPlosmo.id));
      assert.ok(products.some((p) => p.id === tvCheap.id) === false);
    });
  });
});

L’implémentation est très très simple:

// src/entities/product.entity.ts
// ...
interface ProductSearchFilters {
  title?: string;
  priceMin?: number;
  priceMax?: number;
}

@EntityRepository(Product)
export class ProductRepository extends Repository<Product> {
  public search(filters: ProductSearchFilters): SelectQueryBuilder<Product> {
    // ...
    if (filters.priceMin !== undefined) {
      query.andWhere("price >= :priceMin", { priceMin: filters.priceMin });
    }

    if (filters.priceMax !== undefined) {
      query.andWhere("price <= :priceMax", { priceMax: filters.priceMax });
    }

    return query.getMany();
  }
}

C’est suffisant pour que nos tests passent:

$ npm test
...
  ProductRepository
    search
      ✓ should not include unpublished products
      ✓ should filter products by title
      ✓ should filter products by priceMax
      ✓ should filter products by priceMin
...

Super. La dernière étape est de l’intégrer à notre contrôleur.

Intégration dans le contrôleur

Comme d’habitude, nous allons commencer par les tests. Cela va nous aider à définir l’implémentation de notre endpoint.

Comme pour les tests précedents, nous allons créer deux produits spécifiques que nous allons ensuite rechercher via les différents filtres que nous venons d’implémenter. Le test risque donc de vous sembler très familier.

Nous allons définir un nouveau describe qui va regrouper nos deux tests. Commençons par le beforeEach:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("index", () => {
    // ...
    describe("search", () => {
      let computer: Product;
      let tvCheap: Product;

      before(async () => {
        computer = await productRepository.save(
          generateProduct({
            title: "Azos Zeenbok",
            price: 499.99,
            published: true,
          })
        );
        tvCheap = await productRepository.save(
          generateProduct({
            title: "Cheap TV",
            price: 99.99,
            published: true,
          })
        );
      });
    // ...
    });
  });
  // ...
});

Maintenant passons aux tests en eux-même:

// src/controllers/products.controller.spec.ts
// ...
describe("ProductsController", () => {
  // ...
  describe("index", () => {
    // ...
    describe("search", () => {
      // ...
      it("should find cheap TV", () => {
        const params = new URLSearchParams();
        params.append("title", "tv");
        params.append("priceMin", "50");
        params.append("priceMax", "150");

        return agent
          .get(`/products?${params.toString()}`)
          .expect(200)
          .then((response) => assert.ok(response.body.data.some((row) => row.id === String(tvCheap.id))));
      });

      it("should find computer", () => {
        const params = new URLSearchParams();
        params.append("title", "azos");
        params.append("priceMax", "500");

        return agent
          .get(`/products?${params.toString()}`)
          .expect(200)
          .then((response) => {
            assert.ok(
              response.body.data.some((row) => row.id === String(computer.id)),
              response.body
            );
          });
      });
    });
  });
  // ...
});
Note
nous construisons les paramètres avec la classe URLSearchParams. Il suffit ensuite d’utiliser la méthode toString qui va construire les paramètres GET.

Lorsque nous recevons la réponse, nous vérifions que le produit recherché est présent dans la réponse. Tout simplement.

L’implémentation dans la contrôleur est simplissime. Il suffit d’utiliser notre nouvelle méthode.

// src/controllers/products.controller.ts
// ...
@controller("/products")
export class ProductController {
  // ...
  @httpGet("/")
  public async index(req: Request) {
    const repository = await this.databaseService.getRepository(ProductRepository);
    const products = await repository.search(req.query).getMany();
    return productsSerializer.serialize(products);
  }
  // ...
}

Nous pouvons exécuter l’ensemble de la suite de tests, pour nous assurer que l’application est en bonne santé jusqu’ici:

$ npm test
  ProductsController
    index
      ✓ should respond 200 (47ms)
      search
        ✓ should find cheap TV
        ✓ should find computer
...
  33 passing (786ms)

Super! Commitons ces changements:

$ git commit -am "Adds search class method to filter products"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter06

Conclusion

Jusqu’à présent, et grâce à la librairie jsonapi-serializer, c’était facile. Sur les chapitres à venir, nous allons commencer à construire le modèle Order qui associera les utilisateurs aux produits.

Création des commandes

Dans les chapitres précédents nous avons traité les associations entre les produits et les utilisateurs. Nous avons aussi vu comment bien les sérialiser en les optimisant afin de pouvoir scaler, (c’est-à-dire s’adapter facilement à une forte demande sur notre application). Maintenant, il est temps de commencer à passer des commandes. Cela va être une situation plus complexe parce que nous allons gérer les associations entre les trois modèles. Nous devons être assez malins pour gérer la sortie JSON que nous fournissons.

Dans ce chapitre, nous allons faire plusieurs choses:

  • Créer un modèle de commande avec les spécifications correspondantes

  • Gérer l’association de sortie JSON entre l’utilisateur de la commande et les modèles de produits

  • Envoyer un courriel de confirmation avec le récapitulatif de la commande

Maintenant que tout est clair, nous pouvons commencer à travailler. Créons une nouvelle branche afin de commencer à travailler:

$ git checkout -b chapter07

Modélisation de la commande

Si vous vous souvenez des associations entre les modèles, vous devez vous souvenir que le modèle Order est associé aux modèles User et Product. C’est en fait très simple de gérer cela avec Rails. La partie délicate est lors de la sérialisation de ces objets. J’en parlerai plus en détail plus tard.

Commençons par créer le modèle de la commande. Celui possédera deux relations ManyToOne: User et Product. Il aura aussi une colonne total pour le coût total de la commande et ensuite les colonnes classiques createdAt et updatedAt. Voici l’implémentation complète.

// src/entities/order.entity.ts
import {IsDefined, IsPositive, validateOrReject} from 'class-validator';
import {/* ... */} from 'typeorm';
import {Product} from './product.entity';
import {User} from './user.entity';

@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => User, (user) => user.orders)
  user: User;

  @IsNumber()
  @ValidateIf((total) => total >= 0)
  @Column({ type: "number", unsigned: true })
  total: number;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @BeforeInsert()
  @BeforeUpdate()
  async validate() {
    await validateOrReject(this);
  }
}

@EntityRepository(Order)
export class OrderRepository extends Repository<Order> {}

Comme vous pouvez le constater, l’implémentation n’apporte rien de nouveau comparé à ce que nous avons déjà vu.

J’en ai profité pour ajouter la contrainte ValidateIf sur le champ total qui est un number unsigned. Cela signifie qu’il ne peut pas être négatif.

Mais avant d’oublier, nous devons aussi définir la relation côté User:

// src/entities/user.entity.ts
// ...
import {Order} from './order.entity';

@Entity()
export class User {
  // ...
  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];
  // ...
}

Parfait! Nous sommes prêt à passer à la suite. Commitons tout cela avant:

$ git add .
$ git commit -m "Generate orders"

Les commandes et les produits

Nous devons établir la liaison entre la commande et les produits. Cela se fait avec une association many-to-many car les produits seront associé à plusieurs commandes et les commandes auront plusieurs produits. Dans ce cas, nous avons donc besoin d’un modèle supplémentaire qui joindra ces deux autres objets et mappera l’association appropriée. Voici l’implémentation:

// src/entities/placement.entity.ts
import {IsDefined, validateOrReject} from 'class-validator';
import {/* ... */} from 'typeorm';
import {Order} from './order.entity';
import {Product} from './product.entity';
import {User} from './user.entity';

@Entity()
export class Placement {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => Product, (product) => product.placements)
  product: Product;

  @ManyToOne(() => Order, (order) => order.placements)
  order: User;

  @BeforeInsert()
  @BeforeUpdate()
  async validate() {
    await validateOrReject(this);
  }
}

@EntityRepository(Placement)
export class PlacementRepository extends Repository<Placement> {}
Ajout de la relation placements au modèle Product
// src/entities/product.entity.ts
// ...
@Entity()
export class Product {
  // ...
  @OneToMany(() => Placement, (placement) => placement.product)
  placements: Placement[];
  // ...
}
// ...
Ajout de la relation placements au modèle Order
// src/entities/order.entity.ts
// ...
@Entity()
export class Order {
  // ...
  @OneToMany(() => Placement, (placement) => placement.order)
  placements: Placement[];
  // ...
}
// ...

Commitons les changements:

$ git add .
$ git commit -m "Associates products and orders with a placements model"

Exposer le modèle d’utilisateur

Il est maintenant temps de préparer le contrôleur des commandes à exposer les bonnes commandes. Si vous vous souvenez des chapitres précédents où l’on avait utilisé jsonapi-serializer vous devez vous rappeler que c’était vraiment facile.

Définissons d’abord quelles actions nous allons mettre en place:

  1. Une action d’indexation pour récupérer les commandes des utilisateurs en cours

  2. Une action show pour récupérer une commande particulière de l’utilisateur courant

  3. Une action de création pour passer réellement la commande

Commençons par l’action index. Nous devons d’abord créer le contrôleur de commandes. Mais avant de commencer à taper du code, nous devons nous demander:

Est-ce que je dois laisser les routes de ma commande imbriqués dans le UsersController ou bien dois je les isoler?

La réponse est vraiment simple: cela dépend de la quantité d’informations que vous voulez exposer au développeur.

Dans notre cas, nous n’allons pas le faire car nous allons récupérer les commandes de utilisateur sur la route /orders. Commençons par quelques tests:

Tests fonctionnels de la méthode OrdersController.index
// src/controllers/orders.controller.spec.ts
// ...
describe("OrdersController", () => {
  let userRepository: UserRepository;
  let orderRepository: OrderRepository;
  let jsonWebTokenService: JsonWebTokenService;
  let user: User;
  let stranger: User;
  let jwt: string;
  let strangerJwt: string;
  let order: Order;

  before(async () => {
    jsonWebTokenService = container.get(TYPES.JsonWebTokenService);

    const databaseService = container.get<DatabaseService>(TYPES.DatabaseService);
    userRepository = await databaseService.getRepository(UserRepository);
    orderRepository = await databaseService.getRepository(OrderRepository);

    stranger = await userRepository.save(generateUser());
    strangerJwt = jsonWebTokenService.encode({ userId: stranger.id });
  });

  beforeEach(async () => {
    user = await userRepository.save(generateUser());
    order = await orderRepository.save(generateOrder({ user }));
    jwt = jsonWebTokenService.encode({ userId: user.id });
  });

  describe("index", () => {
    it("should forbid orders without auth", () => agent.get("/orders").expect(403));

    it("should get orders of user", () =>
      agent
        .get("/orders")
        .set("Authorization", jwt)
        .expect(200)
        .then(({ body }) => assert.ok(body.data.some(({ id }) => id === String(order.id)))));
  });
});
// src/utils/faker.utils.ts
// ...
export function randomInteger(min: number = 0, max: number = 100): number {
  return Math.floor(Math.random() * (max - min) + min);
}
// ...
export function generateOrder(order?: Partial<Order>): Order {
  const newOrder = new Order();
  newOrder.user = order?.user ?? generateUser();
  newOrder.total = randomInteger(1); // TODO

  return newOrder;
}

L’implémentation de ce test devrait vous rappeler celle de product.controller.spec.ts. Nous essayons d’accéder au nouvel endpoint avec un utilisateur possédant une Order et nous vérifions que cette commande apparaît bien dans le retour JSON.

Note
Vous avez certainement remarqué la syntaxe ({body}) => …​. Il s’agit de la fonctionnalité de la décomposition d’objet. Elle permet tout simplement de récupérer une propriété contenue dans un object directement dans une variable du même nom. Ainsi const data = {a: 1}; const a = data.a; peut être simplifié en const { a } = {a: 1}. Cette syntaxe peu perturber donc j’ai préféré l’utiliser qu’à partir de ce chapitre.

Si nous exécutons la suite de tests maintenant, comme vous pouvez vous y attendre, les deux tests échoueront. C’est normal car nous n’avons même pas défini le contrôleur ni même le sérialiseur spécifique aux commandes. Alors faisons le.

Alors commençons par le sérialiseur:

// src/utils/serializers.utils.ts
// ...
export const ordersSerializer = new Serializer("orders", {
  attributes: ["total", "createdAt", "updatedAt"],
} as any);

Et maintenant nous pouvons l’utiliser dans notre tout nouveau contrôleur:

// src/controllers/orders.controller.ts
// ...
import {ordersSerializer} from '../utils/serializers.utils';

@controller("/orders", TYPES.FetchLoggedUserMiddleware)
export class OrdersController {
  public constructor(
    @inject(TYPES.DatabaseService)
    private readonly databaseService: DatabaseService
  ) {}

  @httpGet("/")
  public async index({ user }: Request & { user: User }) {
    const repository = await this.databaseService.getRepository(OrderRepository);
    const orders = await repository.find({ user });
    return ordersSerializer.serialize(orders);
  }
}

Dans le premier décorateur @controller, nous injection globalement le middleware FetchLoggedUserMiddleware. Cela signifie qu’il faudra donner une jeton JWT pour accéder à toutes les actions de ce contrôleur. Cela nous permet donc de récupérer l’utilisateur dans la méthode index et de l’utiliser directement dans la méthode find. Nous utilisons le sérialseur pour formatter les données et les renvoyer.

N’oublions pas de charger notre contôleur puisqu’il s’agit d’un tout nouveau contrôleur:

// src/core/container.core.ts
// ...
import "../controllers/orders.controller";
// ...

Et maintenant nos tests devraient passer:

$ npm test
...
  OrderController
    index
      ✓ should forbid orders without auth (44ms)
      ✓ should get orders of user
...

Nous aimons nos commits très petits. Alors commitons dès maintenant:

$ git add .
$ git commit -m "Adds the index action for order"

Afficher une seule commande

Comme vous pouvez déjà l’imaginer, cette route est très facile. Nous n’avons qu’à mettre en place quelques configurations (routes, action du contrôleur) et un nouveau middleware qui va s’occuper de récupérer la commande et ce sera tout pour cette section. Nous inclurons plus tard les produits liés à cette commande dans le JSON de sortie.

Commençons par ajouter quelques tests:

// src/controllers/orders.controller.spec.ts
// ...
describe("OrdersController", () => {
  // ...
  describe("show", () => {
    it("should forbid show order for other users", () => {
      agent.get(`/orders/${order.id}`).set("Authorization", strangerJwt).expect(403);
    });

    it("should show order", () => {
      agent
        .get(`/orders/${order.id}`)
        .set("Authorization", jwt)
        .expect(200)
        .then(({ body }) => assert.strictEqual(body.data.id, String(order.id)));
    });
  });
  // ...
});

Passons à l’implémentation. Nous allons commencer par créer un middleware qui se chargera de chercher la commande en fonction du paramètre. Le code est vraiment très similaire au FetchProductMiddleware donc je passerai un peu plus vite la dessus:

Création du FetchOrderMiddleware
// src/middlewares/fetchUser.middleware.ts
// ...
@injectable()
export class FetchOrderMiddleware extends BaseMiddleware {
  constructor(
    @inject(TYPES.DatabaseService)
    private readonly databaseService: DatabaseService
  ) {
    super();
  }

  public async handler(req: Request & { order: Order }, res: Response, next: NextFunction): Promise<void | Response> {
    const orderId = req.query.orderId ?? req.params.orderId;
    const repository = await this.databaseService.getRepository(OrderRepository);
    req.order = await repository.findOne(Number(orderId), {
      relations: ["user"],
    });

    if (!req.order) {
      return res.status(404).send("order not found");
    }
    next();
  }
}
Ajout du Symbol pour l’injection dans le container
// src/core/types.core.ts
export const TYPES = {
  // ...
  FetchOrderMiddleware: Symbol.for("FetchOrderMiddleware"),
};
Ajout FetchOrderMiddleware dans le container
// src/core/container.core.ts
// ...
export const container = new Container();
// ...
container.bind(TYPES.FetchOrderMiddleware).to(FetchOrderMiddleware);

Tous nos tests passent désormais:

$ npm test
  OrderController
    index
      ✓ should forbid orders without auth (44ms)
      ✓ should get orders of user
    show
      ✓ should forbid show order for other users
      ✓ should show orders

Commitons les changements et passons à la suite.

$ git commit -am "Adds the show action for order"

Placement et commandes

Il est maintenant temps de donner la possibilité à l’utilisateur de passer quelques commandes. Cela ajoutera de la complexité à l’application, mais ne vous inquiétez pas, nous allons faire les choses une étape à la fois.

Avant de lancer cette fonctionnalité, prenons le temps de réfléchir aux implications de la création d’une commande dans l’application. Je ne parle pas de la mise en place d’un service de transactions comme Stripe ou Braintree mais de choses comme:

  • la gestion des produits en rupture de stock

  • la diminution de l’inventaire de produits

  • ajouter une certaine validation pour le placement de la commande pour s’assurer qu’il y a suffisamment de produits au moment où la commande est passée

On dirait qu’il reste un paquet de chose à faire mais croyez-moi: vous êtes plus près que vous ne le pensez et ce n’est pas aussi dur que ça en a l’air. Pour l’instant, gardons les choses simples et supposons que nous avons toujours assez de produits pour passer un nombre quelconque de commandes. Nous nous soucions juste de la réponse du serveur pour le moment.

Si vous vous rappelez le modèle de commande, nous avons besoin de trois choses: un total pour la commande, l’utilisateur qui passe la commande et les produits pour la commande. Compte tenu de cette information, nous pouvons commencer à ajouter quelques tests:

// src/controllers/orders.controller.spec.ts
// ...
describe("OrderController", () => {
  // ...
  describe('create', () => {
    let product1: Product;
    let product2: Product;

    before(async () => {
      product1 = await manager.save(generateProduct());
      product2 = await manager.save(generateProduct());
    });

    it('should create order', () =>
      agent
        .post('/orders')
        .set('Authorization', jwt)
        .send({productIds: [product1.id, product2.id]})
        .expect(201));

    it('should not create product without auth', () =>
      agent
        .post('/orders')
        .send({productIds: [product1.id, product2.id]})
        .expect(403));

    it('should not create order with missing title', () =>
      agent.post('/orders').set('Authorization', jwt).send({productIds: []}).expect(400));
  });
  // ...
});

Encore une fois, nous allons créer des tests qui couvrent tous les cas possibles. Respectivement:

  • le cas ou tout se passe bien

  • le cas ou l’utilisateur n’a pas envoyé les paramètres nécessaires

  • le cas ou l’utilisateur n’a pas spécifié sont jeton JWT

Comme vous pouvez le voir dans le premier cas, l’utilisateur envoie un tableau des produits qu’il souhaite ajouter à sa commande. Nous allons donc dans le contrôleur:

  1. récupérer la liste des produits associés via les IDs

  2. calculer la somme totale que représente ces produits

  3. créer l'`Order`

  4. créer les Placements associé à cette commande

Cela parait compliqué mais voyez l’implémentation:

// src/controllers/orders.controller.ts
// ...
@controller("/orders", TYPES.FetchLoggedUserMiddleware)
export class OrdersController {
  // ...

  @httpPost('/')
  public async create(@requestBody() body: {productIds: number[]}, {user}: Request & {user: User}, res: Response) {
    const productRepository = await this.databaseService.getRepository(ProductRepository);
    const orderRepository = await this.databaseService.getRepository(OrderRepository);
    const placementRepository = await this.databaseService.getRepository(PlacementRepository);

    if (!body.productIds?.length) {
      return res.status(400).json({errors: {productIds: 'should be an array of products ids'}});
    }

    const products = await productRepository.findByIds(body.productIds);

    const total = products.reduce((sum, product) => sum + product.price, 0);
    const order = await orderRepository.save({user, total});

    const placements = products.map((product) => ({order, product}));
    order.placements = await placementRepository.save(placements);

    return res.sendStatus(201);
  }
  // ...
}

Et maintenant, nos tests devraient tous passer:

$ npm test
...
  OrderController
...
    create
      ✓ should create order
      ✓ should not create product without auth
      ✓ should not create order with missing title

Commitons nos changements:

$ git commit -am "Adds the create method for the orders controller"

Envoyer un email de confirmation

La dernière section de ce chapitre sera d’envoyer un courriel de confirmation à l’utilisateur qui vient de créer une commande. Si vous le voulez, vous pouvez sauter cette étape et passer au chapitre suivant! Cette section est plus à un bonus.

Nous allons donc utiliser la librairie nodemailer

Installons donc la librairie:

$ npm install nodemailer
$ npm install --save-dev @types/nodemailer

Maintenant créons un nouveau service qui fera l’interface entre la librairie et notre code. Comme je le disait précédemment, c’est toujours une bonne idée de procéder ainsi car cela va nous permettre de Mocker cette fonctionnalité durant nos tests. Ne vous inquiétez pas, nous en reparlerons juste après.

Implémentation d’un service faisant interface à nodemailer.
// src/services/mailer.service.ts
import {inject, injectable} from 'inversify';
import {createTestAccount, createTransport, SendMailOptions, Transporter} from 'nodemailer';
import {TYPES} from '../core/types.core';
import {Logger} from './logger.service';

@injectable()
export class MailerService {
  private static transporter: Transporter;

  public constructor(@inject(TYPES.Logger) private readonly logger: Logger) {}

  public async sendEmail(options: SendMailOptions): Promise<void> {
    await this.initializeTransporter();

    await MailerService.transporter.sendMail(options);

  }

  private async initializeTransporter() {
    if (MailerService.transporter !== undefined) {
      return;
    }

    let { user, pass } = await createTestAccount();

    MailerService.transporter = createTransport({
      host: "smtp.ethereal.email",
      port: 587,
      secure: false,
      auth: { user, pass },
    });
  }
}

Comme vous le voyez, notre service ne fais pas grand chose. Nous initialisons juste ici un transporter qui permet de se connecter à un compte SMTP. Vous pouvez utiliser le compte mail de votre choix et de déplacer les valeurs dans le fichier .env mais ici j’ai choisi d’utiliser la méthode createTestAccount qui permet de créer un compte test à la volée.

Et comme nous venons de créer un service, nous devons l’ajouter au container:

// src/core/types.core.ts
export const TYPES = {
  // ...
  MailerService: Symbol.for("MailerService"),
  // ...
};
// src/core/container.core.ts
// ...
container.bind(TYPES.MailerService).to(MailerService);
// ...

Et voilà. Je trouve que c’est une bonne idée d’ajouter la création du mail du produit dans le MailerService. En revanche, il faut faire attention à ce que ce service ne devienne pas trop gros au fur et à mesure de l’extension de notre application et ne pas hésiter à le redécouper si nécessaire. Dans notre cas cela ne pose pas de problème. Voici donc la méthode:

// src/services/mailer.service.ts
// ...
@injectable()
export class MailerService {
  // ...
  public async sendNewOrderEmail(order: Order): Promise<void> {
    const productText = order.placements.map((p) => `- ${p.product.title}`);
    const text = `Details of products:\n${productText}\nTOTAL:${order.total}€`;

    await this.sendEmail({
      to: order.user.email,
      text,
      subject: "Thanks for order",
    });
  }
  // ...
}

Nous pouvons maintenant appeler cette méthode directement donc notre contrôleur:

// src/controllers/orders.controller.ts
// ...
@controller("/orders", /* ... */)
export class OrdersController {
  // ...
  @httpPost("/")
  public async create(/* ... */) {
    // ...
    await this.mailerService.sendNewOrderEmail(order);
    return res.sendStatus(201);
  }
  // ...
}

Et voilà!

Note
Si notre application grandie, il serait plus intéressant d’utiliser une librairie spécialisée dans la gestion de job comme graphile-worker afin de différer l’envoie d’email. Cela nous permettrait aussi de prioriser les tâches mais aussi de relancer plus tard les tâches qui n’ont pas fonctionnés. Dans notre cas, je ne l’ai pas mis en place afin de garder ce tutoriel plus simple.

Lançons les tests pour êtres sûr:

$ npm test
...
  OrderController
...
    create
      1) should create order
      ✓ should not create product without auth
      ✓ should not create order with missing title
...

  1) OrderController
       create
         should create order:
     Error: Timeout of 2000ms exceeded.

Nous constatons que notre test ne fonctionne plus car il dépasse le temps alloué à un test. Nous pourrions augmenter le temps alloué à ce test avec la méthode timeout mais ce n’est pas optimal. Mais rassurez vous, nous avons une solution très simple offerte par l’injection de dépendence que nous avons mis en place depuis le début: un Mock.

L’idée est donc de créer un classe qui implémente les fonctionnalités du MailerService mais qui se comporte de la façon que nous voulons spécifiquement dans le contexte donnée. C’est à dire que nous voulons que durant les tests, les mails ne soient pas envoyé. Cela semble compliqué mais c’est en fait très simple:

// src/tests/fakeMailer.service.ts
import {injectable} from 'inversify';
import {SendMailOptions} from 'nodemailer';
import {MailerService} from '../services/mailer.service';

@injectable()
export class FakeMailerService extends MailerService {
  public async sendEmail(options: SendMailOptions): Promise<void> {}
  protected async initializeTransporter() {}
}

Et il suffit de rebind le service au début de notre test:

// src/controllers/orders.controller.spec.ts
// ...
describe("OrderController", () => {
  // ...
  before(async () => {
    container.rebind(TYPES.MailerService).to(FakeMailerService);
    // ...
  });
    // ...
});

Et voilà, nos tests devraient passer à nouveau.

Commitons tout ce que nous venons de faire pour terminer cette section:

$ git add .
$ git commit -m "Adds order confirmation mailer"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter07

Conclusion

Ça y est! Vous avez réussi! Vous pouvez vous applaudir. Je sais que ça a été long mais c’est presque fini, croyez moi.

Sur les chapitres à venir, nous continuerons à travailler sur le modèle de commande pour ajouter des validations lors de la passation d’une commande. Certains scénarios sont:

  • Que se passe-t-il lorsque les produits ne sont pas disponibles?

  • Diminuer la quantité du produit en cours lors de la passation d’une commande

Le prochain chapitre sera court, mais il est très important pour la santé de l’application. Alors ne le sautez pas.

Améliorer les commandes

Précédemment nous avons amélioré notre API pour passer des commandes et envoyer un e-mail de confirmation à l’utilisateur (juste pour améliorer l’expérience utilisateur). Ce chapitre va s’occuper de quelques validations sur le modèle de commande afin de s’assurer qu’il est valide. C’est-à-dire:

  • Diminuer la quantité du produit en cours lors de la création d’une commande

  • gérer le cas ou le produit n’est pas disponible

Nous aurons aussi besoin de mettre à jour un peu la sortie JSON pour les commandes. Mais ne divulgâchons pas la suite.

Créons une nouvelle branche afin de commencer à travailler:

$ git checkout -b chapter08

Diminution de la quantité de produit

Dans cette partie nous travaillerons sur la mise à jour de la quantité du produit pour nous assurer que chaque commande livrera le produit réel.

Ajout de l’attribut product.total

Nous allons tout d’abord rajouter un champs total sur le produit qui représentera le stock du produit disponible.

// src/entities/product.entity.ts
// ...
@Entity()
export class Product {
  // ...
  @Column({type: 'integer', default: 0})
  quantity: number = 0;
  // ...
}
// ...

Il faut aussi que ce champ soit disponible lors de la création du produit. Nous devons donc mettre à jour notre contrôleur:

// src/controllers/home.controller.ts
// ...
@controller('/products')
export class ProductController {
  // ...
  public async create(/* ... */) {
    // ...
    const product = new Product();
    product.quantity = Number(body.quantity);
    // ...
  }
  // ...
}

Nous devons aussi mettre à jour la méthode =generateProduct= qui doit gérer ce nouvel attribut:

// src/utils/faker.utils.ts
// ...
export function generateProduct(product?: Partial<Product>): Product {
  // ...
  newProduct.quantity = product?.quantity ?? randomInteger(1);
  // ...
}
// ...

Nous devons maintenant vérifier que le total ne peut jamais être inférieur à zero. Cela permettra de sécuriser notre application et ainsi empêcher qu’une commande soit passée s’il n’y a pas de stock sur le produit.

Commençons donc par ajouter un test qui va décrire le comportement souhaité:

// src/entities/product.entity.spec.ts
// ...
describe('ProductRepository', () => {
  // ...
  describe('validate', () => {
    it('should have a positive quantity', async () => {
      const product = generateProduct({quantity: -1});
      try {
        await productRepository.save(product);
        assert.fail('Should not validate product');
      } catch (errors) {
        assert.ok(errors.some(error => error.property === 'quantity'));
      }
    });
  });
});

Faire passer le test est très facile gràce aux décorateurs de class-validator. Il suffit d’ajouter le décorateur @IsInt et @Min comme cela:

// src/entities/product.entity.ts
// ...
@Entity()
export class Product {
  // ...
  @IsInt()
  @Min(0)
  @Column({type: 'integer', default: 0})
  quantity: number = 0;
  // ...
}
// ...

Comme vous le voyez c’est vraiment très simple et le code est très lisible. Et voilà. Commitons les changements:

$ git commit -am "Add quantity to products"

Mise en place du test fonctionnel

Avant de commencer à aller plus loin, nous devons changer la façon dont nous gérons la création de la commande car nous devons maintenant prendre en compte une quantité pour chaque produit. Si vous vous souvenez, jusqu’à maintenant nous attendons un tableau d’identifiants de produits. Je vais essayer de garder les choses simples et nous allons maintenant accepter un tableau d’objets contenant les attributs id et quantity. Un exemple rapide serait quelque chose comme cela:

const productOrderParams = [
  { id: 1, quantity: 4 },
  { id: 3, quantity: 5 }
]

Commençons donc par modifier notre test fonctionnel à propos du contrôleur des commandes:

Modification du test fonctionnel de la création du produit
// src/controllers/orders.controller.spec.ts
// ...
describe("OrderController", () => {
  // ...
  describe("create", () => {
    let productsParams;

    before(async () => {
      const product1 = await productRepository.save(generateProduct());
      const product2 = await productRepository.save(generateProduct());

      productsParams = [
        {id: product1.id, quantity: 1},
        {id: product2.id, quantity: 1},
      ];
    });

    it('should create order', () =>
      agent
        .post('/orders')
        .set('Authorization', jwt)
        .send({products: productsParams})
        .expect(201));
    // ...
  });
  // ...
});

Comme vous le voyez, nous avons simplement mis à jour les paramètres que nous passons à la requêtes.

Récapitulons un peu ce que nous devons changer dans le contrôleur. Nous devons retrouver le produit associé à l'`id` dans le tableau que créer les placements. Voyons voir l’implémentation du contrôleur:

// src/controllers/orders.controller.ts
// ...
@controller('/orders', TYPES.FetchLoggedUserMiddleware)
export class OrdersController {
  // ...
  @httpPost('/')
  public async create(
    @requestBody() body: {products: {id: number; quantity: number}[]},
    // ...
  ) {
    const {manager} = await this.databaseService.getConnection();

    if (!body.products?.length) {
      return res.status(400).json({
        errors: {
          products: 'should be an array of `{id, quantity}`',
        },
      });
    }

    const order = await manager.save(Order, {
      user,
      total: 0,
      placements: [],
    } as Order);

    for (const {id, quantity} of body.products) {
      const placement = new Placement();
      placement.product = await manager.findOneOrFail(Product, {id});
      placement.order = order;
      placement.quantity = quantity;

      order.placements.push(await manager.save(Placement, placement));
    }
    // ...
  }
  // ...
}

Oula. Le code devient un peu plus long et mérite quelques explications:

  • nous créons la commande avec un total égal à 0 (Nous verrons dans la prochaine section comment faire en sorte que ce total se mette à jour automatiquement.)

  • nous vérifiions les données de l’utilisateur en vérifiant que req.body.products contient des valeurs

  • nous faisons une boucle sur req.body.products dans lequel nous récupérons le produit, nous créons un Placement et nous l’ajoutons au tableau order.placements

  • la suite reste inchangée

Le subscriber

Il est maintenant temps de mettre à jour la quantité du produit une fois qu’une commande est placée.

Nous serions tenté de le faire rapidement dans l’action OrderController.create mais cela serait une mauvaise idée car il faudrait dupliquer cette logique sur l’action OrderController.update et OrderController.destroy qui doivent aussi mettre a jour la quantité de produits. Cela va aussi a l’encontre de la bonne pratique qui est de réduire au maximum la responsabilité des contrôleurs.

C’est pour cela que je pense que un Subscriber de TypeORM est un bien meilleur endroit pour la simple raison que nous sommes certains que notre subscriber sera appelé quoiqu’il arrive sans que nous aillons à nous en soucier.

Note
Il serait possible de d’utiliser les entity listeners comme @afterInsert sur la méthode UserRepository.validate mais je recommande vraiment d’utiliser les subscriber lorsque nous souhaitons manipuler plusieurs types d’entité. Cela permet de mieux découper sont code et ainsi ne pas faire dépendre une classe d’une autre.

Le comportement que nous allons mettre en place est le suivant:

  • lorsqu’un placement est créé

  • nous enlevons placement.quantity à l’attribut product.quantity

  • nous recalculons le coût total de la commande

  • lorsqu’un placement est créé

  • nous ajoutons placement.quantity à l’attribut product.quantity

  • nous recalculons le coût total de la commande

Le subscriber va se matérialiser en un classe qui étends EntitySubscriberInterface. Si nous regardons de plus prêt cette interface, nous voyons que nous avons accès à un paquet de méthodes:

Quelques méthodes de l’interface EntitySubscriberInterface
// node_modules/typeorm/subscriber/EntitySubscriberInterface.d.ts
export interface EntitySubscriberInterface<Entity = any> {
  // ...
  beforeInsert?(event: InsertEvent<Entity>): Promise<any> | void;
  afterInsert?(event: InsertEvent<Entity>): Promise<any> | void;
  beforeUpdate?(event: UpdateEvent<Entity>): Promise<any> | void;
  afterUpdate?(event: UpdateEvent<Entity>): Promise<any> | void;
  beforeRemove?(event: RemoveEvent<Entity>): Promise<any> | void;
  afterRemove?(event: RemoveEvent<Entity>): Promise<any> | void;
  // ...
}

Nous pouvons donc créer notre classe qui implémente EntitySubscriberInterface:

// src/subscribers/placement.subscriber.ts
import {/*...*/} from 'typeorm';
import {Order} from '../entities/order.entity';
import {Placement} from '../entities/placement.entity';
import {Product} from '../entities/product.entity';

@EventSubscriber()
export class PlacementSubscriber
  implements EntitySubscriberInterface<Placement> {

  listenTo() {
    return Placement;
  }

  async afterInsert({entity, manager}: InsertEvent<Placement>) {/*...*/}
  async beforeRemove({entity, manager}: RemoveEvent<Placement>) {/*...*/}
  async afterRemove({entity, manager}: RemoveEvent<Placement>) {/*...*/}
}

Vous pouvez aussi remarquer que ici j’ai implémenté la méthode listenTo qui va spécifier le champ d’écoute de ce subscriber. Mais avant de passer à la suite, nous devons indiquer à TypeORM ou ce trouve nos migration via la variable de configuration suivante que vous devez ajouter à votre fichier .env et .test.env.

Ajout de la configuration des subscribers
TYPEORM_SUBSCRIBERS=src/subscribers/*.subscriber.ts

Nous somme maintenant prêt à passer à l’implémentation des méthodes!

Comme d’habitude, nous allons créer un test dédié à cette nouvelle classe. Ce test va tout simplement créer un produit avec une quantité suffisante et ensuite créer un Placement et vérifier que le total a été mis à jour. Nous faisons ensuite le sens inverse en supprimant le produit et on vérifie que l’on retrouve bien la quantité originelle.

// src/subscribers/placement.subscriber.spec.ts
// ...
describe('PlacementSubscriber', () => {
  let manager: EntityManager;

  before(async () => {
    const databaseService = container.get<DatabaseService>(
      TYPES.DatabaseService,
    );
    const connection = await databaseService.getConnection();
    manager = connection.manager;
  });

  it('should update product.quantity after insert', async () => {
    let product = await manager.save(generateProduct({quantity: 10}));
    const order = await manager.save(generateOrder());

    const placement = await manager.save(
      generatePlacement({order, product, quantity: 2}),
    );

    product = await manager.findOne(Product, product.id);
    assert.strictEqual(product.quantity, 10 - placement.quantity);

    await manager.remove(placement);
    product = await manager.findOne(Product, product.id);
    assert.strictEqual(product.quantity, 10);
  });
});

L’implémentation du subscriber est vraiment très simple. Nous allons utiliser les méthode beforeInsert et beforeRemove afin d’incrémenter ou de décrémenter le total de produit et ensuite de sauvegarder le produit.

// src/subscribers/placement.subscriber.ts
// ...
@EventSubscriber()
export class PlacementSubscriber
  implements EntitySubscriberInterface<Placement> {
  // ...
  async afterInsert({entity, manager}: InsertEvent<Placement>) {
    const productId = entity.product.id;
    const product = await manager.findOneOrFail(Product, {id: productId});
    product.quantity -= entity.quantity;
    await manager.save(product);
  }

  async beforeRemove({entity, manager}: RemoveEvent<Placement>) {
    const productId = entity.product.id;
    const product = await manager.findOneOrFail(Product, {id: productId});
    product.quantity += entity.quantity;
    await manager.save(product);
  }
}
Note
Nous récupérons le produit via le manager au lieu de simplement récupérer via la relation entity.product afin de s’assurer d’avoir la dernière version stocké en base

Et voilà. C’était facile non? Lançons les tests pour être sûr.

$ npm test
...
  PlacementSubscriber
    ✓ should update product.quantity after insert (40ms)

Parfait passons à la suite.

Mise à jour du coup total de la commande

Si vous avez bien compris la section précédente, vous devinez que la mise à jour du coup de la commande va être assez similaire.

Commençons par écrire les tests. Nous allons donc créer un Produit, puis une Order et ensuite un Placement pour vérifier que le total de la commande s’est mis à jour. Nous allons ensuite supprimer ce Placement et vérifier que le

// src/subscribers/placement.subscriber.spec.ts
// ...
describe('PlacementSubscriber', () => {
  // ...
  it('should update order.total after insert', async () => {
    const product = await manager.save(
      generateProduct({quantity: 10, price: 5}),
    );
    let order = await manager.save(generateOrder());

    const placement = generatePlacement({order, product, quantity: 2});
    await manager.save(placement);

    order = await manager.findOne(Order, order.id);
    assert.strictEqual(order.total, 2 * product.price);

    await manager.remove(placement);
    order = await manager.findOne(Order, order.id);
    assert.strictEqual(order.total, 0);
  });
});

Et voilà. Ce test ressemble vraiment au précédente. Passons donc rapidement à l’implémentation:

// src/subscribers/placement.subscriber.ts
// ...
@EventSubscriber()
export class PlacementSubscriber
  implements EntitySubscriberInterface<Placement> {
  // ...
  async afterInsert({entity, manager}: InsertEvent<Placement>) {
    // ...
    await this.updateOrderTotal(manager, entity.order);
  }
  // ...
  async afterRemove({entity, manager}: RemoveEvent<Placement>) {
    await this.updateOrderTotal(manager, entity.order);
  }

  private async updateOrderTotal(manager: EntityManager, order: Order) {
    const placements = await manager.find(Placement, {
      where: {order},
      relations: ['product'],
    });

    order.total = placements.reduce(
      (sum, placement) => sum + placement.quantity * placement.product.price,
      0,
    );

    await manager.save(Order, order);
  }
}

Regardons de plus près la méthode updateOrderTotal:

  1. nous récupérons tous les placements de la commande passé en paramètre avec les produits associés

  2. nous additionnons le total du placement


Il est possible de réécrire le code précédent avec le Query Builder de TypeORM. Le Query Builder permet d’avoir un plus grand contrôle sur la requête SQL générée. Le code peut être plus complexe mais aussi plus performant car nous n’avons pas besoin de charger plusieurs objets en mémoire.

C’est le cas ici donc je tenais à faire une petite apparté. Voici donc l’équivalent avec le Query Builder

const result = await manager
  .createQueryBuilder(Placement, 'pl')
  .select('SUM(pl.quantity) * p.price', 'total')
  .innerJoin('pl.order', 'o')
  .innerJoin('pl.product', 'p')
  .where('o.id = :orderId', {orderId: order.id})
  .groupBy('o.id')
  .getRawOne();
order.total = result?.total ?? 0;

Cette requête va directement effectuer le total en multipliant la quantité par le prix du produit lié. Ainsi, nous obtenons directement le résultat sous forme de number. Cela évite de charger plusieurs objets Javascript et permet d’économiser de la mémoire.

Ce code va générer la requête SQL suivante:

SELECT SUM("pl"."quantity") * "p"."price" AS "total"
FROM "placement" "pl"
INNER JOIN "order" "o" ON "o"."id"="pl"."orderId"
INNER JOIN "product" "p" ON "p"."id"="pl"."productId"
WHERE "o"."id" = ?
GROUP BY "o"."id"

Ainsi, je vous conseille vivement d’essayer de perfectionner votre connaissance avec les gestionnaires de base de données car ils peuvent s’avérer de grand alliés. *

Essayons de voir si les tests passent:

$ npm test
...
  OrderController
...
    create
      ✓ should create order (74ms)
      ✓ should not create product without auth
      ✓ should not create order with missing products
...
  PlacementSubscriber
    ✓ should update product.quantity after insert (42ms)
    ✓ should update order.total after insert (44ms)
...
  42 passing (1s)

Commitons nos changements et récapitulons tout ce que nous venons de faire:

$ git commit -am "Updates the total calculation for order"

Et comme nous arrivons à la fin de notre chapitre, il est temps d’appliquer toutes nos modifications sur la branche master en faisant un merge:

$ git checkout master
$ git merge chapter08

Conclusion

Oh vous êtes ici! Permettez-moi de vous féliciter! Cela fait un long chemin depuis le premier chapitre. Mais vous êtes à un pas de plus. En fait, le chapitre suivant sera le dernier. Alors essayez d’en tirer le meilleur.

Le dernier chapitre portera sur la façon d’optimiser l’API en utilisant la pagination, la mise en cache et les tâches d’arrière-plan. Donc bouclez vos ceintures, ça va être un parcours mouvementé.

Optimisations

Bienvenue dans le dernier chapitre du livre. Le chemin a été long mais vous n’êtes qu’à un pas de la fin. Dans le chapitre précédent, nous avons terminé la modélisation du modèle de commandes. Nous pourrions dire que le projet est maintenant terminé mais je veux couvrir quelques détails importants sur l’optimisation. Les sujets que je vais aborder ici seront:

  • la pagination

  • la mise en cache

  • l’optimisation des requêtes SQL

  • l’activation de CORS

J’essaierai d’aller aussi loin que possible en essayant de couvrir certains scénarios courants. J’espère que ces scenarii vous seront utiles pour certains de vos projets.

Créons une nouvelle branche pour ce chapitre:

$ git checkout -b chapter09

Pagination

Une stratégie très commune pour optimiser la récupération d’enregistrements dans une base de données est de charger seulement une quantité limitée en les paginant. Nous allons le faire très facilement.

La seule partie délicate ici est de savoir comment gérer la sortie JSON pour donner assez d’informations au client sur la façon dont le tableau est paginé. Dans la section précédente, j’ai partagé quelques ressources sur les pratiques que j’allais suivre ici. L’une d’entre elles était JSON:API.

La norme JSON:API impose un format stricte mais claire. Cela nous permet de ne pas se soucier de comment doit être implémentée. Une sous-section appelée Top Level de la documentation officielle de JSON:API mentionnent quelque chose sur la pagination:

"meta": méta-information sur une ressource, telle que la pagination.

Ce n’est pas très descriptif mais au moins nous avons un indice sur ce qu’il faut regarder ensuite au sujet de l’implémentation de la pagination. Ne vous inquiétez pas, c’est exactement ce que nous allons faire ici.

Commençons par la liste des produits.

Les produits

Nous devons fournir les informations de pagination sur la balise meta comme le document JSON suivant:

{
  "data": [
    ...
  ],
  "links": {
    "first": "/api/v1/products?page=1",
    "last": "/api/v1/products?page=30",
    "prev": "/api/v1/products",
    "next": "/api/v1/products?page=2"
  }
}

Maintenant que nous voyons ce que ne devons retourner, il ne nous reste plus qu’à modifier un peu notre code. Mais avant d’aller plus loin, ajoutons d’abord quelques tests:

// src/controllers/products.controller.spec.ts
// ...
describe('ProductsController', () => {
  // ...
  describe('index', () => {
    // ...
    it('should paginate results', async () => {
      for (let i = 0; i < 25; i++) {
        await productRepository.save(generateProduct({published: true}));
      }

      await agent
        .get('/products')
        .expect(200)
        .then(response => {
          assert.strictEqual(response.body.data.length, 20);
          assert.ok(response.body.links);
        });
    });
    // ...
  });
  // ...
});

Nous testons donc deux choses:

  1. nous créons 25 produits nous devons donc en retrouver uniquement 20 lors de la réponse API car les résultats doivent se limiter à une seule page

  2. nous devons retrouver les attributs links que nous avons vu précédemment

Notre but est donc de faire passer ces tests. Nous n’allons pas définir le comportement dans le contrôleur car nous savons d’avance que nous voulons le même comportement pour tous les contrôleurs. Nous allons donc créer une méthode générique qui va prendre en paramètre:

  • la requête HTTP, qui nous permettra de retrouver facilement la paramètre page et aussi de construire les links en fonction de l’URL actuelle de la requête

  • la requête SQL, qui sera utile pour savoir combien il y a de résultats en base de donnée et aussi appliquer les filtres OFFSET et LIMIT pour ne récupérer qu’une partie des résultats

  • le serializer pour sérializer les données selon le schéma JSON:API

Allez c’est parti!

// src/utils/paginate.utils.ts
import {Request} from 'express';
import {Serializer} from 'jsonapi-serializer';
import {SelectQueryBuilder} from 'typeorm';

const PER_PAGE = 20;

export async function paginate<T>(
  queryBuilder: SelectQueryBuilder<T>,
  serializer: Serializer,
  {query, baseUrl}: Request,
) {
  const page = Number(query.page ?? 1);

  const count = await queryBuilder.cache(60 * 1000).getCount();
  const totalPage = Math.floor(count / PER_PAGE);
  const prevPage = page === 1 ? 1 : page - 1;
  const nextPage = page === totalPage ? page : page + 1;
  const offset = page > 1 ? (page - 1) * PER_PAGE : 0;

  const data = await queryBuilder
    .clone()
    .offset(offset)
    .limit(PER_PAGE)
    .getMany();

  const getUrlForPage = page =>
    `${baseUrl}?${new URLSearchParams({...query, page})}`;

  const response = serializer.serialize(data);
  response.links = {
    first: getUrlForPage(1),
    last: getUrlForPage(totalPage),
    prev: getUrlForPage(prevPage),
    next: getUrlForPage(nextPage),
  };

  return response;
}

L’implémentation est un peu longue mais nous allons la revoir ensemble:

  1. queryBuilder.getCount() nous permet d’executer la requête passée en paramètre mais uniquement pour connaître le nombre de résultat

  2. nous utilisons cette valeur pour calculer le nombre de pages et déduire le numéro de la page précédente et suivante

  3. nous exécutons la requête SQL du queryBuilder en ajoutant un offset et une limit

  4. nous générons les URL que nous ajoutons au résultat sérializé précédemment

Vous êtes toujours là? L’implémentation dans le contrôleur est beaucoup plus facile:

// src/controllers/home.controller.ts
// ...
import {paginate} from '../utils/paginate.utils';

@controller('/products')
export class ProductController {
  // ...
  @httpGet('/')
  public async index(/* ... */) {
    // ...
    return paginate(repository.search(req.query), productsSerializer, req);
  }
  // ...
}

Et voilà. Lançons les tests pour être sûr:

---
$ npm test
...
  ProductsController
    index
      ✓ should paginate results (94ms)
...
---

Commitons tout cela et passons à la suite

$ git add .
$ git commit -m "Paginate products"

Maintenant que nous avons fait une superbe optimisation pour la route de la liste des produits, c’est au client de parcourir les pages.

Commitons ces changements et continuons avec la liste des commandes.

$ git add .
$ git commit -m "Adds pagination for products index action to optimize response"

Liste des commandes

Maintenant, il est temps de faire exactement la même chose pour la route de la liste des commandes. Cela devrait être très facile à mettre en œuvre. Mais d’abord, ajoutons quelques tests:

// src/controllers/orders.controller.spec.ts
// ...
describe('OrderController', () => {
  // ...
  describe('index', () => {
    // ...
    it('should paginate results', async () => {
      for (let i = 0; i < 20; i++) {
        await orderRepository.save(generateOrder({user}));
      }

      await agent
        .get('/orders')
        .set('Authorization', jwt)
        .expect(200)
        .then(response => {
          assert.strictEqual(response.body.data.length, 20);
          assert.ok(response.body.links);
        });
    });
  });
  // ...
});

Et, comme vous vous en doutez peut-être déjà, nos tests ne passent plus:

$ npm test
...
  1 failing

  1) OrderController
       index
         should paginate results:

      AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

21 !== 20

      + expected - actual

      -21
      +20

Faire passe ce test est là encore assez facile.

// src/controllers/orders.controller.ts
// ...
@controller('/orders', TYPES.FetchLoggedUserMiddleware)
export class OrdersController {
  // ...
  @httpGet('/')
  public async index(req: Request & {user: User}) {
    const {manager} = await this.databaseService.getConnection();

    return paginate(
      manager
        .createQueryBuilder(Order, 'o')
        .where('o.user = :user', {user: req.user.id}),
      ordersSerializer,
      req,
    );
  }
  // ...
}

La seule différence par rapport à l’implémentation du contrôleur des produit est que ici nous avons eu besoin de transformer repository.find en queryBuilder.

Les tests devraient maintenant passer:

$ npm test
...
  46 passing (781ms)

Faisons un commit avant d’avancer

$ git commit -am "Adds pagination for orders index action"

Mise en cache

Nous pouvons facilement mettre en place une mise en cache simple pour certains de nos requêtes. L’implémentation sera vraiment très facile grâce à TypeORM. TypeORM va ainsi créer une nouvelle table qui va stocker la requête exécutée et le résultat qu’elle a retourné. Lors de la prochaine execution, TypeORM retournera le même résultat que le précédent. Cela permet d’économiser de précieuses ressources à notre gestionnaire de base de données (ici Sqlite) lors de certaines requêtes SQL coûteuses. Ici le résultat ne sera pas flagrant car les requêtes SQL éxécutées restent simple mais nous allons quand même le mettre en place.

Avant de voir un peu le comportement du cache, nous allons créer un script qui va insérer des données fictives dans notre base de données. Cela sera très facile car il nous suffit d’utiliser les méthodes que nous avons créées lors de nos tests. Voici un petit script que nous allons créer dans un nouveau dossier scripts:

// src/scripts/loadFakeData.script.ts
import 'reflect-metadata';
// ...
async function createOrder(manager: EntityManager) {
  const user = await manager.save(User, generateUser());
  const owner = await manager.save(User, generateUser());
  const order = await manager.save(Order, generateOrder({user}));

  for (let j = 0; j < 5; j++) {
    const product = await manager.save(Product, generateProduct({user: owner}));
    await manager.save(Placement, {order, product, quantity: 2});
  }
}

async function main() {
  const {manager} = await container
    .get<DatabaseService>(TYPES.DatabaseService)
    .getConnection();
  const logger = container.get<Logger>(TYPES.Logger);

  for (let i = 0; i < 100; i++) {
    logger.log('DEBUG', `Inserting ${i} / 100`);
    await createOrder(manager);
  }
}

if (require.main === module) {
  main().then().catch(console.error);
}

Et voilà. Quelques explications:

  • createOrder va, comme son nom l’indique, créer une commande mais en plus créer un produit et cinq placements

  • main va créer une boucle autour de createOrder afin de l’appeler plusieurs fois

  • require.main === module peut paraître abstrait mais c’est en fait très simple: cela signifie que la fonction sera exécutée qui si nous exécutons explicitement le fichier. En d’autres termes, cela permet de s’assurer que la méthode ne sera pas exécutée si le fichier est malencontreusement importé

Maintenant nous pouvons lancer le script avec la commande suivante:

$ npm run build && node dist/scripts/loadfakedata.script.js

Nous pouvons vérifier que tout s’est bien passé en envoyant une petite requête SQL directement sur la base de données:

$ sqlite3 db/development.sqlite "SELECT COUNT(*) FROM product"
500

Maintenant essayons d’activer le cache. C’est vraiment très facile. Tout d’abord nous devons ajouter la variable d’environement suivante afin que TypeORM crée une table dédiée au démarrage:

# .env
# ...
TYPEORM_CACHE=true

N’oubliez pas de désactiver ce paramètre lors des test

# .test.env
# ...
TYPEORM_CACHE=false

Maintenant nous allons ajouter deux lignes à notre méthode paginate:

// src/utils/paginate.utils.ts
// ...
export async function paginate<T>(/*...*/) {
  // ...
  const count = await queryBuilder.cache(60 * 1000).getCount();
  // ...
  const data = await queryBuilder
    .clone()
    .offset(offset)
    .limit(PER_PAGE)
    .cache(60 * 1000)
    .getMany();
  // ...
  return response;
}

Et voilà. La méthode cache s’occupe de tout. Essayons pour voir. Lancez le serveur npm start et envoyons une requête HTTP:

$ curl -w 'Total: %{time_total}\n' -o /dev/null -s "http://localhost:3000/products?title=42"
Total: 0,019708
Note
L’option -w nous permet de récupérer le temps de la requête, -o redirige la réponse vers un fichier et -s masque l’affichage de cURL

Le temps de réponse prend environ 20 millisecondes en utilisant cURL. Mais regardons plutôt la console du serveur qui nous affiche les requêtes SQL:

...
query: SELECT * FROM "query-result-cache" "cache" WHERE "cache"."query" = ? -- PARAMETERS: ...
query: SELECT COUNT(1) AS "cnt" FROM "product" "Product" WHERE published = TRUE AND lower(title) LIKE ? -- PARAMETERS: ...
query: INSERT INTO "query-result-cache"("identifier", "query", "time", "duration", "result") VALUES (NULL, ?, ?, ?, ?) -- PARAMETERS: ...
...

Voici quelques explications sur ces requêtes:

  1. une requête est effectuée sur la table "query-result-cache" afin de voir si un cache est présent

  2. la requête est effectuée car le cache n’existait pas

  3. le résultat est insérée dans la table "query-result-cache"

Essayons d’exécuter la commande cURL à nouveau:

$ curl -w 'Total: %{time_total}\n' -o /dev/null -s "http://localhost:3000/products?title=42"
Total: 0,007368

Nous voyons que le temps de réponse est à présent divisé par deux. Bien évidement ce chiffre est à prendre avec des pincettes mais voyons dans la console ce qui vient de ce passer:

query: SELECT * FROM "query-result-cache" "cache" WHERE "cache"."query" = ? -- PARAMETERS: ...

Et voilà. Le cache a été utilisé et …​ rien de plus! Maintenant c’est à vous de juger de quelles requêtes peuvent être mise en cache et pour combien de temps en fonction du besoin.

L’amélioration est donc énorme! Committons une dernière fois nos changements.

$ git commit -am "Adds caching for the serializers"

Activation des CORS

Dans cette dernière section, je vais vous parler d’un dernier problème que vous allez sûrement rencontrer si vous êtes amenés à travailler avec votre API.

Lors de la première requête d’un site externe (via une requête AJAX par exemple), vous aller rencontrer une erreur de ce genre:

Failed to load https://example.com/: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. Origin ‘https://anfo.pl' is therefore not allowed access. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.

"Mais qu’est ce que signifie Access-Control-Allow-Origin??". Le comportement que vous observez est l’effet de l’implémentation CORS des navigateurs. Avant la standardisation de CORS, il n’y avait aucun moyen d’appeler un terminal API sous un autre domaine pour des raisons de sécurité. Ceci a été (et est encore dans une certaine mesure) bloqué par la politique de la même origine.

CORS est un mécanisme qui a pour but de permettre les requêtes faites en votre nom et en même temps de bloquer certaines requêtes faites par des scripts malhonnêtes et est déclenché lorsque vous faites une requête HTTP à:

  • un domaine différent

  • un sous-domaine différent

  • un port différent

  • un protocole différent

Nous devons manuellement activer cette fonctionnalité afin que n’importe quel client puisse effectuer des requêtes sur notre API. Une librairie tout simple existe déjà donc nous allons les installer:

$ npm install --save cors

Et ensuite il suffit de modifier un tout petit peu notre serveur:

// src/main.ts
import 'reflect-metadata';
import cors from 'cors';
// ...
server
  .setConfig(app => app.use(cors()))
  .build()
  .listen(port, () => console.log(`Listen on http://localhost:${port}/`));

Et voilà! Il est maintenant temps de faire notre dernier commit et de merger nos modifications sur la branche master.

$ git commit -am "Activate CORS"
$ git checkout master
$ git merge chapter09

Conclusion

Si vous arrivez à ce point, cela signifie que vous en avez fini avec le livre. Bon travail! Vous venez de devenir un grand développeur Node.js, c’est sûr. Nous avons donc construit ensemble une API solide et complète. Celle-ci possède toutes les qualité pour détrôner Amazon, soyez en sûr.

Merci d’avoir traversé cette grande aventure avec moi. Gardez à l’esprit que vous venez de voir une de nombreuse manière d’architecturer une API avec Node.js. J’espère que celle-ci vous aura permis de découvrir des nouvelles notions et surtout que vous avez pris autant de plaisir à coder que moi.

Je tiens à vous rappeler que tout le code source de ce livre est disponible au format Asciidoctor sur GitHub. Ainsi n’hésitez pas à forker le projet si vous voulez l’améliorer ou corriger une faute qui m’aurait échappée.

Si vous avez aimé ce livre, n’hésitez pas à me le faire savoir par mail contact@rousseau-alexandre.fr. Je suis ouvert à toutes critiques, bonne ou mauvaise, autour d’une bonne bière :) .