Apprendre Express.js : Créer une API REST

Express est un framework JavaScript backend minimaliste se prêtant parfaitement à la création d'API REST.

Icône de calendrier
Intermédiaire
8 chapitres

La création d’API constitue une grande part du développement d’applications en tout genre : web, mobile, IoT, etc.

Dans ce tutoriel, nous allons créer une API Express étape par étape en utilisant l’environnement Node.js.

Configuration de l’environnement

Initialisation d’un projet Express

Commençons par initialiser un projet Node.js avec la ligne de commande suivante :

copié !
npm init -y

Il est désormais temps d’installer le framework Express et l’outil de développement Nodemon :

copié !
npm install express nodemon

Structuration du projet

Pour cet exemple, nous créerons une API dédiée à la gestion d’artistes musicaux.

L’arborescence de notre API respectera la structure suivante :

📁 demo-api
├── 📁 node_modules
│   ├── 📄 module1
│   ├── 📄 module2
│   └── ...
├── 📁 routes
│   ├── 📄 index.js
│   └── 📄 artist.js
├── 📁 controllers
│   └── 📄 artist.js
├── 📁 services
│   └── 📄 artist.js
├── 📁 datas
│   ├── 📄 artists.json
├── 📁 utils
│   ├── 📄 dataManager.js
├── 📄 app.js
├── 📄 package.lock.json
└── 📄 package.json
  • 📄 app.js : serveur Express
  • 📄 routes/index.js : fichier regroupant l’ensemble des routes de l’API
  • 📄 routes/artist.js : définition des endpoints (GET, POST, PUT…) de l’API (partie artistes)
  • 📄 controllers/artist.js : construction de la réponse pour chaque endpoint
  • 📄 services/artist.js : implémentation du CRUD (logique métier)
  • 📄 datas/artists.json : base de données JSON (à des fins de simplification, notre API REST va interagir avec un fichier .json et non un réel SGBD)
  • 📄 utils/dataManager.js : Script utilitaire en charge de la manipulation des fichiers de BDD .json.

Création du serveur

Commençons par créer un serveur on ne peut plus minimaliste, se contentant de répondre au localhost:3000.

app.js
copié !
const express = require('express');
const app = express();

const hostname = '127.0.0.1';
const port = process.env.PORT || 3000;

app.listen(port, hostname, () => {
	console.log(`Serveur démarré sur http://${hostname}:${port}`);
});

Il est temps de créer un script sur-mesure pour lancer notre serveur avec Nodemon.

package.json
{
  // ...
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon app"
  },
  // ...
}

Si vous effectuez la commande npm run dev, vous arriverez sur une page blanche indiquant :

Cannot GET /

Et c’est bien normal, car pour le moment, aucune route n’a été définie…

Création du dataset

Créons désormais une base de données orientée document au sein d’un fichier .json.

Cette BDD contiendra une liste d’artistes, définis par :

  • Un identifiant
  • Un nom
  • Un pays
  • Les genres musicaux dans lesquels il s’illustre

Ajouter les données suivantes à l’intérieur :

datas/artists.json
copié !
[
  {
    "id": 1,
    "name": "John Smith",
    "country": "United States",
    "genres": ["Pop", "Rock"]
  },
  {
    "id": 2,
    "name": "Maria Garcia",
    "country": "Spain",
    "genres": ["Flamenco", "Latin"]
  },
  {
    "id": 3,
    "name": "David Kim",
    "country": "South Korea",
    "genres": ["K-Pop", "Hip-Hop"]
  },
  {
    "id": 4,
    "name": "Marta Johnson",
    "country": "Canada",
    "genres": ["Country", "Folk"]
  }
]

Routes

Les routes d’une API REST, aussi appelées « endpoints », vont opérer sur le serveur et retourner une réponse en fonction des caractéristiques de la requête HTTP.

La définition d’un endpoint se résume à :

  • Un chemin
  • Une méthode HTTP associée

Lors de la création d’une API, les routes déclenchent généralement les opérations classiques d’un CRUD (Create, Read, Update, Delete).

Nous allons donc implémenter les routes suivantes au sein du fichier de routage 📄 routes/artist.js.

routes/artist.js
copié !
const express = require("express")
const router = express.Router()

// Tous les artistes
router.get("/", (req, res) => {
  // ...
});

// Détails d'un artiste
router.get("/:id", (req, res) => {
  // ...
});

// Créer un artiste
router.post("/", (req, res) => {
  // ...
});

// Modifier un artiste
router.put("/:id", (req, res) => {
  // ...
});

// Supprimer un artiste
router.delete("/:id", (req, res) => {
  // ...
});

module.exports = router;

Ce fichier :

  1. Charge dans router le routeur d’Express
  2. Définit 5 endpoints (pour l’instant vides) pour notre CRUD
  3. Exporte le routeur

Il serait judicieux de créer un fichier centralisant l’ensemble des routes de l’API, pour simplifier son import ultérieur dans 📄 app.js.

routes/index.js
copié !
const express = require("express");
const router = express.Router();

const artistRoutes = require("./artist");

