Cours JavaScript et TypeScript · Module 4: JavaScript asynchrone

Gestion des requêtes HTTP - patterns pratiques et techniques avancées

32 min de lecture
Dans cette leçon9

Dans l'exercice précédent, nous avons couvert les bases de la Fetch API. Élargissons maintenant nos connaissances avec des techniques plus avancées et des patterns pratiques pour construire un système de communication robuste pour notre Parc Jurassique.

Gestion complète des erreurs

Dans un environnement aussi critique que le Parc Jurassique, où une panne système pourrait conduire à des situations dangereuses, une bonne gestion des erreurs dans la communication HTTP est extrêmement importante. Créons un mécanisme de gestion des erreurs plus avancé :

1async function safeRequest(url, options = {}) {
2  const controller = new AbortController();
3  const { signal } = controller;
4
5  // Définir un timeout (10 secondes)
6  const timeoutId = setTimeout(() => controller.abort(), 10000);
7
8  try {
9    const response = await fetch(url, { ...options, signal });
10    clearTimeout(timeoutId);
11
12    // Catégories d'erreurs HTTP
13    if (response.status >= 500) {
14      throw new Error('Erreur serveur, probablement un problème temporaire.');
15    } else if (response.status === 404) {
16      throw new Error('Ressource introuvable.');
17    } else if (response.status === 403) {
18      throw new Error('Aucune permission pour accéder à cette ressource.');
19    } else if (response.status === 401) {
20      throw new Error('Authentification requise. Merci de te reconnecter.');
21    } else if (!response.ok) {
22      throw new Error(`Erreur HTTP inattendue : ${response.status}`);
23    }
24
25    // Gérer différents types de réponse
26    const contentType = response.headers.get('content-type');
27    if (contentType && contentType.includes('application/json')) {
28      return await response.json();
29    } else if (contentType && contentType.includes('text/')) {
30      return await response.text();
31    } else {
32      return await response.blob();
33    }
34  } catch (error) {
35    clearTimeout(timeoutId);
36
37    // Gérer différents types d'erreurs
38    if (error.name === 'AbortError') {
39      throw new Error('Requête annulée - délai dépassé (10s).');
40    } else if (!navigator.onLine) {
41      throw new Error('Pas de connexion internet. Vérifie ta connexion réseau.');
42    } else {
43      console.error('Détails de l\'erreur :', error);
44      throw new Error(`Erreur de communication avec le serveur : ${error.message}`);
45    }
46  }
47}

Pattern Retry - nouvelles tentatives automatiques des requêtes

Sur le réseau du Parc Jurassique, des problèmes de connectivité temporaires peuvent survenir, surtout dans les zones reculées de l'île. Implémentons un mécanisme de nouvelles tentatives automatiques pour les requêtes échouées :

1/**
2 * Exécute une requête HTTP avec des nouvelles tentatives automatiques
3 * @param {Function} requestFn - Fonction effectuant la requête, retournant une Promise
4 * @param {Object} options - Options de configuration
5 * @returns {Promise} - Résultat de la requête ou dernière erreur après épuisement des tentatives
6 */
7async function withRetry(requestFn, options = {}) {
8  const {
9    maxAttempts = 3,
10    initialDelay = 1000,
11    delayFactor = 2,
12    maxDelay = 10000,
13    timeoutMs = 10000,
14    shouldRetry = (error) => {
15      // Par défaut, ne réessayer que pour les erreurs serveur (5xx) et les problèmes réseau
16      return (
17        error.message.includes('Erreur serveur') ||
18        error.message.includes('Pas de connexion') ||
19        error.name === 'AbortError'
20      );
21    }
22  } = options;
23
24  let lastError;
25  let attemptNum = 0;
26
27  while (attemptNum <= maxAttempts) {
28    try {
29      // Pour chaque tentative, créer un nouveau contrôleur d'annulation
30      const controller = new AbortController();
31      const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
32
33      // Si ce n'est pas la première tentative, attendre avant de réessayer
34      if (attemptNum > 0) {
35        const delayTime = Math.min(
36          initialDelay * Math.pow(delayFactor, attemptNum - 1),
37          maxDelay
38        );
39        console.log(`Tentative ${attemptNum}/${maxAttempts} échouée. Nouvelle tentative dans ${delayTime}ms...`);
40        await new Promise(resolve => setTimeout(resolve, delayTime));
41      }
42
43      // Exécuter la requête
44      const result = await requestFn(controller.signal);
45      clearTimeout(timeoutId);
46      return result;
47    } catch (error) {
48      clearTimeout(timeoutId);
49      lastError = error;
50
51      // Vérifier s'il faut réessayer
52      if (attemptNum === maxAttempts || !shouldRetry(error)) {
53        break;
54      }
55
56      attemptNum++;
57    }
58  }
59
60  // Lancer la dernière erreur après épuisement de toutes les tentatives
61  throw new Error(`Échec après ${maxAttempts} tentatives : ${lastError.message}`);
62}
63
64// Exemple d'utilisation
65async function fetchDinosaurInfo(id) {
66  return withRetry(
67    async (signal) => {
68      const response = await fetch(`https://api.jurassic-park.com/dinosaurs/${id}`, { signal });
69
70      if (!response.ok) {
71        throw new Error(`Erreur HTTP : ${response.status}`);
72      }
73
74      return response.json();
75    },
76    {
77      maxAttempts: 5,
78      initialDelay: 500,
79      shouldRetry: (error) => error.message.includes('Erreur HTTP : 5')
80    }
81  );
82}

File d'attente de requêtes et limitation des connexions simultanées

Dans les situations où nous devons effectuer de nombreuses requêtes HTTP mais voulons limiter le nombre de connexions simultanées pour ne pas surcharger le réseau ou le serveur, nous pouvons implémenter une file d'attente de requêtes :

1class TaskQueue {
2  constructor(maxConcurrency = 3) {
3    this.maxConcurrency = maxConcurrency;
4    this.activeTasks = 0;
5    this.queue = [];
6  }
7
8  async add(task) {
9    // S'il y a de la place pour plus de tâches parallèles, exécuter immédiatement
10    if (this.activeTasks < this.maxConcurrency) {
11      return this.execute(task);
12    }
13
14    // Sinon, ajouter à la file d'attente
15    return new Promise((resolve, reject) => {
16      this.queue.push({ task, resolve, reject });
17    });
18  }
19
20  async execute(task) {
21    this.activeTasks++;
22
23    try {
24      // Exécuter la tâche
25      const result = await task();
26      return result;
27    } catch (error) {
28      throw error;
29    } finally {
30      this.activeTasks--;
31      this.processNext();
32    }
33  }
34
35  processNext() {
36    if (this.queue.length === 0 || this.activeTasks >= this.maxConcurrency) {
37      return;
38    }
39
40    // Récupérer la prochaine tâche de la file
41    const { task, resolve, reject } = this.queue.shift();
42
43    // Exécuter la tâche et transmettre le résultat à la Promise en attente
44    this.execute(task)
45      .then(resolve)
46      .catch(reject);
47  }
48}
49
50// Exemple d'utilisation de la file pour surveiller de nombreux dinosaures
51async function monitorAllDinosaurs(idList) {
52  const queue = new TaskQueue(5); // Max 5 requêtes simultanées
53  const results = [];
54  const errors = [];
55
56  // Fonctions utilitaires pour suivre la progression
57  const totalTasks = idList.length;
58  let completedTasks = 0;
59
60  function updateProgress() {
61    completedTasks++;
62    const percent = Math.floor((completedTasks / totalTasks) * 100);
63    console.log(`Progression de la surveillance : ${percent}% (${completedTasks}/${totalTasks})`);
64  }
65
66  // Créer un tableau de Promises pour toutes les tâches
67  const promises = idList.map(id => {
68    return queue.add(async () => {
69      try {
70        console.log(`Surveillance du dinosaure #${id}...`);
71        const result = await fetchDinosaurInfo(id);
72        results.push(result);
73        updateProgress();
74        return result;
75      } catch (error) {
76        errors.push({ id, error: error.message });
77        updateProgress();
78        throw error;
79      }
80    }).catch(error => {
81      // Capturer les erreurs pour ne pas interrompre tout le processus
82      console.error(`Erreur lors de la surveillance du dinosaure #${id} :`, error.message);
83    });
84  });
85
86  // Attendre que toutes les tâches soient terminées
87  await Promise.all(promises);
88
89  return {
90    results,
91    errors,
92    summary: {
93      total: totalTasks,
94      success: results.length,
95      error: errors.length
96    }
97  };
98}

