Cartouche

Champ Valeur
Auteur·e Élise
Édition 2025-09-02
Durée 2 séances
Taille des équipes 2~4 personnes
login des auteur·rice·s dans author.txt
Rendu via git, dépôt $YEAR_node_api, droits en lecture à delivery_collector

Introduction

Aujourd’hui vous allez découvrir les différentes étapes de la configuration d’une bibliothèque pour faire une API WEB.

La bibliothèque que l’on va utiliser s’appelle fastify. Ça n’est pas la plus populaire, vous verrez surement un peu partout une autre bibliothèque qui s’appelle express. Le plus gros problème d’express étant l’absence de mise à jour majeure depuis plus de 10 ans et donc l’incompatibilité avec des fonctionnalités de modernes du javascript. Certaines fonctionnalités entre fastify et express sont très similaires, fastify est même capable de charger des plugins écrits pour express, ce qui me pousse à vous conseiller son utilisation pour de nouveaux projets.

Aujourd’hui vous allez :

  • écrire votre propre serveur ;
  • écrire quelques routes ;
  • valider les données reçues ;
  • renvoyer de belles erreurs en cas de données invalides.

Création du serveur

Instructions, explications sur fastify

Installez fastify :

pnpm add fastify

Créez un nouveau fichier src/server.ts et une fonction start_web_server. Dans cette fonction, on va créer une instance de fastify, la configurer, lui ajouter des routes et la lancer.

import Fastify, * as ff from "fastify";

function start_web_server() {
	let web_server = Fastify({logger: true});

	web_server.get("/", async () => {
		return { message: "hello world" };
	});

	web_server.listen({ port: 1234, host: "0.0.0.0" }, (err, address) => {
		if (err) {
			console.error(err);
		} else {
			console.log(`listening on ${address}`);
		}
	});
}

start_web_server();

Compilez et exécutez votre code.

Ensuite, testez votre route avec un outil à la postman, insomnia, httpie.

Avec httpie-cli la commande pour tester cette route serait :

http 127.0.0.1:1234

et le résultat :

HTTP/1.1 200 OK
Connection: keep-alive
Date: Tue, 14 May 2024 10:09:09 GMT
Keep-Alive: timeout=72
content-length: 25
content-type: application/json; charset=utf-8

{
    "message": "hello world"
}

Vos routes peuvent prendre des paramètres, des query strings ou un corps. Tous accessibles depuis le premier paramètre de votre handler (un handler c’est une fonction qui gère une requête) :

    web_server.get<{ Querystring: { name: string } }>("/a", async (req) => {
        let querystr = req.query.name;
        return { message: `hello ${querystr}` };
    });
    web_server.get<{ Params: { name: string } }>("/b/:name", async (req) => {
        let name = req.params.name;
        return { message: `hello ${name}` };
    });
http "127.0.0.1:1234/a?name=patate"
http "127.0.0.1:1234/b/patate"

On peut aussi renvoyer un code http spécifique en changeant le code sur la réponse.

    let names: string[] = ["Jérôme Cahuzac", "Sergueï Choïgou"];
    web_server.post<{ Body: { name: string } }>("/names", async (req, res) => {
        let name = req.body.name;
        names.push(name);
        res.code(201);
        return { message: `created`, id: names.length - 1 };
    });
    web_server.get("/names", async () => {
        return names;
    });
    web_server.get<{ Params: { id: number } }>("/names/:id", async (req, res) => {
        let id = req.params.id;
        if (id < names.length) {
            return names[i];
        }
        res.code(404);
        return { message: `not found`, id };
    });

Exercice : opérations simples

Écrivez un serveur web avec les routes suivantes :

  • POST /operations
    • reçoit une opération, sans son résultat,
    • calcule le résultat,
    • stocke l’opération et le résultat dans un tableau,
    • 201,
    • renvoie l’id de l’opération crée ;
  • GET /operations :
    • renvoie la liste de toutes les opérations faites ;
  • GET /operations/:id :
    • renvoie une opération de la liste,
    • 404 si elle n’existe pas.

Les opérations ont le type suivant :

type Operation = {
	kind: "ADD" | "SUB" | "MUL" | "DIV" | "MOD",
	rhs: number,
	lhs: number,
	res: number,
}

Exemple :

http POST 127.0.0.1:1234/operations rhs:=2 lhs:=3 kind=ADD
HTTP/1.1 201 Created
Connection: keep-alive
Date: Tue, 14 May 2024 14:43:27 GMT
Keep-Alive: timeout=72
content-length: 28
content-type: application/json; charset=utf-8

{
    "id": 1,
    "message": "created"
}
http 127.0.0.1:1234/operations/1
HTTP/1.1 200 OK
Connection: keep-alive
Date: Tue, 14 May 2024 14:43:29 GMT
Keep-Alive: timeout=72
content-length: 28
content-type: application/json; charset=utf-8

