Écrire une REST API avec Express dans Node.js

1. Aperçu

Cet article vous guide dans la construction d’une REST API de gestion des Order avec Express — du démarrage du projet, à la définition des routes, middleware, validation des données, jusqu’à la gestion centralisée des erreurs. Après avoir lu cet article, vous pouvez construire vous-même une API CRUD complète, avec des tests automatisés avec Jest et Supertest, et connaître les erreurs courantes quand on débute avec Express.

L’exemple tout au long de cet article : une API de gestion des commandes (Order) d’un Customer, supportant la création, la consultation, la modification et la suppression.

2. Préparation du projet

Créez un nouveau projet Node.js et installez Express :

mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest

Ajoutez type: module pour utiliser la syntaxe import/export (ES Module), et déclarez le script de test dans package.json :

{
  "name": "order-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

Remarque : Jest avec ES Module nécessite le flag --experimental-vm-modules. Si vous ne voulez pas de complications, vous pouvez supprimer "type": "module" et utiliser require/module.exports (CommonJS) — les deux sont valides, selon les préférences de votre équipe.

3. Créer un serveur Express basique

// src/app.js
import express from 'express';

const app = express();
app.use(express.json()); // parse le corps JSON en req.body

app.get('/health', (req, res) => {
  res.json({ status: 'UP' });
});

export default app;
// src/server.js
import app from './app.js';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Order API est en cours d'exécution sur le port ${PORT}`);
});
  • express.json() est un middleware intégré qui aide Express à parser automatiquement Content-Type: application/json en objet req.body. Sans cette ligne, req.body sera undefined.
  • Séparer app.js de server.js pour que les tests n’aient pas besoin d’ouvrir un port réel — c’est une pratique importante pour les REST API Node.js.

Essayez :

node src/server.js
curl http://localhost:3000/health
{"status":"UP"}

4. Définir la ressource Order

Utilisez une “base de données” en mémoire avec un tableau pour vous concentrer sur la partie API, indépendamment d’une vraie BD.

// src/orders/orderRepository.js
let orders = [];
let nextId = 1;

export function findAll() {
  return orders;
}

export function findById(id) {
  return orders.find((o) => o.id === id);
}

export function create(data) {
  const order = { id: nextId++, ...data, status: 'PENDING' };
  orders.push(order);
  return order;
}

export function update(id, data) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return null;
  orders[index] = { ...orders[index], ...data };
  return orders[index];
}

export function remove(id) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return false;
  orders.splice(index, 1);
  return true;
}

Ce repository simule les opérations CRUD que vous verrez dans n’importe quel ORM (Sequelize, Prisma…), la seule différence est qu’il stocke en RAM au lieu d’une BD.

5. Routage avec Express Router

Au lieu de coller toutes les routes à app, utilisez express.Router() pour modulariser par ressource :

// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';

const router = Router();

router.get('/', (req, res) => {
  res.json(repo.findAll());
});

router.get('/:id', (req, res, next) => {
  const order = repo.findById(Number(req.params.id));
  if (!order) return next({ status: 404, message: 'Order inexistant' });
  res.json(order);
});

router.post('/', (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});

router.put('/:id', (req, res, next) => {
  const order = repo.update(Number(req.params.id), req.body);
  if (!order) return next({ status: 404, message: 'Order inexistant' });
  res.json(order);
});

router.delete('/:id', (req, res, next) => {
  const deleted = repo.remove(Number(req.params.id));
  if (!deleted) return next({ status: 404, message: 'Order inexistant' });
  res.status(204).end();
});

export default router;

Attachez le router à app avec le préfixe /orders :

// src/app.js (mise à jour)
import orderRouter from './orders/orderRouter.js';

app.use('/orders', orderRouter);
  • router.get('/:id', ...) — :id est un paramètre de route, accédé via req.params.id (toujours une string, nécessite Number() pour comparer).
  • Appeler next({ status: 404, message: ... }) transfère l’erreur au middleware de gestion d’erreurs (section 7), au lieu d’écrire res.status(404)... répété à chaque route.
  • 204 No Content pour DELETE réussi — ne retourne pas de body.

6. Valider les données d’entrée

Valider manuellement avec if/else est facile d’oublier des cas. Utilisez express-validator pour être concis et clair :

npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';

export const validateCreateOrder = [
  body('customerName').isString().notEmpty().withMessage('customerName est obligatoire'),
  body('amount').isFloat({ gt: 0 }).withMessage('amount doit être supérieur à 0'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
];

Attachez le validateur à la route POST :

// src/orders/orderRouter.js (mise à jour)
import { validateCreateOrder } from './orderValidator.js';

router.post('/', validateCreateOrder, (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});
  • Chaque body(...) est une règle qui vérifie un champ ; en cas d’erreur, les erreurs sont rassemblées dans validationResult(req).
  • Le dernier middleware dans le tableau vérifie errors.isEmpty() — s’il y a des erreurs, retourne 400 avec des détails, ne l’appelle pas next() pour arrêter la requête de continuer vers le vrai handler.

Envoyez une requête sans amount :

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName": "Nam"}'
{"errors":[{"type":"field","msg":"amount doit être supérieur à 0","path":"amount",...}]}

7. Gestion centralisée des erreurs

Express vous permet de définir un error-handling middleware — c’est un middleware avec 4 paramètres (err, req, res, next), placé après toutes les routes :

// src/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const message = err.message || 'Erreur système';

  if (status === 500) {
    console.error(err); // log l'erreur réelle pour déboguer, ne retournez pas la stack trace au client
  }

  res.status(status).json({ message });
}
// src/app.js (mise à jour, ajoutez en fin de fichier)
import { errorHandler } from './errorHandler.js';