Gestion des données en cache (Caching)

Au Parc Jurassique, nous avons des données qui changent rarement (par ex. les espèces de dinosaures) et des données qui changent fréquemment (par ex. la localisation des dinosaures). Implémenter un cache aidera à optimiser la communication :

1class CacheManager {
2  constructor() {
3    this.cache = new Map();
4    this.stats = {
5      hits: 0,
6      misses: 0,
7      invalidations: 0
8    };
9  }
10
11  /**
12   * Récupère les données du cache ou exécute la fonction de récupération si les données ne sont pas en cache
13   * @param {string} key - Identifiant unique des données dans le cache
14   * @param {Function} fetchFn - Fonction pour récupérer les données si absentes du cache
15   * @param {Object} options - Options du cache
16   * @returns {Promise} - Promise avec les données
17   */
18  async get(key, fetchFn, options = {}) {
19    const {
20      ttl = 60000, // TTL par défaut : 1 minute
21      forceRefresh = false, // Forcer le rafraîchissement même si en cache
22      onlyIfCached = false // Retourner uniquement depuis le cache (pas de requête réseau)
23    } = options;
24
25    // Vérifier si les données sont en cache et toujours valides
26    const entry = this.cache.get(key);
27    const now = Date.now();
28
29    if (entry && !forceRefresh && now < entry.expiresAt) {
30      // Données trouvées en cache et toujours valides
31      this.stats.hits++;
32      return entry.data;
33    }
34
35    // Si on ne veut que des données en cache et qu'il n'y en a pas, retourner null
36    if (onlyIfCached) {
37      return null;
38    }
39
40    // Données absentes du cache ou rafraîchissement forcé
41    this.stats.misses++;
42
43    try {
44      // Récupérer les données
45      const data = await fetchFn();
46
47      // Enregistrer en cache
48      this.cache.set(key, {
49        data,
50        expiresAt: now + ttl,
51        addedAt: now
52      });
53
54      return data;
55    } catch (error) {
56      // Si on a des données périmées en cache, on peut les retourner en secours
57      if (entry) {
58        console.warn(`Erreur lors de la récupération des données pour ${key}, utilisation des données périmées du cache :`, error);
59        return entry.data;
60      }
61
62      // Sinon, propager l'erreur
63      throw error;
64    }
65  }
66
67  /**
68   * Invalide les entrées du cache correspondant à un motif
69   * @param {string|RegExp} pattern - Motif pour faire correspondre les clés du cache
70   */
71  invalidate(pattern) {
72    const isString = typeof pattern === 'string';
73    let count = 0;
74
75    for (const key of this.cache.keys()) {
76      if (isString ? key.includes(pattern) : pattern.test(key)) {
77        this.cache.delete(key);
78        count++;
79      }
80    }
81
82    this.stats.invalidations += count;
83    return count;
84  }
85
86  /**
87   * Vide toutes les entrées du cache
88   */
89  clear() {
90    const count = this.cache.size;
91    this.cache.clear();
92    this.stats.invalidations += count;
93    return count;
94  }
95
96  /**
97   * Retourne les statistiques d'utilisation du cache
98   */
99  getStats() {
100    const totalRequests = this.stats.hits + this.stats.misses;
101    const hitRate = totalRequests === 0 ? 0 : this.stats.hits / totalRequests;
102
103    return {
104      ...this.stats,
105      totalRequests,
106      hitRate: hitRate.toFixed(2),
107      activeEntries: this.cache.size
108    };
109  }
110}
111
112// Créer une instance du gestionnaire de cache
113const dinosaurCache = new CacheManager();
114
115// Exemple d'utilisation
116async function fetchDinosaurInfoWithCache(id, options = {}) {
117  return dinosaurCache.get(
118    `dino-${id}`,
119    async () => {
120      const response = await fetch(`https://api.jurassic-park.com/dinosaurs/${id}`);
121      if (!response.ok) {
122        throw new Error(`Erreur HTTP : ${response.status}`);
123      }
124      return response.json();
125    },
126    {
127      ttl: 300000, // 5 minutes pour les données de dinosaures
128      ...options
129    }
130  );
131}
132
133// Fonction pour invalider le cache après une mise à jour
134async function updateDinosaur(id, data) {
135  const response = await fetch(`https://api.jurassic-park.com/dinosaurs/${id}`, {
136    method: 'PUT',
137    headers: { 'Content-Type': 'application/json' },
138    body: JSON.stringify(data)
139  });
140
141  if (!response.ok) {
142    throw new Error(`Erreur de mise à jour : ${response.status}`);
143  }
144
145  // Invalider le cache de ce dinosaure
146  dinosaurCache.invalidate(`dino-${id}`);
147
148  // Invalider aussi les listes de dinosaures qui pourraient contenir ce dinosaure
149  dinosaurCache.invalidate('dino-list');
150
151  return response.json();
152}
153
154// Récupérer la liste des dinosaures avec un cache plus court (les données changent plus souvent)
155async function getDinosaurList(filter = {}) {
156  const queryParams = new URLSearchParams(filter).toString();
157  const cacheKey = `dino-list-${queryParams || 'all'}`;
158
159  return dinosaurCache.get(
160    cacheKey,
161    async () => {
162      const url = `https://api.jurassic-park.com/dinosaurs?${queryParams}`;
163      const response = await fetch(url);
164      if (!response.ok) {
165        throw new Error(`Erreur HTTP : ${response.status}`);
166      }
167      return response.json();
168    },
169    { ttl: 60000 } // 1 minute pour les listes de dinosaures
170  );
171}

Gestion des requêtes synchronisées avec le serveur

Au Parc Jurassique, nous devons parfois effectuer des opérations qui nécessitent une synchronisation avec le serveur, comme la mise à jour de l'état de santé des dinosaures. Implémentons un mécanisme qui garantit l'enregistrement des données même en cas de problèmes de connectivité :

