Chapitre 1118 minIntermédiaire
Consommer une API REST en C# : HttpClient et JSON
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- appeler une API REST depuis une application WinForms ;
- convertir le JSON en objets C# et inversement ;
- traiter les codes de statut, les délais et les pannes réseau ;
- envoyer un jeton d'authentification ;
- éviter les pièges de BaseAddress et de la casse JSON.
4 exercices corrigés
Pour appeler une API REST depuis C#, on utilise la classe HttpClient : elle envoie une requête HTTP (GET, POST, PUT, DELETE) à une adresse, et reçoit la réponse avec son code de statut et son corps, en général du JSON. Les méthodes d'extension de System.Net.Http.Json convertissent directement ce JSON en objets C#, grâce à System.Text.Json. Ce chapitre fait appeler par GSB Ordonnances l'API du référentiel des médicaments de l'éditeur. Le fonctionnement d'une API (HTTP, REST, codes de statut, JSON) est expliqué dans le cours API REST.
Pourquoi un client lourd appelle une API
Le référentiel des médicaments est tenu par l'éditeur GSB sur ses propres serveurs. Plutôt que d'ouvrir sa base MySQL à tous les cabinets, l'éditeur expose une API REST : GET /v1/medicaments?nom=para renvoie les médicaments dont le nom contient « para », au format JSON. Chaque cabinet l'interroge avec un jeton d'accès.
C'est aussi ce qu'attend l'E6 : un appel à un service applicatif distant dans l'architecture de tes réalisations. Et c'est l'architecture la plus sûre pour un client lourd : l'application ne détient plus de mot de passe de base de données, seulement un jeton personnel (chapitre 12).
Un seul HttpClient pour toute l'application
private static readonly HttpClient Http = new()
{
BaseAddress = new Uri("https://api.gsb.example/v1/"),
Timeout = TimeSpan.FromSeconds(10),
};
- Une instance, réutilisée. Créer un
HttpClientpar requête (dans unusing, dans un gestionnaire de clic) ouvre une nouvelle connexion à chaque fois, et une application qui en crée des milliers épuise les ports réseau disponibles. Garde une instance, dans un champstaticou créée une fois dansProgram.cs. Timeout. Par défaut,HttpClientattend 100 secondes. Pour une interface, dix secondes suffisent : au-delà, l'utilisateur doit être prévenu.BaseAddressse termine par/, et les chemins relatifs n'en commencent pas par un.
Le JSON et les classes C#
L'API renvoie un tableau d'objets :
[
{ "code": "PARA1000", "nom": "Paracétamol", "dosage": "1000 mg", "surOrdonnance": false },
{ "code": "AMOX500", "nom": "Amoxicilline", "dosage": "500 mg", "surOrdonnance": true }
]
On le reçoit dans un record, dont les propriétés portent les mêmes noms, en PascalCase :
public record MedicamentDto(string Code, string Nom, string? Dosage, bool SurOrdonnance);
Les méthodes GetFromJsonAsync, ReadFromJsonAsync et PostAsJsonAsync utilisent les réglages « web » de System.Text.Json : le JSON en camelCase (surOrdonnance) correspond aux propriétés en PascalCase (SurOrdonnance), sans tenir compte de la casse. string? accepte un dosage absent ou null.
Quand un nom JSON ne peut pas devenir un nom C# ("date-peremption"), l'attribut [JsonPropertyName("date-peremption")] fait le lien.
Lire : GET et le code de statut
La version la plus courte, GetFromJsonAsync, lève une HttpRequestException pour tout code d'erreur. Pour réagir différemment à un 401, un 404 ou un 500, on passe par GetAsync et on teste le code de statut (les codes HTTP). Ce client d'API se range dans la couche données :
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace Gsb.Donnees;
public record ProblemeApi(string? Title, int Status, string? Detail);
public class ApiException(string message) : Exception(message);
public class MedicamentApi
{
private readonly HttpClient _http;
public MedicamentApi(HttpClient http, string jeton)
{
_http = http;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jeton);
}
public async Task<List<MedicamentDto>> RechercherAsync(string nom, CancellationToken annulation = default)
{
string url = $"medicaments?nom={Uri.EscapeDataString(nom)}";
using HttpResponseMessage reponse = await _http.GetAsync(url, annulation);
if (reponse.StatusCode == HttpStatusCode.Unauthorized)
throw new ApiException("Session expirée : reconnecte-toi.");
if (!reponse.IsSuccessStatusCode)
{
ProblemeApi? probleme = await LireProblemeAsync(reponse, annulation);
throw new ApiException(probleme?.Detail ?? $"Erreur {(int)reponse.StatusCode} de l'API.");
}
return await reponse.Content.ReadFromJsonAsync<List<MedicamentDto>>(annulation) ?? [];
}
private static async Task<ProblemeApi?> LireProblemeAsync(HttpResponseMessage reponse, CancellationToken annulation)
{
try { return await reponse.Content.ReadFromJsonAsync<ProblemeApi>(annulation); }
catch (System.Text.Json.JsonException) { return null; } // corps vide ou pas en JSON
catch (NotSupportedException) { return null; } // type de contenu non JSON
}
}
Uri.EscapeDataStringencode la saisie dans l'URL : un espace devient%20, un&ne casse pas la chaîne de requête.- Le jeton d'accès part dans l'en-tête
Authorization: Bearer …, réglé une fois (authentifier une API). - Une API bien conçue décrit ses erreurs au format « problem details » (RFC 9457) :
title,status,detail(des erreurs normalisées). On affichedetailà l'utilisateur quand il existe.
Envoyer : POST, PUT et DELETE
PostAsJsonAsync sérialise l'objet en JSON (en camelCase) et l'envoie avec l'en-tête Content-Type: application/json. Une création réussie répond 201 Created avec l'objet créé.
public async Task<MedicamentDto> AjouterAsync(MedicamentDto medicament, CancellationToken annulation = default)
{
using HttpResponseMessage reponse = await _http.PostAsJsonAsync("medicaments", medicament, annulation);
if (reponse.StatusCode != HttpStatusCode.Created)
{
ProblemeApi? probleme = await LireProblemeAsync(reponse, annulation);
throw new ApiException(probleme?.Detail ?? $"Erreur {(int)reponse.StatusCode} de l'API.");
}
return (await reponse.Content.ReadFromJsonAsync<MedicamentDto>(annulation))!;
}
Le corps envoyé pour l'ibuprofène est {"code":"IBUP400","nom":"Ibuprof\u00E8ne",…} : System.Text.Json échappe par défaut les caractères non ASCII, ce qui reste du JSON parfaitement valide (l'API relit bien « Ibuprofène »). Si le code existe déjà, l'API répond 409 Conflict avec un detail comme « Le code IBUP400 existe déjà. », que la méthode transforme en ApiException.
PutAsJsonAsync (remplacer une ressource) et DeleteAsync (la supprimer) s'utilisent de la même façon. EnsureSuccessStatusCode() lève une HttpRequestException pour tout code hors de la famille 2xx : pratique quand on n'a pas de message particulier à afficher.
public async Task ModifierAsync(MedicamentDto medicament, CancellationToken annulation = default)
{
string url = $"medicaments/{Uri.EscapeDataString(medicament.Code)}";
using HttpResponseMessage reponse = await _http.PutAsJsonAsync(url, medicament, annulation);
if (reponse.StatusCode == HttpStatusCode.NotFound)
throw new ApiException($"Le médicament {medicament.Code} n'existe pas.");
reponse.EnsureSuccessStatusCode();
}
public async Task SupprimerAsync(string code, CancellationToken annulation = default)
{
using HttpResponseMessage reponse = await _http.DeleteAsync($"medicaments/{Uri.EscapeDataString(code)}", annulation);
if (reponse.StatusCode == HttpStatusCode.NotFound) return; // déjà supprimé : le résultat voulu est atteint
reponse.EnsureSuccessStatusCode();
}
Traiter un 404 comme un succès pour une suppression n'est pas une négligence : DELETE est idempotent, le répéter laisse la ressource dans le même état (méthodes sûres et idempotentes). Si deux postes suppriment le même médicament, le second n'a pas à afficher d'erreur.
Remplir une liste à partir de l'API
Dans la fenêtre de l'ordonnance, le médecin choisit le médicament dans une ComboBox alimentée par l'API. Le principe est celui du chapitre 2 : une liste d'objets, un membre affiché et un membre valeur.
private async void FrmOrdonnance_Load(object? sender, EventArgs e)
{
try
{
List<MedicamentDto> medicaments = await _api.RechercherAsync("");
cboMedicament.DisplayMember = nameof(MedicamentDto.Nom);
cboMedicament.ValueMember = nameof(MedicamentDto.Code);
cboMedicament.DataSource = medicaments;
}
catch (Exception ex) when (ex is ApiException or HttpRequestException or TaskCanceledException)
{
cboMedicament.Enabled = false;
lblStatut.Text = "Référentiel des médicaments indisponible : réessaie plus tard.";
}
}
Le filtre when (ex is … or …) regroupe dans un seul bloc les trois exceptions qu'on sait traiter de la même façon. Si l'API est indisponible, la fenêtre s'ouvre quand même, avec la liste désactivée et une explication : une panne du service distant ne doit pas bloquer tout le reste de l'application.
Brancher l'API sur un formulaire
Le formulaire attend la réponse sans se figer (chapitre 7), propose une annulation, et traduit chaque type d'erreur en message :
public partial class FrmMedicaments : Form
{
private static readonly HttpClient Http = new()
{
BaseAddress = new Uri("https://api.gsb.example/v1/"),
Timeout = TimeSpan.FromSeconds(10),
};
private readonly MedicamentApi _api;
private CancellationTokenSource? _annulation;
public FrmMedicaments(string jeton)
{
InitializeComponent();
_api = new MedicamentApi(Http, jeton);
}
private async void btnRechercher_Click(object? sender, EventArgs e)
{
_annulation?.Cancel();
_annulation = new CancellationTokenSource();
btnRechercher.Enabled = false;
btnAnnuler.Enabled = true;
lblStatut.Text = "Recherche en cours…";
try
{
List<MedicamentDto> resultats = await _api.RechercherAsync(txtNom.Text.Trim(), _annulation.Token);
dgvMedicaments.DataSource = resultats;
lblStatut.Text = $"{resultats.Count} médicament(s)";
}
catch (OperationCanceledException) when (_annulation.IsCancellationRequested)
{
lblStatut.Text = "Recherche annulée.";
}
catch (TaskCanceledException)
{
lblStatut.Text = "L'API ne répond pas (délai dépassé).";
}
catch (ApiException ex)
{
lblStatut.Text = ex.Message;
}
catch (HttpRequestException ex)
{
lblStatut.Text = $"Serveur injoignable : {ex.Message}";
}
finally
{
btnRechercher.Enabled = true;
btnAnnuler.Enabled = false;
}
}
private void btnAnnuler_Click(object? sender, EventArgs e) => _annulation?.Cancel();
}
| Situation | Exception reçue |
|---|---|
| L'utilisateur clique sur Annuler | OperationCanceledException (en fait une TaskCanceledException), avec le jeton annulé |
Le délai Timeout est dépassé | TaskCanceledException, dont InnerException est une TimeoutException |
| Serveur arrêté, nom introuvable, pas de réseau | HttpRequestException (« Connection refused »…) |
| Code d'erreur HTTP (401, 404, 409, 500…) | aucune avec GetAsync : on teste StatusCode, et notre code lève une ApiException |
L'ordre des catch compte : TaskCanceledException hérite d'OperationCanceledException. Le filtre when (_annulation.IsCancellationRequested) réserve le premier bloc à l'annulation volontaire ; un délai dépassé tombe dans le second.
Exercices
Exercice 1 sur 4 Quelle URL ? Réussi À revoir
Pour chaque combinaison, donne l'URL réellement appelée.
BaseAddress=https://api.gsb.example/v1/,GetAsync("medicaments/PARA1000")BaseAddress=https://api.gsb.example/v1,GetAsync("medicaments")BaseAddress=https://api.gsb.example/v1/,GetAsync("/medicaments")BaseAddress=https://api.gsb.example/v1/,GetAsync($"medicaments?nom={Uri.EscapeDataString("acide folique")}")
Voir la réponseMasquer la réponse de l'exercice 1
https://api.gsb.example/v1/medicaments/PARA1000https://api.gsb.example/medicaments: sans/final,v1est remplacé.https://api.gsb.example/medicaments: un chemin qui commence par/repart de la racine.https://api.gsb.example/v1/medicaments?nom=acide%20folique: l'espace est encodé en%20.
Tu avais trouvé ?
Exercice 2 sur 4 Les propriétés vides Réussi À revoir
Un étudiant lit la réponse ainsi, et tous les médicaments affichés ont un code et un nom vides, sans aucune erreur. Pourquoi ? Corrige.
string json = await reponse.Content.ReadAsStringAsync();
var liste = JsonSerializer.Deserialize<List<MedicamentDto>>(json);
Voir la réponseMasquer la réponse de l'exercice 2
JsonSerializer.Deserialize sans options compare les noms en respectant la casse : "code" et "nom" du JSON ne correspondent pas à Code et Nom. Les propriétés restent à leur valeur par défaut, sans exception. Deux corrections :
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var liste = JsonSerializer.Deserialize<List<MedicamentDto>>(json, options);
// ou, plus simple :
var liste2 = await reponse.Content.ReadFromJsonAsync<List<MedicamentDto>>();
Tu avais trouvé ?
Exercice 3 sur 4 Le bon traitement par code de statut Réussi À revoir
L'API répond aux cas suivants. Que doit faire l'application WinForms dans chacun ?
200 OKavec un tableau vide[].401 Unauthorized.404 Not FoundsurGET medicaments/XXX999.503 Service Unavailable.
Voir la réponseMasquer la réponse de l'exercice 3
- Ce n'est pas une erreur : afficher une grille vide et « Aucun médicament trouvé ».
- Le jeton est absent, invalide ou expiré : proposer à l'utilisateur de se reconnecter pour obtenir un nouveau jeton.
- Le médicament n'existe pas : afficher « Médicament XXX999 introuvable », sans parler d'erreur technique.
- L'API est momentanément indisponible : afficher « Service indisponible, réessaie dans quelques minutes ». Une requête
GETpeut être réessayée automatiquement, une ou deux fois, en espaçant les essais (réessayer intelligemment).
Tu avais trouvé ?
Exercice 4 sur 4 Trouve les deux problèmes Réussi À revoir
Ce gestionnaire fonctionne en test, mais pose deux problèmes en production. Lesquels ?
private async void btnCharger_Click(object? sender, EventArgs e)
{
using var http = new HttpClient();
var liste = await http.GetFromJsonAsync<List<MedicamentDto>>(
"https://api.gsb.example/v1/medicaments");
dgvMedicaments.DataSource = liste;
}
Voir la réponseMasquer la réponse de l'exercice 4
- Un
HttpClientpar clic : chaque clic crée une instance et de nouvelles connexions ; sur une journée d'utilisation, l'application peut épuiser les ports réseau du poste. Il faut une instance partagée. - Aucune gestion d'erreur dans un
async void: si l'API est arrêtée ou répond 401,GetFromJsonAsynclève uneHttpRequestExceptionque personne n'intercepte : l'utilisateur voit la boîte d'erreur technique de WinForms, avec la pile d'appels. Il faut untry … catch(et, au passage, un jeton d'authentification et un délai d'attente raisonnable).
Tu avais trouvé ?