{
    "lhs": 3,
    "result": 5,
    "rhs": 2
}

Validation des types

Instructions, explications sur zod

On a précisé le type que l’on attend en entrée d’une route à fastify mais en réalité il ne va pas s’en servir pour vérifier les données reçues : c’est tout bonnement impossible de le faire juste avec un type en argument générique (en typescript).

Lorsqu’on veut vérifier le type d’une valeur en typescript on a plusieurs méthodes que l’on a vues dans TD 2 • Typescript, TU & dates. Il s’agit d’essayer de préciser le type avec des conditions jusqu’à ce qu’il soit parfait pour notre utilisation. On va découvrir une nouvelle méthode pour le faire qui est plus adaptée à la programmation d’une API.

Découvrez la bibliothèque zod. Elle permet de décrire la tête que doit avoir un objet et va générer pour vous les fonctions qui permettent de valider qu’un objet reçu à le bon type.

Exemples d’utilisations basiques de la documentation :

Creating a simple string schema

import { z } from “zod”;

// creating a schema for strings const mySchema = z.string();

// parsing mySchema.parse(“tuna”); // => “tuna” mySchema.parse(12); // => throws ZodError

// “safe” parsing (doesn’t throw error if validation fails) mySchema.safeParse(“tuna”); // => { success: true; data: “tuna” } mySchema.safeParse(12); // => { success: false; error: ZodError }


Creating an object schema

