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 :
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 :
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();
});
}
);
});
});