router.use("/artists", artistRoutes);

module.exports = router;

Ce fichier monte les routes associées au CRUD des artistes sur le routeur en préfixant la route de /artists.

Focus évolutivité

Dans le cas où notre API manipulerait plusieurs éléments (artistes, chansons, albums…), l’interêt de ce fichier serait d’autant plus marqué :

routes/index.js
const express = require("express");
const router = express.Router();

const artistRoutes = require("./artist");
const songRoutes = require("./song");
const albumRoutes = require("./album");

router.use("/artists", artistRoutes);
router.use("/songs", songRoutes);
router.use("/albums", albumRoutes);

module.exports = router;

Il est temps d’importer notre routeur global depuis 📄 app.js et de le monter sur le chemin /api :

app.js
copié !
// ...

const routes = require("./routes");

app.use("/api", routes);

// ...

Désormais, toutes les routes de l’API commenceront par /api. Les routes définies dans le routeur des artistes, seront quant à elle suivies de /artists, donnant ainsi :

  • GET /api/artists
  • GET /api/artists/:id
  • POST /api/artists
  • PUT /api/artists/:id
  • DELETE /api/artists/:id

Il est temps de créer un contrôleur en charge de construire les réponses HTTP associées aux endpoints des artistes.

Contrôleurs

Les contrôleurs sont en charge de créer la réponse HTTP adaptée à la requête HTTP envoyée par le client. Ce sont eux qui vont définir, au sein de fonctions, les traitements à exécuter pour chaque route de l’API.

controllers/artist.js
copié !
const artistService = require('../services/artist');

function list(req, res) {
  const artists = artistService.findAll();
  res.status(200).json(artists);
}

function read(req, res) {
  const artistId = req.params.id;
  const artist = artistService.find(artistId);
  if (artist)
    res.status(200).json(artist);
  else
    res.status(404).json({ message: "Artiste non trouvé" });
}

function create(req, res) {
  const datas = req.body;
  const createdArtist = artistService.create(datas);
  if (createdArtist)
    res.status(201).json({ message: "Artiste créé" });
  else
    res.status(400).json({ message: "Erreur lors de l'insertion" });
}

function update(req, res) {
  const artistId = req.params.id;
  const datas = req.body;
  const updatedArtist = artistService.update(artistId, datas);
  if (updatedArtist) {
    res.status(200).json({ message: "Artiste édité" });
  } else {
    res.status(400).json({ message: "Erreur lors de l'édition" });
  }
}

function remove(req, res) {
  const artistId = req.params.id;
  const removedArtist = artistService.remove(artistId);
  if (removedArtist) {
    res.status(200).json({ message: "Artiste supprimé" });
  } else {
    res.status(400).json({ message: "Erreur lors de la suppression" });
  }
}

module.exports = {
  list,
  read,
  create,
  update,
  remove
};

S’agissant d’une API basique, ce contrôleur se contente d’appeler les services (encore inexistants).

Les services vont contenir des fonctions d’interaction (écriture et lecture) avec la base de données. Ce sont eux qui implémentent la logique métier (les opérations de CRUD) de l’application.

En fonction de la valeur de retour transmise par le service, le contrôleur construira la réponse et lui attachera le code de statut correspondant (200, 201, 400, 404…).

Parser les données du corps de la requête

create et update font appel à req.body. La propriété body permet d’extraire le corps de la requête HTTP. C’est de cette manière que l’on peut récupérer les données des artistes envoyées au format JSON :

{
	"name": "Patrick Sébastien",
	"country": "France",
	"genres": ["Beauf", "Variété française"]
}

Pour exploiter les données récupérées par req.body il faut les parser : transformer le JSON en objet JavaScript.

Pour cela, il faut exploiter le middleware Express express.json(). Montons le sur l’ensemble des routes depuis le fichier de route global 📄 routes/index.js.

routes/index.js
copié !
const express = require("express");
const router = express.Router();

const artistRoutes = require("./artist");

router.use(express.json());
router.use("/artists", artistRoutes);

module.exports = router;

Mettons désormais à jour notre routeur, en appelant pour chaque endpoint, la fonction appropriée du contrôleur.

routes/artist.js
copié !
const express = require("express");
const router = express.Router();

const artistController = require("../controllers/artist");

// Tous les artistes
router.get("/", artistController.list);

// Détails d'un artiste
router.get("/:id", artistController.read);

// Créer un artiste
router.post("/", artistController.create);

// Modifier un artiste
router.put("/:id", artistController.update);

// Supprimer un artiste
router.delete("/:id", artistController.remove);

module.exports = router;

Services (logique métier)

Vous l’avez compris, nous aurions pu écrire la logique métier de nos opérations CRUD directement au sein de nos endpoints ou fonctions du contrôleur, mais un peu de découpage ne fait jamais de mal

Le rôle des services va être d’encapsuler cette logique au sein de fonctions. Mais avant d’écrire la moindre fonction dans notre service, créons un module utilitaire nommé « DataManager », dont le rôle va être d’écrire et lire dans notre BDD JSON.

Gestionnaire de données

