À propos de ce cours
Apprenez à envoyer des données avec fetch : options method, headers et body, fausse API REST locale avec json-server, module api.js pour toutes les méthodes, exemple complet de gestion des stagiaires (création, modification, suppression), différence entre PUT et PATCH, FormData et envoi de fichiers, erreurs de validation 400 et 422, API protégées (401, 403, jetons, CSRF) et bonnes pratiques d'interface. Avec des activités corrigées.
-
Objectifs
- Envoyer des données avec
fetch: optionsmethod,headersetbody. - Créer, remplacer, modifier et supprimer une ressource (
POST,PUT,PATCH,DELETE). - Envoyer du JSON, un formulaire et un fichier (
FormData). - Traiter les réponses : 201, 204, erreurs de validation 400 et 422, authentification 401 et 403.
- Mettre en place une fausse API REST locale avec json-server pour s'entraîner.
- Envoyer des données avec
-
Les options de fetch
-
S'entraîner avec une vraie API : json-server
- Les API publiques de démonstration (comme JSONPlaceholder) font semblant d'enregistrer les modifications : elles répondent correctement, mais ne gardent rien. Pour un vrai CRUD, on lance une API locale en une commande avec json-server, qui transforme un fichier JSON en API REST complète.
- Créez un dossier
fetch-crudcontenant ce fichierdb.json:
{ "stagiaires": [ { "id": "1", "nom": "Amira Ben Ali", "groupe": "DEV-101", "moyenne": 15.5 }, { "id": "2", "nom": "Youssef Trabelsi", "groupe": "DEV-102", "moyenne": 12 }, { "id": "3", "nom": "Sana Gharbi", "groupe": "DEV-101", "moyenne": 17.25 } ] }- Dans un terminal ouvert dans ce dossier (Node.js doit être installé) :
npx json-server db.json # JSON Server started on PORT :3000 # http://localhost:3000/stagiaires- L'API répond aussitôt à toutes les routes REST, avec CORS autorisé, et écrit les modifications dans
db.json:
Requête Effet Réponse GET /stagiairesListe 200 + tableau GET /stagiaires?groupe=DEV-101Liste filtrée 200 + tableau GET /stagiaires/2Un stagiaire 200 + objet, ou 404 POST /stagiairesCréation (identifiant généré) 201 + objet créé PUT /stagiaires/2Remplacement complet 200 + objet PATCH /stagiaires/2Modification partielle 200 + objet DELETE /stagiaires/2Suppression 200 + objet supprimé - json-server est un outil de développement (maquettes, cours, tests) : en production, l'API est écrite avec un vrai serveur (PHP/Symfony, Node.js, Python…) et une base de données.
-
Une fonction pour toutes les méthodes
const reponse = await fetch(url, {
method: "POST", // GET par défaut
headers: { // en-têtes de la requête
"Content-Type": "application/json", // format du corps envoyé
Accept: "application/json", // format de réponse souhaité
},
body: JSON.stringify(donnees), // le corps : du TEXTE, pas un objet
});
| Option | Valeurs | Rôle |
|---|---|---|
method | "GET", "POST", "PUT", "PATCH", "DELETE"… | L'action demandée (écrivez PATCH en majuscules : c'est la seule méthode que le navigateur ne corrige pas) |
headers | Objet ou new Headers() | En-têtes à envoyer |
body | Texte, FormData, URLSearchParams, Blob | Données envoyées (interdit avec GET et HEAD) |
signal | AbortSignal | Délai maximal et annulation (leçon 4) |
credentials | "same-origin" (défaut), "include", "omit" | Envoi des cookies ; "include" pour une API d'un autre domaine qui utilise des cookies |
cache | "default", "no-store", "reload"… | Utilisation du cache HTTP |
keepalive | true | La requête continue même si la page est fermée (statistiques, sauvegarde finale) |
Les deux oublis les plus fréquents : envoyer l'objet sans
JSON.stringify() (le serveur reçoit le texte "[object Object]"), et oublier l'en-tête Content-Type: application/json (le serveur ne sait pas lire le corps et répond souvent 400 ou 415).Toutes les requêtes se ressemblent : on les centralise dans un module api.js.
// Toutes les requêtes vers l'API REST des stagiaires
const API = "http://localhost:3000/stagiaires";
async function requete(url, methode = "GET", donnees) {
const options = { method: methode, headers: { Accept: "application/json" } };
if (donnees !== undefined) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(donnees);
}
const reponse = await fetch(url, options);
if (!reponse.ok) {
throw new Error(`${methode} ${url} : erreur HTTP ${reponse.status}`);
}
// 204 No Content : pas de corps à lire
return reponse.status === 204 ? null : reponse.json();
}
export const lister = () => requete(API); // GET /stagiaires
export const lire = (id) => requete(`${API}/${id}`); // GET /stagiaires/2
export const creer = (stagiaire) => requete(API, "POST", stagiaire); // POST /stagiaires
export const remplacer = (id, stagiaire) => requete(`${API}/${id}`, "PUT", stagiaire); // PUT /stagiaires/2
export const modifier = (id, champs) => requete(`${API}/${id}`, "PATCH", champs); // PATCH /stagiaires/2
export const supprimer = (id) => requete(`${API}/${id}`, "DELETE"); // DELETE /stagiaires/2
- Le corps et l'en-tête
Content-Typene sont ajoutés que s'il y a des données à envoyer. - Une réponse 204 No Content n'a pas de corps : appeler
json()dessus provoquerait une erreur. - Chaque opération CRUD tient ensuite en une ligne, et le reste de l'application ne connaît ni l'adresse de l'API ni le détail des requêtes.
-
Exemple complet : gestion des stagiaires
Une page qui liste les stagiaires de l'API, permet d'en ajouter (POST), de modifier la moyenne (PATCH) et de supprimer (DELETE). Après chaque opération, la liste est rechargée depuis le serveur.
index.html:
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>Gestion des stagiaires – CRUD avec fetch</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.9/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<script type="module" src="crud.js"></script>
</head>
<body class="container py-4" style="max-width: 760px">
<h1 class="h3">Gestion des stagiaires</h1>
<form id="form-stagiaire" class="row g-2 mb-3">
<div class="col-sm-5">
<input id="nom" class="form-control" placeholder="Nom et prénom" required />
</div>
<div class="col-sm-3">
<select id="groupe" class="form-select">
<option>DEV-101</option>
<option>DEV-102</option>
</select>
</div>
<div class="col-sm-2">
<input
id="moyenne"
class="form-control"
type="number"
min="0"
max="20"
step="0.25"
placeholder="Moy."
required
/>
</div>
<div class="col-sm-2">
<button class="btn btn-primary w-100" type="submit">Ajouter</button>
</div>
</form>
<div id="message" class="alert d-none"></div>
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Nom</th>
<th>Groupe</th>
<th>Moyenne</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="lignes"></tbody>
</table>
</body>
</html>
crud.js:
import { lister, creer, modifier, supprimer } from "./api.js";
const lignes = document.getElementById("lignes");
const message = document.getElementById("message");
function informer(texte, type = "success") {
message.className = `alert alert-${type}`;
message.textContent = texte;
}
function bouton(texte, classe, action) {
const element = document.createElement("button");
element.type = "button";
element.className = `btn btn-sm ${classe} ms-1`;
element.textContent = texte;
element.dataset.action = action;
return element;
}
async function afficher() {
try {
const stagiaires = await lister();
lignes.replaceChildren(
...stagiaires.map((stagiaire) => {
const tr = document.createElement("tr");
tr.dataset.id = stagiaire.id;
for (const valeur of [stagiaire.nom, stagiaire.groupe, `${stagiaire.moyenne}/20`]) {
const td = document.createElement("td");
td.textContent = valeur;
tr.append(td);
}
const actions = document.createElement("td");
actions.className = "text-end";
actions.append(
bouton("✏️ Note", "btn-outline-primary", "noter"),
bouton("🗑️", "btn-outline-danger", "supprimer"),
);
tr.append(actions);
return tr;
}),
);
} catch (erreur) {
informer(
`Serveur injoignable : lancez « npx json-server db.json ». (${erreur.message})`,
"danger",
);
}
}
// CREATE : POST
document.getElementById("form-stagiaire").addEventListener("submit", async (event) => {
event.preventDefault();
const formulaire = event.target;
try {
const cree = await creer({
nom: document.getElementById("nom").value.trim(),
groupe: document.getElementById("groupe").value,
moyenne: Number(document.getElementById("moyenne").value),
});
informer(`« ${cree.nom} » ajouté (identifiant ${cree.id}).`);
formulaire.reset();
afficher();
} catch (erreur) {
informer(erreur.message, "danger");
}
});
// UPDATE (PATCH) et DELETE, par délégation
lignes.addEventListener("click", async (event) => {
const action = event.target.dataset.action;
const id = event.target.closest("tr")?.dataset.id;
if (!action || !id) {
return;
}
try {
if (action === "noter") {
const saisie = prompt("Nouvelle moyenne (0 à 20) :");
const moyenne = Number(saisie);
if (
saisie === null ||
saisie.trim() === "" ||
Number.isNaN(moyenne) ||
moyenne < 0 ||
moyenne > 20
) {
return;
}
await modifier(id, { moyenne });
informer("Moyenne mise à jour.");
} else if (action === "supprimer" && confirm("Supprimer ce stagiaire ?")) {
await supprimer(id);
informer("Stagiaire supprimé.");
}
afficher();
} catch (erreur) {
informer(erreur.message, "danger");
}
});
afficher();
- Lancez
npx json-server db.json, puis la page avec Live Server. Ouvrezdb.jsondans VS Code : il change à chaque action. - Dans l'onglet Réseau, observez pour chaque action la méthode, le corps envoyé (Payload), le statut et la réponse. Pour PATCH et DELETE, vous verrez aussi une requête
OPTIONS: c'est la vérification CORS (preflight), car la page (port 5500) et l'API (port 3000) n'ont pas la même origine. - Les boutons de chaque ligne sont gérés par délégation (un seul écouteur sur le tableau) avec
data-action, et l'identifiant est lu dansdata-idsur la ligne.
PUT ou PATCH ?
// Le stagiaire 2 vaut : { id: "2", nom: "Youssef Trabelsi", groupe: "DEV-102", moyenne: 12 }
await modifier("2", { moyenne: 13 });
// PATCH → { id: "2", nom: "Youssef Trabelsi", groupe: "DEV-102", moyenne: 13 }
await remplacer("2", { moyenne: 13 });
// PUT → { id: "2", moyenne: 13 } ← le nom et le groupe ont disparu !
PUTremplace toute la ressource par ce que vous envoyez : il faut envoyer l'objet complet.PATCHne modifie que les champs envoyés : c'est généralement ce que l'on veut pour un formulaire de modification.
Envoyer un formulaire et des fichiers : FormData
- Pour un formulaire HTML classique, inutile de lire chaque champ :
new FormData(formulaire)récupère tous les champs qui ont un attributname, fichiers compris.
// Envoyer un formulaire HTML complet, fichier compris
const formulaire = document.getElementById("form-inscription"); // contient <input type="file" name="cv">
formulaire.addEventListener("submit", async (event) => {
event.preventDefault();
const donnees = new FormData(formulaire); // tous les champs, d'après leur attribut name
const reponse = await fetch("/api/inscriptions", {
method: "POST",
body: donnees, // surtout PAS de Content-Type : le navigateur écrit multipart/form-data
});
if (reponse.status === 422) {
const { erreurs } = await reponse.json(); // le serveur détaille les champs invalides
console.table(erreurs);
}
});
- Avec
FormData, n'écrivez pas l'en-têteContent-Type: le navigateur le génère lui-même (multipart/form-data) avec un séparateur indispensable au serveur. - Pour transformer un formulaire en objet JSON :
Object.fromEntries(new FormData(formulaire)). - Pour envoyer des données « comme un formulaire » sans fichier,
body: new URLSearchParams({ email, motDePasse })produit le formatapplication/x-www-form-urlencoded, lu directement par PHP dans$_POST.
Les erreurs de validation : 400 et 422
- Un serveur sérieux revérifie toujours les données (la validation dans le navigateur peut être contournée). En cas de problème, il répond
400ou422avec un corps qui détaille les erreurs, par exemple :
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
{
"message": "Les données envoyées sont invalides.",
"erreurs": {
"moyenne": "La moyenne doit être comprise entre 0 et 20.",
"nom": "Le nom est obligatoire."
}
}
- Côté client, on lit ce corps pour afficher chaque message sous le bon champ :
const reponse = await fetch(API, { method: "POST", headers, body: JSON.stringify(stagiaire) });
if (reponse.status === 422) {
const { erreurs } = await reponse.json();
for (const [champ, message] of Object.entries(erreurs)) {
const element = document.getElementById(champ);
element.classList.add("is-invalid"); // style Bootstrap
element.nextElementSibling.textContent = message; // <div class="invalid-feedback">
}
}
Les API protégées : 401 et 403
- La plupart des API demandent une preuve d'identité. Deux méthodes courantes :
- un cookie de session
HttpOnlyposé à la connexion : le navigateur l'envoie tout seul (ajoutezcredentials: "include"si l'API est sur un autre domaine) ; - un jeton (token, souvent au format JWT) reçu à la connexion, envoyé dans l'en-tête
Authorization:
- un cookie de session
// Appel d'une API protégée par un jeton (token)
const reponse = await fetch("https://api.centre.tn/v1/notes", {
headers: {
Accept: "application/json",
Authorization: `Bearer ${jeton}`, // jeton reçu à la connexion
},
});
if (reponse.status === 401) {
// non authentifié (jeton absent ou expiré) → retour à la page de connexion
location.href = "/connexion";
} else if (reponse.status === 403) {
// authentifié mais pas autorisé
console.error("Accès refusé à cette ressource.");
}
- 401: pas ou plus authentifié → rediriger vers la connexion. 403: identifié mais pas autorisé → afficher un message, ne pas redemander le mot de passe.
- Rappel (chapitre 20) : un jeton stocké dans le
localStoragepeut être volé par une faille XSS ; les cookiesHttpOnlysont plus sûrs pour les sessions. - Une page qui envoie des requêtes modifiant des données avec des cookies doit aussi être protégée contre les attaques CSRF (un autre site qui déclenche une action à votre insu) : cookies
SameSiteet jeton anti-CSRF fourni par le serveur, que les frameworks comme Symfony gèrent pour vous.
Bonnes pratiques pour l'interface
- Désactivez le bouton pendant l'envoi, pour éviter un double clic qui créerait deux fois la même ressource (POST n'est pas idempotent).
- Confirmez les suppressions et affichez un message de réussite ou d'échec.
- Mise à jour optimiste: pour une interface très réactive, on peut modifier l'affichage avant la réponse du serveur, puis revenir en arrière si la requête échoue. C'est ce que font les réseaux sociaux pour le bouton « J'aime ».
- Validez dans le navigateur (
required,min,max) pour le confort de l'utilisateur, mais ne comptez jamais dessus pour la sécurité.
Activités
Activité 1 : l'API avec curl ou un client HTTP
- Lancez json-server avec le
db.jsonde la leçon. Avec curl, Bruno ou l'extension REST Client de VS Code, envoyez : la liste ; le stagiaire 3 ; une création ; un PATCH de la moyenne du stagiaire 1 ; une suppression ; une demande du stagiaire 999. Notez les codes de statut et vérifiezdb.json.
Activité 2 : la page de gestion
- Reproduisez l'exemple complet. Désactivez le bouton « Ajouter » pendant l'envoi et affichez « Envoi… » à la place de son texte.
- Remplacez le
prompt()de modification par un petit formulaire dans la ligne (champ numérique + boutons Enregistrer et Annuler). - Ajoutez un filtre par groupe qui utilise le paramètre
?groupe=de l'API.
Activité 3 : une deuxième ressource
- Ajoutez dans
db.jsonune collectionformations(id,intitule,duree,niveau) et créez une page qui permet de les lister, d'en ajouter et d'en supprimer, en réutilisant la fonctionrequete().
Activité 4 : questions
- Pourquoi faut-il
JSON.stringify()sur le corps ? Que reçoit le serveur sans lui ? - Quelle est la différence entre PUT et PATCH ?
- Pourquoi ne faut-il pas écrire l'en-tête
Content-Typequand on envoie unFormData? - Que signifient les codes 201, 204, 401, 403 et 422 ?
Corrigés. Activité 1 : 200, 200, 201, 200, 200 (json-server renvoie l'objet supprimé ; d'autres API renvoient 204) et 404. Activité 2 :
bouton.disabled = true; bouton.textContent = "Envoi…"; avant l'await, et retour à l'état initial dans un finally. Activité 4 : body doit être du texte ; un objet serait converti en "[object Object]"; PUT remplace toute la ressource, PATCH ne modifie que les champs envoyés ; le navigateur doit écrire lui-même multipart/form-data avec la limite (boundary) qui sépare les champs ; 201 ressource créée, 204 succès sans contenu, 401 non authentifié, 403 non autorisé, 422 données invalides.À retenir
fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(donnees) }).- POST crée (201), PUT remplace, PATCH modifie en partie, DELETE supprime (204 ou 200).
FormDatapour les formulaires et les fichiers, sans en-têteContent-Type.- Lisez le corps des erreurs 400/422 pour guider l'utilisateur ; 401 → connexion, 403 → accès refusé ; json-server permet de s'entraîner sur une vraie API REST.
Sources : MDN Web Docs, WHATWG (XMLHttpRequest, Fetch), IETF, open-meteo.com.