6.2 · fetch

Les diapositives de la séance 6

  • appeler une API HTTP depuis TypeScript avec fetch
  • expliquer pourquoi il faut deux await pour obtenir des données JSON
  • vérifier response.ok et distinguer une erreur réseau d’une erreur HTTP
  • typer le résultat d’une API avec une interface plutôt que de laisser any
  • gérer proprement les échecs avec try / catch
  • injecter les données reçues dans la page avec createElement et appendChild

Les promesses et async / await (6.1 · async / await), le JSON (5.1 · JSON) et les exceptions (5.3 · Exceptions).

Qu’est-ce que fetch ?

fetch est la fonction standard du navigateur (et de Node.js depuis la version 18) pour envoyer une requête HTTP. Elle est asynchrone, donc elle retourne une promesse :

fetch(url)   // type : Promise<Response>

Elle ne retourne pas les données. Elle retourne une Response : un objet qui décrit la réponse HTTP (code de statut, en-têtes) et qui sait, à la demande, lire le corps du message.

La double attente

C’est le point qui surprend tout le monde :

const response: Response = await fetch(url);   // 1er await
const data = await response.json();            // 2e await

Pourquoi deux ? Parce qu’une réponse HTTP n’arrive pas d’un bloc.

  1. Le premier await se termine dès que les en-têtes sont arrivés. À cet instant vous connaissez déjà le code de statut, mais le corps du message peut encore être en train de transiter : il peut faire plusieurs mégaoctets.
  2. Le second await attend que tout le corps soit arrivé, puis le décode. response.json() lit le flux jusqu’au bout et applique JSON.parse au résultat : c’est aussi une opération asynchrone, donc elle retourne encore une promesse.

Selon le format attendu, vous disposez de plusieurs méthodes de lecture :

Méthode Retourne Usage
response.json() Promise<any> corps JSON, le cas courant
response.text() Promise<string> HTML, CSV, texte brut
response.blob() Promise<Blob> image, fichier binaire

Le corps d’une Response ne se lit qu’une seule fois. Appeler response.json() après avoir déjà appelé response.text() sur la même réponse lève TypeError: body stream already read. Si vous voulez déboguer, lisez le texte une fois et appliquez JSON.parse vous-même.

fetch ne rejette pas sur un 404

Voici l’erreur la plus coûteuse du chapitre. La promesse retournée par fetch est tenue dès que le serveur a répondu, même si la réponse est un 404 ou un 500. Du point de vue de fetch, « le serveur a répondu 404 » est un succès de communication.

La promesse n’est rompue que si la requête n’a pas pu aboutir du tout : DNS introuvable, câble débranché, requête bloquée par le navigateur (voir 6.3 · CORS).

Un try / catch autour d’un fetch n’attrape pas les erreurs 404 et 500. Il faut tester response.ok explicitement :

const response = await fetch(url);
if (!response.ok) {
  throw new Error(`HTTP ${response.status} ${response.statusText}`);
}

response.ok vaut true si et seulement si le statut est entre 200 et 299.

Typer ce qu’on reçoit

response.json() retourne Promise<any>. Et any, comme on l’a vu au chapitre 1.1, désactive toute vérification : data.propriete.qui.nexiste.pas passerait la transpilation sans un mot.

La bonne pratique consiste à décrire ce qu’on attend avec une interface, et à n’y déclarer que les champs qu’on utilise réellement. Voici l’exemple du cours, qui interroge l’API de l’institut géologique américain pour lister les séismes récents :

app.ts
// On ne décrit que ce dont on a besoin, pas tout le GeoJSON renvoyé
interface Earthquake {
  properties: {
    time: number;      // date en millisecondes depuis le 1er janvier 1970
    place: string;
    mag: number;
  };
}

