.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 で実行しました。
$ 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 つのケースは例外をまったく投げません。
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| レスポンス | 結果 | なぜ大事か |
|---|---|---|
| 途中で切れた、または無効な JSON | Path、LineNumber、BytePositionInLine 付きの JsonException | 捕捉してどの呼び出しが壊れたかを報告しないと、原因不明のクラッシュとして表に出る |
| 空の 200、または 204 | JsonException:JSON のトークンがない | API が空のボディを返しうるなら、解析の前に確認する |
| text/html と表示された JSON | 何も言わずに解析される | ReadFromJsonAsync は Content-Type を確認しない。本物の HTML エラーページは後で無効な JSON として失敗する |
| バイトオーダーマーク | 解析される | 自動で処理される |
| 同じキーが 2 回 | 解析される:最後の値が黙って勝つ | id は 2、name は "second value" になった。AllowDuplicateProperties = false(.NET 10)でエラーにできる |
| 形の違い(文字列、混在した配列) | 最初の不正な値で JsonException | Path が場所を示す。ルートは $、2 番目の要素は $[1] |
数値
C# は大きな整数を JavaScript よりうまく扱えます。JavaScript が丸めてしまう 9007199254740993 も、long なら正確に保てます。しかし 30 桁の整数は long に収まらず、文字列で送られた数値はオプション次第です。ReadFromJsonAsync は Web 向けの既定値を使うので受け付けますが、NumberHandling.AllowReadingFromString を指定しないオプションでは受け付けません。
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 時にログで必要になるのはこれです。
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
}重複は $.id の 5 行目で報告されます。Item には Id しかないので、問題になるのはその重複したプロパティです。型が対応づけないキーは、どちらにしても無視されます。
xUnit テスト
1 つの theory で壊れたレスポンスをすべてカバーし、1 つの fact で正しい JSON が読めることを確かめます。上の JsonResponses クラスを使い、.NET 10 上の xUnit 2.9 で通ります。
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では失敗のステータスコードを扱っています。