1class SyncQueue {
2  constructor(options = {}) {
3    // Clé localStorage pour stocker la file
4    this.key = options.key || 'dinosaurs-sync-queue';
5
6    // Charger la file enregistrée
7    this.queue = this.loadQueue();
8
9    // Indicateur précisant si la synchronisation est active
10    this.isSyncing = false;
11
12    // Démarrage automatique de la synchronisation
13    if (options.autoSync !== false) {
14      this.startSync();
15    }
16
17    // Écouter les changements d'état online/offline
18    window.addEventListener('online', () => {
19      console.log('Connexion rétablie. Démarrage de la synchronisation...');
20      this.startSync();
21    });
22
23    window.addEventListener('offline', () => {
24      console.log('Connexion perdue. Synchronisation en pause.');
25    });
26  }
27
28  /**
29   * Charge la file depuis localStorage
30   */
31  loadQueue() {
32    try {
33      const savedQueue = localStorage.getItem(this.key);
34      return savedQueue ? JSON.parse(savedQueue) : [];
35    } catch (error) {
36      console.error('Erreur lors du chargement de la file de synchronisation :', error);
37      return [];
38    }
39  }
40
41  /**
42   * Enregistre la file dans localStorage
43   */
44  saveQueue() {
45    try {
46      localStorage.setItem(this.key, JSON.stringify(this.queue));
47    } catch (error) {
48      console.error('Erreur lors de l\'enregistrement de la file de synchronisation :', error);
49    }
50  }
51
52  /**
53   * Ajoute une opération à la file de synchronisation
54   * @param {string} url - URL de la requête
55   * @param {Object} options - Options de Fetch
56   * @param {string} type - Type d'opération (par ex. 'mise-a-jour-sante')
57   * @returns {string} - ID de l'opération dans la file
58   */
59  addToQueue(url, options = {}, type = 'inconnu') {
60    const id = `op-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
61
62    this.queue.push({
63      id,
64      url,
65      options,
66      type,
67      addedAt: new Date().toISOString(),
68      attempts: 0
69    });
70
71    this.saveQueue();
72    this.startSync();
73
74    return id;
75  }
76
77  /**
78   * Démarre le processus de synchronisation
79   */
80  async startSync() {
81    // Si la synchronisation est déjà en cours ou pas de connexion, ne rien faire
82    if (this.isSyncing || !navigator.onLine || this.queue.length === 0) {
83      return;
84    }
85
86    this.isSyncing = true;
87
88    try {
89      let index = 0;
90      while (index < this.queue.length) {
91        const operation = this.queue[index];
92
93        try {
94          console.log(`Synchronisation de l'opération : ${operation.type} (${operation.id})`);
95
96          // Incrémenter le nombre de tentatives
97          operation.attempts++;
98          this.saveQueue();
99
100          // Exécuter la requête
101          const response = await fetch(operation.url, operation.options);
102
103          if (!response.ok) {
104            throw new Error(`Erreur HTTP : ${response.status}`);
105          }
106
107          // Opération réussie, retirer de la file
108          this.queue.splice(index, 1);
109          this.saveQueue();
110
111          console.log(`Opération ${operation.id} synchronisée avec succès`);
112
113          // Ne pas incrémenter index car on a retiré l'élément
114        } catch (error) {
115          console.error(`Erreur lors de la synchronisation de l'opération ${operation.id} :`, error);
116
117          // Si on a atteint le nombre max de tentatives, marquer comme échouée
118          if (operation.attempts >= 5) {
119            console.warn(`L'opération ${operation.id} a dépassé la limite de tentatives. Marquée comme échouée.`);
120            operation.status = 'échouée';
121            operation.errorMessage = error.message;
122            this.saveQueue();
123          }
124
125          // Passer à l'opération suivante
126          index++;
127        }
128      }
129    } finally {
130      this.isSyncing = false;
131
132      // S'il reste des éléments dans la file, réessayer dans 1 minute
133      if (this.queue.some(op => !op.status || op.status !== 'échouée')) {
134        setTimeout(() => this.startSync(), 60000);
135      }
136    }
137  }
138
139  /**
140   * Retire les opérations échouées de la file
141   */
142  clearFailedOperations() {
143    const previousCount = this.queue.length;
144    this.queue = this.queue.filter(op => !op.status || op.status !== 'échouée');
145    this.saveQueue();
146    return previousCount - this.queue.length;
147  }
148
149  /**
150   * Retourne l'état actuel de la file
151   */
152  getStatus() {
153    const total = this.queue.length;
154    const failed = this.queue.filter(op => op.status === 'échouée').length;
155    const pending = total - failed;
156
157    return {
158      total,
159      pending,
160      failed,
161      isSyncing: this.isSyncing
162    };
163  }
164}
165
166// Créer une instance de la file de synchronisation
167const dinosaurSyncQueue = new SyncQueue({
168  key: 'jurassic-park-dino-sync'
169});
170
171// Exemple d'utilisation : mise à jour de la santé d'un dinosaure qui sera synchronisée
172function updateDinosaurHealth(id, healthParams) {
173  // Préparer les données à envoyer
174  const data = {
175    id,
176    health: healthParams.health,
177    lastCheckTime: new Date().toISOString(),
178    symptoms: healthParams.symptoms || [],
179    updatedBy: 'dr-wu'
180  };
181
182  // Si on est en ligne, essayer immédiatement
183  if (navigator.onLine) {
184    return fetch(`https://api.jurassic-park.com/dinosaurs/${id}/health`, {
185      method: 'PUT',
186      headers: { 'Content-Type': 'application/json' },
187      body: JSON.stringify(data)
188    })
189    .then(response => {
190      if (!response.ok) {
191        throw new Error(`Erreur HTTP : ${response.status}`);
192      }
193      return response.json();
194    })
195    .catch(error => {
196      console.warn('Échec de la mise à jour de la santé en ligne, ajout à la file de synchronisation');
197
198      // Ajouter à la file de synchronisation
199      dinosaurSyncQueue.addToQueue(
200        `https://api.jurassic-park.com/dinosaurs/${id}/health`,
201        {
202          method: 'PUT',
203          headers: { 'Content-Type': 'application/json' },
204          body: JSON.stringify(data)
205        },
206        `mise-a-jour-sante-${id}`
207      );
208
209      // Retourner les données comme si l'opération avait réussi, bien qu'elle soit synchronisée plus tard
210      return {
211        ...data,
212        pendingSync: true
213      };
214    });
215  } else {
216    // Si on est hors ligne, ajouter immédiatement à la file
217    dinosaurSyncQueue.addToQueue(
218      `https://api.jurassic-park.com/dinosaurs/${id}/health`,
219      {
220        method: 'PUT',
221        headers: { 'Content-Type': 'application/json' },
222        body: JSON.stringify(data)
223      },
224      `mise-a-jour-sante-${id}`
225    );
226
227    return Promise.resolve({
228      ...data,
229      pendingSync: true
230    });
231  }
232}

Authentification et rafraîchissement du token

Dans le système du Parc Jurassique, nous devons sécuriser l'accès à l'API à l'aide de tokens d'authentification. Implémentons un mécanisme qui rafraîchit automatiquement le token lorsqu'il expire :