class EarthquakeService {
  private async getEarthquakesFromUSGS(): Promise<Earthquake[]> {
    const response = await fetch(
      "https://earthquake.usgs.gov/fdsnws/event/1/query" +
        "?format=geojson&starttime=yesterday&endtime=today&minmagnitude=4"
    );
    if (!response.ok) {
      throw new Error(`USGS a répondu ${response.status}`);
    }
    const earthquakejson = await response.json();
    return earthquakejson.features;   // le tableau utile est dans .features
  }
}

Annoter le type de retour Promise<Earthquake[]> est ce qui donne sa valeur au code : à partir de là, l’éditeur connaît properties.mag et refuse properties.magnitude.

AvertissementUne interface n’est pas une garantie

Le type n’existe qu’à la transpilation. Si l’API change son format, TypeScript ne s’en apercevra pas : votre code plantera à l’exécution. L’interface documente et protège votre code, elle ne valide pas les données entrantes. Pour une validation réelle, il faut écrire une fonction de vérification, comme le isServerData du chapitre 6.3.

Consommer et afficher

La méthode qui consomme le service enveloppe l’appel dans un try / catch, ce qui capture à la fois les erreurs réseau et le throw déclenché par !response.ok :

app.ts
async displayEarthquakes() {
  try {
    const earthquakes = await this.getEarthquakesFromUSGS();
    earthquakes.forEach((earthquake) => {
      const date: Date = new Date(earthquake.properties.time);
      const options: Intl.DateTimeFormatOptions = {
        year: "numeric", month: "2-digit", day: "2-digit",
        hour: "2-digit", minute: "2-digit",
        timeZone: "Europe/Paris",
      };
      const dateString: string = date.toLocaleDateString("fr-FR", options);
      const finalString: string =
        `${dateString} - ${earthquake.properties.mag} - ${earthquake.properties.place}`;
      console.log(finalString);
    });
  } catch (error) {
    console.error("Récupération des séismes impossible :", error);
  }
}

const earthquakeService = new EarthquakeService();
earthquakeService.displayEarthquakes();

télécharger app.ts

Trois éléments méritent un mot :

  • new Date(millisecondes) construit une date à partir du nombre de millisecondes écoulées depuis le 1er janvier 1970. C’est le format habituel des API.
  • toLocaleDateString("fr-FR", options) formate cette date selon les conventions françaises. L’objet options choisit les champs affichés et le fuseau horaire.
  • console.log ne montre rien à l’utilisateur. Pour afficher dans la page, il faut passer par le DOM.

Écrire dans la page

Trois méthodes suffisent :

Méthode Rôle
document.createElement("ul") crée un élément <ul>, encore détaché de la page
element.textContent = "..." définit le texte d’un élément
parent.appendChild(enfant) insère l’élément dans la page

Tant qu’un élément créé n’a pas été inséré avec appendChild, il n’est visible nulle part.

AstucetextContent plutôt que innerHTML

textContent insère du texte, point. innerHTML interprète le contenu comme du HTML : si la chaîne vient d’une API ou d’un utilisateur et contient une balise <script>, vous venez d’exécuter du code étranger dans votre page. C’est une faille de type XSS. Utilisez textContent par défaut.

Exercices

Exercice 6.5 : Afficher les séismes dans la page

essentiel

Reprenez app.ts et faites-le tourner côté client, servi par Astro (voir 1.2 · Astro).

Insérez ces cinq lignes au bon endroit pour construire une liste HTML :

const ul: HTMLUListElement = document.createElement("ul");
const li: HTMLLIElement = document.createElement("li");
li.textContent = finalString;
ul.appendChild(li);
document.body.appendChild(ul);

Réfléchissez avant de coder : lesquelles doivent être à l’intérieur de la boucle forEach, lesquelles avant, laquelle après ?

Critère de réussite : une seule liste <ul> dans la page, contenant autant de <li> que de séismes.

La répartition est dictée par la question « combien d’exemplaires en veut-on ? ». Une seule liste, donc le <ul> est créé avant la boucle et inséré après. Un <li> par séisme, donc les trois lignes qui le concernent sont dans la boucle.

src/scripts/app.ts
interface Earthquake {
  properties: {
    time: number;
    place: string;
    mag: number;
  };
}

