Aller au contenu

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 :

console
npx express-generator-typescript auteur_mongoose_simulacre

Ajouter le module Mongoose à votre application :

console
npm install mongoose

Ajouter les tests de mongoose :

console
npm i @jazim/mock-mongoose -D

É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);
      },
    );
  });
});

Étape 2 - Exécuter les tests de l'API

console
npm test