Aller au contenu

Projet API complet

Objectif

Créer une API complète avec une base de données MongoDB et un serveur Express.

Attention

Le projet suivant a été généré avec la version du 5 août 2024 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 chenil24

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 :

env/development.env
## Environment ##
NODE_ENV=development


## Server ##
PORT=3000
HOST=localhost

## ------- Code à ajouter -------
MONGODB_URI=mongodb://localhost:27017/chenil
## ----- Fin code à ajouter -----

## Setup jet-logger ##
JET_LOGGER_MODE=CONSOLE
JET_LOGGER_FILEPATH=jet-logger.log
JET_LOGGER_TIMESTAMP=TRUE
JET_LOGGER_FORMAT=LINE


## Authentication ##
COOKIE_DOMAIN=localhost
COOKIE_PATH=/
SECURE_COOKIE=false
JWT_SECRET=xxxxxxxxxxxxxx
COOKIE_SECRET=xxxxxxxxxxxxxx
# expires in 3 days
COOKIE_EXP=259200000

Configurer EnvVars pour exposer la variable d'environnement :

src/common/EnvVars.ts
/**
 * Environments variables declared here.
 */

/* eslint-disable node/no-process-env */

export default {
  NodeEnv: process.env.NODE_ENV ?? '',
  // ----- Code à ajouter pour la connexion à la base de données -----
  MongoDb_URI: process.env.MONGODB_URI ?? '',
  // ----- Fin du code à ajouter pour la connexion à la base de données -----
  Port: process.env.PORT ?? 0,
  CookieProps: {
    Key: 'ExpressGeneratorTs',
    Secret: process.env.COOKIE_SECRET ?? '',
    // Casing to match express cookie options
    Options: {
      httpOnly: true,
      signed: true,
      path: process.env.COOKIE_PATH ?? '',
      maxAge: Number(process.env.COOKIE_EXP ?? 0),
      domain: process.env.COOKIE_DOMAIN ?? '',
      secure: process.env.SECURE_COOKIE === 'true',
    },
  },
  Jwt: {
    Secret: process.env.JWT_SECRET ?? '',
    Exp: process.env.COOKIE_EXP ?? '', // exp at the same time as the cookie
  },
} as const;

Démarer la connexion à MongoDB :

src/index.ts
import './pre-start'; // Must be the first import
import logger from 'jet-logger';

import EnvVars from '@src/common/EnvVars';
import server from './server';
import { connect } from 'mongoose';

// **** Run **** //

const SERVER_START_MSG =
  'Express server started on port: ' + EnvVars.Port.toString();

// Connecter à la base de données
connect(EnvVars.MongoDb_URI)
  .then(() => server.listen(EnvVars.Port, () => logger.info(SERVER_START_MSG)))
  .catch((err) => logger.err(err, true));

Étape 3 - Créer le schéma Mongoose

src/models/Animal.ts
import mongoose, { Schema, model } from 'mongoose';

// **** Types **** //

const AnimalTypes = ['Chien', 'Chat'];

export interface IAnimal {
  nom: string;
  age: number;
  type: string;
  proprietaire: string;
  telephone: string;
  _id?: string;
}

// **** Schema **** //
const AnimalSchema = new Schema<IAnimal>({
  nom: { type: String, required: [true, 'Le nom est obligatoire'] },
  age: {
    type: Number,
    required: [true, "L'âge est obligatoire"],
    min: [1, "L'âge doit être plus grand que 0"],
  },
  type: {
    type: String,
    required: [true, 'Le type est obligatoire'],
    enum: {
      values: AnimalTypes,
      message: 'Le type doit être un chien ou un chat',
    },
  },
  proprietaire: {
    type: String,
    required: [true, 'Le propriétaire est obligatoire'],
  },
  telephone: {
    type: String,
    required: [true, 'Le numéro de téléphone est obligatoire'],
    validate: {
      // Code inspiré de la documentation de Mongoose sur les validateurs personnalisés
      // https://mongoosejs.com/docs/validation.html#custom-validators
      validator: function (v: string) {
        return /^(?:\+\d{1,3}\s?)?(?:\(\d{3}\)|\d{3})[-\s]?\d{3}[-\s]?\d{4}$/.test(
          v
        );
      },
      message: (props) =>
        `${props.value} n'est pas un numéro de téléphone valide!`,
    },
  },
});