Ce module utilitaire, va implémenter les fonctions loadData() et saveData(), permettant respectivement de lire et écrire dans des fichiers .json situés dans le dossier 📂 datas.

Pour cela, notre gestionnaire de données exploite les méthodes readFileSync() et writeFileSync() du module natif fs.

La fonction validateData() aura pour sa part le rôle de vérifier que les objets à écrire dans la BDD sont au bon format (possèdent toutes les propriétés requises et nulle autre).

utils/dataManager.js
copié !
const fs = require("fs");

const dataDirectory = "datas";

function loadData(filename) {
  const filePath = `${dataDirectory}/${filename}.json`;
  try {
    const data = fs.readFileSync(filePath, "utf8");
    return JSON.parse(data);
  } catch (err) {
    console.error(`Impossible de lire le fichier ${filename}.json :`, err);
  }
}

function saveData(filename, data) {
  const filePath = `${dataDirectory}/${filename}.json`;
  try {
    fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
  } catch (err) {
    console.error(
      `Impossible d'écrire dans le fichier ${filename}.json :`,
      err
    );
  }
}

function validateData(data, requiredKeys) {
  const keys = Object.keys(data);
  if (keys.length !== requiredKeys.length) {
    return false;
  }
  for (const key of requiredKeys) {
    if (!data.hasOwnProperty(key)) {
      return false;
    }
  }
  return true;
}

module.exports = { loadData, saveData, validateData };

CRUD

Il est enfin temps d’implémenter le CRUD des artistes.

Mettons en place le squelette de notre service en :

  1. Important les fonctions utiles du gestionnaire de données (📄 dataManager.js)
  2. Chargeant les données de la BDD dans la variable artists
  3. Définissant les 5 fonctions du CRUD : findAll(), find(), create(), update() et remove().
  4. Exportant ces fonctions
services/artist.js
copié !
const { loadData, saveData, validateData } = require("../utils/dataManager");

const filename = "artists";

let artists = loadData(filename);

function findAll() { ... }

function find(id) { ... }

function create(newArtist) { ... }

function update(id, updatedArtist) { ... }

function remove(id) { ... }

module.exports = {
  findAll,
  find,
  create,
  update,
  remove,
};

Il est temps d’implémenter la logique métier au sein des 5 fonctions.

1. Liste des artistes

services/artist.js
copié !
function findAll() {
  return artists;
}

On se contente de retourner les artistes stockés dans 📄 datas/artists.json.

2. Détails d’un artiste

services/artist.js
copié !
function find(id) {
  return artists.find((artist) => artist.id === parseInt(id));
}

On retourne l’artiste ayant l’id transmis à la fonction dans 📄 datas/artists.json.

3. Création d’un artiste

services/artist.js
copié !
function create(newArtist) {
  if (!validateData(newArtist, ["name", "country", "genres"])) return false;
  const id = artists.length > 0 ? artists[artists.length - 1].id + 1 : 1;
  const artist = { id, ...newArtist };
  artists.push(artist);
  saveData(filename, artists);
  return artist;
}

On ajoute à la fin du tableau artists un nouvel artiste en lui définissant un id automatiquement incrémenté en se basant sur l’id de l’artiste précédent + 1.

Le tableau artists étant mis à jour, on demande à ce qu’il remplace le contenu du fichier 📄 datas/artists.json.

4. Modification d’un artiste

services/artist.js
copié !
function update(id, updatedArtist) {
  if (!validateData(updatedArtist, ["name", "country", "genres"])) return false;
  let index = artists.findIndex((artist) => artist.id === parseInt(id));
  if (index === -1) return false;
  artists[index] = { id: parseInt(id), ...updatedArtist };
  saveData(filename, artists);
  return artists[index];
}

On modifie dans le tableau artists, les propriétés de l’artiste ayant l’id transmis à la fonction.

Le tableau artists étant mis à jour, on demande à ce qu’il remplace le contenu du fichier 📄 datas/artists.json.

5. Suppression d’un artiste

services/artist.js
copié !
function remove(id) {
  const index = artists.findIndex(artist => artist.id === parseInt(id));
  if (index === -1) return false;
  artists.splice(index, 1);
  saveData(filename, artists);
  return true;
}

On supprime dans le tableau artists, l’artiste étant situé à la position de l’objet ayant l’id transmis à la fonction.

Le tableau artists étant mis à jour, on demande à ce qu’il remplace le contenu du fichier 📄 datas/artists.json.

Tester l’API

S’il est facile de tester des requêtes GET et POST au sein de son navigateur, les méthodes PUT, PATCH et DELETE ne sont pas directement prises en charge par les navigateurs web.

C’est là que des outils de test API tels que Postman ou Insomnia s’avèrent indispensables.

Ils offrent une interface conviviale permettant de construire, envoyer et visualiser des requêtes HTTP pour toutes les méthodes HTTP, y compris PUT, PATCH et DELETE.

Avec ces outils, il devient aisé pour les développeurs de simuler et inspecter les réponses des serveurs, ce qui facilite le débogage et le développement d’API REST.