Cours
Une simple to-do list suffit au début : vous ajoutez quelques tâches et vous les cochez une fois terminées. Mais à mesure que la liste s'allonge, son utilisation devient laborieuse. Une solution efficace consiste à ajouter des catégories, pour classer chaque tâche (Travail, Études, Personnel, etc.) et filtrer la liste selon la catégorie souhaitée.
Dans ce tutoriel, vous allez créer une API REST qui organise des tâches par catégorie avec Node.js et MongoDB. À la fin, vous disposerez d'un serveur Node.js opérationnel avec cinq endpoints : créer, lister, obtenir, mettre à jour et supprimer, ainsi qu'un filtre par catégorie. Vous verrez aussi comment imposer la validation des catégories avec le driver natif de MongoDB, sans recourir à un ODM comme Mongoose.
Ce que vous allez apprendre
- Comment créer une API REST avec Node.js, Express et MongoDB, incluant des endpoints CRUD complets
- Comment valider les entrées, imposer des catégories et gérer correctement les valeurs ObjectId en toute sécurité
- Comment structurer et tester votre API puis la connecter à un frontend
Vous trouverez le code complet de ce tutoriel sur GitHub si vous préférez le cloner et suivre en parallèle.
Prérequis
Avant de commencer, vous devez avoir :
- Node.js 18+ installé (npm est inclus par défaut)
- MongoDB en local ou un cluster MongoDB Atlas gratuit
- Postman ou tout client HTTP
- Des bases en JavaScript et en concepts REST
Étape 1 : configurer le projet
Commençons à bâtir notre gestionnaire de tâches. Créez d'abord un nouveau dossier et initialisez un projet Node en exécutant les commandes ci-dessous dans votre terminal :
mkdir task-manager-categories
cd task-manager-categories
npm init -y
Ensuite, installez les quatre dépendances runtime nécessaires : express pour le routage, le driver officiel MongoDB, dotenv pour charger les variables d'environnement, et enfin helmet pour les en-têtes de sécurité.
npm install express mongodb dotenv helmet
Pour le développement, installez nodemon comme dépendance de dev avec la commande ci-dessous afin que le serveur se recharge automatiquement quand les fichiers changent :
npm install --save-dev nodemon
Ouvrez ensuite le dossier du projet dans votre IDE et mettez à jour la section scripts de package.json comme suit. Cela vous permettra de démarrer le serveur avec npm start ou de le lancer en mode développement avec npm run dev :
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Voici la structure que nous allons construire au fil du tutoriel. Chaque dossier a un rôle clair : db/ gère la connexion à la base, lib/ contient des fonctions utilitaires, middleware/ regroupe les middlewares Express et routes/ définit les gestionnaires de requêtes. Le dossier public/ accueillera un petit frontend que nous ajouterons plus tard. Même pour un petit projet, cette structure reste lisible et indique clairement où ajouter du code. Vous pouvez créer l'arborescence dès maintenant et la compléter au fur et à mesure, ou passer cette étape et ajouter chaque fichier lors des étapes suivantes :
task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js
Étape 2 : se connecter à MongoDB
Pour stocker et récupérer des tâches, il faut d'abord connecter l'application à MongoDB. Cela se fait via une chaîne de connexion qui indique au driver MongoDB comment joindre votre base. Plutôt que de la coder en dur, placez-la dans un fichier d'environnement. Vous évitez ainsi de compromettre des informations sensibles et vous facilitez le passage d'un environnement à l'autre.
Créez un fichier .env.example pour documenter les variables nécessaires, et un fichier .env pour vos valeurs réelles, comme ceci :
# Copiez ce fichier en `.env` et renseignez des valeurs réelles.
# --- MongoDB local ---
# À utiliser si vous exécutez MongoDB en local
MONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---
# Remplacez <username>, <password> et <cluster-url> par vos valeurs
# Exemple : mongodb+srv://user:pass@cluster0.abcde.mongodb.net/
# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority
DB_NAME=taskmanager
PORT=3000
Maintenant que la chaîne de connexion est prête, utilisons-la pour configurer la connexion MongoDB. Créez le fichier db/connect.js. Nous y initialiserons le client MongoDB et le rendrons accessible au reste de l'application :
const { MongoClient } = require("mongodb");
let client;
let db;
async function connectDB() {
if (!process.env.MONGO_URI) {
throw new Error("MONGO_URI is not set. Check your .env file.");
}
client = new MongoClient(process.env.MONGO_URI, {
appName: "devrel-tutorial-javascript-crud-geeksforgeeks",
});
await client.connect();
db = client.db(process.env.DB_NAME || "taskmanager");
await db.collection("tasks").createIndex({ category: 1 });
console.log(`MongoDB connected (db: ${db.databaseName})`);
}
function getTasksCollection() {
if (!db) {
throw new Error("Database not initialized. Call connectDB() first.");
}
return db.collection("tasks");
}
async function closeDB() {
if (client) await client.close();
}
module.exports = { connectDB, getTasksCollection, closeDB };
Ce fichier met en place un client MongoDB unique réutilisé par toute l'application. Il ne faut le créer qu'une seule fois, le driver gérant déjà le pool de connexions en interne. Instancier un nouveau client à chaque requête peut sembler anodin au début, mais provoquera rapidement des problèmes de performance.
Étape 3 : définir la structure et la validation des tâches
À ce stade, l'application peut se connecter à MongoDB. Il faut maintenant définir à quoi ressemble une tâche, avant d'enregistrer quoi que ce soit.
C'est ici que l'utilisation du driver natif MongoDB diffère de Mongoose. Il n'y a pas de fichier de schéma : le « schéma » est simplement la forme de l'objet inséré en base. Cela peut paraître plus souple, mais c'est utile : vous restez proche du fonctionnement réel de MongoDB, sans couches d'abstraction qui masquent les opérations.
Nous voulons néanmoins de la validation. Plutôt que d'éparpiller cette logique dans différentes routes, nous allons la centraliser pour appliquer les mêmes règles partout. Créez un fichier nommé lib/taskDocument.js et ajoutez-y le code ci-dessous :
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
const ALLOWED_CATEGORIES = ['Work', 'Personal', 'Study', 'Other'];
function assertValidCategory(category) {
if (!ALLOWED_CATEGORIES.includes(category)) {
throw new ValidationError(
`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
);
}
}
function buildTaskDocument(body = {}) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new ValidationError('title is required and must be a non-empty string');
}
if (body.category != null) {
assertValidCategory(body.category);
}
const now = new Date();
return {
title: body.title.trim(),
description: typeof body.description === 'string' ? body.description.trim() : '',
category: body.category != null ? body.category : 'Other',
completed: Boolean(body.completed),
createdAt: now,
updatedAt: now
};
}
function buildTaskUpdate(body = {}) {
const updates = {};
if (typeof body.title === 'string' && body.title.trim()) {
updates.title = body.title.trim();
}
if (typeof body.description === 'string') {
updates.description = body.description.trim();
}
if (body.category != null) {
assertValidCategory(body.category);
updates.category = body.category;
}
if (body.completed != null) {
updates.completed = Boolean(body.completed);
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('no valid fields provided for update');
}
updates.updatedAt = new Date();
return updates;
}
module.exports = {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
};
Ce fichier joue le rôle de garde-barrière pour toute donnée qui entre dans la base. Chaque création ou mise à jour y transite, garantissant l'application uniforme des règles.
Il s'assure que les tâches ont toujours la bonne structure, maintient des catégories cohérentes et évite les mauvaises surprises lors des requêtes. La classe personnalisée ValidationError vous permet aussi de distinguer clairement une entrée invalide d'une vraie erreur serveur, pour des réponses API adaptées. Avec cela en place, le reste de l'app reste simple : chaque route se concentre sur son rôle, en sachant que les données reçues sont déjà valides. Passons maintenant aux routes pour enregistrer des tâches dans MongoDB.
Étape 4 : gérer ObjectId et la validation des requêtes
Maintenant que nous savons à quoi ressemble une tâche, voyons comment la référencer. Lorsqu'une route inclut un paramètre :id, celui-ci arrive en chaîne de caractères. Mais MongoDB attend un ObjectId. Si la chaîne est mal formée, le driver renvoie une erreur peu parlante. Plutôt que de gérer cela dans chaque route, centralisons la logique via un middleware afin d'obtenir le même comportement sur tous les endpoints.
Pour cela, créez le fichier middleware/parseObjectId.js et ajoutez-y le contenu suivant :
const { ObjectId } = require('mongodb');
function parseObjectId(req, res, next) {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ error: 'invalid task id' });
}
req.taskId = new ObjectId(id);
next();
}
module.exports = parseObjectId;
Ce middleware s'exécute avant votre gestionnaire de route. Il vérifie la validité de l'id, la convertit en ObjectId et l'attache à req.taskId. Ainsi, quand la logique de route s'exécute, vous travaillez toujours avec un véritable ObjectId, et les entrées invalides sont rejetées tôt avec un statut 400 clair. Nous allons maintenant l'intégrer aux routes et tout raccorder.
Étape 5 : créer les routes tâches (CRUD + filtrage)
À ce stade, l'essentiel est prêt. Nous avons défini la structure d'une tâche valide et géré la validation des identifiants. Les gestionnaires de routes peuvent donc se concentrer sur l'essentiel : dialoguer avec la base.
Créons les routes de l'API dans un fichier nommé routes/tasks.js en y ajoutant le contenu suivant :
const express = require('express');
const { getTasksCollection } = require('../db/connect');
const {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
} = require('../lib/taskDocument');
const parseObjectId = require('../middleware/parseObjectId');
const router = express.Router();
// POST /tasks
router.post('/', async (req, res, next) => {
try {
const doc = buildTaskDocument(req.body);
const result = await getTasksCollection().insertOne(doc);
res.status(201).json({ _id: result.insertedId, ...doc });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// GET /tasks (optionally ?category=Work)
router.get('/', async (req, res, next) => {
try {
const { category } = req.query;
if (category && !ALLOWED_CATEGORIES.includes(category)) {
return res.status(400).json({
error: `category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
});
}
const filter = category ? { category } : {};
const tasks = await getTasksCollection()
.find(filter)
.sort({ createdAt: -1 })
.toArray();
res.json(tasks);
} catch (err) { next(err); }
});
// GET /tasks/:id
router.get('/:id', parseObjectId, async (req, res, next) => {
try {
const task = await getTasksCollection().findOne({ _id: req.taskId });
if (!task) return res.status(404).json({ error: 'task not found' });
res.json(task);
} catch (err) { next(err); }
});
// PUT /tasks/:id
router.put('/:id', parseObjectId, async (req, res, next) => {
try {
const updates = buildTaskUpdate(req.body);
const result = await getTasksCollection().findOneAndUpdate(
{ _id: req.taskId },
{ $set: updates },
{ returnDocument: 'after' }
);
if (!result) return res.status(404).json({ error: 'task not found' });
res.json(result);
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// DELETE /tasks/:id
router.delete('/:id', parseObjectId, async (req, res, next) => {
try {
const result = await getTasksCollection().deleteOne({ _id: req.taskId });
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'task not found' });
}
res.status(204).end();
} catch (err) { next(err); }
});
module.exports = router;
Chaque route suit le même schéma : récupération des données de la requête, passage par nos helpers, appel à MongoDB, puis réponse. Avec la validation et l'analyse des IDs déjà gérées, le code reste concis et prévisible. Concrètement :
- La route POST construit une nouvelle tâche avec buildTaskDocument avant insertion
- Les routes GET filtrent éventuellement par catégorie et renvoient les résultats triés du plus récent au plus ancien
- La route PUT utilise buildTaskUpdate pour des mises à jour partielles sûres et cohérentes
- La route DELETE supprime une tâche via son ObjectId déjà analysé
Quelques détails supplémentaires à noter :
- find() renvoie un curseur, pas un tableau ; on chaîne donc .toArray() après le tri pour obtenir les résultats
- findOneAndUpdate avec returnDocument: 'after' renvoie immédiatement le document mis à jour
- Les codes HTTP adaptés sont renvoyés : 201 pour créer, 204 pour supprimer, et 400 ou 404 selon le cas
- Les erreurs inattendues sont passées à next(err) pour un traitement centralisé plutôt que dans chaque route
Étape 6 : tout raccorder dans le serveur
À présent, toutes les pièces sont en place : validation, routes propres et connexion base de données opérationnelle. Il reste à tout raccorder et démarrer le serveur. Créez un fichier server.js, le point d'entrée où tout converge :
require('dotenv').config();
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const { connectDB, closeDB } = require('./db/connect');
const tasksRouter = require('./routes/tasks');
if (!process.env.MONGO_URI) {
console.error('Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.');
process.exit(1);
}
const PORT = Number(process.env.PORT) || 3000;
const app = express();
app.use(helmet());
app.use(express.json({ limit: '100kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'task-manager-with-categories' });
});
app.use('/tasks', tasksRouter);
app.use((req, res) => {
res.status(404).json({ error: 'not found' });
});
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'payload too large' });
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'invalid JSON body' });
}
console.error(err);
res.status(500).json({ error: 'internal server error' });
});
async function start() {
try {
await connectDB();
const server = app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('\nShutting down gracefully...');
server.close(async () => {
await closeDB();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
if (require.main === module) {
start();
}
module.exports = { app, start };
Ce fichier assemble le tout proprement. Il vérifie vos variables d'environnement avant de lancer quoi que ce soit, applique des protections de base avec helmet et limite la taille du JSON entrant pour éviter d'accepter par erreur de très gros payloads. Il sert aussi le dossier public/ pour que le frontend vive dans la même app sans serveur séparé.
Toutes les routes sont montées sous /tasks, et toute route non trouvée renvoie un 404 propre. Les erreurs sont gérées au même endroit, ce qui garantit cohérence et maintenabilité. La base est connectée avant l'écoute du serveur, vous n'acceptez donc jamais de requêtes avant d'être prêt, et l'arrêt est géré proprement avec fermeture des connexions.
Lançons le tout pour vérifier que tout fonctionne avec npm run dev. Si tout est bien raccordé, vous devriez voir ceci :
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
À ce stade, tout tourne ! Nous avons une API pleinement fonctionnelle, passons aux tests.
Étape 7 : tester les endpoints de l'API
Avant de construire l'interface, vérifiez que chaque endpoint fonctionne isolément. Le débogage est ainsi bien plus simple : vous savez vite si le problème vient de l'API ou du frontend. Nous allons utiliser Postman (ou tout client HTTP) et exécuter quelques requêtes dans l'ordre. Chacune s'appuie sur les données créées à l'étape précédente.
Créer une tâche : envoyez une requête POST avec un titre, une description et une catégorie. Si tout va bien, l'API renvoie 201 Created avec la nouvelle tâche et son _id généré.
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
En créer quelques autres : exécutez la même requête POST avec des corps différents pour avoir de quoi tester la liste et le filtrage. Vous obtiendrez ainsi un petit jeu de données à interroger.
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
Lister toutes les tâches : renvoie toutes les tâches, triées de la plus récente à la plus ancienne.
GET http://localhost:3000/tasks
Filtrer par catégorie : renvoie uniquement les tâches Travail. L'index ajouté plus tôt permet à cette requête de rester rapide à mesure que vos données grandissent.
GET http://localhost:3000/tasks?category=Work
Mettre à jour une tâche : utilisez l'_id d'une des tâches créées (copiez-le depuis la réponse du POST ou depuis l'endpoint de liste). Cela renvoie la tâche mise à jour. N'envoyez que les champs à modifier pour garder des mises à jour partielles simples.
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
Supprimer une tâche : utilisez l'_id de la tâche à supprimer. La réponse est 204 No Content. Si vous tentez de la récupérer ensuite, vous obtiendrez un 404 avec "task not found".
DELETE http://localhost:3000/tasks/<paste-task-id-here>
Vous avez maintenant confirmé que toutes les routes se comportent comme prévu. L'API fait son travail : nous pouvons construire une interface par-dessus.
Étape 8 : ajouter un frontend pour interagir avec l'API
Postman est parfait pour valider l'API, mais rien ne vaut une mise en situation sur un site. Ajoutons donc un peu de frontend à notre application.
Plutôt que de coller ici de gros fichiers HTML et CSS, le code est disponible dans ce dépôt GitHub. Rendez-vous dessus et copiez le contenu de index.html, styles.css et favicon.svg dans votre dossier public/ à la racine du projet. Enregistrez, puis rendez-vous sur localhost:3000. Collez ensuite le code ci-dessous dans le fichier app.js :
const form = document.getElementById('task-form');
const titleEl = document.getElementById('title');
const descEl = document.getElementById('description');
const catEl = document.getElementById('category');
const errEl = document.getElementById('form-error');
const listEl = document.getElementById('task-list');
const emptyEl = document.getElementById('empty');
const filterEl = document.getElementById('filter');
const countEl = document.getElementById('task-count');
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
const fetchTasks = (category) =>
api('GET', '/tasks' + (category ? `?category=${encodeURIComponent(category)}` : ''));
const createTask = (body) => api('POST', '/tasks', body);
const updateTask = (id, body) => api('PUT', `/tasks/${id}`, body);
const deleteTask = (id) => api('DELETE', `/tasks/${id}`);
function updateCount(tasks) {
const active = tasks.filter((t) => !t.completed).length;
const total = tasks.length;
if (total === 0) {
countEl.textContent = '';
return;
}
const scope = filterEl.value ? ` in ${filterEl.value}` : '';
countEl.textContent = `${total} tasks${scope} · ${active} active`;
}
function renderTask(task) {
const li = document.createElement('li');
li.className = 'task' + (task.completed ? ' completed' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', () =>
updateTask(task._id, { completed: checkbox.checked }).then(refresh)
);
const title = document.createElement('div');
title.textContent = task.title;
const badge = document.createElement('span');
badge.textContent = task.category;
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deleteTask(task._id).then(refresh);
li.append(checkbox, title, badge, del);
return li;
}
function render(tasks) {
listEl.innerHTML = '';
emptyEl.classList.toggle('hidden', tasks.length > 0);
for (const task of tasks) {
listEl.appendChild(renderTask(task));
}
updateCount(tasks);
}
async function refresh() {
try {
const tasks = await fetchTasks(filterEl.value);
render(tasks);
} catch (e) {
errEl.textContent = e.message;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errEl.textContent = '';
const title = titleEl.value.trim();
if (!title) {
errEl.textContent = 'Title is required.';
return;
}
try {
await createTask({
title,
description: descEl.value.trim(),
category: catEl.value
});
form.reset();
await refresh();
} catch (e) {
errEl.textContent = e.message;
}
});
filterEl.addEventListener('change', refresh);
refresh();
Le JavaScript relie le tout à l'API créée plus tôt. Chaque fonction correspond à une route, ce qui rend le frontend facile à suivre. Toutes les requêtes passent aussi par un unique helper api(), donc la gestion des erreurs est centralisée et non dupliquée.
Le filtrage fonctionne via une query string comme ?category=Work, directement reliée à la logique backend déjà ajoutée. Après création, mise à jour ou suppression d'une tâche, l'app recharge la liste pour garder l'UI synchronisée. Le compteur de tâches est un petit plus qui rend l'application plus vivante.
Redémarrez maintenant le serveur avec npm run dev, puis ouvrez http://localhost:3000 pour essayer le gestionnaire de tâches. Ajoutez des tâches, mettez-les à jour, changez de catégorie et supprimez-en. Le frontend et le backend sont désormais reliés de bout en bout.
Résumé
Félicitations ! Vous avez créé une API complète de gestion de tâches à partir de zéro avec Node.js, Express et MongoDB. Vous avez géré la validation, gardé des routes concises et tout raccordé dans un système opérationnel. Surtout, vous êtes resté au plus près du fonctionnement réel de MongoDB, sans cacher sa logique derrière des abstractions. À partir d'ici, vous pouvez faire évoluer l'application vers la production.
Points clés à retenir
- Centraliser la validation et utiliser des middlewares garde un code propre et prévisible.
- Le driver natif MongoDB offre du contrôle sans abstraction superflue.
- Une API bien structurée se prolonge facilement en application prête pour la production.
FAQs
Ai-je besoin de Mongoose pour créer une API MongoDB ?
Non. Ce tutoriel utilise le driver natif de MongoDB, qui offre plus de contrôle et reste léger. Vous pourrez ajouter Mongoose plus tard si vous souhaitez des abstractions de schéma.
Comment valider des données sans schéma ?
Vous pouvez centraliser la validation dans des fonctions utilitaires (comme buildTaskDocument), garantissant que chaque route applique les mêmes règles avant l'écriture en base.
Que se passe-t-il si je transmets un ObjectId invalide ?
Le middleware la rejette immédiatement avec une réponse 400, évitant des erreurs confuses côté MongoDB.
