Chapitre 639 minIntermédiaire
Appeler une API REST depuis l'application : fetch, jetons et erreurs
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- expliquer pourquoi l'application ne se connecte jamais à la base ;
- joindre ton API depuis un téléphone ou un émulateur ;
- écrire un client d'API qui distingue erreurs réseau et erreurs HTTP ;
- gérer la connexion avec un jeton d'accès et un jeton de rafraîchissement ;
- éviter les doublons avec une clé d'idempotence.
4 exercices corrigés1 schéma
Une application mobile de gestion ne contient pas ses données : elle les demande à une API REST sur un serveur, avec fetch, en s'authentifiant par un jeton. Ce chapitre explique pourquoi l'application ne se connecte jamais directement à la base, comment joindre ton API depuis un téléphone ou un émulateur, puis écrit le client d'API d'AppliFrais : délai maximal, erreurs réseau et erreurs HTTP, connexion avec un jeton d'accès et un jeton de rafraîchissement, et les écrans de connexion, de liste et de saisie qui s'en servent.
L'application ne parle jamais à la base de données
L'architecture d'AppliFrais Mobile a trois étages : l'application sur le téléphone, l'API sur un serveur, la base MySQL derrière l'API. L'application n'a aucun moyen d'atteindre la base, et c'est voulu.
- Pour se connecter à MySQL, il faudrait mettre un identifiant et un mot de passe de base dans l'application. Or le code d'une application se lit (un APK se décompile en quelques minutes) : n'importe qui obtiendrait un accès direct à toutes les données.
- Les règles de gestion (un visiteur ne voit que ses fiches, une fiche clôturée n'est plus modifiable) doivent être appliquées à un seul endroit que l'utilisateur ne contrôle pas : le serveur.
- Les anciennes versions de l'application, installées sur des téléphones jamais mis à jour, continuent d'appeler l'API : la base peut évoluer derrière l'API sans les casser.
Le fonctionnement d'une API REST (méthodes, codes de statut, JSON, jetons) est expliqué dans le cours API REST, et sa construction avec Express dans le cours Node.js (API REST CRUD, authentification JWT). Ici, tu écris surtout le client. L'application mobile utilise ces routes de l'API d'AppliFrais :
| Opération | Méthode et adresse | Succès | Où est le code serveur |
|---|---|---|---|
| Se connecter | POST /v1/auth/connexion | 200 avec les jetons | Cours Node.js, complété plus bas (jeton de rafraîchissement) |
| Rafraîchir le jeton d'accès | POST /v1/auth/rafraichissement | 200 avec de nouveaux jetons | Chapitre 15 |
| Se déconnecter | POST /v1/auth/deconnexion | 204 | Chapitre 15 |
| Lister mes fiches | GET /v1/fiches?page=1&limite=20 | 200 | Cours Node.js |
| Lire une fiche | GET /v1/fiches/{id} | 200 | Cours Node.js |
| Ajouter un frais hors forfait | POST /v1/fiches/{id}/frais-hors-forfait | 201 | Cours Node.js, plus la clé d'idempotence du chapitre 15 |
| Joindre le justificatif | PUT /v1/frais-hors-forfait/{id}/justificatif | 204 | Cours Node.js |
| Enregistrer l'appareil pour les notifications | PUT /v1/appareils | 204 | Chapitre 15 |
Le code des routes que l'application ajoute est donné au chapitre 15 : ce chapitre-ci se concentre sur le client.
Joindre ton API depuis le téléphone
Pendant le développement, ton API Node.js tourne sur ton ordinateur, sur le port 3000. Depuis le navigateur de l'ordinateur, http://localhost:3000 fonctionne. Depuis le téléphone, non : pour le téléphone, localhost désigne le téléphone lui-même.
| L'application tourne sur | Adresse de l'API à utiliser |
|---|---|
| Ton téléphone, sur le même Wi-Fi | L'adresse IP de l'ordinateur sur le réseau local, par exemple http://192.168.1.20:3000/v1 (ipconfig sous Windows, ip addr sous Linux, Réglages réseau sous macOS) |
| L'émulateur Android | http://10.0.2.2:3000/v1 : une adresse spéciale de l'émulateur qui désigne l'ordinateur hôte |
| Le simulateur iOS | http://localhost:3000/v1 : le simulateur partage le réseau du Mac |
| L'application publiée | L'adresse HTTPS de production, par exemple https://api.gsb.example/v1 |
Trois conditions pour que le téléphone joigne l'API de ton ordinateur :
- l'API écoute sur toutes les interfaces, ce que fait
app.listen(3000)par défaut ; ne lui passe pas'127.0.0.1'ou'localhost'comme hôte (au besoin,app.listen(3000, '0.0.0.0')) ; - le pare-feu de l'ordinateur autorise les connexions entrantes sur le port 3000 ;
- le téléphone et l'ordinateur sont sur le même réseau, sans isolation des clients (même problème que pour Metro, chapitre 2).
L'adresse de l'API change selon l'environnement : elle ne s'écrit donc pas en dur dans le code. Expo lit les variables d'environnement dont le nom commence par EXPO_PUBLIC_ dans un fichier .env à la racine du projet :
# .env (développement, à ne pas confondre avec l'adresse de production)
EXPO_PUBLIC_API_URL=http://192.168.1.20:3000/v1
// src/lib/config.ts
// Adresse de l'API : jamais de secret ici, tout ce qui commence par EXPO_PUBLIC_ finit dans l'application.
export const URL_API = process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.1.20:3000/v1';
Un client d'API dans un seul fichier
Plutôt que d'écrire des fetch dans chaque écran, on regroupe tous les appels dans un client d'API, src/lib/api.ts. Les écrans appellent api.listerFiches() ou api.ajouterFrais(…) sans connaître les adresses, les en-têtes ni les jetons. Le fichier n'importe rien de React Native : il tourne tel quel dans l'application, dans les tests et dans un simple script Node.js. Voici sa première moitié : les types, les erreurs et l'envoi d'une requête.
// Client de l'API d'AppliFrais. Aucun import de React Native ici : le même fichier
// tourne dans l'application, dans les tests Jest et dans un script Node.
export type Etat = 'CR' | 'CL' | 'VA' | 'MP';
export type ResumeFiche = { id: number; mois: string; etat: Etat; montantValide: number | null };
export type FraisHorsForfait = { id: number; date: string; libelle: string; montant: number; justificatif: boolean };
export type Fiche = ResumeFiche & { fraisHorsForfait: FraisHorsForfait[] };
export type NouveauFrais = { date: string; libelle: string; montant: number };
// Où ranger les jetons : SecureStore dans l'application, un objet en mémoire dans les tests.
export interface CoffreJetons {
lireRafraichissement(): Promise<string | null>;
ecrireRafraichissement(jeton: string): Promise<void>;
effacer(): Promise<void>;
}
export class ErreurApi extends Error {
status: number;
type: string;
erreurs: { champ: string; message: string }[];
constructor(status: number, corps: any) {
super(corps?.detail ?? corps?.title ?? `Erreur HTTP ${status}`);
this.name = 'ErreurApi';
this.status = status;
this.type = corps?.type ?? 'about:blank';
this.erreurs = corps?.erreurs ?? [];
}
}
export class ErreurReseau extends Error {
constructor(message: string) {
super(message);
this.name = 'ErreurReseau';
}
}
type Options = { urlBase: string; coffre: CoffreJetons; delaiMs?: number; surDeconnexion?: () => void };
export function creerApi({ urlBase, coffre, delaiMs = 10000, surDeconnexion }: Options) {
let jetonAcces: string | null = null; // en mémoire seulement
let rafraichissementEnCours: Promise<boolean> | null = null;
async function envoyer(chemin: string, init: RequestInit = {}): Promise<Response> {
const controleur = new AbortController();
const minuterie = setTimeout(() => controleur.abort(), delaiMs);
const entetes = new Headers(init.headers);
entetes.set('Accept', 'application/json');
if (jetonAcces) entetes.set('Authorization', `Bearer ${jetonAcces}`);
try {
return await fetch(`${urlBase}${chemin}`, { ...init, headers: entetes, signal: controleur.signal });
} catch {
throw new ErreurReseau('Serveur injoignable. Vérifie ta connexion.');
} finally {
clearTimeout(minuterie);
}
}
Deux sortes d'échecs, et donc deux classes d'erreur :
ErreurReseau: il n'y a pas de réponse du tout. Le téléphone est hors réseau, le serveur est éteint, ou le délai de 10 secondes est dépassé (leAbortControllerannule alors la requête).fetchrejette sa promesse, et on la transforme enErreurReseau.ErreurApi: le serveur a répondu, avec un code d'erreur (401,404,409,422,500…).fetchne rejette pas sa promesse dans ce cas : c'est à toi de testerreponse.ok(le piège de fetch). L'erreur garde le code, le type de problème et la liste des champs en faute envoyés par l'API au formatapplication/problem+json.
Cette distinction pilote toute la suite : une ErreurReseau sur un enregistrement met le frais en attente pour plus tard (chapitre 7), une ErreurApi 422 affiche les erreurs sous les champs, une ErreurApi 409 explique que la fiche n'est plus modifiable.
Se connecter : jeton d'accès et jeton de rafraîchissement
À la connexion, l'API renvoie deux jetons (authentifier une API) :
- un jeton d'accès, valable 15 minutes, présenté à chaque requête dans l'en-tête
Authorization: Bearer …; - un jeton de rafraîchissement, valable plusieurs jours, qui sert uniquement à obtenir un nouveau jeton d'accès, sans redemander le mot de passe.
Sur un téléphone, on ne demande pas le mot de passe à chaque ouverture de l'application : c'est le jeton de rafraîchissement, conservé dans le stockage sécurisé du système, qui permet de rester connecté des jours. Le jeton d'accès, lui, ne quitte jamais la mémoire : à chaque démarrage, l'application en redemande un.
La seconde moitié de creerApi gère ce cycle :
async function rafraichir(): Promise<boolean> {
const jeton = await coffre.lireRafraichissement();
if (!jeton) return false;
const reponse = await envoyer('/auth/rafraichissement', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: jeton }),
});
if (!reponse.ok) {
await coffre.effacer();
return false;
}
const corps = await reponse.json();
jetonAcces = corps.accessToken;
await coffre.ecrireRafraichissement(corps.refreshToken); // rotation : l'ancien ne vaut plus rien
return true;
}
// Un seul rafraîchissement à la fois, même si trois écrans reçoivent 401 en même temps.
function rafraichirUneFois(): Promise<boolean> {
rafraichissementEnCours ??= rafraichir().finally(() => (rafraichissementEnCours = null));
return rafraichissementEnCours;
}
async function requete<T>(chemin: string, init: RequestInit = {}, authentifiee = true): Promise<T> {
let reponse = await envoyer(chemin, init);
if (authentifiee && reponse.status === 401 && (await rafraichirUneFois())) {
reponse = await envoyer(chemin, init); // on rejoue une seule fois
}
if (authentifiee && reponse.status === 401) {
jetonAcces = null;
surDeconnexion?.();
}
if (!reponse.ok) {
const corps = await reponse.json().catch(() => null);
throw new ErreurApi(reponse.status, corps);
}
if (reponse.status === 204) return undefined as T;
return reponse.json() as Promise<T>;
}
return {
async connexion(login: string, motDePasse: string): Promise<void> {
const corps = await requete<{ accessToken: string; refreshToken: string }>('/auth/connexion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, motDePasse }),
}, false);
jetonAcces = corps.accessToken;
await coffre.ecrireRafraichissement(corps.refreshToken);
},
async reprendreSession(): Promise<boolean> {
return rafraichirUneFois();
},
async deconnexion(): Promise<void> {
const jeton = await coffre.lireRafraichissement();
if (jeton) {
await envoyer('/auth/deconnexion', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: jeton }),
}).catch(() => undefined); // hors ligne : on efface quand même localement
}
jetonAcces = null;
await coffre.effacer();
},
listerFiches: (page = 1) =>
requete<{ donnees: ResumeFiche[]; pagination: { page: number; limite: number; total: number; pages: number } }>(`/fiches?page=${page}&limite=20`),
lireFiche: (id: number) => requete<Fiche>(`/fiches/${id}`),
ajouterFrais: (idFiche: number, frais: NouveauFrais, cleIdempotence: string) =>
requete<FraisHorsForfait>(`/fiches/${idFiche}/frais-hors-forfait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': cleIdempotence },
body: JSON.stringify(frais),
}),
envoyerJustificatif: (idFrais: number, formulaire: FormData) =>
requete<void>(`/frais-hors-forfait/${idFrais}/justificatif`, { method: 'PUT', body: formulaire }),
enregistrerAppareil: (jetonPush: string, plateforme: string) =>
requete<void>('/appareils', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jetonPush, plateforme }),
}),
};
}
export type Api = ReturnType<typeof creerApi>;
Les points délicats :
- Un 401 déclenche un seul rafraîchissement, puis une seule nouvelle tentative. Si la requête rejouée reçoit encore 401, ou si le rafraîchissement est refusé (jeton expiré ou révoqué), la session est terminée :
surDeconnexionprévient l'application, qui affiche l'écran de connexion. rafraichirUneFoisévite les rafraîchissements en rafale. Au retour d'une pause, trois écrans peuvent recevoir 401 en même temps. Sans précaution, ils lanceraient trois rafraîchissements avec le même jeton ; avec la rotation des jetons, le deuxième présenterait un jeton déjà utilisé, et l'API, qui y verrait un vol, révoquerait toute la session. L'opérateur??=ne lance le rafraîchissement que s'il n'y en a pas déjà un en cours ; les trois requêtes attendent la même promesse.- La connexion ne déclenche pas de rafraîchissement (le troisième argument
false) : un 401 y veut dire « mauvais mot de passe », pas « jeton expiré ». - La déconnexion prévient l'API, pour qu'elle révoque le jeton de rafraîchissement, mais efface le jeton local même si l'API est injoignable.
- Le jeton ne passe jamais dans l'adresse et n'est jamais écrit dans un
console.log: les journaux d'un téléphone se lisent avec un câble USB.
Le client est créé une seule fois, dans src/lib/client.ts, avec le coffre SecureStore du chapitre 7 :
import { creerApi } from './api';
import { URL_API } from './config';
import { coffreSecureStore } from './jetons';
let surExpiration = () => {};
export const api = creerApi({
urlBase: URL_API,
coffre: coffreSecureStore,
surDeconnexion: () => surExpiration(),
});
export function quandSessionExpire(action: () => void) {
surExpiration = action;
}
Le contexte de session
L'état de connexion concerne toute l'application : le layout racine en a besoin pour protéger les écrans (chapitre 4), l'écran de connexion pour se connecter, le profil pour se déconnecter. On le partage avec un contexte React, dans src/context/session.tsx :
import { createContext, use, useEffect, useState, type PropsWithChildren } from 'react';
import { ErreurReseau } from '@/lib/api';
import { api, quandSessionExpire } from '@/lib/client';
type Etat = 'chargement' | 'connecte' | 'deconnecte';
type Session = {
etat: Etat;
connexion: (login: string, motDePasse: string) => Promise<void>;
deconnexion: () => Promise<void>;
};
const SessionContext = createContext<Session | null>(null);
export function SessionProvider({ children }: PropsWithChildren) {
const [etat, setEtat] = useState<Etat>('chargement');
useEffect(() => {
quandSessionExpire(() => setEtat('deconnecte'));
api
.reprendreSession()
.then((ok) => setEtat(ok ? 'connecte' : 'deconnecte'))
// Hors réseau, on garde la session : le jeton de rafraîchissement servira au retour du réseau.
.catch((e) => setEtat(e instanceof ErreurReseau ? 'connecte' : 'deconnecte'));
}, []);
const valeur: Session = {
etat,
connexion: async (login, motDePasse) => {
await api.connexion(login, motDePasse); // lève une erreur en cas de refus : l'écran l'affiche
setEtat('connecte');
},
deconnexion: async () => {
await api.deconnexion();
setEtat('deconnecte');
},
};
return <SessionContext value={valeur}>{children}</SessionContext>;
}
export function useSession(): Session {
const session = use(SessionContext);
if (!session) throw new Error('useSession doit être appelé sous <SessionProvider>');
return session;
}
Au démarrage, l'état vaut 'chargement' et l'écran de démarrage reste affiché. Le provider tente de reprendre la session : s'il y a un jeton de rafraîchissement dans SecureStore et que l'API l'accepte, Lucas arrive directement sur ses fiches. Sans jeton, ou si l'API le refuse, il voit l'écran de connexion. Et s'il ouvre l'application dans un train sans réseau, l'erreur réseau ne le déconnecte pas : il garde l'accès aux données locales et à la saisie hors ligne.
Deux nouveautés de React 19 apparaissent ici : use(SessionContext) remplace useContext(SessionContext), et <SessionContext value={…}> remplace <SessionContext.Provider value={…}>. Les anciennes écritures fonctionnent toujours.
L'écran src/app/connexion.tsx utilise ce contexte :
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, StyleSheet, Text } from 'react-native';
import { Bouton } from '@/components/Bouton';
import { Champ } from '@/components/Champ';
import { useSession } from '@/context/session';
import { ErreurApi } from '@/lib/api';
export default function Connexion() {
const { connexion } = useSession();
const [login, setLogin] = useState('');
const [motDePasse, setMotDePasse] = useState('');
const [erreur, setErreur] = useState<string | null>(null);
const [envoi, setEnvoi] = useState(false);
async function valider() {
setErreur(null);
setEnvoi(true);
try {
await connexion(login.trim(), motDePasse);
// Pas de navigation ici : la garde du _layout.tsx affiche les onglets.
} catch (e) {
setErreur(e instanceof ErreurApi && e.status === 401 ? 'Identifiant ou mot de passe incorrect.' : (e as Error).message);
setMotDePasse('');
} finally {
setEnvoi(false);
}
}
return (
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.page}>
<Text style={styles.titre}>AppliFrais</Text>
<Champ
libelle="Identifiant"
value={login}
onChangeText={setLogin}
autoCapitalize="none"
autoCorrect={false}
autoComplete="username"
textContentType="username"
/>
<Champ
libelle="Mot de passe"
value={motDePasse}
onChangeText={setMotDePasse}
secureTextEntry
autoComplete="current-password"
textContentType="password"
onSubmitEditing={valider}
/>
{erreur && (
<Text style={styles.erreur} accessibilityLiveRegion="polite">
{erreur}
</Text>
)}
<Bouton titre={envoi ? 'Connexion…' : 'Se connecter'} onPress={valider} desactive={envoi || !login || !motDePasse} />
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
page: { flex: 1, justifyContent: 'center', padding: 24, backgroundColor: '#f9fafb' },
titre: { fontSize: 32, fontWeight: '700', textAlign: 'center', marginBottom: 32 },
erreur: { color: '#b91c1c', marginBottom: 16 },
});
Le message d'échec ne dit pas si c'est l'identifiant ou le mot de passe qui est faux : le préciser aiderait un attaquant à trouver les identifiants existants. Le mot de passe est effacé après un échec, et le bouton est désactivé pendant l'envoi pour éviter un double appui.
Afficher les données : chargement, erreur, liste vide
L'écran « Mes fiches » du chapitre 5 appelle api.listerFiches() dans un useFocusEffect plutôt que dans un useEffect. La différence compte sur mobile : un écran de la pile reste monté quand un autre se pose par-dessus. Avec useEffect, la liste serait chargée une seule fois, et Lucas ne verrait pas le frais qu'il vient d'ajouter en revenant. useFocusEffect relance le chargement chaque fois que l'écran redevient visible.
Le détail d'une fiche, src/app/fiche/[id]/index.tsx, ajoute une précaution :
export default function DetailFiche() {
const { id } = useLocalSearchParams<{ id: string }>();
const [fiche, setFiche] = useState<Fiche | null>(null);
const [erreur, setErreur] = useState<string | null>(null);
useFocusEffect(
useCallback(() => {
let actif = true; // évite de modifier l'état d'un écran quitté entre-temps
api.lireFiche(Number(id)).then(
(f) => actif && setFiche(f),
(e: Error) => actif && setErreur(e.message),
);
return () => {
actif = false;
};
}, [id]),
);
if (erreur) return <Text style={styles.erreur}>{erreur}</Text>;
if (!fiche) return <ActivityIndicator style={styles.centre} size="large" />;
const total = fiche.fraisHorsForfait.reduce((somme, frais) => somme + frais.montant, 0);
return (
<View style={styles.page}>
<Stack.Screen options={{ title: formatMois(fiche.mois) }} />
<Text style={styles.etat}>{LIBELLES_ETAT[fiche.etat]}</Text>
<FlatList
data={fiche.fraisHorsForfait}
keyExtractor={(frais) => String(frais.id)}
renderItem={({ item }) => (
<View style={styles.frais}>
<View style={styles.texteFrais}>
<Text style={styles.libelle}>{item.libelle}</Text>
<Text style={styles.date}>
{item.date}
{item.justificatif ? ' · justificatif joint' : ''}
</Text>
</View>
<Text style={styles.montant}>{formatEuros(item.montant)}</Text>
</View>
)}
ListEmptyComponent={<Text style={styles.vide}>Aucun frais hors forfait.</Text>}
ListFooterComponent={<Text style={styles.total}>Total hors forfait : {formatEuros(total)}</Text>}
/>
{fiche.etat === 'CR' && (
<Bouton titre="Ajouter un frais" onPress={() => router.push({ pathname: '/fiche/[id]/frais', params: { id } })} />
)}
</View>
);
}
La variable actif et la fonction de nettoyage renvoyée par l'effet traitent un cas propre au mobile : Lucas ouvre une fiche, le réseau est lent, il revient en arrière avant la réponse. Sans la précaution, la réponse arriverait sur un écran qui n'est plus affiché. Le bouton « Ajouter un frais » n'apparaît que pour une fiche en cours de saisie (CR) ; l'API refusera de toute façon un ajout sur une fiche clôturée, avec un 409.
Envoyer un frais
Dans l'écran « Nouveau frais », la fonction enregistrer du chapitre précédent appelle maintenant l'API :
const cle = useRef(Crypto.randomUUID()); // la même clé si l'utilisateur réessaie : pas de doublon
async function enregistrer() {
const { frais, erreurs: trouvees } = validerFrais(saisie, aujourdhui());
setErreurs(trouvees);
if (!frais) return;
setEnvoi(true);
setMessage(null);
try {
const cree = await api.ajouterFrais(Number(id), frais, cle.current);
if (photo) await api.envoyerJustificatif(cree.id, formulaireJustificatif(photo));
router.back();
} catch (e) {
if (e instanceof ErreurReseau) {
await mettreEnAttente(Number(id), frais, cle.current, photo); // envoyé au retour du réseau
router.back();
} else if (e instanceof ErreurApi && e.status === 422) {
setErreurs(Object.fromEntries(e.erreurs.map((x) => [x.champ, x.message])));
} else {
setMessage((e as Error).message);
}
} finally {
setEnvoi(false);
}
}
La clé d'idempotence est un identifiant unique (UUID) généré une fois par formulaire avec expo-crypto (npx expo install expo-crypto), et envoyée dans l'en-tête Idempotency-Key (la clé d'idempotence). Si la requête part mais que la réponse se perd (le train entre dans un tunnel), Lucas peut réessayer, ou la file d'attente du chapitre 7 renvoyer le frais : l'API reconnaît la clé et renvoie le frais déjà créé au lieu d'en créer un second.
Selon l'erreur, la réaction change : une ErreurReseau met le frais en attente ; un 422 affiche sous chaque champ le message renvoyé par l'API ; tout autre cas (409 fiche clôturée, 500) affiche le message en bas du formulaire. Le traitement de la photo est expliqué au chapitre 8.
Ce que l'API doit faire de son côté
Le client mobile ne suffit pas. Côté serveur, l'API d'AppliFrais doit :
- vérifier le jeton à chaque requête, et le propriétaire de chaque fiche demandée : une fiche d'un autre visiteur renvoie
404(la faille BOLA du cours sécurité) ; - revalider toutes les données reçues, et renvoyer les erreurs au format
application/problem+jsonavec la liste des champs ; - plafonner la pagination et limiter le débit, surtout sur
/auth/connexion; - gérer la clé d'idempotence des créations ;
- servir en HTTPS en production.
En revanche, une application mobile n'a pas besoin de CORS. CORS est une protection des navigateurs, qui empêche une page d'un domaine d'appeler une API d'un autre domaine sans autorisation ; une application native n'est pas un navigateur et n'envoie pas d'en-tête Origin. Si tu vois une erreur CORS, c'est que tu testes la version web de l'application (touche w) ou une PWA (CORS expliqué).
Tester le client sans téléphone
Comme api.ts ne dépend pas de React Native, un script Node.js peut l'exercer contre l'API : celle du cours Node.js, avec les routes du chapitre 15, sur une base de test remplie par sql/donnees.sql. Le coffre est un objet en mémoire, et la durée du jeton d'accès est réglée à une seconde pour provoquer l'expiration. Node.js 24 exécute directement le TypeScript simple. Extrait du script, puis de sa sortie (les lignes 409, 422, rejeu, après déconnexion et réseau viennent des cas d'erreur testés plus loin dans le même script) :
const api = creerApi({ urlBase: 'http://localhost:3999/v1', coffre, surDeconnexion: () => deconnecte++ });
try { await api.connexion('lmartin', 'mauvais'); } catch (e) { console.log('connexion KO :', (e as ErreurApi).status, (e as Error).message); }
await api.connexion('lmartin', 'Visiteur#2026');
const premierRefresh = stocke;
await new Promise((r) => setTimeout(r, 1500)); // le jeton d'accès (1 s) expire
const [a, b, c] = await Promise.all([api.lireFiche(12), api.lireFiche(11), api.listerFiches()]);
console.log('après expiration :', a.id, b.id, c.donnees.length, 'refresh changé :', stocke !== premierRefresh);
const cle = crypto.randomUUID();
const f1 = await api.ajouterFrais(12, { date: '2026-10-20', libelle: 'Parking gare Part-Dieu', montant: 12 }, cle);
const f2 = await api.ajouterFrais(12, { date: '2026-10-20', libelle: 'Parking gare Part-Dieu', montant: 12 }, cle);
console.log('idempotence :', f1.id, f2.id, (await api.lireFiche(12)).fraisHorsForfait.length);
connexion KO : 401 Login ou mot de passe incorrect.
après expiration : 12 11 2 refresh changé : true
idempotence : 60 60 2
409 : 409 La fiche est à l'état VA : seuls les frais d'une fiche en cours se modifient.
422 : 422 [{"champ":"date","message":"date ISO invalide"},{"champ":"libelle","message":"Trop petit : chaîne de caractères doit avoir >=3 caractères"},{"champ":"montant","message":"Trop petit : nombre doit être >0"}]
rejeu du même jeton : 200 401 Jeton déjà utilisé : reconnecte-toi.
après déconnexion : 401 surDeconnexion appelé 1 fois
réseau : true Serveur injoignable. Vérifie ta connexion.
Les trois requêtes lancées en même temps après l'expiration réussissent toutes, avec un seul rafraîchissement ; les deux envois avec la même clé donnent le même frais (n° 60), et la fiche ne compte que deux frais. La ligne rejeu présente deux fois le même jeton de rafraîchissement : le premier essai obtient 200, le second 401, et toute la session est révoquée. C'est la preuve que le client et l'API se comportent bien avant même d'ouvrir l'application.
Exercice 1 sur 4 « Network request failed » Réussi À revoir
Pour chaque situation, explique pourquoi l'appel échoue et corrige.
EXPO_PUBLIC_API_URL=http://localhost:3000/v1, application ouverte dans Expo Go sur le téléphone.- Même adresse que la question 1, mais avec l'adresse IP de l'ordinateur ; l'API Express démarre avec
app.listen(3000, '127.0.0.1'). - Tout marche dans Expo Go. Dans l'APK de démonstration, compilé pour la publication, toutes les requêtes vers
http://192.168.1.20:3000/v1échouent.
Voir la réponseMasquer la réponse de l'exercice 1
- Pour le téléphone,
localhostdésigne le téléphone. Utilise l'adresse IP de l'ordinateur sur le réseau local (http://192.168.1.20:3000/v1), ou10.0.2.2depuis l'émulateur Android. - L'API n'écoute que sur l'interface de bouclage de l'ordinateur : elle refuse les connexions venues du réseau. Retire l'hôte,
app.listen(3000)écoute sur toutes les interfaces (ou écrisapp.listen(3000, '0.0.0.0')), et vérifie le pare-feu. - Une application compilée pour la publication refuse le HTTP en clair par défaut. Déploie l'API en HTTPS (un hébergement avec certificat, ou un tunnel HTTPS pour la démonstration) plutôt que de réautoriser le HTTP.
Tu avais trouvé ?
Exercice 2 sur 4 Réagir aux codes de statut Réussi À revoir
L'écran « Nouveau frais » reçoit successivement les réponses suivantes à POST /v1/fiches/12/frais-hors-forfait. Que doit faire l'application dans chaque cas ?
201 Created.422avec"erreurs": [{ "champ": "montant", "message": "Montant positif attendu" }].409: la fiche a été clôturée entre-temps depuis le site web.401, puis le rafraîchissement du jeton échoue.- Aucune réponse : le délai de 10 secondes est dépassé.
Voir la réponseMasquer la réponse de l'exercice 2
- Envoyer la photo s'il y en a une, puis revenir au détail de la fiche (
router.back()), qui se recharge grâce àuseFocusEffect. - Afficher « Montant positif attendu » sous le champ montant, sans quitter le formulaire.
- Afficher le message de l'API (« La fiche est à l'état CL : seuls les frais d'une fiche en cours se modifient. ») ; inutile de réessayer, la fiche ne repassera pas en cours de saisie.
- La session est terminée :
surDeconnexionpasse l'état à'deconnecte', la garde affiche l'écran de connexion. Le frais saisi est perdu s'il n'a pas été mis en attente : une amélioration consiste à le mettre en attente avant de rediriger. - Le client lève une
ErreurReseau: le frais est mis en attente dans la base locale avec sa clé d'idempotence, et sera envoyé au retour du réseau. Si la requête était arrivée au serveur malgré tout, la clé évitera le doublon.
Tu avais trouvé ?
Exercice 3 sur 4 La clé de l'API de cartographie Réussi À revoir
Pour afficher la carte des visites, un développeur veut appeler un service de cartographie payant depuis l'application avec EXPO_PUBLIC_MAPS_KEY=sk_live_… dans le fichier .env. Qu'en penses-tu ? Propose une architecture.
Voir la réponseMasquer la réponse de l'exercice 3
C'est une faille : une variable EXPO_PUBLIC_ est recopiée dans le code de l'application, qui se lit dans le fichier publié. N'importe qui récupérerait la clé et consommerait le service aux frais de GSB. L'application appelle une route de l'API d'AppliFrais (par exemple GET /v1/carte?…), authentifiée par le jeton du visiteur ; c'est l'API, sur le serveur, qui détient la clé du service de cartographie et l'appelle. L'API peut en plus limiter le nombre d'appels par visiteur.
Tu avais trouvé ?
Exercice 4 sur 4 Pourquoi un seul rafraîchissement ? Réussi À revoir
Avec la rotation des jetons, l'API invalide le jeton de rafraîchissement à chaque utilisation, et révoque toute la session si un jeton déjà utilisé lui est présenté. Décris ce qui se passerait si trois requêtes recevant 401 au même instant lançaient chacune leur propre rafraîchissement, sans rafraichirUneFois.
Voir la réponseMasquer la réponse de l'exercice 4
Les trois requêtes lisent le même jeton de rafraîchissement dans SecureStore et l'envoient. La première est acceptée : l'API renvoie de nouveaux jetons et invalide l'ancien. La deuxième et la troisième présentent l'ancien jeton, déjà utilisé : l'API y voit un vol possible et révoque toute la session. Lucas est déconnecté sans raison apparente, alors qu'il n'a rien fait. Avec rafraichirUneFois, les trois requêtes attendent la même promesse : un seul rafraîchissement, puis chacune rejoue sa requête avec le nouveau jeton d'accès.
Tu avais trouvé ?