Express.js est le framework web Node.js le plus populaire. Ce tutoriel construit une API CRUD complète pour gérer des articles de blog : création, lecture, mise à jour, suppression, avec validation, middlewares et gestion des erreurs propre.
mkdir api-blog && cd api-blog
npm init -y
npm install express mongoose dotenv cors helmet morgan joi
npm install --save-dev nodemon jest supertest
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest --runInBand"
}
}
src/
├── server.js # Point d'entrée
├── app.js # Configuration Express
├── models/
│ └── Article.js # Schéma Mongoose
├── routes/
│ └── articles.js # Routes CRUD
├── middlewares/
│ ├── validate.js # Validation Joi
│ └── errorHandler.js # Gestion des erreurs
└── __tests__/
└── articles.test.js
Définition : Blueprint qui définit la structure d'un document MongoDB : types de champs, validations, valeurs par défaut et méthodes.
But : Imposer une structure et des règles aux données flexibles de MongoDB.
Pourquoi ici : Bien que MongoDB soit sans schéma, Mongoose impose un schéma pour éviter l'chaos et garder l'intégrité des données côté application.
Définition : Couche d'abstraction qui mappe les documents MongoDB à des objets JavaScript (comme un ORM pour SQL).
But : Simplifier l'interaction avec MongoDB en utilisant des méthodes plutôt que du code de requête direct.
Pourquoi ici : Mongoose est l'ODM le plus populaire pour Node.js + MongoDB, offrant validation, middlewares et requêtes type.
const mongoose = require('mongoose');
const articleSchema = new mongoose.Schema({
title: {
type: String,
required: [true, 'Le titre est obligatoire'],
trim: true,
maxlength: [200, 'Le titre ne doit pas dépasser 200 caractères']
},
content: {
type: String,
required: [true, 'Le contenu est obligatoire'],
minlength: [10, 'Le contenu doit faire au moins 10 caractères']
},
author: {
type: String,
required: true
},
tags: [{
type: String,
lowercase: true,
trim: true
}],
published: {
type: Boolean,
default: false
},
views: {
type: Number,
default: 0
}
}, {
timestamps: true, // Ajoute createdAt et updatedAt automatiquement
versionKey: false
});
// Index pour la recherche full-text
articleSchema.index({ title: 'text', content: 'text' });
// Méthode d'instance
articleSchema.methods.getSummary = function() {
return `${this.title} — ${this.content.substring(0, 100)}...`;
};
module.exports = mongoose.model('Article', articleSchema);
createdAt et updatedAt.Définition : Fonction qui intercept la requête HTTP avant qu'elle n'atteigne le gestionnaire de route, effectue une action (validation, logs, sécurité), puis passe la requête au middleware suivant avec next().
But : Découpler les préoccupations transversales (sécurité, logs, authentification) de la logique métier.
Pourquoi ici : Express fonctionne comme une chaîne de middlewares : chaque app.use() ajoute un maillon à cette chaîne. La requête traverse tous les middlewares dans l'ordre jusqu'à atteindre la route.
helmet() (sécurité) → cors() (CORS) → morgan() (logs) → express.json() (parsing) → vos routes → gestionnaire d'erreursconst express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const articleRoutes = require('./routes/articles');
const errorHandler = require('./middlewares/errorHandler');
const app = express();
// ── Middlewares de sécurité ──
app.use(helmet()); // Headers de sécurité HTTP
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
}));
app.use(morgan('dev')); // Logs des requêtes
app.use(express.json()); // Parser JSON
// ── Routes ──
app.use('/api/articles', articleRoutes);
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// ── 404 ──
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} introuvable` });
});
// ── Gestionnaire d'erreurs global ──
app.use(errorHandler);
module.exports = app;
Définition : Vérification que les données envoyées par le client respectent les règles métier (types, formats, longueurs) avant traitement.
But : Rejeter les données malformées tôt, avant qu'elles ne polluent la base de données.
Pourquoi ici : Joi offre une validation déclarative côté serveur, indépendante du schéma Mongoose, pour avoir deux couches de protection.
Définition : Technique de division des résultats en pages : on retourne un sous-ensemble (ex: 10 résultats) au lieu de tous les résultats.
But : Réduire la charge mémoire et réseau, améliorer la performance des API.
Pourquoi ici : skip((page - 1) * limit) saute les premiers N documents, limit() en retourne au maximum M. Compromis : pagination complète mais plus lente que keyset pagination.
const express = require('express');
const router = express.Router();
const Article = require('../models/Article');
const Joi = require('joi');
// ── Validation Joi ──
const articleSchema = Joi.object({
title: Joi.string().max(200).required(),
content: Joi.string().min(10).required(),
author: Joi.string().required(),
tags: Joi.array().items(Joi.string()),
published: Joi.boolean()
});
const validate = (schema) => (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(422).json({
error: 'Données invalides',
details: error.details.map(d => d.message)
});
}
next();
};
// ── GET /api/articles — avec pagination et filtres ──
router.get('/', async (req, res, next) => {
try {
const { page = 1, limit = 10, tag, published, search } = req.query;
const filter = {};
if (tag) filter.tags = tag;
if (published !== undefined) filter.published = published === 'true';
if (search) filter.$text = { $search: search };
const [articles, total] = await Promise.all([
Article.find(filter)
.sort({ createdAt: -1 })
.skip((Number(page) - 1) * Number(limit))
.limit(Number(limit))
.select('-content'), // Exclure le contenu pour la liste
Article.countDocuments(filter)
]);
res.json({
data: articles,
pagination: {
page: Number(page),
limit: Number(limit),
total,
pages: Math.ceil(total / Number(limit))
}
});
} catch (err) { next(err); }
});
// ── GET /api/articles/:id ──
router.get('/:id', async (req, res, next) => {
try {
const article = await Article.findByIdAndUpdate(
req.params.id,
{ $inc: { views: 1 } }, // Incrémenter les vues
{ new: true }
);
if (!article) return res.status(404).json({ error: 'Article non trouvé' });
res.json(article);
} catch (err) { next(err); }
});
// ── POST /api/articles ──
router.post('/', validate(articleSchema), async (req, res, next) => {
try {
const article = await Article.create(req.body);
res.status(201).json(article);
} catch (err) { next(err); }
});
// ── PATCH /api/articles/:id ──
router.patch('/:id', async (req, res, next) => {
try {
const article = await Article.findByIdAndUpdate(
req.params.id, req.body, { new: true, runValidators: true }
);
if (!article) return res.status(404).json({ error: 'Article non trouvé' });
res.json(article);
} catch (err) { next(err); }
});
// ── DELETE /api/articles/:id ──
router.delete('/:id', async (req, res, next) => {
try {
const article = await Article.findByIdAndDelete(req.params.id);
if (!article) return res.status(404).json({ error: 'Article non trouvé' });
res.json({ message: 'Article supprimé', id: req.params.id });
} catch (err) { next(err); }
});
module.exports = router;
Définition : Middleware Express spécial (4 paramètres : err, req, res, next) qui capture toutes les erreurs jetées ou passées via next(err) dans l'application.
But : Centraliser la gestion des erreurs, en lieu de dupliquer try-catch partout, pour offrir des réponses cohérentes.
Pourquoi ici : Sans gestionnaire global, les erreurs non attrapées causent des crashes. Avec celui-ci, elles retournent des réponses JSON propres au client et des logs au serveur.
module.exports = (err, req, res, next) => {
console.error(err.stack);
// Erreur de validation Mongoose
if (err.name === 'ValidationError') {
return res.status(422).json({
error: 'Données invalides',
details: Object.values(err.errors).map(e => e.message)
});
}
// ID MongoDB invalide (CastError)
if (err.name === 'CastError') {
return res.status(400).json({ error: 'ID invalide' });
}
// Doublon (index unique)
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({ error: `${field} déjà utilisé` });
}
res.status(err.status || 500).json({
error: err.message || 'Erreur interne du serveur'
});
};