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.
$ 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": truIn 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
| Request | Was zurückkommt |
|---|---|
GET /api/formats/malformed-jsonanmelden zum Anzeigen | 200, application/json, ungültiges und abgeschnittenes JSON |
GET /api/formats/emptyanmelden zum Anzeigen | 200 mit leerem Body |
GET /api/formats/no-contentanmelden zum Anzeigen | 204 No Content |
GET /api/formats/wrong-content-typeanmelden zum Anzeigen | Gültiges JSON mit dem Label text/html |
GET /api/formats/bomanmelden zum Anzeigen | Gültiges JSON mit einer UTF-8-Byte-Order-Mark davor |
GET /api/edge-cases/duplicate-keysanmelden zum Anzeigen | Gültiges JSON mit demselben Schlüssel zweimal |
GET /api/edge-cases/numbersanmelden zum Anzeigen | int64-Grenzen, Zahlen jenseits des sicheren Bereichs von JavaScript, eine 30-stellige Ganzzahl, Zahlen als Strings |
GET /api/edge-cases/top-level/stringanmelden zum Anzeigen | Ein JSON-String, wo ein Objekt erwartet wird |
GET /api/edge-cases/mixed-arrayanmelden zum Anzeigen | Ein 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:
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| Antwort | Ergebnis | Warum es wichtig ist |
|---|---|---|
| Abgeschnittenes oder ungültiges JSON | JsonException, mit Path, LineNumber und BytePositionInLine | Fang sie ab und melde, welcher Aufruf kaputt war, sonst taucht sie als unerklärter Absturz auf |
| Leeres 200 oder 204 | JsonException: keine JSON-Tokens | Prüfe vor dem Parsen auf einen leeren Body, wenn die API einen schicken kann |
| JSON mit dem Label text/html | Ohne Murren geparst | ReadFromJsonAsync prüft den Content-Type nicht. Eine echte HTML-Fehlerseite scheitert erst später, als ungültiges JSON |
| Byte-Order-Mark | Geparst | Wird für dich erledigt |
| Derselbe Schlüssel zweimal | Geparst: Der letzte Wert gewinnt, stillschweigend | id 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 Wert | Path 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.
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 $.bigIntegerEin 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.
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
}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.
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);
}
}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, oderstring, 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.