Express - rocambille/start-express-react GitHub Wiki
Express est un framework web minimaliste et versatile permettant le développement de serveurs. Voici un exemple de code pour mesurer si vous êtes à l'aise avec l'outil :
import express from "express";
const app = express();
const port = 5173;
app.get("/api", (req, res) => {
res.send("hello, world!");
});
app.listen(port, () => {
console.info(`Listening on http://localhost:${port}`);
});
Si vous visualisez le message "hello, world!" retourné dans une réponse HTTP, vous maitrisez les bases du framework.
Sinon, nous vous recommandons de revoir la documentation d'Express avant d'aller plus loin. Plus particulièrement, assurez-vous d'être à l'aise sur le routage dans Express.
Dans le code proposé de base, la création du serveur unique de StartER utilise les routes définies dans le fichier src/express/routes.ts
.
const app = express();
// ...
app.use((await import("./src/express/routes")).default);
// ...
Le fichier src/express/routes.ts
déclare et exporte une instance de express.Router
.
import { Router } from "express";
const router = Router();
// ...
export default router;
Vous pouvez définir des routes avec cet mini-application router
comme s'il s'agissait de l'application principale.
Une route Express "simple" associe une méthode HTTP (get
, post
...), un chemin d'URL et une fonction de rappel (callback en anglais).
router.get("/api", (req, res) => { /* ... */ });
/* ↓ ↓ ↓
method path callback
*/
Le callback (req, res) => { /* ... */ }
est une fonction ayant accès à l'objet requête (req
) et à l'objet réponse (res
) dans le cycle requête-réponse de l'application. Cette fonction peut :
- Exécuter du code,
- Apporter des modifications aux objets requête et réponse,
- Terminer le cycle requête-réponse.
Par exemple, pour terminer le cycle en envoyant une réponse "hello, world!" :
import { Router } from "express";
const router = Router();
/* ************************************************************************ */
router.get("/api", (req, res) => {
res.send("hello, world!");
});
/* ************************************************************************ */
export default router;
Méthode | Chemin | Callback |
router. get ("/api",(req, res) => { res.send("hello, world!"); }); |
router.get( "/api" ,(req, res) => { res.send("hello, world!"); }); |
router.get("/api", (req, res) => { res.send("hello, world!"); } ); |
Dans cet exemple, le callback est inséré directement dans la déclaration de la route.
De façon alternative, vous pouvez séparer la déclaration du callback et la déclaration de la route.
import { type RequestHandler, Router } from "express";
const router = Router();
/* ************************************************************************ */
// Callback declaration
const sendHelloWorld: RequestHandler = (req, res) => {
res.send("hello, world!");
};
// Route declaration
router.get("/api", sendHelloWorld);
/* ************************************************************************ */
export default router;
Séparer les déclarations permet de déplacer le code des callbacks dans des fichiers séparés, et de garder uniquement les déclarations des routes dans le fichier routes.ts
: un fichier = une responsabilité.
import { type RequestHandler, Router } from "express";
const router = Router();
/* ************************************************************************ */
// Callback import
import { sendHelloWorld } from "./path/to/module";
// Route declaration
router.get("/api", sendHelloWorld);
/* ************************************************************************ */
export default router;
Cette pratique est le fondement de la construction de modules dans la partie Express de StartER.
Express est un framework minimaliste qui n'impose pas d'organisation particulière des fichiers. Dans StartER, nous avons choisi de cadrer la partie Express avec une architecture modulaire.
L'architecture modulaire vise à créer des parties distinctes d'une application avec des fonctionnalités isolées. Dans StartER, notre critère pour isoler les fonctionnalités est de les regrouper par "thématique".
Le plus souvent, une thématique correspond à une ressource servie par l'API web. Par exemple, recréons ensemble un module pour une ressource item
(dans cet exemple, un item est un objet fictif avec un titre : le module item
est proposé de base dans le code de StartER). La première étape est de créer un dossier item
dans le dossier src/express/modules
. La deuxième étape est de créer un "sous-fichier" de routes dans notre dossier item
:
src/express
├── routes.ts
└── modules
└── item
└── itemRoutes.ts
Le fichier itemRoutes.ts
est le point d'entrée du module item
: il doit déclarer et exporter un router isolé pour les routes des "items".
import { Router } from "express";
const itemRoutes = Router();
// ...
export default itemRoutes;
Vous pouvez alors utiliser ce module item
dans src/express/routes.ts
:
// ...
import itemRoutes from "./modules/item/itemRoutes";
router.use(itemRoutes);
// ...
À ce stade, le module item
va se construire de manière "libre" en ajoutant tout ce qui est nécessaire aux routes des items. Au bout de quelques fichiers, cela pourrait donner un dossier item
comme celui-ci :
src/express
├── routes.ts
└── modules
└── item
├── itemActions.ts
├── itemParamConverter.ts
├── itemRepository.ts
├── itemRoutes.ts
└── itemValidator.ts
La manière "libre" dépend de vos propres pratiques et habitudes, et un module n'est pas obligé de ressembler à un autre. Quelques habitudes qui peuvent justifier d'ajouter un fichier dans un module (liste non-exhaustive) :
- Un fichier = une responsabilité. C'est le principe de base qui pourra vous guider dans vos choix.
- Isoler une fonctionnalité réutilisable entre plusieurs modules.
- Répéter une construction d'un module similaire.
Faites des modules jusqu'à trouver une organisation qui vous parle.
Faites confiance à votre logique. Donnez une chance à des logiques qui ne sont pas les vôtres !
Pour vous partager notre logique, quand nous avons construit notre module item
, nous avons créé un premier fichier à côté de itemRoutes.ts
: un fichier itemActions.ts
pour déclarer les callbacks utilisés par les routes des "items".
const browse = (req, res) => {
const items = /* we will tackle this next */;
res.json(items);
};
export default { browse };
L'action browse
est un callback importable et utilisable dans itemRoutes.ts
:
import { Router } from "express";
const itemRoutes = Router();
/* ************************************************************************ */
import itemActions from "./modules/item/itemActions";
itemRoutes.get("/api/items", itemActions.browse);
/* ************************************************************************ */
export default itemRoutes;
Un mot sur TypeScript : pour permettre l'inférence de type sur les objets req
et res
, nous vous recommandons de typer votre action avec le type RequestHandler
d'Express.
import type { RequestHandler } from "express";
const browse: RequestHandler = (req, res) => {
const items = /* we will tackle this next */;
res.json(items);
};
export default { browse };
Pour poursuivre la construction du module item
, nous devons compléter l'action browse
vu précédemment avec des données "items".
const browse = (req, res) => {
const items = /* time to get data */;
res.json(items);
};
Une première approche "basique" serait d'écrire directement les données dans browse
.
const browse = (req, res) => {
const items = [
{ id: 1, title: "Stuff" },
{ id: 2, title: "Doodads" },
];
res.json(items);
};
Cela peut être une étape dans votre prototypage. Dans notre logique, l'accès aux données doit être isolée des actions : un fichier = une responsabilité.
C'est pourquoi nous avons créé un fichier itemRepository.ts
dans le dossier src/express/modules/item
pour contenir la couche d'accès aux données pour nos items. Le mot "repository" (dépôt en français) est couramment utilisé dans ce contexte. Selon la documentation de Symfony :
Lorsque vous recherchez un type d'objet particulier, vous utilisez toujours ce que l'on appelle son "repository". Un repository peut être considéré comme une classe [...] dont la seule fonction est de vous aider à récupérer les entités d'une classe donnée.
Pour varier les styles d'écriture, nous avons pris cet extrait au pied de la lettre et avons construit une classe dans le fichier itemRepository.ts
.
class ItemRepository {
// ...
}
export default new ItemRepository();
Dans cette classe, nous fournirons les méthodes pour récupérer et modifier les données des items. Une première méthode pour récupérer tous les items :
class ItemRepository {
readAll() {
return [
{ id: 1, title: "Stuff" },
{ id: 2, title: "Doodads" },
];
}
}
export default new ItemRepository();
Nous pouvons maintenant utiliser notre repository dans le reste du module item
:
import itemRepository from "./itemRepository";
const browse = (req, res) => {
const items = itemRepository.readAll();
res.json(items);
};
export default { browse };
Toutes les actions des items et le repository complet (lié à la base de données) sont disponibles dans les fichiers suivants :
Voir aussi :
Dans StartER, nous utilisons le mécanisme de "param converters" d'Express pour transformer des paramètres d'URL en objets métier. Cette approche a deux avantages :
- Les ID invalides sont détectés immédiatement.
- Pas besoin de charger l'entité dans chaque route.
Le fichier itemParamConverter.ts
montre comment implémenter cette fonctionnalité :
import type { RequestHandler } from "express";
import itemRepository from "./itemRepository";
const convert: RequestHandler = (req, res, next) => {
const item = itemRepository.read(req.params.itemId);
if (item == null) {
res.sendStatus(404);
} else {
req.item = item; // Store item in req to use it next
next();
}
};
export default { convert };
Un mot sur TypeScript : le type Request
de base dans Express ne définit pas de typage pour les propriétés "custom" comme req.item
. Pour compléter le typage avec vos propriétés, nous vous recommandons d'étendre le type Request
d'Express en utilisant la "fusion de déclarations" (declaration merging en anglais dans le manuel).
import type { RequestHandler } from "express";
import itemRepository from "./itemRepository";
/* ************************************************************************ */
declare global {
namespace Express {
interface Request {
item: Item; // Extends Request interface of Express
}
}
}
/* ************************************************************************ */
const convert: RequestHandler = (req, res, next) => {
// ...
};
export default { convert };
Pour utiliser le convertisseur, nous l'associons au paramètre itemId
dans itemRoutes.ts
:
import itemParamConverter from "./itemParamConverter";
itemRoutes.param("itemId", itemParamConverter.convert);
Ensuite, toute route contenant :itemId
dans son chemin activera automatiquement le convertisseur. Par exemple :
import itemParamConverter from "./itemParamConverter";
itemRoutes.param("itemId", itemParamConverter.convert);
/* ************************************************************************ */
itemRoutes.get("/api/items/:itemId", itemActions.read);
/* ************************************************************************ */
L'action read
dans itemActions.ts
peut alors accéder directement à l'objet item
sans se soucier de la récupération ou de la validation :
const read: RequestHandler = (req, res) => {
res.json(req.item); // item is fetched and valid
};
La validation des données est cruciale pour toute application web robuste. Dans StartER, nous utilisons zod pour la validation des entrées.
Le fichier itemValidator.ts
illustre notre approche :
import type { RequestHandler } from "express";
import { type ZodError, z } from "zod";
/* ************************************************************************ */
// Validation schema
const itemDTOSchema = z.object({
id: z.number().optional(),
title: z.string().max(255),
user_id: z.number(),
});
/* ************************************************************************ */
// Validation middleware
const validate: RequestHandler = (req, res, next) => {
try {
// Valider et transformer les données d'entrée
req.body = itemDTOSchema.parse({
...req.body,
user_id: Number(req.auth.sub),
});
next();
} catch (err) {
res.status(400).json((err as ZodError).issues);
}
};
/* ************************************************************************ */
export default { validate };
Le middleware validate
est ensuite utilisé dans les routes qui nécessitent une validation :
import itemValidator from "./itemValidator";
itemRoutes.post(
"/api/items",
itemValidator.validate, itemActions.add
);
itemRoutes.put(
"/api/items/:itemId",
itemValidator.validate, itemActions.edit
);