7.1 · HTTP et CRUD
Les diapositives de la séance 7
- associer chaque opération CRUD au verbe HTTP correspondant
- interpréter les codes de statut HTTP les plus courants
- envoyer un corps de requête JSON avec les bons en-têtes
- distinguer
PUTetPATCH, et comprendre ce qu’est l’idempotence - écrire un client HTTP générique avec
HttpClient<T> - savoir où placer un secret d’authentification, et surtout où ne jamais le placer
6.2 · fetch pour les appels réseau, 5.2 · Génériques pour HttpClient<T>, et 3.2 · Contrats, héritage et polymorphisme pour l’héritage et les classes abstraites.
La séance 7 est plus courte que les autres. En séance, faites les exercices marqués « essentiel » des chapitres 7.1 et 7.2. Ce qui n’est pas terminé se finit en autonomie, avec le composant minimal du chapitre 7.3. La page Préparer l’évaluation 2 propose ensuite un plan de travail étape par étape.
HTTP : requête, réponse
HTTP est un protocole client-serveur fondé sur un modèle requête/réponse. Le client envoie une requête, le serveur renvoie une réponse, et la conversation s’arrête là : chaque requête est indépendante des précédentes.
Une requête contient une méthode (GET, POST, PUT, PATCH, DELETE), une URL, des en-têtes, et parfois un corps. Une réponse contient un code de statut, des en-têtes, et parfois un corps.
Les verbes face aux opérations CRUD
CRUD désigne les quatre opérations de base sur des données persistantes.
| Opération | Verbe HTTP | URL type | Corps ? | Statut attendu |
|---|---|---|---|---|
| Create | POST |
/restaurants |
oui | 201 Created |
| Read | GET |
/restaurants ou /restaurants/12b3 |
non | 200 OK |
| Update | PUT ou PATCH |
/restaurants/12b3 |
oui | 200 OK |
| Delete | DELETE |
/restaurants/12b3 |
non | 200 OK ou 204 No Content |
Notez la logique des URL : /restaurants désigne la collection, /restaurants/12b3 désigne un élément. On crée dans une collection, on modifie ou supprime un élément.
PUT ou PATCH ?
PUTremplace l’objet entier. Les champs que vous n’envoyez pas sont perdus.PATCHne modifie que les champs fournis. Les autres restent tels quels.
Dans la pratique, PATCH est presque toujours ce que vous voulez.
GET, PUT et DELETE sont idempotents : les rejouer dix fois donne le même état final que les jouer une fois. POST ne l’est pas : dix POST créent dix objets. C’est important dès qu’un réseau instable peut provoquer une nouvelle tentative.
Si vous faites un PUT ou un PATCH sur un identifiant qui n’existe pas, le comportement dépend du serveur : certains renvoient 404, d’autres créent l’objet silencieusement. Ne supposez rien, testez l’API que vous utilisez.
Les codes de statut
| Code | Nom | Sens pratique |
|---|---|---|
200 |
OK | ça a marché, la réponse contient le résultat |
201 |
Created | l’objet a été créé, son URL est dans l’en-tête Location |
204 |
No Content | ça a marché, il n’y a rien à renvoyer |
400 |
Bad Request | votre requête est mal formée, corrigez-la |
401 |
Unauthorized | vous n’êtes pas authentifié, en réalité « non identifié » |
403 |
Forbidden | vous êtes identifié, mais vous n’avez pas le droit |
404 |
Not Found | la ressource n’existe pas |
500 |
Internal Server Error | le serveur a planté, ce n’est pas votre faute |
La famille du code suffit souvent : 2xx succès, 4xx erreur du client, 5xx erreur du serveur. Un 4xx se corrige dans votre code ; un 5xx se signale à celui qui exploite l’API.
Le serveur de test : json-server
Pour travailler sans écrire de back-end, on utilise json-server, qui transforme un simple fichier JSON en API REST complète.
Le fichier db.json fourni contient trois collections : restaurants, categories, et restaurantCategories qui relie les deux.
db.json
{
"restaurants": [
{
"id": "a45d",
"name": "Le Bistrot du Rire",
"description": "Un restaurant où le rire est au menu",
"categoryIds": ["71b2"]
}
],
"categories": [
{ "id": "71b2", "name": "Humour", "restaurantIds": ["a45d", "55fa", "3aa8"] }
]
}1. Installer le serveur :
npm install json-server2. Le démarrer sur le fichier de données :
npx json-server db.json3. Vérifier dans un navigateur ou avec curl :
curl http://localhost:3000/restaurantsChaque clé de premier niveau du JSON devient une route : /restaurants, /categories.
db.json est modifié pour de bon
Un POST, un PATCH ou un DELETE réécrit le fichier sur le disque. Si vous relancez le programme d’exemple une seconde fois, le DELETE renverra 404 (l’objet a déjà disparu) et le POST créera un doublon avec un nouvel identifiant, car json-server ne dédoublonne pas. Gardez une copie de db.json de côté.
Un client HTTP générique
HttpClient<T> est une classe abstraite : elle écrit une fois le code commun (l’URL, les en-têtes, l’appel à fetch), et chaque classe fille (CreateClient, ReadClient…) hérite de tout avec extends, puis complète les options. Ses champs sont protected : invisibles de l’extérieur, accessibles aux filles. Voir 3.2 · Les classes abstraites.
Le code du cours factorise les quatre opérations dans une classe abstraite paramétrée par le type T des données manipulées.
app.ts
abstract class HttpClient<T> {
protected url: string;
protected options: RequestInit;
constructor(url: string) {
this.url = url;
this.options = {
headers: { "Content-Type": "application/json" },
};
}
public async execute(): Promise<T | void> {
try {
const response = await fetch(this.url, this.options);
if (response.ok) {
const data: T = await response.json();
return data;
}
} catch (error) {
console.error("There was a problem with the fetch operation: ", error);
}
}
}RequestInit est le type standard du second paramètre de fetch : il regroupe method, headers, body, et quelques autres options. Les sous-classes n’ont plus qu’à le compléter :
app.ts
class CreateClient<T> extends HttpClient<T> {
constructor(url: string, data: T) {
super(url);
this.options.method = "POST";
this.options.body = JSON.stringify(data); // le corps est TOUJOURS une chaîne
}
}
class ReadClient<T> extends HttpClient<T> {
constructor(url: string) {
super(url);
this.options.method = "GET";
}
}
class UpdateClient<T> extends HttpClient<T> {
constructor(url: string, data: T) {
super(url);
this.options.method = "PATCH";
this.options.body = JSON.stringify(data);
}
}Le générique paie immédiatement : new ReadClient<Restaurant[]>(url) et new CreateClient<Category>(url, data) réutilisent le même code, avec un typage exact pour chacun.
Les en-têtes
Content-Type: application/json indique au serveur comment interpréter le corps envoyé. Sans cet en-tête, beaucoup de serveurs reçoivent votre JSON comme du texte brut et répondent 400.
Retenez aussi que body est toujours une chaîne de caractères. C’est le rôle de JSON.stringify : voir 5.1 · JSON.
fetch ne lève pas d’exception sur un 404 ou un 500. Dans execute, le catch ne se déclenche que pour une panne réseau. Si response.ok est faux, la méthode retourne undefined en silence, ce qui explique le if (restaurants) chez l’appelant. C’est acceptable pour un exemple, mais dans du vrai code vous voudrez au minimum journaliser response.status.
L’authentification
Dès que l’API n’est plus publique, il faut prouver qui vous êtes. Le mécanisme le plus courant est un jeton placé dans l’en-tête Authorization :
this.options = {
headers: {
"Authorization": "Bearer eyJhbGciOi...",
},
};Le préfixe Bearer (« au porteur ») dit tout : quiconque détient ce jeton peut agir en votre nom. Il n’y a aucune autre vérification.
Tout ce qui est envoyé au navigateur est lisible : le code source des scripts et les en-têtes des requêtes dans l’onglet Réseau. Une clé écrite en dur dans un fichier servi au client est une clé publiée.
Les conséquences sont concrètes : quota épuisé par un tiers, facturation à votre charge, accès en écriture à vos données.
La règle pratique :
- le secret reste sur le serveur, dans une variable d’environnement, jamais dans le code ;
- le navigateur appelle votre serveur, qui appelle l’API avec la clé : c’est le proxy du chapitre 6.3 ;
- si vous versionnez ou partagez votre projet, vérifiez que le fichier
.envn’en fait pas partie ; - si une clé a fuité, la retirer du code ne suffit pas : il faut la révoquer et la régénérer.
Ordre d’exécution
Les quatre appels du programme d’exemple sont lancés à la suite, sans await entre eux. Ils partent donc en parallèle, et rien ne garantit l’ordre des réponses : la sortie console varie d’une exécution à l’autre.
Si l’ordre compte (supprimer avant de recréer, par exemple), il faut enchaîner les await dans une fonction async, comme vu au chapitre 6.1.
Exercices
Exercice 7.1 : Explorer l’API à la main
essentiel
Avant d’écrire du TypeScript, manipulez l’API directement avec curl. Affichez le code de statut avec l’option -i.
GETla liste des restaurants, puis un seul restaurant par son identifiant.GETun identifiant inexistant. Quel code recevez-vous ?- Créez un restaurant en
POSTavec un corps JSON et l’en-têteContent-Type:
curl -i -X POST http://localhost:3000/restaurants \
-H "Content-Type: application/json" \
-d '{"name":"Le Test","description":"essai","categoryIds":[]}'- Renommez-le en
PATCH, puis supprimez-le enDELETE. - Refaites le
DELETEune seconde fois. Que se passe-t-il, et pourquoi ?
Critère de réussite : vous savez annoncer le code de statut attendu avant de lancer chaque commande.
# 1. la collection, puis un élément -> 200 dans les deux cas
curl -i http://localhost:3000/restaurants
curl -i http://localhost:3000/restaurants/a45d
# 2. identifiant inexistant -> 404 Not Found
curl -i http://localhost:3000/restaurants/zzzz
# 3. création -> 201 Created
curl -i -X POST http://localhost:3000/restaurants \
-H "Content-Type: application/json" \
-d '{"name":"Le Test","description":"essai","categoryIds":[]}'
# 4. modification puis suppression -> 200 puis 200
# (remplacez <id> par l'identifiant renvoyé par le POST)
curl -i -X PATCH http://localhost:3000/restaurants/<id> \
-H "Content-Type: application/json" \
-d '{"name":"Le Test Renommé"}'
curl -i -X DELETE http://localhost:3000/restaurants/<id>
# 5. second DELETE sur le même identifiant -> 404 Not Found
curl -i -X DELETE http://localhost:3000/restaurants/<id>Deux observations à faire au passage :
- le
201s’accompagne d’un en-têteLocationqui donne l’URL de l’objet créé, et le corps de la réponse contient l’objet complet avec l’idgénéré par le serveur ; - si vous oubliez
-H "Content-Type: application/json", le serveur reçoit votre JSON comme du texte brut.json-serverrépond alors400, ou crée un objet vide selon sa version.
Le point clé. Le second DELETE répond 404, et pourtant DELETE est bien idempotent. L’idempotence porte sur l’état du serveur, pas sur le code de réponse : après un ou dix DELETE, la ressource est absente, l’état final est identique. Rejouer un POST dix fois, à l’inverse, crée dix restaurants : c’est exactement ce qui se produit si vous relancez le programme d’exemple plusieurs fois.
Exercice 7.2 : Lire et chercher avec ReadClient
essentiel
json-server tourne sur db.json. En réutilisant HttpClient et ReadClient du cours, écrivez un programme lire.ts, lancé avec npx tsx lire.ts, qui :
- récupère tous les restaurants et affiche leurs noms sur une seule ligne, séparés par
", "; - récupère les catégories et affiche pour chacune
Humour : 3 restaurant(s), d’après la longueur de son tableaurestaurantIds; - définit une fonction
nomDuRestaurant(id: string): Promise<string>qui renvoie le nom du restaurant, ou"inconnu"si l’identifiant n’existe pas ; testez-la sur"12b3"puis sur"zzzz".
Réussite : aucun any, et le cas "zzzz" affiche inconnu au lieu de planter.
GET /restaurants/zzzz répond 404. Relisez ce que renvoie alors execute() dans la version du cours.
lire.ts
interface Restaurant {
id: string;
name: string;
description: string;
categoryIds: string[];
}
interface Category {
id: string;
name: string;
restaurantIds: string[];
}
// HttpClient et ReadClient : ceux du cours, recopiés pour que le fichier soit complet
abstract class HttpClient<T> {
protected url: string;
protected options: RequestInit;
constructor(url: string) {
this.url = url;
this.options = {
headers: { "Content-Type": "application/json" },
};
}
public async execute(): Promise<T | void> {
try {
const response = await fetch(this.url, this.options);
if (response.ok) {
const data: T = await response.json();
return data;
}
} catch (error) {
console.error("There was a problem with the fetch operation: ", error);
}
}
}
class ReadClient<T> extends HttpClient<T> {
constructor(url: string) {
super(url);
this.options.method = "GET";
}
}
const BASE = "http://localhost:3000";
// 3. Un seul restaurant : execute() renvoie undefined sur un 404
async function nomDuRestaurant(id: string): Promise<string> {
const restaurant = await new ReadClient<Restaurant>(`${BASE}/restaurants/${id}`).execute();
return restaurant ? restaurant.name : "inconnu";
}
async function main(): Promise<void> {
// 1. Tous les restaurants, sur une seule ligne
const restaurants = await new ReadClient<Restaurant[]>(`${BASE}/restaurants`).execute();
if (restaurants) {
console.log(restaurants.map((r) => r.name).join(", "));
}
// 2. Les catégories, avec leur nombre de restaurants
const categories = await new ReadClient<Category[]>(`${BASE}/categories`).execute();
if (categories) {
categories.forEach((c) => {
console.log(`${c.name} : ${c.restaurantIds.length} restaurant(s)`);
});
}
console.log(await nomDuRestaurant("12b3"));
console.log(await nomDuRestaurant("zzzz"));
}
main();Sur le db.json d’origine :
Le Bistrot du Rire, Le Grill Marrant, Le Café Rigolo, La Crêperie Rigolote
Humour : 3 restaurant(s)
Pizza : 1 restaurant(s)
Grillades : 1 restaurant(s)
Le Grill Marrant
inconnu
Le point clé : le même ReadClient sert trois fois, avec trois types différents (Restaurant[], Category[], Restaurant). C’est le paramètre de type T qui dit au compilateur ce que contiendra la réponse, et c’est lui qui autorise ensuite r.name ou c.restaurantIds.length sans aucun any.
La question 3 repose sur un comportement du client du cours : sur un 404, response.ok est faux et execute() renvoie undefined sans rien dire. D’où le test restaurant ? … : "inconnu", exactement comme après un find. L’exercice suivant rend cet échec visible.
Les interfaces sont écrites ici avec des propriétés obligatoires, parce que json-server renvoie toujours les champs de db.json. Celles de l’exemple du cours, générées automatiquement, rendent tout optionnel (name?: string) : plus prudent, mais chaque accès demande alors un test.
L’erreur classique : oublier les await dans main. restaurants serait alors une Promise, et TypeScript refuserait restaurants.map avec Property 'map' does not exist on type 'Promise<void | Restaurant[]>'.
Exercice 7.3 : Rendre les erreurs visibles
essentiel
Modifiez HttpClient.execute pour qu’il ne retourne plus undefined en silence :
- si
response.okest faux, levez uneErrorcontenant la méthode, l’URL etresponse.status; - adaptez les quatre appels pour attraper cette erreur et afficher un message utile ;
- vérifiez en supprimant deux fois de suite le même restaurant.
Critère de réussite : la console indique DELETE http://localhost:3000/restaurants/3aa8 a échoué avec le statut 404 plutôt que rien du tout.
Le try / catch disparaît de execute : ce n’est pas au client HTTP de décider quoi faire d’un échec, mais à l’appelant.
app.ts
abstract class HttpClient<T> {
protected url: string;
protected options: RequestInit;
constructor(url: string) {
this.url = url;
this.options = {
headers: { "Content-Type": "application/json" },
};
}
public async execute(): Promise<T | void> {
const response = await fetch(this.url, this.options);
if (!response.ok) {
throw new Error(
`${this.options.method} ${this.url} a échoué avec le statut ${response.status}`
);
}
if (response.status === 204) {
return; // 204 No Content : il n'y a pas de corps à décoder
}
return (await response.json()) as T;
}
}Les sous-classes CreateClient, ReadClient, UpdateClient et DeleteClient ne changent pas. Côté appelant :
app.ts
function signaler(error: unknown): void {
if (error instanceof Error) {
console.error(error.message);
} else {
console.error("Échec pour une raison inconnue :", error);
}
}
async function main(): Promise<void> {
const url = "http://localhost:3000/restaurants";
try {
const restaurants = await new ReadClient<Restaurant[]>(url).execute();
if (restaurants) {
restaurants.forEach((restaurant) => {
console.log(`READ id : ${restaurant.id} name : ${restaurant.name}`);
});
}
} catch (error) {
signaler(error);
}
try {
await new DeleteClient<Restaurant>(`${url}/3aa8`).execute();
console.log("DELETE 3aa8 : effectué");
} catch (error) {
signaler(error);
}
// Point 3 : le même DELETE une seconde fois
try {
await new DeleteClient<Restaurant>(`${url}/3aa8`).execute();
console.log("DELETE 3aa8 : effectué");
} catch (error) {
signaler(error);
// DELETE http://localhost:3000/restaurants/3aa8 a échoué avec le statut 404
}
}
main();Le point clé. La version d’origine écrasait deux échecs très différents : une panne réseau était journalisée puis oubliée, un 404 ne produisait même rien. Dans les deux cas execute retournait undefined, et l’appelant ne pouvait pas distinguer « pas de données » de « ça a planté ». En laissant l’exception remonter, l’appelant retrouve ce choix. C’est la règle générale du chapitre 5.3 : on n’attrape une exception que là où l’on sait quoi en faire.
Notez que le catch attrape désormais aussi les pannes réseau, puisque la promesse de fetch est rompue dans ce cas : un seul catch couvre les deux familles d’échec. Pour aller plus loin, une classe HttpError extends Error portant status permettrait à l’appelant de réagir différemment selon le code.
Exercice 7.4 : Maintenir les relations
approfondissement transfert
restaurantCategories est une table de relation, et le programme d’exemple ne la gère pas du tout. Les données deviennent donc incohérentes dès la première suppression.
Modifiez app.ts pour que :
- à chaque suppression ou modification d’une catégorie, les
categoryIdsde tous les restaurants concernés soient mis à jour ; - à chaque suppression ou modification d’un restaurant, les
restaurantIdsde toutes les catégories concernées soient mis à jour ; - les entrées de
restaurantCategoriessuivent dans les deux cas.
Ces opérations sont dépendantes : il faut lire l’état courant avant de le modifier. Écrivez une fonction async et enchaînez les await. Un Promise.all conviendrait en revanche pour mettre à jour plusieurs catégories indépendantes une fois la lecture faite.
Critère de réussite : après suppression d’un restaurant, aucune catégorie ne référence encore son identifiant, et restaurantCategories ne contient plus de ligne le mentionnant.
Préalable à faire dans db.json. Les objets de restaurantCategories n’ont pas d’id. Sans identifiant, json-server ne permet pas de supprimer une ligne de relation ciblée. Ajoutez-en un à chaque entrée avant de commencer :
db.json
"restaurantCategories": [
{ "id": "r1", "restaurantId": "a45d", "categoryId": "71b2" },
{ "id": "r2", "restaurantId": "12b3", "categoryId": "34b0" },
{ "id": "r3", "restaurantId": "3aa8", "categoryId": "71b2" }
]app.ts
interface RestaurantCategory {
id?: string;
restaurantId?: string;
categoryId?: string;
}
const BASE = "http://localhost:3000";
async function lire<T>(chemin: string): Promise<T> {
const response = await fetch(`${BASE}/${chemin}`);
if (!response.ok) {
throw new Error(`GET ${chemin} a échoué avec le statut ${response.status}`);
}
return (await response.json()) as T;
}
async function patcher(chemin: string, data: unknown): Promise<void> {
const response = await fetch(`${BASE}/${chemin}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`PATCH ${chemin} a échoué avec le statut ${response.status}`);
}
}
async function supprimer(chemin: string): Promise<void> {
const response = await fetch(`${BASE}/${chemin}`, { method: "DELETE" });
if (!response.ok) {
throw new Error(`DELETE ${chemin} a échoué avec le statut ${response.status}`);
}
}
async function supprimerRestaurant(restaurantId: string): Promise<void> {
// 1. LIRE l'état courant AVANT de le détruire
const restaurant = await lire<Restaurant>(`restaurants/${restaurantId}`);
const relations = await lire<RestaurantCategory[]>("restaurantCategories");
const categoryIds: string[] = restaurant.categoryIds ?? [];
// 2. retirer l'identifiant du restaurant de chaque catégorie liée
// Promise.all : ces catégories sont indépendantes entre elles
await Promise.all(
categoryIds.map(async (categoryId) => {
const category = await lire<Category>(`categories/${categoryId}`);
const restants = (category.restaurantIds ?? []).filter((id) => id !== restaurantId);
await patcher(`categories/${categoryId}`, { restaurantIds: restants });
})
);
// 3. supprimer les lignes de la table de relation
await Promise.all(
relations
.filter((relation) => relation.restaurantId === restaurantId && relation.id)
.map((relation) => supprimer(`restaurantCategories/${relation.id}`))
);
// 4. seulement maintenant, supprimer le restaurant lui-même
await supprimer(`restaurants/${restaurantId}`);
}
async function supprimerCategorie(categoryId: string): Promise<void> {
const category = await lire<Category>(`categories/${categoryId}`);
const relations = await lire<RestaurantCategory[]>("restaurantCategories");
const restaurantIds: string[] = category.restaurantIds ?? [];
await Promise.all(
restaurantIds.map(async (restaurantId) => {
const restaurant = await lire<Restaurant>(`restaurants/${restaurantId}`);
const restantes = (restaurant.categoryIds ?? []).filter((id) => id !== categoryId);
await patcher(`restaurants/${restaurantId}`, { categoryIds: restantes });
})
);
await Promise.all(
relations
.filter((relation) => relation.categoryId === categoryId && relation.id)
.map((relation) => supprimer(`restaurantCategories/${relation.id}`))
);
await supprimer(`categories/${categoryId}`);
}
supprimerRestaurant("3aa8").catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
});Le cas de la modification se traite avec le même squelette : on compare l’ancienne liste d’identifiants à la nouvelle, on PATCH les catégories retirées pour les délier, on PATCH celles ajoutées pour les lier, et on crée ou supprime les lignes de restaurantCategories correspondantes.
Le point clé. L’ordre n’est pas négociable : il faut lire avant de détruire. Si vous supprimez le restaurant en premier, ses categoryIds disparaissent avec lui et vous ne savez plus quelles catégories nettoyer : les données restent définitivement incohérentes. À l’intérieur d’une étape, en revanche, les opérations sont indépendantes : Promise.all les lance en parallèle. Une boucle for ... of avec des await successifs est également correcte, seulement plus lente.
Ce travail de synchronisation manuelle est le prix de la duplication de l’information à trois endroits. Une vraie base de données ferait cela seule, par une clé étrangère et une transaction ; ici, rien ne garantit l’atomicité : si le programme s’interrompt entre l’étape 2 et l’étape 4, db.json reste à moitié modifié.
À retenir
POSTcrée,GETlit,PUT/PATCHmodifie,DELETEsupprime ;/collectionpour créer,/collection/idpour agir sur un élémentPUTremplace tout,PATCHne touche qu’aux champs fournis2xxsuccès,4xxvotre faute,5xxcelle du serveur- Le corps d’une requête est toujours une chaîne :
JSON.stringify+Content-Type: application/json - Un jeton
Bearerdonne tous vos droits à qui le détient - Aucun secret dans du code front-end : il reste sur le serveur, et se révoque s’il fuite