class EarthquakeService {
  private async getEarthquakesFromUSGS(): Promise<Earthquake[]> {
    const response = await fetch(
      "https://earthquake.usgs.gov/fdsnws/event/1/query" +
        "?format=geojson&starttime=yesterday&endtime=today&minmagnitude=4"
    );
    if (!response.ok) {
      throw new Error(`USGS a répondu ${response.status}`);
    }
    const earthquakeJson = await response.json();
    return earthquakeJson.features;
  }

  async displayEarthquakes(): Promise<void> {
    try {
      const earthquakes = await this.getEarthquakesFromUSGS();

      // AVANT la boucle : une seule liste pour tous les séismes
      const ul: HTMLUListElement = document.createElement("ul");

      earthquakes.forEach((earthquake) => {
        const date: Date = new Date(earthquake.properties.time);
        const options: Intl.DateTimeFormatOptions = {
          year: "numeric", month: "2-digit", day: "2-digit",
          hour: "2-digit", minute: "2-digit",
          timeZone: "Europe/Paris",
        };
        const dateString: string = date.toLocaleDateString("fr-FR", options);
        const finalString: string =
          `${dateString} - ${earthquake.properties.mag} - ${earthquake.properties.place}`;

        // DANS la boucle : un élément de liste par séisme
        const li: HTMLLIElement = document.createElement("li");
        li.textContent = finalString;
        ul.appendChild(li);
      });

      // APRÈS la boucle : la liste complète entre dans la page en une fois
      document.body.appendChild(ul);
    } catch (error) {
      console.error("Récupération des séismes impossible :", error);
    }
  }
}

const earthquakeService = new EarthquakeService();
earthquakeService.displayEarthquakes();

Côté Astro, le script doit être chargé par la page pour s’exécuter dans le navigateur :

src/pages/index.astro
<script src="../scripts/app.ts"></script>

Le point clé. Créer le <ul> dans la boucle produirait une liste par séisme. Créer le <li> avant la boucle est plus vicieux : appendChild ne copie pas un nœud, il le déplace. Le même <li> serait donc déplacé à chaque tour, et la page n’afficherait qu’une seule ligne, la dernière. Insérer le <ul> une seule fois, à la fin, évite aussi de faire recalculer la mise en page du document à chaque itération.

Exercice 6.6 : Gérer les échecs

essentiel

En repartant de l’exercice 6.5 :

  1. Remplacez le nom de domaine par https://earthquake.usgs.invalid/... et observez ce qui se passe. Quel type d’erreur obtenez-vous ?
  2. Remettez le bon domaine mais demandez une ressource inexistante, par exemple https://earthquake.usgs.gov/pas-une-route. Le catch se déclenche-t-il ? Pourquoi ?
  3. Ajoutez le test if (!response.ok) et affichez le message d’erreur dans la page, en rouge, plutôt que dans la console.

Critère de réussite : dans les deux cas d’échec, l’utilisateur voit un message compréhensible et la page ne reste pas vide.

1. Domaine .invalid. La résolution DNS échoue, la requête ne part pas. La promesse de fetch est rompue et le catch se déclenche immédiatement avec une TypeError : le message varie selon le navigateur : Failed to fetch sous Chrome, NetworkError when attempting to fetch resource sous Firefox. Aucun response.status n’existe : il n’y a jamais eu de réponse.

2. Route inexistante. Le serveur répond 404. La promesse de fetch est tenue : le catch ne se déclenche pas à cause du statut. Il se déclenche pourtant, mais une ligne plus loin et pour une autre raison : USGS renvoie une page HTML d’erreur, et response.json() échoue sur une SyntaxError: Unexpected token '<'. C’est exactement le symptôme trompeur qu’il faut savoir reconnaître : un message parlant de JSON alors que le vrai problème est un 404.

src/scripts/app.ts
function showError(message: string): void {
  const p: HTMLParagraphElement = document.createElement("p");
  p.style.color = "red";
  p.textContent = message;   // textContent : le message peut venir du serveur
  document.body.appendChild(p);
}