1class AuthManager {
2  constructor() {
3    this.tokenAccessKey = 'jp-access-token';
4    this.tokenRefreshKey = 'jp-refresh-token';
5    this.expiryKey = 'jp-token-expiry';
6    this.refreshInProgress = false;
7    this.refreshListeners = [];
8  }
9
10  /**
11   * Récupère le token d'accès actuel
12   * @param {boolean} autoRefresh - Rafraîchir automatiquement le token s'il a expiré
13   * @returns {Promise<string>} - Promise avec le token d'accès
14   */
15  async getAccessToken(autoRefresh = true) {
16    const token = localStorage.getItem(this.tokenAccessKey);
17    const expiry = localStorage.getItem(this.expiryKey);
18
19    // Vérifier si le token existe et n'a pas expiré
20    if (token && expiry) {
21      const expiryDate = new Date(expiry);
22      const now = new Date();
23
24      // Ajouter une marge de 1 minute pour éviter d'utiliser un token sur le point d'expirer
25      const bufferTime = 60 * 1000; // 1 minute en millisecondes
26
27      if (expiryDate.getTime() - now.getTime() > bufferTime) {
28        return token;
29      }
30
31      // Le token a expiré ou va bientôt expirer
32      if (autoRefresh) {
33        return this.refreshToken();
34      }
35    }
36
37    throw new Error('Aucun token d\'accès valide');
38  }
39
40  /**
41   * Rafraîchit le token d'accès à l'aide du refresh token
42   * @returns {Promise<string>} - Promise avec le nouveau token d'accès
43   */
44  async refreshToken() {
45    // Si un rafraîchissement est déjà en cours, attendre son résultat
46    if (this.refreshInProgress) {
47      return new Promise((resolve, reject) => {
48        this.refreshListeners.push({ resolve, reject });
49      });
50    }
51
52    this.refreshInProgress = true;
53
54    try {
55      const refreshToken = localStorage.getItem(this.tokenRefreshKey);
56
57      if (!refreshToken) {
58        throw new Error('Aucun refresh token');
59      }
60
61      const response = await fetch('https://api.jurassic-park.com/auth/refresh', {
62        method: 'POST',
63        headers: { 'Content-Type': 'application/json' },
64        body: JSON.stringify({ refresh_token: refreshToken })
65      });
66
67      if (!response.ok) {
68        if (response.status === 401) {
69          // Le refresh token est invalide, déconnecter l'utilisateur
70          this.logout();
71          throw new Error('Session expirée. Merci de te reconnecter.');
72        }
73        throw new Error(`Erreur de rafraîchissement du token : ${response.status}`);
74      }
75
76      const data = await response.json();
77
78      // Enregistrer les nouveaux tokens
79      localStorage.setItem(this.tokenAccessKey, data.access_token);
80      localStorage.setItem(this.tokenRefreshKey, data.refresh_token);
81
82      // Calculer le temps d'expiration
83      const expiry = new Date();
84      expiry.setSeconds(expiry.getSeconds() + data.expires_in);
85      localStorage.setItem(this.expiryKey, expiry.toISOString());
86
87      // Notifier tous les écouteurs en attente
88      this.refreshListeners.forEach(listener => {
89        listener.resolve(data.access_token);
90      });
91
92      this.refreshListeners = [];
93      return data.access_token;
94    } catch (error) {
95      // En cas d'erreur, notifier tous les écouteurs en attente
96      this.refreshListeners.forEach(listener => {
97        listener.reject(error);
98      });
99
100      this.refreshListeners = [];
101      throw error;
102    } finally {
103      this.refreshInProgress = false;
104    }
105  }
106
107  /**
108   * Enregistre les tokens après connexion
109   * @param {Object} authData - Données d'authentification
110   */
111  saveTokens(authData) {
112    localStorage.setItem(this.tokenAccessKey, authData.access_token);
113    localStorage.setItem(this.tokenRefreshKey, authData.refresh_token);
114
115    // Calculer le temps d'expiration
116    const expiry = new Date();
117    expiry.setSeconds(expiry.getSeconds() + authData.expires_in);
118    localStorage.setItem(this.expiryKey, expiry.toISOString());
119  }
120
121  /**
122   * Déconnecte l'utilisateur
123   */
124  logout() {
125    localStorage.removeItem(this.tokenAccessKey);
126    localStorage.removeItem(this.tokenRefreshKey);
127    localStorage.removeItem(this.expiryKey);
128  }
129
130  /**
131   * Vérifie si l'utilisateur est connecté
132   * @returns {boolean}
133   */
134  isLoggedIn() {
135    return !!localStorage.getItem(this.tokenAccessKey);
136  }
137}
138
139// Créer une instance du gestionnaire d'authentification
140const auth = new AuthManager();
141
142// Exemple d'utilisation - fonction pour effectuer des requêtes authentifiées
143async function authenticatedRequest(url, options = {}) {
144  try {
145    // Récupérer le token d'accès (avec rafraîchissement automatique)
146    const token = await auth.getAccessToken();
147
148    // Ajouter le token aux en-têtes
149    const authOptions = {
150      ...options,
151      headers: {
152        ...options.headers,
153        'Authorization': `Bearer ${token}`
154      }
155    };
156
157    // Exécuter la requête
158    const response = await fetch(url, authOptions);
159
160    // Gérer les erreurs d'autorisation
161    if (response.status === 401) {
162      // Essayer de rafraîchir le token et de réessayer
163      try {
164        const newToken = await auth.refreshToken();
165
166        // Réessayer la requête avec le nouveau token
167        const refreshedOptions = {
168          ...options,
169          headers: {
170            ...options.headers,
171            'Authorization': `Bearer ${newToken}`
172          }
173        };
174
175        return fetch(url, refreshedOptions);
176      } catch (refreshError) {
177        // Le rafraîchissement du token a échoué, déconnecter l'utilisateur
178        auth.logout();
179        window.location.href = '/login'; // Rediriger vers la connexion
180        throw new Error('Session expirée. Merci de te reconnecter.');
181      }
182    }
183
184    return response;
185  } catch (error) {
186    console.error('Erreur de requête authentifiée :', error);
187    throw error;
188  }
189}
190
191// Exemple d'utilisation
192async function fetchEnclosureData(enclosureId) {
193  const response = await authenticatedRequest(
194    `https://api.jurassic-park.com/paddocks/${enclosureId}`
195  );
196
197  if (!response.ok) {
198    throw new Error(`Erreur lors de la récupération des données de l'enclos : ${response.status}`);
199  }
200
201  return response.json();
202}
203
204// Fonction de connexion
205async function login(email, password) {
206  try {
207    const response = await fetch('https://api.jurassic-park.com/auth/login', {
208      method: 'POST',
209      headers: { 'Content-Type': 'application/json' },
210      body: JSON.stringify({ email, password: password })
211    });
212
213    if (!response.ok) {
214      throw new Error(`Erreur de connexion : ${response.status}`);
215    }
216
217    const authData = await response.json();
218
219    // Enregistrer les tokens
220    auth.saveTokens(authData);
221
222    return {
223      success: true,
224      user: authData.user
225    };
226  } catch (error) {
227    console.error('Erreur de connexion :', error);
228    return {
229      success: false,
230      error: error.message
231    };
232  }
233}

Téléversement de fichiers avec progression

Au Parc Jurassique, nous devons souvent téléverser de gros fichiers, comme des images ou des vidéos des caméras de surveillance des dinosaures. Implémentons une fonction qui gère le téléversement de fichiers avec rapport de progression :

