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.

Terminal
$ 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": tru
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

Deux 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êteCe qui revient
GET /api/formats/malformed-jsonconnectez-vous pour voir200, application/json, JSON invalide et tronqué
GET /api/formats/emptyconnectez-vous pour voir200 avec un corps vide
GET /api/formats/no-contentconnectez-vous pour voir204 No Content
GET /api/formats/wrong-content-typeconnectez-vous pour voirDu JSON valide étiqueté text/html
GET /api/formats/bomconnectez-vous pour voirDu JSON valide précédé d’une marque d’ordre des octets UTF-8
GET /api/edge-cases/duplicate-keysconnectez-vous pour voirDu JSON valide avec deux fois la même clé
GET /api/edge-cases/numbersconnectez-vous pour voirLimites 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 voirUne chaîne JSON là où un objet est attendu
GET /api/edge-cases/mixed-arrayconnectez-vous pour voirUn 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 :

Program.cs
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);
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit
Sortie
/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
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit
RéponseRésultatPourquoi c’est important
JSON tronqué ou invalideJsonException, avec Path, LineNumber et BytePositionInLineAttrapez-la et signalez quel appel a cassé, sinon elle remonte comme un plantage inexpliqué
200 vide, ou 204JsonException : aucun token JSONVérifiez si le corps est vide avant l’analyse quand l’API peut en envoyer un
JSON étiqueté text/htmlAnalysé sans broncherReadFromJsonAsync ne vérifie pas le Content-Type. Une vraie page d’erreur HTML échoue plus tard, comme JSON invalide
Marque d’ordre des octetsAnalyséGéré pour vous
Deux fois la même cléAnalysé : la dernière valeur l’emporte, en silenceid 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 valeurPath 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.

Program.cs
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);
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit
Sortie
{"Int64Max":9223372036854775807,"BeyondSafeInteger":9007199254740993,"PointOnePlusPointTwo":0.30000000000000004}
Web defaults: "9223372036854775807" read as 9223372036854775807
Without web defaults: JsonException at $.int64MaxAsString
bigInteger into long: JsonException at $.bigInteger

Un 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.

JsonResponses.cs
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);
        }
    }
}
Program.cs
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);
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit
Sortie
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
}
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

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.

JsonResponseTests.cs
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);
    }
}
Cet exemple utilise des points d’accès réservés aux membres connectés.Se connecterCréer un compte gratuit

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, ou string quand 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.