class EarthquakeService {
  private async getEarthquakesFromUSGS(): Promise<Earthquake[]> {
    const response = await fetch(
      "https://earthquake.usgs.gov/fdsnws/event/1/query" +
        "?format=geojson&starttime=yesterday&endtime=today&minmagnitude=4"
    );

    // fetch ne rejette pas sur 404 ni sur 500 : c'est ici qu'on transforme
    // une réponse d'erreur en exception
    if (!response.ok) {
      throw new Error(`Le service USGS a répondu ${response.status} ${response.statusText}.`);
    }

    const earthquakeJson = await response.json();
    return earthquakeJson.features;
  }

  async displayEarthquakes(): Promise<void> {
    try {
      const earthquakes = await this.getEarthquakesFromUSGS();
      // ... construction de la liste, comme à l'exercice 6.5
    } catch (error) {
      if (error instanceof TypeError) {
        showError("Serveur injoignable : vérifiez votre connexion réseau.");
      } else if (error instanceof Error) {
        showError(`Séismes indisponibles. ${error.message}`);
      } else {
        showError("Séismes indisponibles pour une raison inconnue.");
      }
    }
  }
}

Le point clé. Un seul try / catch suffit pour les deux familles de panne, à condition d’avoir converti l’erreur HTTP en exception avec throw new Error(...). Sans le test !response.ok, le catch existe mais ne voit jamais le 404, ou le voit déguisé en erreur de parsing JSON. Le instanceof Error avant d’accéder à .message est nécessaire car, en TypeScript, la variable du catch est de type unknown : on peut lancer n’importe quoi en JavaScript, pas seulement une Error.

Une alternative acceptable consiste à définir une classe HttpError extends Error portant le statut, pour que l’appelant puisse distinguer un 404 d’un 500 (voir 5.3 · Exceptions).

Exercice 6.7 : Service de recherche d’adresses

entraînement transfert

Utilisez l’API Adresse de l’État français. Exemple de requête : https://api-adresse.data.gouv.fr/search/?q=8+bd+du+port&limit=5

Le retour est un GeoJSON FeatureCollection : les données utiles sont dans le champ properties de chaque objet du tableau features.

  1. Écrivez une interface Address avec city, postcode, street, housenumber, context, lat et lon.
  2. Écrivez une classe AddressService avec une méthode searchAddress(query: string, limit: number): Promise<Address[]>. Encodez query avec encodeURIComponent pour que les espaces deviennent %20.
  3. Instanciez le service, cherchez "8 bd du port", et affichez les résultats dans une liste HTML.
  4. Traitez les erreurs : réseau injoignable et statut HTTP non-2xx.

Construisez-la proprement plutôt qu’en concaténant :

const url = new URL("https://api-adresse.data.gouv.fr/search/");
url.searchParams.set("q", query);
url.searchParams.set("limit", String(limit));

URLSearchParams encode les paramètres pour vous.

Critère de réussite : la recherche fonctionne avec une adresse contenant des espaces et des accents, et une adresse introuvable affiche un message plutôt qu’une liste vide sans explication.

src/scripts/app.ts
interface Address {
  city: string;
  postcode: string;
  street: string;
  housenumber: string | null;
  context: string;
  lat: number;
  lon: number;
}

// Ce que l'API renvoie réellement : un GeoJSON, où les coordonnées
// ne sont PAS dans properties
interface AddressFeature {
  properties: {
    city: string;
    postcode: string;
    street?: string;
    housenumber?: string;
    context: string;
    label: string;
  };
  geometry: {
    coordinates: [number, number];   // [longitude, latitude]
  };
}

interface AddressApiResponse {
  features: AddressFeature[];
}