1/**
2 * Envoie un fichier au serveur avec suivi de progression
3 * @param {File} file - Fichier à envoyer
4 * @param {string} url - URL cible
5 * @param {Object} options - Options supplémentaires
6 * @returns {Promise} - Promise avec le résultat de l'opération
7 */
8async function sendFile(file, url, options = {}) {
9  return new Promise((resolve, reject) => {
10    // Options par défaut
11    const {
12      method = 'POST',
13      fieldName = 'file',
14      additionalData = {},
15      onProgress = null,
16      onError = null,
17      maxRetries = 3
18    } = options;
19
20    // Compteur de tentatives
21    let attempts = 0;
22
23    // Fonction pour exécuter le téléversement
24    function performUpload() {
25      // Créer un objet XHR
26      const xhr = new XMLHttpRequest();
27
28      // Configurer les gestionnaires d'événements
29      xhr.upload.addEventListener('progress', (event) => {
30        if (event.lengthComputable && typeof onProgress === 'function') {
31          const percent = Math.round((event.loaded / event.total) * 100);
32          onProgress({
33            loaded: event.loaded,
34            total: event.total,
35            percent
36          });
37        }
38      });
39
40      xhr.addEventListener('load', () => {
41        if (xhr.status >= 200 && xhr.status < 300) {
42          try {
43            // Analyser la réponse JSON
44            const responseData = JSON.parse(xhr.responseText);
45            resolve(responseData);
46          } catch (e) {
47            resolve({
48              success: true,
49              status: xhr.status,
50              message: xhr.statusText
51            });
52          }
53        } else {
54          const error = new Error(`Erreur HTTP : ${xhr.status}`);
55          error.status = xhr.status;
56          error.statusText = xhr.statusText;
57
58          if (xhr.status >= 500 && attempts < maxRetries) {
59            // Pour les erreurs serveur, réessayer
60            attempts++;
61            console.warn(`Erreur de téléversement (${xhr.status}). Nouvelle tentative ${attempts}/${maxRetries}...`);
62            setTimeout(performUpload, 2000 * attempts); // Délai croissant entre les tentatives
63          } else {
64            if (typeof onError === 'function') {
65              onError(error);
66            }
67            reject(error);
68          }
69        }
70      });
71
72      xhr.addEventListener('error', () => {
73        const error = new Error('Erreur réseau pendant le téléversement du fichier');
74        if (typeof onError === 'function') {
75          onError(error);
76        }
77
78        if (attempts < maxRetries) {
79          attempts++;
80          console.warn(`Erreur réseau pendant le téléversement. Nouvelle tentative ${attempts}/${maxRetries}...`);
81          setTimeout(performUpload, 2000 * attempts);
82        } else {
83          reject(error);
84        }
85      });
86
87      xhr.addEventListener('abort', () => {
88        const error = new Error('Le téléversement du fichier a été annulé');
89        if (typeof onError === 'function') {
90          onError(error);
91        }
92        reject(error);
93      });
94
95      // Ouvrir la connexion
96      xhr.open(method, url, true);
97
98      // Préparer FormData
99      const formData = new FormData();
100      formData.append(fieldName, file);
101
102      // Ajouter les données supplémentaires
103      Object.entries(additionalData).forEach(([key, value]) => {
104        formData.append(key, value);
105      });
106
107      // Envoyer la requête
108      xhr.send(formData);
109
110      // Retourner un objet avec une méthode d'annulation
111      return {
112        abort: () => xhr.abort()
113      };
114    }
115
116    // Démarrer le téléversement
117    performUpload();
118  });
119}
120
121// Exemple d'utilisation
122async function sendCameraFootage(file, location) {
123  const controller = document.getElementById('upload-controller');
124  const progressBar = document.getElementById('upload-progress');
125  const statusText = document.getElementById('upload-status');
126
127  controller.classList.remove('hidden');
128  statusText.textContent = 'Préparation...';
129
130  try {
131    const result = await sendFile(
132      file,
133      'https://api.jurassic-park.com/security/camera-footage',
134      {
135        additionalData: {
136          location: location,
137          timestamp: new Date().toISOString(),
138          operator: 'dr-wu'
139        },
140        onProgress: (info) => {
141          progressBar.value = info.percent;
142          statusText.textContent = `Téléversement : ${info.percent}%`;
143        },
144        onError: (error) => {
145          statusText.textContent = `Erreur : ${error.message}`;
146          progressBar.classList.add('error');
147        }
148      }
149    );
150
151    statusText.textContent = 'Téléversement terminé !';
152    progressBar.value = 100;
153
154    setTimeout(() => {
155      controller.classList.add('hidden');
156    }, 3000);
157
158    return result;
159  } catch (error) {
160    statusText.textContent = `Erreur : ${error.message}`;
161    progressBar.classList.add('error');
162    throw error;
163  }
164}

Surveillance de l'état du réseau

Au Parc Jurassique, nous devons être préparés aux problèmes de connectivité, surtout dans les zones reculées de l'île. Implémentons un système de surveillance de l'état du réseau :

1class NetworkMonitor {
2  constructor(options = {}) {
3    this.listeners = {
4      change: [],
5      online: [],
6      offline: []
7    };
8
9    this.lastPing = null;
10    this.delayTime = null;
11    this.status = navigator.onLine ? 'online' : 'offline';
12    this.endpointUrl = options.endpointPing || 'https://api.jurassic-park.com/ping';
13    this.pingIntervalMs = options.pingIntervalMs || 30000; // 30 secondes
14    this.pingTimeout = options.pingTimeout || 5000; // 5 secondes
15
16    // Ajouter les écouteurs pour les événements online/offline
17    window.addEventListener('online', this.handleOnline.bind(this));
18    window.addEventListener('offline', this.handleOffline.bind(this));
19
20    // Démarrer les pings réguliers
21    if (options.autostartPing !== false) {
22      this.startPinging();
23    }
24  }
25
26  /**
27   * Gère la transition vers le mode en ligne
28   */
29  handleOnline() {
30    const previousStatus = this.status;
31    this.status = 'online';
32
33    // Notifier les écouteurs
34    this.notifyListeners('online');
35
36    if (previousStatus !== this.status) {
37      this.notifyListeners('change', { status: this.status, previous: previousStatus });
38    }
39
40    // Effectuer un ping immédiat pour vérifier la connectivité réelle
41    this.ping();
42  }
43
44  /**
45   * Gère la transition vers le mode hors ligne
46   */
47  handleOffline() {
48    const previousStatus = this.status;
49    this.status = 'offline';
50    this.delayTime = null;
51
52    // Notifier les écouteurs
53    this.notifyListeners('offline');
54
55    if (previousStatus !== this.status) {
56      this.notifyListeners('change', { status: this.status, previous: previousStatus });
57    }
58  }
59
60  /**
61   * Ping le serveur pour vérifier la connectivité réelle
62   * @returns {Promise<number>} - Promise avec la latence en ms
63   */
64  async ping() {
65    if (!navigator.onLine) {
66      this.delayTime = null;
67      return Promise.reject(new Error('Le navigateur est en mode hors ligne'));
68    }
69
70    try {
71      const startTime = Date.now();
72
73      // Définir un timeout pour fetch
74      const controller = new AbortController();
75      const timeoutId = setTimeout(() => controller.abort(), this.pingTimeout);
76
77      const response = await fetch(`${this.endpointUrl}?t=${startTime}`, {
78        method: 'GET',
79        cache: 'no-store',
80        signal: controller.signal
81      });
82
83      clearTimeout(timeoutId);
84
85      if (!response.ok) {
86        throw new Error(`Erreur HTTP : ${response.status}`);
87      }
88
89      // Calculer la latence
90      const endTime = Date.now();
91      this.delayTime = endTime - startTime;
92      this.lastPing = new Date();
93
94      // Mettre à jour le statut
95      const previousStatus = this.status;
96      this.status = 'online';
97
98      if (previousStatus !== this.status) {
99        this.notifyListeners('change', { status: this.status, previous: previousStatus });
100        this.notifyListeners('online');
101      }
102
103      return this.delayTime;
104    } catch (error) {
105      // Si timeout ou erreur réseau, marquer comme hors ligne
106      if (error.name === 'AbortError' || error instanceof TypeError) {
107        const previousStatus = this.status;
108        this.status = 'offline';
109        this.delayTime = null;
110
111        if (previousStatus !== this.status) {
112          this.notifyListeners('change', { status: this.status, previous: previousStatus });
113          this.notifyListeners('offline');
114        }
115      }
116
117      throw error;
118    }
119  }
120
121  /**
122   * Démarre les pings réguliers du serveur
123   */
124  startPinging() {
125    // Arrêter l'intervalle existant, le cas échéant
126    this.stopPinging();
127
128    // Effectuer un premier ping immédiatement
129    this.ping().catch(e => console.warn('Erreur du ping initial :', e));
130
131    // Définir l'intervalle de ping
132    this.pingInterval = setInterval(() => {
133      this.ping().catch(e => console.warn('Erreur de ping :', e));
134    }, this.pingIntervalMs);
135  }
136
137  /**
138   * Arrête les pings réguliers du serveur
139   */
140  stopPinging() {
141    if (this.pingInterval) {
142      clearInterval(this.pingInterval);
143      this.pingInterval = null;
144    }
145  }
146
147  /**
148   * Ajoute un écouteur d'événement
149   * @param {string} eventType - Type d'événement ('change', 'online', 'offline')
150   * @param {Function} callback - Fonction callback
151   */
152  on(eventType, callback) {
153    if (this.listeners[eventType]) {
154      this.listeners[eventType].push(callback);
155    }
156    return this; // Pour une API en chaîne
157  }
158
159  /**
160   * Retire un écouteur
161   * @param {string} eventType - Type d'événement
162   * @param {Function} callback - Fonction callback à retirer
163   */
164  off(eventType, callback) {
165    if (this.listeners[eventType]) {
166      this.listeners[eventType] = this.listeners[eventType].filter(cb => cb !== callback);
167    }
168    return this;
169  }
170
171  /**
172   * Notifie les écouteurs d'un événement
173   * @param {string} eventType - Type d'événement
174   * @param {Object} data - Données de l'événement
175   */
176  notifyListeners(eventType, data = {}) {
177    if (this.listeners[eventType]) {
178      this.listeners[eventType].forEach(callback => {
179        try {
180          callback({
181            type: eventType,
182            timestamp: new Date(),
183            status: this.status,
184            delayTime: this.delayTime,
185            ...data
186          });
187        } catch (e) {
188          console.error(`Erreur dans l'écouteur ${eventType} :`, e);
189        }
190      });
191    }
192  }
193
194  /**
195   * Retourne l'état actuel du réseau
196   */
197  getState() {
198    return {
199      status: this.status,
200      delayTime: this.delayTime,
201      lastPing: this.lastPing,
202      browserOnline: navigator.onLine
203    };
204  }
205}
206
207// Créer une instance du moniteur réseau
208const networkMonitor = new NetworkMonitor();
209
210// Exemple d'utilisation
211networkMonitor.on('change', (info) => {
212  console.log(`Changement d'état du réseau : ${info.previous} -> ${info.status}`);
213
214  // Mettre à jour l'interface
215  const statusIndicator = document.getElementById('network-status');
216  if (statusIndicator) {
217    statusIndicator.className = `status-${info.status}`;
218    statusIndicator.textContent = info.status === 'online'
219      ? `En ligne (ping : ${info.delayTime}ms)`
220      : 'Hors ligne';
221  }
222});
223
224networkMonitor.on('offline', () => {
225  // Afficher une notification à l'utilisateur
226  showNotification('Connexion réseau perdue', 'error');
227
228  // Activer le mode hors ligne dans l'application
229  app.enableOfflineMode();
230});
231
232networkMonitor.on('online', (info) => {
233  showNotification(`Connexion rétablie (ping : ${info.delayTime}ms)`, 'success');
234
235  // Synchroniser les données avec le serveur
236  dinosaurSyncQueue.startSync();
237
238  // Désactiver le mode hors ligne
239  app.disableOfflineMode();
240});
241
242function showNotification(text, type) {
243  const notification = document.createElement('div');
244  notification.className = `notification ${type}`;
245  notification.textContent = text;
246
247  document.body.appendChild(notification);
248
249  // Retirer la notification après 5 secondes
250  setTimeout(() => {
251    notification.classList.add('fade-out');
252    setTimeout(() => {
253      document.body.removeChild(notification);
254    }, 500);
255  }, 5000);
256}

