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.

Créer des routes

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.

Créer un module

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 !


Isoler des actions

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 };

Fournir un accès aux données

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 :

Convertir des paramètres

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
};

Valider les entrées

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
);
⚠️ **GitHub.com Fallback** ⚠️