```ts

import { z } from “zod”;

const User = z.object({ username: z.string(), });

User.parse({ username: “Ludwig” });

// extract the inferred type type User = z.infer<typeof User>; // { username: string }

Intégration

Il se trouve que l’on peut donner un schéma zod à fastify pour qu’il s’en serve pour valider les données reçues avant d’appeler votre fonction handler.

Commencez par installer les nouvelles dépendances :

pnpm add zod fastify-type-provider-zod

Ajoutez à votre instance les fonctions pour valider les schémas

import {
    serializerCompiler,
    validatorCompiler,
    type ZodTypeProvider,
} from "fastify-type-provider-zod";

function start_web_server() {
    const web_server = Fastify({ logger: true }).withTypeProvider<ZodTypeProvider>();
    web_server.setValidatorCompiler(validatorCompiler);
    web_server.setSerializerCompiler(serializerCompiler);
    // [...]
}

Enfin vous pouvez spécifier vos schémas juste avants les handlers :

web_server.get<{Params: {id: number}}>(
    "/names/:id",
    { schema: {params: z.object({id: z.number()})} },
    (req, res) => {
        let id = req.params.id;
        if (id < names.length) {
            return names[i];
        }
        res.code(404);
        return { message: `not found`, id };
    }
);

Sauf que si vous essayez cette route : vous allez vous rendre compte d’un problème : une erreur va se plaindre du fait qu’id est une chaîne de caractères. Car en réalité, il n’est pas possible de recevoir un nombre dans une URL. Même si on considérait id comme un nombre dans le premier exemple, ça n’en était pas un. Et en javascript le langage vous laisse faire des opérations arithmétiques entre des chaînes et des nombres donc ça n’est pas forcément grave.

Pour indiquer à zod qu’il faut essayer de transformer la valeur reçue en un nombre il suffit de remplacer z.number() par z.coerce.number().

Modèles partiels

Partial et Omit en typescript ont leur équivalent zod. Ainsi on peut faire :

const ZUser = z.object({
	id: z..number(),
	login: z.string(),
	nick_name: z.string().optional(),
	joined_on: z.coerce.date(),
});
const ZPartialUser = ZUser.partial(); // tous les champs sont devenus optionels
const ZInputUser = ZUser.omit({ id: true }); // le même objet sans l'id

type User = z.infer<typeof ZUser>; // Le type typescript qui correspond à l'objet
type PartialUser = z.infer<typeof ZPartialUser>; // Le type typescript avec toutes les props optionelles
type InputUser = z.infer<typeof ZInputUser>; // Le type typescript sans l'id

Exercice : validation des opérations simples

Modifiez vos routes de l’exercice sur les opérations simples, ajoutez-y la validation des paramètres de routes et des corps de requêtes, de sorte que si on vous envoie une chaîne au lieu d’un nombre par exemple, une erreur est renvoyée.

Instructions pour de meilleures erreurs

En cas d’erreur de validation fastify va vous afficher une erreur par défaut qui n’est pas forcément adapté à la lecture par un client.

Il existe une bibliothèque pour générer un message à partir d’une erreur zod, et une fonction qui nous permet de changer le comportement par défaut de fastify en cas de problème :

  • zod-validation-error propose une fonction fromError qui convertie une erreur zod en une chaîne de caractères ;
  • .setErrorHandler sur une instance fastify permet de remplacer une partie de la gestion d’erreur par une fonction à nous.
import { hasZodFastifySchemaValidationErrors, isResponseSerializationError } from 'fastify-type-provider-zod';
import * as ZodValidationError from 'zod-validation-error';


function start_web_server() {
  // [...}

  web_server.setErrorHandler((err, req, reply) => {
    if (hasZodFastifySchemaValidationErrors(err)) {
      return reply.code(400).send({
        error: 'Response Validation Error',
        details: {
          issues: err.validation,
          method: req.method,
          url: req.url,
        },
        message: ZodValidationError.fromError(err).toString(),
      });
    }
    // the rest of the error handler if you need it
  });
}

Source

Exercice : appliqué à notre métier

Le Repository que vous avez écrit travaille sur plusieurs types :

  • Profils utilisateurs ;
  • Identifiants utilisateurs ;
  • Événements ;
  • Activités.

Notre objectif c’est d’écrire les objets zod pour chacun de ces types, et pour leurs variantes (partielles, et sans ID, car quand on crée une activité par exemple, on ne connait pas encore son ID).

Créez un dossier src/models/ et créez un fichier source par type.

Dans chacun de ces fichiers faites :

  • un objet zod avec tous les champs qu’il y avait dans votre type ;
  • un objet zod qui omet l’id (utile pour la création d’un nouvel élément, vous allez voir) ;
  • un objet zod partiel (avec tous les champs marqués optionnels) ;
  • 3 types: tous une inférences de chacun ques objets zod.

Théorie : les bases de REST

On a parlé d’API, mais pas encore de ce que c’est que REST. Il s’agit d’une norme que l’on peut utiliser pour faciliter la conception et la consommation d’une API.

Exemple qui ne respecte pas les principes de REST :

  • GET /list_activities
  • GET /get_activity_by_id/:id
  • GET /create_activity
  • GET /edit_activity

Le même exemple en respectant les principes REST :

  • GET /activites liste les activités ;
  • GET /activities/:id renvoie une activité depuis un identifiant ;
  • POST /activities ajoute une nouvelle activité ;
  • PUT /activities/:id modifie l’activité qui à l’identifiant donné.

Ce qu’offre REST c’est une prédictibilité dans le nom de routes, si on en connais une on peut deviner les autres. Mais ce n’est pas qu’une question de design, c’est aussi une question de sécurité. Si toutes nos routes sont en GET elles nous rendent vulnérables à des attaques XSS.

Les principes du REST dont nous avons besoin pour le moment sont :

  • les routes ne doivent pas contenir de verbe qui contredit la méthode HTTP (et doit simplement éviter d’en contenir un si ça n’apporte rien) ;
  • les routes doivent porter le nom de la resource sur laquelle elles opèrent ;
  • une resource peut correspondre directement à une entité en base de donnée, mais aussi à un concept qui a du sens pour l’API (comme une demande de connexion) ;
  • les requêtes GET, DELETE ne doivent pas recevoir de body ;
  • les routes doivent avoir une méthode HTTP qui correspond à l’action que l’on veut faire :
    • GET pour récupérer,
    • POST pour insérer,
    • PUT pour modifier,
    • DELETE pour supprimer.

Pour représenter des associations entre entités, on peut faire des routes qui ressemblent à ceci :

  • /events/:evt_id/activities :
    • GET récupère toutes les activités d’un événement (ce qui peut aussi être fait avec une jointure automatique quand on récupère /events/:evt_id)
    • POST ajoute une activité à l’événement

Exercice : routes pour notre métier

Écrivez les routes qui permettent de faire les opérations suivantes sur les événements et les activités :

  • création ;
  • lecture par id ;
  • listage ;
  • modification par id ;
  • suppression par id.

Utilisez votre Repository que vous avez déjà écrit pour faire ces opérations.

Règles de cohérence :

  • Un événement doit toujours finir après avoir commencé.
  • Une activité doit toujours être ajoutée à un événement existant.
  • Une activité doit toujours finir après avoir commencé.
  • Une activité doit se produire entre la date de début et la date de fin d’un événement.
  • La suppression d’un événement doit provoquer la suppression de ses activités (mais ça postgres doit s’en charger).

Exercice : gestion centralisée des erreurs de cohérence

Vous vous êtes surement mis·e·s à gérer les erreurs de cohérence des données directement dans vos routes. Mais sachez qu’il existe une méthode pour éviter de se répéter : c’est de throw une erreur, et vous allez la recevoir dans la fonction passée à setErrorHandler, où vous pouvez toutes les gérer. L’avantage de s’y prendre ainsi c’est que vous ne pouvez pas oublier de gérer un type d’erreurs dans une route et pas dans une autre.

Écrivez donc votre gestion d’erreur (qui modifie le status de la requête et renvoie un message un peu comme le fait zod-validation-error) dans le Error Handler passé à fastify.