app.use(errorHandler);
  • Express reconnaît ce middleware comme error handler grâce au nombre de paramètres (4), pas le nom de la fonction.
  • Vous devez placer app.use(errorHandler) en dernier, après chaque app.use(router) — Express traite les middleware dans l’ordre déclaré.
  • Toute erreur next(err) ou lancée dans une route (si vous utilisez express-async-errors ou try/catch) tombera ici.

Erreur courante : oublier d’appeler next(err) dans du code async (par exemple await db.query(...) lance) empêche Express de capturer l’erreur, le serveur plante ou la requête gèle. Avec Express 5 (les versions plus récentes), les gestionnaires async qui lancent des erreurs sont automatiquement capturés — avec Express 4, vous devez envelopper avec try/catch et appeler next(err) manuellement.

8. Ajouter une couche Service pour séparer la logique métier

Quand la logique devient plus complexe (par exemple : ne pas permettre de créer une commande si amount dépasse la limite du client), ne mettez pas tout dans le routeur. Séparez en orderService :

// src/orders/orderService.js
import * as repo from './orderRepository.js';

const MAX_AMOUNT = 100_000_000;

export function createOrder(data) {
  if (data.amount > MAX_AMOUNT) {
    const err = new Error('Dépassement de la limite autorisée');
    err.status = 422;
    throw err;
  }
  return repo.create(data);
}
// src/orders/orderRouter.js (mise à jour route POST)
import * as orderService from './orderService.js';

router.post('/', validateCreateOrder, (req, res, next) => {
  try {
    const order = orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

Le routeur gère maintenant uniquement HTTP (code de statut, parse param), tandis que orderService gère la logique métier. C’est un modèle similaire à la couche Controller–Service dans d’autres frameworks, ce qui aide à tester la logique métier indépendamment du HTTP.

9. Écrire des tests avec Jest et Supertest

Supertest vous permet d’appeler l’API directement sur l’objet app (instance Express) sans avoir besoin d’ouvrir un vrai port — c’est pourquoi la section 3 sépare app.js de server.js.

// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';

describe('Order API', () => {
  it('créer une commande avec succès retourne 201 et id', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam', amount: 500000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      customerName: 'Nam',
      amount: 500000,
      status: 'PENDING',
    });
    expect(response.body.id).toBeDefined();
  });

  it('amount manquant retourne 400', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam' });

    expect(response.status).toBe(400);
  });

  it('récupérer une commande inexistante retourne 404', async () => {
    const response = await request(app).get('/orders/9999');

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('Order inexistant');
  });
});

Exécutez les tests :

npm test
PASS  tests/order.test.js
  Order API
    ✓ créer une commande avec succès retourne 201 et id
    ✓ amount manquant retourne 400
    ✓ récupérer une commande inexistante retourne 404
  • request(app) initialise un client HTTP virtuel attaché directement à l’application Express — plus rapide que de tester sur le réseau réel.
  • toMatchObject vérifie uniquement les champs spécifiés, en ignorant les autres (comme id auto-généré) — pratique pour tester les réponses avec des valeurs dynamiques.

Remarque : puisque orders est un tableau en mémoire partagé entre les tests, les tests exécutés dans un certain ordre peuvent s’influencer mutuellement (par exemple, une commande créée dans le test 1 existe toujours quand le test 2 s’exécute). Avec une vraie API connectée à une BD, réinitialisez les données dans beforeEach ou utilisez un rollback de transaction.

10. Quand ne pas utiliser Express pur

  • API grande, domaines complexes multiples : Express n’a pas de convention obligatoire sur la structure, il est facile de générer du “spaghetti code” si l’équipe ne définit pas ses propres conventions. Envisagez NestJS (avec DI, module, decorator comme Spring) si l’équipe est habituée à une architecture en couches bien définie.
  • Besoin de type-safety fort : Express + JavaScript pur ne capture pas les erreurs de type au compile. Si le projet est grand, utilisez TypeScript dès le départ, ne attendez pas à “convertir plus tard” — le coût de conversion ultérieure est beaucoup plus élevé.
  • Débit élevé, flux/WebSocket complexes : Express peut toujours fonctionner mais Fastify ou d’autres frameworks optimisés pour les performances (benchmark de sérialisation JSON plus rapide) peuvent être mieux adaptés aux systèmes à très fort trafic.

Pour les API de petite à moyenne taille, CRUD clair comme dans l’exemple ci-dessus, Express reste un choix rapide et simple, avec une grande communauté et des middleware tout-faits faciles à trouver (auth, rate-limit, CORS…).

11. Conclusion

À travers cet article, vous avez construit une REST API Order complète avec Express : le routage avec Router, la validation d’entrée avec express-validator, la gestion centralisée des erreurs avec un middleware à 4 paramètres, et la séparation de la logique métier dans une couche service pour faciliter les tests. Les tests avec Jest + Supertest vous permettent de refactoriser en confiance sans craindre de casser le comportement de l’API. À partir de cette base, vous pouvez ajouter une vraie connexion à une base de données (Prisma, Mongoose), l’authentification (JWT), ou passer à TypeScript à mesure que le projet grandit.

nodejsexpressrest-apijavascripttesting