// **** Export **** //
mongoose.pluralize(null);
export default model<IAnimal>('animaux', AnimalSchema);

Étape 4 - Créer le repo

src/repos/AnimalRepo.ts
import Animal, { IAnimal } from '@src/models/Animal';

// **** Functions **** //

/**
 * Lire un animal.
 */
async function getOne(id: string): Promise<IAnimal | null> {
  const animal = Animal.findById(id);

  return animal;
}

/**
 * Vérifie si l'animal existe.
 */
async function persists(id: string): Promise<boolean> {
  const animal = await Animal.findById(id);
  return animal !== null;
}

/**
 * Lire tous les animaux.
 */
async function getAll(): Promise<IAnimal[]> {
  const animaux = Animal.find();
  return animaux;
}

/**
 * Ajoute un animal.
 */
async function add(animal: IAnimal): Promise<IAnimal> {
  const nouvelAnimal = new Animal(animal);
  await nouvelAnimal.save();
  return nouvelAnimal;
}

/**
 * Mets à jour un animal.
 */
async function update(animal: IAnimal): Promise<IAnimal> {
  const animalToUpdate = await Animal.findById(animal._id);
  if (animalToUpdate === null) {
    throw new Error('Animal non trouvé');
  }

  animalToUpdate.nom = animal.nom;
  animalToUpdate.age = animal.age;
  animalToUpdate.type = animal.type;
  animalToUpdate.proprietaire = animal.proprietaire;
  animalToUpdate.telephone = animal.telephone;
  await animalToUpdate.save();

  return animalToUpdate;
}

/**
 * Supprimer un animal.
 */
async function delete_(id: string): Promise<void> {
  await Animal.findByIdAndDelete(id);
}

// **** Export default **** //

export default {
  getOne,
  persists,
  getAll,
  add,
  update,
  delete: delete_,
} as const;

Étape 5 - Créer le service

src/services/AnimalService.ts
import AnimalRepo from '@src/repos/AnimalRepo';
import { IAnimal } from '@src/models/Animal';
import RouteError from '@src/common/RouteError';
import HttpStatusCodes from '@src/common/HttpStatusCodes';

// **** Variables **** //

export const ANIMAL_NOT_FOUND_ERR = 'Animal non trouvé';

// **** Functions **** //

/**
 * Lire tous les animaux.
 */
function getAll(): Promise<IAnimal[]> {
  return AnimalRepo.getAll();
}

/**
 * Ajouter un animal.
 */
function addOne(animal: IAnimal): Promise<IAnimal> {
  return AnimalRepo.add(animal);
}

/**
 * Mise à jour d'un animal.
 */
async function updateOne(animal: IAnimal): Promise<IAnimal> {
  const persists = await AnimalRepo.persists(animal._id!);
  if (!persists) {
    throw new RouteError(HttpStatusCodes.NOT_FOUND, ANIMAL_NOT_FOUND_ERR);
  }
  // Return user
  return AnimalRepo.update(animal);
}

/**
 * Supprimer un animal par son ID.
 */
async function _delete(id: string): Promise<void> {
  const persists = await AnimalRepo.persists(id);

  if (!persists) {
    throw new RouteError(HttpStatusCodes.NOT_FOUND, ANIMAL_NOT_FOUND_ERR);
  }
  // Supprimer l'animal
  return AnimalRepo.delete(id);
}

// **** Export default **** //

export default {
  getAll,
  addOne,
  updateOne,
  delete: _delete,
} as const;

Étape 6 - Créer la route

src/routes/AnimalRoutes.ts
import HttpStatusCodes from '@src/common/HttpStatusCodes';

import AnimalService from '@src/services/AnimalService';
import { IAnimal } from '@src/models/Animal';
import { IReq, IRes } from './types/express/misc';

// **** Functions **** //

