Projet API complet
Objectif
Créer une API complète avec une base de données MongoDB, un serveur Express et Mongoose.
Attention
Le projet suivant a été généré avec la version du 12 septembre 2025 du générateur express typescript. Il se peut que certaines parties du code ne fonctionnent pas avec les versions plus récentes du générateur.
Étape 1 - Initialisation du projet
Générer le projet avec le générateur express-typescript :
Ajouter le module Mongoose à votre application :
Ajouter les tests de mongoose :
Étape 2 - Préparer la connexion à la base de données
Créer la variable d'environnement pour l'URI de MongoDB :
config/.env.development
## Environment ##
NODE_ENV=development
## Server ##
PORT=3000
HOST=localhost
## MongoDB ##
MONGODB=mongodb://localhost:27017/
## Setup jet-logger ##
JET_LOGGER_MODE=CONSOLE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE
Configurer EnvVars pour exposer la variable d'environnement :
src/common/EnvVars.ts
import jetEnv, { num, str } from 'jet-env';
import { isEnumVal } from 'jet-validators';
import { NodeEnvs } from '.';
/******************************************************************************
Setup
******************************************************************************/
const ENV = jetEnv({
NodeEnv: isEnumVal(NodeEnvs),
Port: num,
Mongodb: str,
});
/******************************************************************************
Export default
******************************************************************************/
export default ENV;
Démarer la connexion à MongoDB :
src/index.ts
import logger from "jet-logger";
import { connect } from "mongoose";
import ENV from "@src/common/constants/ENV";
import server from "./server";
/******************************************************************************
Constants
******************************************************************************/
const SERVER_START_MSG =
"Express server started on port: " + ENV.Port.toString();
/******************************************************************************
Run
******************************************************************************/
// Start the server
connect(ENV.Mongodb).then(() =>
server.listen(ENV.Port, () => logger.info(SERVER_START_MSG))
);
Étape 3 - Créer le schéma Mongoose
src/models/Auteur.ts
import { Schema, model } from 'mongoose';
// Interface pour un auteur
// Notez l'utilisation de id au lieu de _id.
// Mongoose crée automatiquement un _id pour chaque document.
export interface IAuteur {
id: string;
prenom: string;
nom: string;
date_naissance?: Date;
date_deces?: Date;
}
const AuteurSchema = new Schema<IAuteur>({
id: { type: String, required: true, maxlength: 100 },
prenom: { type: String, required: true, maxlength: 100 },
nom: { type: String, required: true, maxlength: 100 },
date_naissance: Date,
date_deces: Date,
});
export const Auteur = model<IAuteur>('Auteur', AuteurSchema);
Étape 4 - Créer le repo
src/repos/AuteurRepo.ts
import ENV from "@src/common/constants/ENV";
import { IAuteur, Auteur } from "@src/models/Auteur";
import mongoose from "mongoose";
/******************************************************************************
Functions
******************************************************************************/
/**
* Extraire un auteur.
*
* @param {string} id - ID de l'auteur à extraire
*
* @returns {IAuteur} - Un auteur si trouvé
*/
async function getOne(id: string): Promise<IAuteur | null> {
const auteur = await Auteur.findOne({
id: id,
});
return auteur;
}
/**
* Extraire tous les auteurs.
*
* @returns {IAuteur[]} Un tableau de tous les auteurs
*/
async function getAll(): Promise<IAuteur[]> {
const auteurs = await Auteur.find();
return auteurs;
}
/**
* Ajouter un auteur.
*
* @param {IAuteur} auteur - Auteur à ajouter
*/
async function add(auteur: IAuteur): Promise<void> {
const nouvelAuteur = new Auteur(auteur);
await nouvelAuteur.save();
}
/**
* Mettre à jour un auteur.
*
* @param {IAuteur} auteur - Auteur à modifier
*/
async function update(auteur: IAuteur): Promise<void> {
const auteurAModifier = await Auteur.findOne({ id: auteur.id });
if (auteurAModifier === null) {
throw new Error("Auteur non trouvé");
}
auteurAModifier.nom = auteur.nom;
auteurAModifier.prenom = auteur.prenom;
auteurAModifier.date_naissance = auteur.date_naissance;
auteurAModifier.date_deces = auteur.date_deces;
await auteurAModifier.save();
}
/**
* Supprimer un auteur.
*
* @param {string} id - id de l'auteur à supprimer
*/
async function delete_(id: string): Promise<void> {
await Auteur.deleteOne({ id: id });
}
/******************************************************************************
Export default
******************************************************************************/
export default {
getOne,
getAll,
add,
update,
delete: delete_,
} as const;
Étape 5 - Créer le service
src/services/AuteurService.ts
import { RouteError } from '@src/common/util/route-errors';
import HttpStatusCodes from '@src/common/constants/HttpStatusCodes';
import AuteurRepo from '@src/repos/AuteurRepo';
import { IAuteur } from '@src/models/Auteur';
/******************************************************************************
Constants
******************************************************************************/
export const AUTEUR_NON_TROUVE = 'Auteur non trouvé';
/******************************************************************************
Functions
******************************************************************************/
/**
* Extraire tous les auteurs.
*/
function getAll(): Promise<IAuteur[]> {
return AuteurRepo.getAll();
}
/**
* Ajouter un auteur.
*/
function addOne(auteur: IAuteur): Promise<void> {
return AuteurRepo.add(auteur);
}
/**
* Mets à jour un auteur.
*/
async function updateOne(auteur: IAuteur): Promise<void> {
const persists = await AuteurRepo.getOne(auteur.id);
if (!persists) {
throw new RouteError(HttpStatusCodes.NOT_FOUND, AUTEUR_NON_TROUVE);
}
// Return auteur
return AuteurRepo.update(auteur);
}
/**
* Supprimer un auteur par son id
*/
async function _delete(id: string): Promise<void> {
const persists = await AuteurRepo.getOne(id);
if (!persists) {
throw new RouteError(HttpStatusCodes.NOT_FOUND, AUTEUR_NON_TROUVE);
}
// Delete auteur
return AuteurRepo.delete(id);
}
/******************************************************************************
Export default
******************************************************************************/
export default {
getAll,
addOne,
updateOne,
delete: _delete,
} as const;
Étape 6 - Créer la route
src/routes/AuteurRoutes.ts
import HttpStatusCodes from '@src/common/constants/HttpStatusCodes';
import AuteurService from '@src/services/AuteurService';
import { IAuteur } from '@src/models/Auteur';
import { IReq, IRes } from './common/types';
/******************************************************************************
Functions
******************************************************************************/
/**
* Extraire tous les auteurs
*/
async function getAll(_: IReq, res: IRes) {
const auteurs = await AuteurService.getAll();
res.status(HttpStatusCodes.OK).json({ auteurs });
}
/**
* Ajouter un auteur
*/
async function add(req: IReq, res: IRes) {
const { auteur } = req.body;
await AuteurService.addOne(auteur as IAuteur);
res.status(HttpStatusCodes.CREATED).end();
}
/**
* Mettre à jour un auteur
*/
async function update(req: IReq, res: IRes) {
const { auteur } = req.body;
await AuteurService.updateOne(auteur as IAuteur);
res.status(HttpStatusCodes.OK).end();
}
/**
* Supprimer un auteur
*/
async function delete_(req: IReq, res: IRes) {
const { id } = req.params;
await AuteurService.delete(id as string);
res.status(HttpStatusCodes.OK).end();
}
/******************************************************************************
Export default
******************************************************************************/
export default {
getAll,
add,
update,
delete: delete_,
} as const;
Étape 7 - Créer le chemin de l'API
src/common/constants/Paths.ts
export default {
Base: '/api',
Auteur: {
Base: '/auteurs',
Get: '/all',
Add: '/add',
Update: '/update',
Delete: '/delete/:id',
},
} as const;
Étape 8 - Consolider les routes dans l'API
src/routes/index.ts
import { Request, Response, NextFunction, Router } from 'express';
import Paths from '@src/common/constants/Paths';
import AuteurRoutes from './AuteurRoutes';
import HttpStatusCodes from '@src/common/constants/HttpStatusCodes';
import { Auteur } from '@src/models/Auteur';
/******************************************************************************
Setup
******************************************************************************/
const apiRouter = Router();
// ** Add AuteurRouter ** //
// ** Validation d'un auteur ** //
function validateAuteur(req: Request, res: Response, next: NextFunction) {
if (req.body === null) {
res
.status(HttpStatusCodes.BAD_REQUEST)
.send({ error: 'Auteur requis' })
.end();
return;
}
if (req.body.auteur === null) {
res
.status(HttpStatusCodes.BAD_REQUEST)
.send({ error: 'Auteur requis' })
.end();
return;
}
const nouvelAuteur = new Auteur(req.body.auteur);
const error = nouvelAuteur.validateSync();
if (error !== null && error !== undefined) {
res.status(HttpStatusCodes.BAD_REQUEST).send(error).end();
} else {
next();
}
}
// Init router
const auteurRouter = Router();
auteurRouter.get(Paths.Auteur.Get, AuteurRoutes.getAll);
auteurRouter.post(Paths.Auteur.Add, validateAuteur, AuteurRoutes.add);
auteurRouter.put(Paths.Auteur.Update, AuteurRoutes.update);
auteurRouter.delete(Paths.Auteur.Delete, AuteurRoutes.delete);
// Add AuteurRouter
apiRouter.use(Paths.Auteur.Base, auteurRouter);
/******************************************************************************
Export default
******************************************************************************/
export default apiRouter;
Tests unitaires automatiques
Étape 1 - Créer le test de l'API
tests/auteur.test.ts
import insertUrlParams from 'inserturlparams';
import { customDeepCompare } from 'jet-validators/utils';
import AuteurRepo from '@src/repos/AuteurRepo';
import { AUTEUR_NON_TROUVE } from '@src/services/AuteurService';
import HttpStatusCodes from '@src/common/constants/HttpStatusCodes';
import { ValidationError } from '@src/common/util/route-errors';
import Paths from './common/Paths';
import { parseValidationErr, TRes } from './common/util';
import { agent } from './support/setup';
import { IAuteur, Auteur } from '@src/models/Auteur';
/******************************************************************************
Constants
******************************************************************************/
// Données bidon pour les auteurs (simulacre de GET)
const DB_AUTEURS: IAuteur[] = [
{
id: '1',
nom: 'Halpert',
prenom: 'Jim',
},
{
id: '2',
nom: 'Schrute',
prenom: 'Dwight',
},
{
id: '3',
nom: 'Scott',
prenom: 'Michael',
},
] as const;
// Don't compare 'id' and 'created' cause those are set dynamically by the
// database
const compareUserArrays = customDeepCompare({
onlyCompareProps: ['nom', 'courriel', 'typeChambre', 'prixParNuit'],
});
const mockify = require('@jazim/mock-mongoose');
/******************************************************************************
Tests
IMPORTANT: Following TypeScript best practices, we test all scenarios that
can be triggered by a user under normal circumstances. Not all theoretically
scenarios (i.e. a failed database connection).
******************************************************************************/
describe('auteurRouter', () => {
let dbAuteurs: IAuteur[] = [];
// Extraire tous les auteurs
describe(`'GET:${Paths.Auteur.Get}'`, () => {
// Succès
it(
'doit retourner un JSON avec tous les auteurs et un code de ' +
`of '${HttpStatusCodes.OK}' si réussi.`,
async () => {
// Préparer le simulacre de Mongoose
const data = [...DB_AUTEURS];
mockify(Auteur).toReturn(data, 'find');
const res: TRes<{ auteurs: IAuteur[] }> = await agent.get(
Paths.Auteur.Get,
);
expect(res.status).toBe(HttpStatusCodes.OK);
expect(compareUserArrays(res.body.auteurs, DB_AUTEURS)).toBeTruthy();
},
);
});
// Tester l'ajout d'un auteur
describe(`'POST:${Paths.Auteur.Add}'`, () => {
// Ajout réussi
it(
`doit retourner le code '${HttpStatusCodes.CREATED}' si la ` +
'transaction est réussie',
async () => {
const auteur: IAuteur = {
id: '12',
nom: 'Beasley',
prenom: 'Pam',
};
// Préparer le simulacre de Mongoose
mockify(Auteur).toReturn(auteur, 'save');
const res = await agent.post(Paths.Auteur.Add).send({ auteur });
expect(res.status).toBe(HttpStatusCodes.CREATED);
},
);
// Paramètre manquant
it(
'doit retourner un JSON avec les erreurs et un code de ' +
`'${HttpStatusCodes.BAD_REQUEST}' si un paramètre est ` +
'manquant.',
async () => {
const res: TRes = await agent
.post(Paths.Auteur.Add)
.send({ auteur: null });
expect(res.status).toBe(HttpStatusCodes.BAD_REQUEST);
expect(res.body.error).toBe('Auteur requis');
},
);
});
// Mise à jour d'un auteur
describe(`'PUT:${Paths.Auteur.Update}'`, () => {
// Succès
it(
`doit retourner un code de '${HttpStatusCodes.OK}' si la mise à jour ` +
'est réussie.',
async () => {
const auteur = DB_AUTEURS[0];
auteur.nom = 'Johnny B. Good';
// Préparer le simulacre de Mongoose
mockify(Auteur).toReturn(auteur, 'findOne').toReturn(auteur, 'save');
const res = await agent.put(Paths.Auteur.Update).send({ auteur });
expect(res.status).toBe(HttpStatusCodes.OK);
},
);
// Réservation non trouvée
it(
'doit retourner un JSON avec erreur ' +
`'${AUTEUR_NON_TROUVE}' et un code de ` +
`'${HttpStatusCodes.NOT_FOUND}' si l'id n'est pas trouvé.`,
async () => {
// Préparer le simulacre de Mongoose
mockify(Auteur).toReturn(null, 'findOne');
const auteur = {
id: 4,
nom: 'a',
prenom: 'b',
},
res: TRes = await agent.put(Paths.Auteur.Update).send({ auteur });
expect(res.status).toBe(HttpStatusCodes.NOT_FOUND);
expect(res.body.error).toBe(AUTEUR_NON_TROUVE);
},
);
});
// Supprimer la réservation
describe(`'DELETE:${Paths.Auteur.Delete}'`, () => {
const getPath = (id: string) =>
insertUrlParams(Paths.Auteur.Delete, { id });
// Succès
it(
`doit retourner un code de '${HttpStatusCodes.OK}' si la ` +
'suppression est réussie.',
async () => {
// Préparer le simulacre de Mongoose
mockify(Auteur)
.toReturn(DB_AUTEURS[0], 'findOne')
.toReturn(DB_AUTEURS[0], 'findOneAndRemove');
const id = DB_AUTEURS[0].id,
res = await agent.delete(getPath(id));
expect(res.status).toBe(HttpStatusCodes.OK);
},
);
// Réservation non trouvée
it(
'doit retourner un JSON avec erreur ' +
`'${AUTEUR_NON_TROUVE}' et un code de ` +
`'${HttpStatusCodes.NOT_FOUND}' si la réservation est introuvable.`,
async () => {
// Préparer le simulacre de Mongoose
mockify(Auteur).toReturn(null, 'findOne');
const res: TRes = await agent.delete(getPath('-1'));
expect(res.status).toBe(HttpStatusCodes.NOT_FOUND);
expect(res.body.error).toBe(AUTEUR_NON_TROUVE);
},
);
});
});