Fehlerhaftes JSON in .NET mit System.Text.Json behandeln

Was ReadFromJsonAsync mit kaputtem und schwierigem JSON macht, welche Fälle nicht werfen, und ein Leser, der jeden in einen klaren Fehler verwandelt.

Beispiele zuletzt am gegen die API ausgeführt

Eine API kann mit 200 OK und einem Body antworten, der kein gültiges JSON ist: eine von einem Proxy abgeschnittene Antwort, ein leerer Body, eine HTML-Fehlerseite, ein Wert vom falschen Typ. Wie dein .NET-Code reagiert, entscheidet, ob Nutzer einen klaren Fehler sehen oder einen Absturz mit einer JsonException tief in einem Log. Um das zu testen, brauchst du absichtlich kaputte Antworten. https://api.testingapis.com hat eine ganze Reihe davon; sie sind für angemeldete Mitglieder und kostenlos. Dieser Leitfaden zeigt genau, was System.Text.Json und ReadFromJsonAsync mit jeder machen, die Fälle, die nicht werfen, es aber sollten, einen sicheren Leser und xUnit-Tests. Alles lief auf .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
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

In diesen 61 Bytes stimmen zwei Dinge nicht: ein überzähliges Komma nach "b" und ein Body, der mitten in true abbricht. Der Status ist trotzdem 200, und der Content-Type sagt trotzdem JSON.

Kaputtes und schwieriges JSON auf Wunsch

RequestWas zurückkommt
GET /api/formats/malformed-jsonanmelden zum Anzeigen200, application/json, ungültiges und abgeschnittenes JSON
GET /api/formats/emptyanmelden zum Anzeigen200 mit leerem Body
GET /api/formats/no-contentanmelden zum Anzeigen204 No Content
GET /api/formats/wrong-content-typeanmelden zum AnzeigenGültiges JSON mit dem Label text/html
GET /api/formats/bomanmelden zum AnzeigenGültiges JSON mit einer UTF-8-Byte-Order-Mark davor
GET /api/edge-cases/duplicate-keysanmelden zum AnzeigenGültiges JSON mit demselben Schlüssel zweimal
GET /api/edge-cases/numbersanmelden zum Anzeigenint64-Grenzen, Zahlen jenseits des sicheren Bereichs von JavaScript, eine 30-stellige Ganzzahl, Zahlen als Strings
GET /api/edge-cases/top-level/stringanmelden zum AnzeigenEin JSON-String, wo ein Objekt erwartet wird
GET /api/edge-cases/mixed-arrayanmelden zum AnzeigenEin Array mit Zahlen, Strings, Booleans, null, einem Objekt und einem Array

Was ReadFromJsonAsync mit jeder macht

Jede Antwort wurde in einen einfachen Record gelesen. Ungültiges JSON wirft eine JsonException, deren Path auf das Problem zeigt; drei der Fälle werfen überhaupt nicht:

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);
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Ausgabe
/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
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
AntwortErgebnisWarum es wichtig ist
Abgeschnittenes oder ungültiges JSONJsonException, mit Path, LineNumber und BytePositionInLineFang sie ab und melde, welcher Aufruf kaputt war, sonst taucht sie als unerklärter Absturz auf
Leeres 200 oder 204JsonException: keine JSON-TokensPrüfe vor dem Parsen auf einen leeren Body, wenn die API einen schicken kann
JSON mit dem Label text/htmlOhne Murren geparstReadFromJsonAsync prüft den Content-Type nicht. Eine echte HTML-Fehlerseite scheitert erst später, als ungültiges JSON
Byte-Order-MarkGeparstWird für dich erledigt
Derselbe Schlüssel zweimalGeparst: Der letzte Wert gewinnt, stillschweigendid wurde 2 und name "second value". Mach es mit AllowDuplicateProperties = false (.NET 10) zum Fehler
Falsche Form (ein String, ein gemischtes Array)JsonException beim ersten falschen WertPath sagt, wo: $ für die Wurzel, $[1] für das zweite Element

Zahlen

C# kommt mit großen Ganzzahlen besser zurecht als JavaScript: Ein long hält 9007199254740993 exakt, wo JavaScript rundet. Aber eine 30-stellige Ganzzahl passt nicht in einen long, und als Strings gesendete Zahlen hängen von deinen Optionen ab. ReadFromJsonAsync nutzt die Web-Standards, die sie annehmen; Optionen ohne NumberHandling.AllowReadingFromString tun das nicht.

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);
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Ausgabe
{"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

Ein sicherer Leser: ein klarer Fehler für jeden schlechten Body

Lies den Body zuerst als String, damit ein Fehler ihn zitieren kann, und parse dann. Jeder Fehlschlag wird zu einer InvalidDataException, die die URL, den Content-Type, die Stelle, an der das Parsen stoppte, und den Anfang des Bodys nennt. Genau das brauchst du um 3 Uhr nachts in einem Log.

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);
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen
Ausgabe
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
}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Das Duplikat wird bei $.id, Zeile 5, gemeldet: Item hat nur eine Id, das ist also die doppelte Eigenschaft, die für ihn zählt. Schlüssel, die der Typ nicht abbildet, werden ohnehin übersprungen.

xUnit-Tests

Eine Theory deckt jede kaputte Antwort ab; ein Fact prüft, dass gutes JSON weiterhin gelesen wird. Sie bestehen mit xUnit 2.9 auf .NET 10 und nutzen die Klasse JsonResponses von oben.

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);
    }
}
Dieses Beispiel nutzt Endpunkte für angemeldete Mitglieder.AnmeldenKostenloses Konto erstellen

Was du in deinem Client prüfen solltest

  • Ungültiges JSON mit Status 200 ist ein Fehler, kein Absturz: abgefangen, mit der URL protokolliert, dem Nutzer gezeigt.
  • Ein leerer Body oder ein 204 wird vor dem Parsen behandelt, wo die API so etwas schicken kann.
  • Doppelte Schlüssel werden abgelehnt, wo ein stilles "der Letzte gewinnt" einen Bug verbergen würde.
  • Große IDs passen in ihren Typ: long, oder string, wenn sie ihn überschreiten können.
  • Fehler zitieren genug vom Body, um ihn zu diagnostizieren, ohne Geheimnisse zu protokollieren.

Dieselben Antworten helfen auch in anderen Sprachen: Die Referenz der JSON-Grenzfälle und die Referenz der Formate listen sie alle auf, und Fehler auf Wunsch behandelt Fehlerstatuscodes.