.NET の System.Text.Json で不正な JSON を扱う

ReadFromJsonAsync が壊れた JSON や扱いにくい JSON をどう扱うか、例外を投げないケース、それぞれをわかりやすいエラーにする読み取り方法。

例を API に対して最後に実行した日:

API は、有効な JSON ではないボディで 200 OK を返すことがあります。プロキシに途中で切られたレスポンス、空のボディ、 HTML のエラーページ、型の違う値などです。.NET のコードがどう反応するかで、ユーザーに見えるのがわかりやすいエラーなのか、 ログの奥に JsonException を残してのクラッシュなのかが決まります。テストするには、わざと壊したレスポンスが必要です。https://api.testingapis.com にはそうしたものがそろっています。サインインしたメンバー向けで、無料です。このガイドでは、System.Text.Json と ReadFromJsonAsync がそれぞれをどう扱うか、例外を投げるべきなのに投げないケース、安全な読み取り方法、xUnit のテストを紹介します。すべて .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
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

この 61 バイトには間違いが 2 つあります。"b" の後の余分なカンマと、true の途中で切れたボディです。それでもステータスは 200 で、コンテンツタイプは JSON のままです。

壊れた JSON や扱いにくい JSON を好きなときに

リクエスト返ってくるもの
GET /api/formats/malformed-jsonサインインして表示200、application/json、無効で途中で切れた JSON
GET /api/formats/emptyサインインして表示空のボディの 200
GET /api/formats/no-contentサインインして表示204 No Content
GET /api/formats/wrong-content-typeサインインして表示text/html と表示された有効な JSON
GET /api/formats/bomサインインして表示先頭に UTF-8 のバイトオーダーマークが付いた有効な JSON
GET /api/edge-cases/duplicate-keysサインインして表示同じキーが 2 回ある有効な JSON
GET /api/edge-cases/numbersサインインして表示int64 の限界値、JavaScript の安全な範囲を超える数値、30 桁の整数、文字列の数値
GET /api/edge-cases/top-level/stringサインインして表示オブジェクトを期待するところに JSON の文字列
GET /api/edge-cases/mixed-arrayサインインして表示数値、文字列、真偽値、null、オブジェクト、配列が混ざった配列

ReadFromJsonAsync はそれぞれをどう扱うか

それぞれのレスポンスを単純な record に読み込みました。無効な JSON は JsonException を投げ、その Path が問題の場所を指します。3 つのケースは例外をまったく投げません。

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);
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
出力
/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
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
レスポンス結果なぜ大事か
途中で切れた、または無効な JSONPath、LineNumber、BytePositionInLine 付きの JsonException捕捉してどの呼び出しが壊れたかを報告しないと、原因不明のクラッシュとして表に出る
空の 200、または 204JsonException:JSON のトークンがないAPI が空のボディを返しうるなら、解析の前に確認する
text/html と表示された JSON何も言わずに解析されるReadFromJsonAsync は Content-Type を確認しない。本物の HTML エラーページは後で無効な JSON として失敗する
バイトオーダーマーク解析される自動で処理される
同じキーが 2 回解析される:最後の値が黙って勝つid は 2、name は "second value" になった。AllowDuplicateProperties = false(.NET 10)でエラーにできる
形の違い(文字列、混在した配列)最初の不正な値で JsonExceptionPath が場所を示す。ルートは $、2 番目の要素は $[1]

数値

C# は大きな整数を JavaScript よりうまく扱えます。JavaScript が丸めてしまう 9007199254740993 も、long なら正確に保てます。しかし 30 桁の整数は long に収まらず、文字列で送られた数値はオプション次第です。ReadFromJsonAsync は Web 向けの既定値を使うので受け付けますが、NumberHandling.AllowReadingFromString を指定しないオプションでは受け付けません。

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);
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
出力
{"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

安全な読み取り:不正なボディごとにわかりやすいエラーを 1 つ

エラーでボディを引用できるよう、まずボディを文字列として読み、それから解析します。失敗はすべて InvalidDataException になり、URL、コンテンツタイプ、解析が止まった場所、ボディの先頭を含みます。深夜 3 時にログで必要になるのはこれです。

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);
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成
出力
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
}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

重複は $.id の 5 行目で報告されます。Item には Id しかないので、問題になるのはその重複したプロパティです。型が対応づけないキーは、どちらにしても無視されます。

xUnit テスト

1 つの theory で壊れたレスポンスをすべてカバーし、1 つの fact で正しい JSON が読めることを確かめます。上の JsonResponses クラスを使い、.NET 10 上の xUnit 2.9 で通ります。

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);
    }
}
この例はサインインしたメンバー向けのエンドポイントを使います。サインイン無料アカウントを作成

クライアントで確認すること

  • ステータス 200 の無効な JSON はクラッシュではなくエラーとして扱い、捕捉して URL 付きで記録し、ユーザーに見せる。
  • API が返しうるなら、空のボディや 204 は解析の前に処理する。
  • 黙って「最後が勝つ」とバグが隠れる場面では、重複したキーを拒否する。
  • 大きな ID はその型に収める。long、超えうるなら string。
  • エラーには診断に足るだけのボディを引用し、秘密情報は記録しない。

同じレスポンスはほかの言語でも役立ちます。JSON エッジケースのリファレンスと形式のリファレンスにすべて載っており、好きなときにエラーを返す APIでは失敗のステータスコードを扱っています。