class AddressService {
  async searchAddress(query: string, limit: number): Promise<Address[]> {
    const url = new URL("https://api-adresse.data.gouv.fr/search/");
    url.searchParams.set("q", query);
    url.searchParams.set("limit", String(limit));

    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`API Adresse : HTTP ${response.status}`);
    }

    const data: AddressApiResponse = await response.json();

    return data.features.map((feature) => ({
      city: feature.properties.city,
      postcode: feature.properties.postcode,
      street: feature.properties.street ?? feature.properties.label,
      housenumber: feature.properties.housenumber ?? null,
      context: feature.properties.context,
      lon: feature.geometry.coordinates[0],
      lat: feature.geometry.coordinates[1],
    }));
  }

  displayAddresses(addresses: Address[]): void {
    const ul: HTMLUListElement = document.createElement("ul");

    addresses.forEach((address) => {
      const numero: string = address.housenumber === null ? "" : `${address.housenumber} `;
      const li: HTMLLIElement = document.createElement("li");
      li.textContent =
        `${numero}${address.street}, ${address.postcode} ${address.city}` +
        ` (${address.context}), lat ${address.lat}, lon ${address.lon}`;
      ul.appendChild(li);
    });

    document.body.appendChild(ul);
  }
}

function showMessage(text: string): void {
  const p: HTMLParagraphElement = document.createElement("p");
  p.textContent = text;
  document.body.appendChild(p);
}

async function main(): Promise<void> {
  const addressService = new AddressService();
  try {
    const addresses = await addressService.searchAddress("8 bd du port", 5);
    if (addresses.length === 0) {
      showMessage("Aucune adresse ne correspond à cette recherche.");
      return;
    }
    addressService.displayAddresses(addresses);
  } catch (error) {
    const detail: string = error instanceof Error ? error.message : "cause inconnue";
    showMessage(`Recherche impossible : ${detail}`);
  }
}

main();

Le point clé. Trois pièges se cumulent dans cet exercice.

  1. Les coordonnées ne sont pas dans properties mais dans geometry.coordinates, et l’ordre GeoJSON est [longitude, latitude], l’inverse de l’habitude française. Inverser les deux place vos points au milieu de l’océan sans qu’aucune erreur ne soit signalée.
  2. Une recherche sans résultat renvoie 200 OK avec features: []. Ce n’est pas une erreur HTTP : response.ok vaut true. Le cas « rien trouvé » doit donc être traité séparément du cas « la requête a échoué », d’où le test sur addresses.length.
  3. url.searchParams.set encode les espaces et les accents pour vous. L’écriture `...?q=${encodeURIComponent(query)}&limit=${limit}` est strictement équivalente et également acceptable ; ce qui ne l’est pas, c’est de concaténer query brut dans l’URL.

QCM

1. Le serveur répond 404 Not Found. Que se passe-t-il à la ligne suivante ?

const response = await fetch(url);

A. La promesse est rompue : il faut un catch. B. La promesse est tenue, et response.ok vaut false. C. response vaut null. D. response.status vaut 0.

B. Pour fetch, un serveur qui répond 404 est un succès de communication. Testez toujours response.ok.

2. Quel est le type de fetch(url) ?

A. Promise<any> B. Promise<Response> C. Response D. string

B. fetch ne renvoie pas les données, mais la réponse HTTP, plus tard. Le corps se lit ensuite avec un second await : response.json().

3. Que se passe-t-il ?

const response = await fetch(url);
const a = await response.json();
const b = await response.text();

A. a contient l’objet, b le texte brut. B. La troisième ligne lève une erreur : le corps a déjà été lu. C. b vaut "". D. Une erreur de compilation.

B. Le corps d’une réponse ne se lit qu’une fois (body stream already read).

À retenir

  • fetch(url) retourne une Promise<Response>, pas les données
  • Deux await : le premier pour les en-têtes, le second pour lire et décoder le corps
  • fetch ne rejette pas sur 404 ou 500 : testez toujours response.ok
  • Le corps d’une réponse ne se lit qu’une fois
  • response.json() retourne any : annotez avec une interface pour retrouver le typage
  • textContent plutôt que innerHTML pour les données venues du réseau