Menangani JSON yang rusak di .NET dengan System.Text.Json

Apa yang dilakukan ReadFromJsonAsync pada JSON rusak dan janggal, kasus mana yang tidak melempar, dan pembaca yang mengubah masing-masing menjadi error yang jelas.

Contoh terakhir dijalankan terhadap API pada

API bisa menjawab 200 OK dengan body yang bukan JSON valid: response yang dipotong proxy, body kosong, halaman error HTML, nilai dengan tipe yang salah. Cara kode .NET Anda bereaksi menentukan apakah pengguna melihat error yang jelas atau crash dengan JsonException yang terkubur di log. Untuk mengujinya, Anda butuh response yang sengaja dirusak. https://api.testingapis.com punya sekumpulan; semuanya untuk anggota yang sudah masuk, dan gratis. Panduan ini menunjukkan persis apa yang dilakukan System.Text.Json dan ReadFromJsonAsync pada masing-masing, kasus yang tidak melempar padahal seharusnya, pembaca yang aman, dan tes xUnit. Semuanya dijalankan di .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
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Ada dua kesalahan dalam 61 byte itu: koma berlebih setelah "b" dan body yang terpotong di tengah true. Statusnya tetap 200 dan content type-nya tetap menyebut JSON.

JSON rusak dan janggal sesuai permintaan

RequestYang kembali
GET /api/formats/malformed-jsonmasuk untuk melihat200, application/json, JSON tidak valid dan terpotong
GET /api/formats/emptymasuk untuk melihat200 dengan body kosong
GET /api/formats/no-contentmasuk untuk melihat204 No Content
GET /api/formats/wrong-content-typemasuk untuk melihatJSON valid berlabel text/html
GET /api/formats/bommasuk untuk melihatJSON valid dengan byte order mark UTF-8 di depannya
GET /api/edge-cases/duplicate-keysmasuk untuk melihatJSON valid dengan key yang sama dua kali
GET /api/edge-cases/numbersmasuk untuk melihatBatas int64, angka di luar rentang aman JavaScript, bilangan bulat 30 digit, angka sebagai string
GET /api/edge-cases/top-level/stringmasuk untuk melihatString JSON di tempat yang seharusnya objek
GET /api/edge-cases/mixed-arraymasuk untuk melihatSatu array berisi angka, string, boolean, null, objek, dan array

Apa yang dilakukan ReadFromJsonAsync pada masing-masing

Setiap response dibaca ke record sederhana. JSON yang tidak valid melempar JsonException yang Path-nya menunjuk ke masalah; tiga kasus tidak melempar sama sekali:

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);
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis
Output
/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
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis
ResponseHasilMengapa penting
JSON terpotong atau tidak validJsonException, dengan Path, LineNumber, dan BytePositionInLineTangkap dan laporkan panggilan mana yang rusak, atau ia muncul sebagai crash tanpa penjelasan
200 kosong, atau 204JsonException: tidak ada token JSONPeriksa body kosong sebelum parsing bila API bisa mengirimnya
JSON berlabel text/htmlDi-parse tanpa keluhanReadFromJsonAsync tidak memeriksa Content-Type. Halaman error HTML sungguhan baru gagal kemudian, sebagai JSON tidak valid
Byte order markDi-parseDitangani untuk Anda
Key yang sama dua kaliDi-parse: nilai terakhir menang, diam-diamid menjadi 2 dan name "second value". Jadikan error dengan AllowDuplicateProperties = false (.NET 10)
Bentuk salah (string, array campuran)JsonException pada nilai buruk pertamaPath menyebut lokasinya: $ untuk root, $[1] untuk elemen kedua

Angka

C# menangani bilangan bulat besar lebih baik daripada JavaScript: long menyimpan 9007199254740993 persis, sementara JavaScript membulatkannya. Tetapi bilangan bulat 30 digit tidak muat di long, dan angka yang dikirim sebagai string bergantung pada opsi Anda. ReadFromJsonAsync memakai default web, yang menerimanya; opsi tanpa NumberHandling.AllowReadingFromString tidak menerima.

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);
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis
Output
{"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

Pembaca yang aman: satu error yang jelas untuk setiap body buruk

Baca body sebagai string lebih dulu, agar error bisa mengutipnya, lalu parse. Setiap kegagalan menjadi InvalidDataException yang menyebut URL, content type, di mana parsing berhenti, dan awal body. Itulah yang Anda butuhkan di log pukul 3 pagi.

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);
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis
Output
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
}
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Duplikatnya dilaporkan di $.id, baris 5: Item hanya punya Id, jadi itulah properti berulang yang penting baginya. Key yang tidak dipetakan tipe itu dilewati bagaimanapun juga.

Tes xUnit

Satu theory mencakup setiap response yang rusak; satu fact memeriksa bahwa JSON yang baik tetap terbaca. Lulus dengan xUnit 2.9 di .NET 10, memakai kelas JsonResponses di atas.

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);
    }
}
Contoh ini memakai endpoint untuk anggota yang sudah masuk.MasukBuat akun gratis

Yang perlu diperiksa di klien Anda

  • JSON tidak valid dengan status 200 adalah error, bukan crash: ditangkap, dicatat dengan URL, ditampilkan ke pengguna.
  • Body kosong atau 204 ditangani sebelum parsing, di mana API bisa mengirimnya.
  • Key duplikat ditolak di tempat "yang terakhir menang" secara diam-diam akan menyembunyikan bug.
  • ID besar muat di tipenya: long, atau string bila bisa melampauinya.
  • Error mengutip cukup banyak body untuk mendiagnosisnya, tanpa mencatat rahasia.

Response yang sama juga berguna di bahasa lain: referensi kasus tepi JSON dan referensi format mendaftar semuanya, dan error sesuai permintaan membahas kode status kegagalan.