Résumé - Construire un client API complet

En combinant toutes les techniques abordées, nous pouvons créer un système de communication API très performant, résistant aux erreurs et convivial. Voici une solution complète :

1/**
2 * Client API complet pour le système du Parc Jurassique
3 */
4class JurassicAPIClient {
5  constructor(options = {}) {
6    // Configuration de l'URL de base
7    this.baseUrl = options.baseUrl || 'https://api.jurassic-park.com';
8
9    // Initialiser le gestionnaire d'authentification
10    this.auth = new AuthManager();
11
12    // Initialiser le cache
13    this.cache = new CacheManager();
14
15    // Initialiser la file de synchronisation
16    this.syncQueue = new SyncQueue({
17      key: 'jp-sync-queue',
18      autoSync: true
19    });
20
21    // Initialiser le moniteur réseau
22    this.networkMonitor = new NetworkMonitor({
23      endpointPing: `${this.baseUrl}/ping`,
24      pingIntervalMs: 30000
25    });
26
27    // Surveiller l'état du réseau
28    this.networkMonitor.on('online', () => {
29      this.syncQueue.startSync();
30    });
31
32    // Options de requête par défaut
33    this.defaultOptions = {
34      timeout: 30000,
35      retries: 3,
36      cacheTime: 300000, // 5 minutes
37      useCache: true
38    };
39  }
40
41  /**
42   * Exécute une requête HTTP
43   * @param {string} endpoint - Endpoint de l'API
44   * @param {Object} options - Options de la requête
45   * @returns {Promise} - Promise avec le résultat de la requête
46   */
47  async request(endpoint, options = {}) {
48    // Fusionner les options par défaut avec celles fournies
49    const {
50      method = 'GET',
51      body,
52      headers = {},
53      useAuth = true,
54      useCache = this.defaultOptions.useCache,
55      cacheTime = this.defaultOptions.cacheTime,
56      forceRefresh = false,
57      timeout = this.defaultOptions.timeout,
58      retries = this.defaultOptions.retries,
59      offlineFallback = true
60    } = options;
61
62    // Préparer la clé de cache
63    const cacheKey = `${method}:${endpoint}:${JSON.stringify(body || {})}`;
64
65    // Vérifier si la requête est en cache (uniquement pour GET)
66    if (method === 'GET' && useCache && !forceRefresh) {
67      try {
68        const cachedData = await this.cache.get(
69          cacheKey,
70          () => {}, // Fonction vide - on ne veut pas encore exécuter la requête
71          { ttl: cacheTime, onlyIfCached: true }
72        );
73
74        if (cachedData) {
75          return cachedData;
76        }
77      } catch (error) {
78        // Ignorer les erreurs de cache
79      }
80    }
81
82    // Vérifier l'état du réseau
83    if (!this.networkMonitor.getState().status === 'online') {
84      // On est hors ligne, vérifier si on a des données en cache
85      if (method === 'GET' && useCache && offlineFallback) {
86        try {
87          const cachedData = await this.cache.get(
88            cacheKey,
89            () => {}, // Fonction vide - on ne veut pas encore exécuter la requête
90            { ttl: Infinity, onlyIfCached: true, ignoreExpiry: true }
91          );
92
93          if (cachedData) {
94            // Information sur le mode hors ligne
95            return {
96              ...cachedData,
97              _meta: {
98                fromCache: true,
99                offlineMode: true,
100                timestamp: new Date()
101              }
102            };
103          }
104        } catch (error) {
105          // Ignorer les erreurs de cache
106        }
107      }
108
109      // S'il s'agit d'une requête modifiant les données, l'ajouter à la file de synchronisation
110      if (method !== 'GET' && offlineFallback) {
111        return this.addToSyncQueue(endpoint, { method, body, headers, useAuth });
112      }
113
114      throw new Error('Pas de connexion réseau. Impossible d\'exécuter la requête.');
115    }
116
117    // Fonction qui exécute la requête réelle
118    const executeRequest = async (signal) => {
119      // Préparer l'URL
120      const url = `${this.baseUrl}${endpoint}`;
121
122      // Préparer les options de la requête
123      const requestOptions = {
124        method,
125        headers: {
126          'Content-Type': 'application/json',
127          ...headers
128        },
129        signal
130      };
131
132      // Ajouter le corps de la requête s'il existe
133      if (body) {
134        requestOptions.body = JSON.stringify(body);
135      }
136
137      // Ajouter le token d'authentification si requis
138      if (useAuth) {
139        try {
140          const token = await this.auth.getAccessToken();
141          requestOptions.headers['Authorization'] = `Bearer ${token}`;
142        } catch (error) {
143          // Si impossible d'obtenir le token et que la requête nécessite l'auth
144          throw new Error('Non authentifié. Merci de te reconnecter.');
145        }
146      }
147
148      // Exécuter la requête avec timeout
149      const controller = new AbortController();
150      const { signal: abortSignal } = controller;
151      if (signal) {
152        // Si un signal externe a été passé, le combiner avec le nôtre
153        signal.addEventListener('abort', () => controller.abort());
154      }
155
156      const timeoutId = setTimeout(() => controller.abort(), timeout);
157      requestOptions.signal = abortSignal;
158
159      try {
160        const response = await fetch(url, requestOptions);
161        clearTimeout(timeoutId);
162
163        // Gérer les erreurs HTTP
164        if (response.status === 401 && useAuth) {
165          // Essayer de rafraîchir le token
166          try {
167            await this.auth.refreshToken();
168            // Réessayer avec le nouveau token
169            return this.request(endpoint, options);
170          } catch (refreshError) {
171            throw new Error('Session expirée. Merci de te reconnecter.');
172          }
173        }
174
175        if (!response.ok) {
176          const error = new Error(`Erreur HTTP : ${response.status}`);
177          error.status = response.status;
178          error.statusText = response.statusText;
179
180          // Essayer de récupérer les détails de l'erreur depuis la réponse
181          try {
182            const errorData = await response.json();
183            error.data = errorData;
184            error.message = errorData.message || error.message;
185          } catch (e) {
186            // Ignorer les erreurs d'analyse
187          }
188
189          throw error;
190        }
191
192        // Analyser la réponse selon son type
193        const contentType = response.headers.get('content-type');
194        let data;
195
196        if (contentType && contentType.includes('application/json')) {
197          data = await response.json();
198        } else if (contentType && contentType.includes('text/')) {
199          data = await response.text();
200        } else {
201          data = await response.blob();
202        }
203
204        // Enregistrer la réponse en cache pour les requêtes GET
205        if (method === 'GET' && useCache) {
206          this.cache.get(cacheKey, () => data, { ttl: cacheTime, forceRefresh: true });
207        }
208
209        return data;
210      } catch (error) {
211        clearTimeout(timeoutId);
212
213        if (error.name === 'AbortError') {
214          throw new Error(`Requête annulée après expiration du délai (${timeout}ms)`);
215        }
216
217        throw error;
218      }
219    };
220
221    // Exécuter la requête avec des nouvelles tentatives
222    return withRetry(
223      (signal) => executeRequest(signal),
224      {
225        maxAttempts: retries,
226        initialDelay: 1000,
227        delayFactor: 2,
228        timeoutMs: timeout
229      }
230    );
231  }
232
233  /**
234   * Ajoute une requête à la file de synchronisation (mode hors ligne)
235   * @param {string} endpoint - Endpoint de l'API
236   * @param {Object} options - Options de la requête
237   * @returns {Promise} - Promise avec un résultat temporaire
238   */
239  addToSyncQueue(endpoint, options) {
240    const { method, body } = options;
241
242    // Générer un ID d'opération unique
243    const operationId = `op-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
244
245    // Ajouter à la file de synchronisation
246    this.syncQueue.addToQueue(
247      `${this.baseUrl}${endpoint}`,
248      {
249        method,
250        headers: {
251          'Content-Type': 'application/json',
252          // Le token sera ajouté lors de la synchronisation réelle
253        },
254        body: body ? JSON.stringify(body) : undefined
255      },
256      `${method}-${endpoint}`
257    );
258
259    // Retourner une réponse temporaire
260    return Promise.resolve({
261      _meta: {
262        pendingSync: true,
263        operationId,
264        timestamp: new Date(),
265        offlineMode: true
266      },
267      ...body // Retourner les données passées comme résultat temporaire
268    });
269  }
270
271  // Méthodes utilitaires pour différents types de requêtes
272
273  /**
274   * Exécute une requête GET
275   */
276  async get(endpoint, options = {}) {
277    return this.request(endpoint, { ...options, method: 'GET' });
278  }
279
280  /**
281   * Exécute une requête POST
282   */
283  async post(endpoint, body, options = {}) {
284    return this.request(endpoint, { ...options, method: 'POST', body });
285  }
286
287  /**
288   * Exécute une requête PUT
289   */
290  async put(endpoint, body, options = {}) {
291    return this.request(endpoint, { ...options, method: 'PUT', body });
292  }
293
294  /**
295   * Exécute une requête DELETE
296   */
297  async delete(endpoint, options = {}) {
298    return this.request(endpoint, { ...options, method: 'DELETE' });
299  }
300
301  /**
302   * Téléverse un fichier sur le serveur
303   */
304  async uploadFile(endpoint, file, options = {}) {
305    const {
306      fieldName = 'file',
307      additionalData = {},
308      onProgress = null,
309      useAuth = true
310    } = options;
311
312    // Si hors ligne, impossible de téléverser des fichiers
313    if (!this.networkMonitor.getState().status === 'online') {
314      throw new Error('Impossible de téléverser un fichier en mode hors ligne.');
315    }
316
317    // Préparer FormData
318    const formData = new FormData();
319    formData.append(fieldName, file);
320
321    // Ajouter les données supplémentaires
322    Object.entries(additionalData).forEach(([key, value]) => {
323      formData.append(key, JSON.stringify(value));
324    });
325
326    // Options de la requête
327    const requestOptions = {
328      method: 'POST',
329      body: formData
330    };
331
332    // Ajouter le token d'authentification si requis
333    if (useAuth) {
334      try {
335        const token = await this.auth.getAccessToken();
336        requestOptions.headers = {
337          'Authorization': `Bearer ${token}`
338        };
339      } catch (error) {
340        throw new Error('Non authentifié. Merci de te reconnecter.');
341      }
342    }
343
344    return new Promise((resolve, reject) => {
345      const xhr = new XMLHttpRequest();
346
347      xhr.upload.addEventListener('progress', (event) => {
348        if (event.lengthComputable && typeof onProgress === 'function') {
349          const percent = Math.round((event.loaded / event.total) * 100);
350          onProgress({
351            loaded: event.loaded,
352            total: event.total,
353            percent
354          });
355        }
356      });
357
358      xhr.addEventListener('load', () => {
359        if (xhr.status >= 200 && xhr.status < 300) {
360          try {
361            const responseData = JSON.parse(xhr.responseText);
362            resolve(responseData);
363          } catch (e) {
364            resolve({
365              success: true,
366              status: xhr.status,
367              message: xhr.statusText
368            });
369          }
370        } else {
371          const error = new Error(`Erreur HTTP : ${xhr.status}`);
372          error.status = xhr.status;
373          error.statusText = xhr.statusText;
374          reject(error);
375        }
376      });
377
378      xhr.addEventListener('error', () => {
379        reject(new Error('Erreur réseau pendant le téléversement du fichier'));
380      });
381
382      xhr.addEventListener('abort', () => {
383        reject(new Error('Le téléversement du fichier a été annulé'));
384      });
385
386      xhr.open('POST', `${this.baseUrl}${endpoint}`, true);
387
388      if (requestOptions.headers) {
389        Object.entries(requestOptions.headers).forEach(([key, value]) => {
390          xhr.setRequestHeader(key, value);
391        });
392      }
393
394      xhr.send(formData);
395    });
396  }
397
398  /**
399   * Authentifie l'utilisateur
400   */
401  async login(email, password) {
402    try {
403      const response = await this.request('/auth/login', {
404        method: 'POST',
405        body: { email, password: password },
406        useAuth: false
407      });
408
409      // Enregistrer les tokens
410      this.auth.saveTokens(response);
411
412      return {
413        success: true,
414        user: response.user
415      };
416    } catch (error) {
417      return {
418        success: false,
419        error: error.message
420      };
421    }
422  }
423
424  /**
425   * Déconnecte l'utilisateur
426   */
427  logout() {
428    this.auth.logout();
429    // Optionnellement, vider le cache à la déconnexion
430    this.cache.clear();
431  }
432}
433
434// Créer une instance du client API
435const api = new JurassicAPIClient({
436  baseUrl: 'https://api.jurassic-park.com'
437});
438
439// Exemples d'utilisation du client API
440
441// Connexion
442async function loginUser(email, password) {
443  const result = await api.login(email, password);
444  if (result.success) {
445    console.log(`Connecté en tant que ${result.user.name}`);
446    return true;
447  } else {
448    console.error(`Erreur de connexion : ${result.error}`);
449    return false;
450  }
451}
452
453// Récupérer les données d'un dinosaure
454async function getDinosaur(id) {
455  try {
456    return await api.get(`/dinosaurs/${id}`);
457  } catch (error) {
458    console.error(`Échec de la récupération du dinosaure #${id} :`, error.message);
459    throw error;
460  }
461}
462
463// Mettre à jour les données d'un dinosaure
464async function updateDinosaur(id, data) {
465  try {
466    return await api.put(`/dinosaurs/${id}`, data);
467  } catch (error) {
468    console.error(`Échec de la mise à jour du dinosaure #${id} :`, error.message);
469    throw error;
470  }
471}
472
473// Récupérer la liste des dinosaures avec filtrage
474async function getDinosaurList(filter = {}) {
475  try {
476    return await api.get('/dinosaurs', {
477      body: filter,
478      cacheTime: 60000 // Cache plus court pour les listes (1 minute)
479    });
480  } catch (error) {
481    console.error('Échec de la récupération de la liste des dinosaures :', error.message);
482    throw error;
483  }
484}
485
486// Téléverser les dossiers médicaux d'un dinosaure
487async function sendMedicalRecords(dinoId, file, data) {
488  const progressBar = document.getElementById('upload-progress');
489  const statusText = document.getElementById('upload-status');
490
491  try {
492    return await api.uploadFile(`/dinosaurs/${dinoId}/medical-records`, file, {
493      additionalData: data,
494      onProgress: (info) => {
495        if (progressBar) progressBar.value = info.percent;
496        if (statusText) statusText.textContent = `Téléversement : ${info.percent}%`;
497      }
498    });
499  } catch (error) {
500    console.error(`Échec du téléversement de la documentation pour le dinosaure #${dinoId} :`, error.message);
501    if (statusText) statusText.textContent = `Erreur : ${error.message}`;
502    throw error;
503  }
504}

