Gérer du JSON mal formé en .NET avec System.Text.Json
Ce que fait ReadFromJsonAsync de JSON cassé ou bizarre, quels cas ne lèvent pas d’exception, et un lecteur qui transforme chacun en erreur claire.
Exemples exécutés pour la dernière fois contre l’API le
Une API peut répondre 200 OK avec un corps qui n’est pas du JSON valide : une réponse coupée par un proxy, un corps vide, une page d’erreur HTML, une valeur du mauvais type. La réaction de votre code .NET décide si les utilisateurs voient une erreur claire ou un plantage avec une JsonException perdue dans un log. Pour le tester, il faut des réponses cassées exprès. https://api.testingapis.com en propose toute une série ; elles sont réservées aux membres connectés, et gratuites. Ce guide montre précisément ce que font System.Text.Json et ReadFromJsonAsync de chacune, les cas qui ne lèvent pas d’exception alors qu’ils devraient, un lecteur sûr et des tests xUnit. Tout a tourné sur .NET 10.
$ curl -i https://api.testingapis.com/api/formats/malformed-json
HTTP/1.1 200 OK
content-length: 61
content-type: application/json; charset=utf-8
{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": truDeux choses clochent dans ces 61 octets : une virgule finale après "b" et un corps coupé au milieu de true. Le statut reste 200 et le type de contenu annonce toujours du JSON.
Du JSON cassé ou bizarre à la demande
| Requête | Ce qui revient |
|---|---|
GET /api/formats/malformed-jsonconnectez-vous pour voir | 200, application/json, JSON invalide et tronqué |
GET /api/formats/emptyconnectez-vous pour voir | 200 avec un corps vide |
GET /api/formats/no-contentconnectez-vous pour voir | 204 No Content |
GET /api/formats/wrong-content-typeconnectez-vous pour voir | Du JSON valide étiqueté text/html |
GET /api/formats/bomconnectez-vous pour voir | Du JSON valide précédé d’une marque d’ordre des octets UTF-8 |
GET /api/edge-cases/duplicate-keysconnectez-vous pour voir | Du JSON valide avec deux fois la même clé |
GET /api/edge-cases/numbersconnectez-vous pour voir | Limites d’int64, nombres hors de la plage sûre de JavaScript, un entier de 30 chiffres, des nombres en chaînes |
GET /api/edge-cases/top-level/stringconnectez-vous pour voir | Une chaîne JSON là où un objet est attendu |
GET /api/edge-cases/mixed-arrayconnectez-vous pour voir | Un tableau contenant des nombres, des chaînes, des booléens, null, un objet et un tableau |
Ce que fait ReadFromJsonAsync de chacune
Chaque réponse a été lue dans un record simple. Le JSON invalide lève une JsonException dont le Path désigne le problème ; trois des cas ne lèvent rien du tout :
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
async Task Read<T>(string path, JsonSerializerOptions? options = null)
{
try
{
using var response = await http.GetAsync(path);
var value = await response.Content.ReadFromJsonAsync<T>(options);
Console.WriteLine($"{path,-38} OK {JsonSerializer.Serialize(value)}");
}
catch (JsonException ex)
{
Console.WriteLine($"{path,-38} JsonException at {ex.Path}: {ex.Message[..ex.Message.IndexOf(". ")]}");
}
}
await Read<Person>("/api/formats/malformed-json");
await Read<Person>("/api/formats/empty");
await Read<Person>("/api/formats/no-content");
await Read<Person>("/api/formats/wrong-content-type");
await Read<Person>("/api/formats/bom");
await Read<Person>("/api/edge-cases/duplicate-keys");
await Read<Person>("/api/edge-cases/top-level/string");
await Read<List<int>>("/api/edge-cases/mixed-array");
record Person(int Id, string Name);/api/formats/malformed-json JsonException at $.tags: The JSON array contains a trailing comma at the end which is not supported in this mode
/api/formats/empty JsonException at $: The input does not contain any JSON tokens
/api/formats/no-content JsonException at $: The input does not contain any JSON tokens
/api/formats/wrong-content-type OK {"Id":1,"Name":"Alice Johnson"}
/api/formats/bom OK {"Id":1,"Name":"Alice Johnson"}
/api/edge-cases/duplicate-keys OK {"Id":2,"Name":"second value"}
/api/edge-cases/top-level/string JsonException at $: The JSON value could not be converted to Person
/api/edge-cases/mixed-array JsonException at $[1]: The JSON value could not be converted to System.Int32| Réponse | Résultat | Pourquoi c’est important |
|---|---|---|
| JSON tronqué ou invalide | JsonException, avec Path, LineNumber et BytePositionInLine | Attrapez-la et signalez quel appel a cassé, sinon elle remonte comme un plantage inexpliqué |
| 200 vide, ou 204 | JsonException : aucun token JSON | Vérifiez si le corps est vide avant l’analyse quand l’API peut en envoyer un |
| JSON étiqueté text/html | Analysé sans broncher | ReadFromJsonAsync ne vérifie pas le Content-Type. Une vraie page d’erreur HTML échoue plus tard, comme JSON invalide |
| Marque d’ordre des octets | Analysé | Géré pour vous |
| Deux fois la même clé | Analysé : la dernière valeur l’emporte, en silence | id est devenu 2 et name "second value". Faites-en une erreur avec AllowDuplicateProperties = false (.NET 10) |
| Mauvaise forme (une chaîne, un tableau mixte) | JsonException à la première mauvaise valeur | Path dit où : $ pour la racine, $[1] pour le deuxième élément |
Les nombres
C# gère les grands entiers mieux que JavaScript : un long conserve exactement 9007199254740993, là où JavaScript l’arrondit. Mais un entier de 30 chiffres ne tient pas dans un long, et les nombres envoyés en chaînes dépendent de vos options. ReadFromJsonAsync utilise les réglages web par défaut, qui les acceptent ; des options sans NumberHandling.AllowReadingFromString, non.
using System.Net.Http.Json;
using System.Text.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
using var response = await http.GetAsync("/api/edge-cases/numbers");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(JsonSerializer.Serialize(
JsonSerializer.Deserialize<Numbers>(json, JsonSerializerOptions.Web)));
// The same field, read with the web defaults and without them.
var strict = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
foreach (var (label, options) in new[] { ("Web defaults", JsonSerializerOptions.Web), ("Without web defaults", strict) })
{
try
{
var value = JsonSerializer.Deserialize<AsString>(json, options);
Console.WriteLine($"{label}: \"9223372036854775807\" read as {value!.Int64MaxAsString}");
}
catch (JsonException ex) { Console.WriteLine($"{label}: JsonException at {ex.Path}"); }
}
try { JsonSerializer.Deserialize<TooBig>(json, JsonSerializerOptions.Web); }
catch (JsonException ex) { Console.WriteLine($"bigInteger into long: JsonException at {ex.Path}"); }
record Numbers(long Int64Max, long BeyondSafeInteger, double PointOnePlusPointTwo);
record AsString(long Int64MaxAsString);
record TooBig(long BigInteger);{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigIntegerUn lecteur sûr : une erreur claire pour chaque mauvais corps
Lisez d’abord le corps comme une chaîne, pour qu’une erreur puisse le citer, puis analysez-le. Chaque échec devient une InvalidDataException qui indique l’URL, le type de contenu, l’endroit où l’analyse s’est arrêtée et le début du corps. C’est ce dont vous avez besoin dans un log à 3 heures du matin.
using System.Text.Json;
public static class JsonResponses
{
// Web defaults (camelCase, case-insensitive, numbers in strings allowed), but a repeated
// property is an error instead of the last value silently winning (.NET 10).
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
AllowDuplicateProperties = false,
};
/// Reads a JSON body, or throws an InvalidDataException that says what came back and where it broke.
public static async Task<T> ReadJsonAsync<T>(this HttpResponseMessage response, CancellationToken cancellationToken = default)
{
response.EnsureSuccessStatusCode();
var url = response.RequestMessage?.RequestUri;
var contentType = response.Content.Headers.ContentType?.ToString() ?? "no content type";
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(body))
{
throw new InvalidDataException($"{(int)response.StatusCode} from {url} had an empty body ({contentType}).");
}
try
{
return JsonSerializer.Deserialize<T>(body, Options)
?? throw new InvalidDataException($"{url} returned JSON null.");
}
catch (JsonException ex)
{
var preview = body.Length > 120 ? body[..120] + "..." : body;
throw new InvalidDataException(
$"Invalid JSON from {url} ({contentType}) at {ex.Path}, line {ex.LineNumber}, byte {ex.BytePositionInLine}: {preview}", ex);
}
}
}using var http = new HttpClient { BaseAddress = new Uri("https://api.testingapis.com") };
foreach (var path in new[] { "/api/Products/1", "/api/formats/malformed-json", "/api/formats/empty", "/api/edge-cases/duplicate-keys" })
{
try
{
using var response = await http.GetAsync(path);
var item = await response.ReadJsonAsync<Item>();
Console.WriteLine($"OK: {item}");
}
catch (InvalidDataException ex)
{
Console.WriteLine(ex.Message);
}
}
record Item(int Id);OK: Item { Id = 1 }
Invalid JSON from https://api.testingapis.com/api/formats/malformed-json (application/json; charset=utf-8) at $.tags, line 0, byte 45: {"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru
200 from https://api.testingapis.com/api/formats/empty had an empty body (no content type).
Invalid JSON from https://api.testingapis.com/api/edge-cases/duplicate-keys (application/json; charset=utf-8) at $.id, line 5, byte 7: {
"id": 1,
"name": "first value",
"name": "second value",
"nested": { "flag": true, "flag": false },
"id": 2
}Le doublon est signalé en $.id, ligne 5 : Item n’a qu’un Id, c’est donc la propriété répétée qui le concerne. Les clés que le type ne mappe pas sont ignorées de toute façon.
Des tests xUnit
Une theory couvre toutes les réponses cassées ; un fact vérifie que le bon JSON se lit toujours. Ils passent avec xUnit 2.9 sur .NET 10, en utilisant la classe JsonResponses ci-dessus.
using Xunit;
public class JsonResponseTests
{
private static readonly HttpClient Http = new() { BaseAddress = new Uri("https://api.testingapis.com") };
private record Item(int Id);
[Theory]
[InlineData("/api/formats/malformed-json")]
[InlineData("/api/formats/empty")]
[InlineData("/api/edge-cases/duplicate-keys")]
[InlineData("/api/edge-cases/top-level/string")]
public async Task Bad_json_becomes_one_clear_error(string path)
{
using var response = await Http.GetAsync(path);
var ex = await Assert.ThrowsAsync<InvalidDataException>(() => response.ReadJsonAsync<Item>());
Assert.Contains(path, ex.Message); // the message says which call broke
}
[Fact]
public async Task Good_json_still_reads()
{
using var response = await Http.GetAsync("/api/Products/1");
var item = await response.ReadJsonAsync<Item>();
Assert.Equal(1, item.Id);
}
}Ce qu’il faut vérifier dans votre client
- Du JSON invalide avec un statut 200 est une erreur, pas un plantage : attrapée, consignée avec l’URL, affichée à l’utilisateur.
- Un corps vide ou un 204 est traité avant l’analyse, là où l’API peut en envoyer.
- Les clés en double sont rejetées là où un "le dernier gagne" silencieux masquerait un bug.
- Les grands identifiants tiennent dans leur type :
long, oustringquand ils peuvent le dépasser. - Les erreurs citent assez du corps pour le diagnostiquer, sans consigner de secrets.
Les mêmes réponses servent dans d’autres langages : la référence des cas limites JSON et la référence des formats les listent toutes, et les erreurs à la demande couvrent les codes de statut d’échec.