/**
 * Lire tous les animaux.
 */
async function getAll(_: IReq, res: IRes) {
  const animaux = await AnimalService.getAll();
  return res.status(HttpStatusCodes.OK).json({ animaux });
}

/**
 * Ajoute un animal.
 */
async function add(req: IReq<{ animal: IAnimal }>, res: IRes) {
  let { animal } = req.body;
  animal = await AnimalService.addOne(animal);
  return res.status(HttpStatusCodes.CREATED).json({ animal });
}

/**
 * Mise à jour d'un animal.
 */
async function update(req: IReq<{ animal: IAnimal }>, res: IRes) {
  let { animal } = req.body;
  animal = await AnimalService.updateOne(animal);
  return res.status(HttpStatusCodes.OK).json({ animal });
}

/**
 * Supprimer un animal.
 */
async function delete_(req: IReq, res: IRes) {
  const id = req.params.id;
  await AnimalService.delete(id);
  return 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/Paths.ts
/**
 * Express router paths go here.
 */

export default {
  Base: '/',
  Animaux: {
    Base: '/animaux',
    Get: '/',
    Add: '/',
    Update: '/',
    Delete: '/:id',
  },
} as const;

Étape 8 - Consolider les routes dans l'API

src/routes/index.ts
import { Router, Request, Response, NextFunction } from 'express';
import jetValidator from 'jet-validator';

import Paths from '../common/Paths';

import AnimalRoutes from './AnimalRoutes';
import Animal from '@src/models/Animal';
import HttpStatusCodes from '@src/common/HttpStatusCodes';

// **** Variables **** //

const apiRouter = Router(),
  validate = jetValidator();

// ** Validation d'un animal ** //
function validateAnimal(req: Request, res: Response, next: NextFunction) {
  if (req.body === null) {
    res
      .status(HttpStatusCodes.BAD_REQUEST)
      .send({ error: 'Animal requis' })
      .end();
    return;
  }

  if (req.body.animal === null) {
    res
      .status(HttpStatusCodes.BAD_REQUEST)
      .send({ error: 'Animal requis' })
      .end();
    return;
  }

  const nouvelAnimal = new Animal(req.body.animal);
  const error = nouvelAnimal.validateSync();
  if (error !== null && error !== undefined) {
    res.status(HttpStatusCodes.BAD_REQUEST).send(error).end();
  } else {
    next();
  }
}

// ** Ajoute AnimalRouter ** //

const animalRouter = Router();

// Lire tous les animaux
animalRouter.get(Paths.Animaux.Get, AnimalRoutes.getAll);

// Ajouter un animal
animalRouter.post(Paths.Animaux.Add, validateAnimal, AnimalRoutes.add);

// Mettre à jour un animal
animalRouter.put(Paths.Animaux.Update, validateAnimal, AnimalRoutes.update);

// Supprimer un animal
animalRouter.delete(
  Paths.Animaux.Delete,
  validate(['id', 'string', 'params']),
  AnimalRoutes.delete
);

// Add AnimalRouter
apiRouter.use(Paths.Animaux.Base, animalRouter);

// **** Export default **** //

export default apiRouter;

Tests unitaires automatiques

Étape 1 - Ajuster le type de retour de l'API

spec/types/supertest/index.d.ts
import { IAnimal } from '@src/models/Animal';
import 'supertest';

declare module 'supertest' {
  export interface Response {
    headers: Record<string, string[]>;
    body: {
      error?: string;
      errors?: Error.ValidationError;
      animaux: IAnimal[];
    };
  }
}

Étape 2 - Créer le test de l'API

spec/tests/animaux.spec.ts
import supertest, { Test } from 'supertest';
import TestAgent from 'supertest/lib/agent';

import insertUrlParams from 'inserturlparams';

import app from '@src/server';

import Animal, { IAnimal } from '@src/models/Animal';
import HttpStatusCodes from '@src/common/HttpStatusCodes';
import { ANIMAL_NOT_FOUND_ERR } from '@src/services/AnimalService';

import apiCb from 'spec/support/apiCb';
import { TApiCb } from 'spec/types/misc';

const mockify = require('@jazim/mock-mongoose');

// Données bidon pour les tests
const obtenirDonneesBidonAnimaux = () => {
  return [
    {
      nom: 'Gordan Freeman',
      age: 10,
      type: 'Chat',
      proprietaire: 'Jean-Luc Picard',
      telephone: '514-123-4567',
      _id: '66b0dff443bfceb8069a60e7',
    },
    {
      nom: 'Fanta Le Magnifique',
      age: 11,
      type: 'Chat',
      proprietaire: 'Elliott Alderson',
      telephone: '819-363-4567',
      _id: '66b0dff443bfceb8069a60e8',
    },
    {
      nom: 'Olive La Galante',
      age: 3,
      type: 'Chat',
      proprietaire: 'Jenny Curran',
      telephone: '819-344-2232',
      _id: '66b0dff443bfceb8069a60e9',
    },
  ];
};

// Tests
describe('AnimalRouter', () => {
  let agent: TestAgent<Test>;

  // Ce code est exécuté avant tous les tests, pour préparer l'agent SuperTest
  beforeAll((done) => {
    agent = supertest.agent(app);
    done();
  });

  // Get all de Animaux
  describe(`"GET:${'/animaux/'}"`, () => {
    // Initialise l'API
    const api = (cb: TApiCb) => agent.get('/animaux/').end(apiCb(cb));

    // Réussite
    it(
      'doit retourner un objet JSON avec tous les animaux et un code de status de ' +
        `"${HttpStatusCodes.OK}" si la requête est réussie.`,
      (done) => {
        // Préparer le simulacre de Mongoose
        const data = obtenirDonneesBidonAnimaux();
        mockify(Animal).toReturn(data, 'find');

        // Appel de l'API
        api((res) => {
          expect(res.status).toBe(HttpStatusCodes.OK);
          expect(res.body).toEqual({ animaux: data });
          const animaux = res.body.animaux as IAnimal[];
          expect(animaux.length).toBe(data.length);
          done();
        });
      }
    );
  });

  // Test l'ajout d'un animal
  describe(`"POST:/animaux/"`, () => {
    const ERROR_MSG = `Animal requis`,
      DUMMY_Animal = obtenirDonneesBidonAnimaux()[0];

    // Initialise l'API
    const callApi = (animal: IAnimal | null, cb: TApiCb) =>
      agent.post('/animaux/').send({ animal }).end(apiCb(cb));

    // Test un ajout réussi
    it(
      `doit retourner un code de statut "${HttpStatusCodes.CREATED}" si la ` +
        'requête est réussie.',
      (done) => {
        // Préparer le simulacre de Mongoose
        mockify(Animal).toReturn(DUMMY_Animal, 'save');

        // Appel de l'API
        callApi(DUMMY_Animal, (res) => {
          expect(res.status).toBe(HttpStatusCodes.CREATED);
          done();
        });
      }
    );

    // Test avec un animal manquant
    it(
      `doit retourner un objet JSON avec un message d'erreur de "Animal requis" ` +
        `et un code de statut "${HttpStatusCodes.BAD_REQUEST}" si le paramètre animal ` +
        'est manquant.',
      (done) => {
        // Appel de l'API
        callApi(null, (res) => {
          expect(res.status).toBe(HttpStatusCodes.BAD_REQUEST);
          expect(res.body.error).toBe(ERROR_MSG);
          done();
        });
      }
    );

    // Test avec un animal sans nom
    it(
      `doit retourner un objet JSON avec un message d'erreur de "Animal requis" ` +
        `et un code de statut "${HttpStatusCodes.BAD_REQUEST}" si le paramètre animal ` +
        'est manquant.',
      (done) => {
        // Appel de l'API
        const animal = { ...DUMMY_Animal, nom: '' };
        const animalError = {
          nom: {
            name: 'ValidatorError',
            message: 'Le nom est obligatoire',
            properties: {
              message: 'Le nom est obligatoire',
              type: 'required',
              path: 'nom',
              value: '',
            },
            kind: 'required',
            path: 'nom',
            value: '',
          },
        };

        callApi(animal, (res) => {
          expect(res.status).toBe(HttpStatusCodes.BAD_REQUEST);
          const errors = res.body.errors;
          expect(errors).toEqual(animalError);
          done();
        });
      }
    );
  });

  // Mise à jour d'un animal
  describe(`"PUT:/animaux/"`, () => {
    const ERROR_MSG = `Animal requis`,
      DUMMY_Animal = obtenirDonneesBidonAnimaux()[0];

    // Configuration de l'API
    const callApi = (animal: IAnimal | null, cb: TApiCb) =>
      agent.put('/animaux/').send({ animal }).end(apiCb(cb));

    // Réussite
    it(
      `doit retourner un code de statut "${HttpStatusCodes.OK}" si la ` +
        'requête est réussie.',
      (done) => {
        // Préparer le simulacre de Mongoose
        mockify(Animal)
          .toReturn(DUMMY_Animal, 'findOne')
          .toReturn(DUMMY_Animal, 'save');

        // Appel de l'API
        callApi(DUMMY_Animal, (res) => {
          expect(res.status).toBe(HttpStatusCodes.OK);
          done();
        });
      }
    );

    // Paramètre manquant
    it(
      `doit retourner un objet JSON avec un message d'erreur de "${ERROR_MSG}" ` +
        `et un code de statut "${HttpStatusCodes.BAD_REQUEST}" si le paramètre ` +
        'Animal est manquant.',
      (done) => {
        // Appeler l'API
        callApi(null, (res) => {
          expect(res.status).toBe(HttpStatusCodes.BAD_REQUEST);
          expect(res.body.error).toBe(ERROR_MSG);
          done();
        });
      }
    );

    // Animal non trouvé
    it(
      'doit retourner un objet JSON avec le message d\'erreur "' +
        `${ANIMAL_NOT_FOUND_ERR}" et un code de statut ` +
        `"${HttpStatusCodes.NOT_FOUND}" si l'identifiant n'a pas été trouvé.`,
      (done) => {
        // Préparer le simulacre de Mongoose
        mockify(Animal).toReturn(null, 'findOne');
        // Appeler l'API
        callApi(DUMMY_Animal, (res) => {
          expect(res.status).toBe(HttpStatusCodes.NOT_FOUND);
          expect(res.body.error).toBe(ANIMAL_NOT_FOUND_ERR);
          done();
        });
      }
    );
  });

  // Supprimer un animal
  describe(`"DELETE:/animaux/}"`, () => {
    const DUMMY_Animal = obtenirDonneesBidonAnimaux()[0];
    // Appeler l'API
    const callApi = (id: string, cb: TApiCb) =>
      agent.delete(insertUrlParams('/animaux/:id', { id })).end(apiCb(cb));

    // Succès
    it(
      `doit retourner un code de statut "${HttpStatusCodes.OK}" si la ` +
        'requête est réussie.',
      (done) => {
        // Préparer le simulacre de Mongoose
        mockify(Animal)
          .toReturn(DUMMY_Animal, 'findOne')
          .toReturn(DUMMY_Animal, 'findOneAndRemove');

        // Appeler l'API
        callApi(DUMMY_Animal._id, (res) => {
          expect(res.status).toBe(HttpStatusCodes.OK);
          done();
        });
      }
    );

    // Animal non trouvé
    it(
      'doit retourner un objet JSON avec le message d\'erreur "' +
        `${ANIMAL_NOT_FOUND_ERR}" et un code de statut ` +
        `"${HttpStatusCodes.NOT_FOUND}" si l'identifiant n'a pas été trouvé.`,
      (done) => {
        // Préparer le simulacre de Mongoose
        mockify(Animal).toReturn(null, 'findOne');

        // Appeler l'API
        callApi('aaa', (res) => {
          expect(res.status).toBe(HttpStatusCodes.NOT_FOUND);
          expect(res.body.error).toBe(ANIMAL_NOT_FOUND_ERR);
          done();
        });
      }
    );
  });
});

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

console
npm tests