Dans cet exemple complet, nous avons implémenté un client API avancé qui gère :

  1. Une gestion complète des erreurs - différents types d'erreurs HTTP et réseau
  2. Des nouvelles tentatives automatiques des requêtes - pour les problèmes temporaires
  3. La mise en cache des réponses - pour un accès plus rapide et le support du mode hors ligne
  4. La mise en file d'attente des opérations hors ligne - pour garantir la cohérence des données
  5. La surveillance de l'état du réseau - pour prendre les bonnes décisions
  6. L'authentification et le rafraîchissement du token - pour un accès sécurisé à l'API
  7. Le téléversement de fichiers avec suivi de progression - pour une meilleure expérience utilisateur

Un tel client API complet offre une résilience face aux problèmes réseau, une efficacité grâce à la mise en cache, le support du mode hors ligne, et des mécanismes conviviaux comme le suivi de progression. C'est une solution idéale pour des systèmes critiques comme le système de gestion du Parc Jurassique, où la fiabilité et la sécurité sont primordiales.

Dans la prochaine leçon, nous verrons comment traiter efficacement les données JSON reçues de l'API pour les présenter aux utilisateurs de façon accessible.

Code de cette leçon : index.js
1// Function Factory - Fabrique de fonctions
2console.log("FUNCTION FACTORY - FABRIQUE DE FONCTIONS\n");
3
4// Factory function - a function that creates other functions
5function createDinosaurHandler(species, defaultDangerLevel) {
6  console.log(`Création du handler pour: ${species}`);
7
8  // Nous retournons a new function with "remembered" parameters
9  return function(action) {
10    console.log(`\n[${species}] Action: ${action}`);
11    console.log(`  Default danger: ${defaultDangerLevel}/10`);
12
13    switch(action) {
14      case 'FEED':
15        console.log(`  Feeding ${species}`);
16        break;
17      case 'MONITOR':
18        console.log(`  Monitoring ${species}`);
19        break;
20      case 'ALERT':
21        console.log(`  ALARM! ${species} needs attention!`);
22        break;
23      default:
24        console.log(`  Action inconnue`);
25    }
26  };
27}
28
29// Creating specialized handlers
30console.log("=== CREATING HANDLERS ===\n");
31
32const trexHandler = createDinosaurHandler('T-Rex', 10);
33const triceratopsHandler = createDinosaurHandler('Triceratops', 3);
34const velociraptorHandler = createDinosaurHandler('Velociraptor', 8);
35
36// Using handlers
37console.log("\n=== USING HANDLERS ===");
38
39trexHandler('FEED');
40trexHandler('MONITOR');
41
42triceratopsHandler('FEED');
43velociraptorHandler('ALERT');
44
45// Factory avec options supplémentaires
46console.log("\n=== FACTORY AVANCÉE ===\n");
47
48function createAdvancedHandler(config) {
49  const {
50    species,
51    dangerLevel,
52    feedingTime,
53    maxCapacity
54  } = config;
55
56  let currentCount = 0;
57
58  return {
59    add: function() {
60      if (currentCount < maxCapacity) {
61        currentCount++;
62        console.log(`Added ${species}. Currently: ${currentCount}/${maxCapacity}`);
63        return true;
64      } else {
65        console.log(`Pas de place pour ${species} ! (${currentCount}/${maxCapacity})`);
66        return false;
67      }
68    },
69
70    remove: function() {
71      if (currentCount > 0) {
72        currentCount--;
73        console.log(`Removed ${species}. Currently: ${currentCount}/${maxCapacity}`);
74        return true;
75      } else {
76        console.log(`Aucun ${species} à retirer`);
77        return false;
78      }
79    },
80
81    getInfo: function() {
82      return {
83        species,
84        dangerLevel,
85        feedingTime,
86        count: currentCount,
87        capacity: maxCapacity,
88        occupancy: `${((currentCount / maxCapacity) * 100).toFixed(1)}%`
89      };
90    },
91
92    feed: function() {
93      console.log(`Feeding ${currentCount} x ${species} à ${feedingTime}`);
94    }
95  };
96}
97
98// Creating advanced handlers
99const trexManager = createAdvancedHandler({
100  species: 'T-Rex',
101  dangerLevel: 10,
102  feedingTime: '18:00',
103  maxCapacity: 5
104});
105
106const velociraptorManager = createAdvancedHandler({
107  species: 'Velociraptor',
108  dangerLevel: 8,
109  feedingTime: '16:00',
110  maxCapacity: 10
111});
112
113console.log("=== POPULATION MANAGEMENT ===\n");
114
115trexManager.add();
116trexManager.add();
117trexManager.add();
118
119console.log("\nT-Rex Info:", trexManager.getInfo());
120
121trexManager.feed();
122
123console.log("");
124
125velociraptorManager.add();
126velociraptorManager.add();
127velociraptorManager.add();
128velociraptorManager.add();
129velociraptorManager.add();
130
131console.log("\nVelociraptor Info:", velociraptorManager.getInfo());
132
133console.log("\n=== BENEFITS OF FACTORY PATTERN ===");
134console.log("Encapsulation - hidden internal data");
135console.log("Reusability - easy creation of similar objects");
136console.log("Personnalisation - each handler can have its own configuration");
137console.log("Closures - each handler remembers its settings");

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce que le pattern retry dans le contexte des requêtes HTTP ?

  2. 2. Quelle technique est la plus efficace pour déboguer des erreurs non gérées dans une Promise ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Écris une fonction async fetchWithRetry(url, maxRetries) qui relance fetch après une erreur avec un délai croissant.

  • Tri vertical

    Ordonne les étapes du pattern retry avec exponential backoff :

  • Clique dans l'ordre

    Ordonne les éléments d'une course de Promises (race) :

  • Tri vertical

    Ordonne l'ordre d'exécution de ce code : console.log('A'); setTimeout(() => console.log('B'), 0); Promise.resolve().then(() => console.log('C')); console.log('D');

  • Tri vertical

    Ordonne l'ordre dans lequel l'Event Loop traite le code :

  • Tri vertical

    Ordonne les étapes de récupération de données depuis une API :

Articles utiles