エラーレスポンスの形を決める
このレッスンでは、エラー応答の本文をどんな形にそろえるかを決め、そのとおりに組み立てられるようになります。
形がばらばらな API は使えません
同じアプリなのに、ある画面では {"error":"not found"} が返り、別の画面では {"message":"見つかりません"} が返り、さらに別のところでは HTML のエラーページが返ってくる。こうした API を相手にすると、クライアントはエラーごとに読み取りのコードを書き分けることになります。エラーの本文は、成功時の本文よりもむしろ形をそろえる価値が高い部分です。成功のときは呼び出し側が何を期待しているか分かっていますが、失敗はいつどこで起きるか分からないため、共通の読み方が必要になるからです。
そろえるべき項目は次の通りです。
| 項目 | 役割 |
|---|---|
| ステータス | HTTP のステータス行と同じ数字。本文だけを見ても状況が分かる |
| エラーコード | プログラムが分岐に使う短い識別子。文言が変わっても壊れない |
| メッセージ | 人が読むための説明。開発者や運用者が原因を掴むためのもの |
エラーコードとメッセージを分けるのが要点です。メッセージは日本語にしたり文面を練り直したりと頻繁に変わりますが、クライアントの分岐がメッセージの文字列に依存していると、文面を直した瞬間に相手のアプリが壊れます。USER_NOT_FOUND のような不変のコードを別に持たせておけば、文面は自由に直せます。
Problem Details という共通の答え
この「エラー本文をどうそろえるか」という問いには、RFC 9457 の Problem Details という標準の答えがあります。以前の RFC 7807 を置き換えたもので、application/problem+json という媒体型で、決まった名前の項目を持つ JSON を返す取り決めです。よく使われる項目は次の通りです。
type— 問題の種類を表す URI。同じ種類のエラーには同じ URI を使うtitle— 種類を短く表した、人が読む見出しstatus— HTTP ステータスコードdetail— この 1 回の発生に固有の説明instance— この 1 回の発生を指す URI
JSON
{
"type": "https://example.com/probs/out-of-credit",
"title": "You do not have enough credit.",
"status": 403,
"detail": "Your current balance is 30, but that costs 50."
}title は種類ごとに固定、detail は発生ごとに変わる、という役割分担が肝心です。この区別が守られていると、クライアントは type と title で分岐し、detail は画面に出すだけ、という素直な扱いができます。Spring Framework 6 以降には ProblemDetail という型が用意されているので、独自の形を発明する前に、まずこの標準に寄せられないかを検討してください。
本文に載せてはいけないもの
スタックトレース、SQL 文、内部のクラス名、環境変数の値は、エラー本文に載せてはいけません。攻撃者にとっては構成を推測する材料になります。原因の追跡に必要な情報はサーバーのログへ書き、本文には次のレッスンで扱うトレース識別子だけを載せます。利用者から「エラーになりました」と連絡が来たとき、その識別子でログを引ければ十分です。
演習で書くもの
Spring では @RestControllerAdvice のハンドラが返したオブジェクトを、Jackson が JSON へ変換します。ここではその組み立てを自分で書きます。文字列として JSON を作る以上、本文に引用符や円記号が含まれていたときに壊れないよう、自分でエスケープする必要があります。Jackson が黙ってやってくれている仕事を、1 度だけ手で書いてみる回です。
要件
- 項目の順を status、code、message に固定し、空白を入れずに組み立てること
- message に含まれる引用符と円記号と改行を JSON のエスケープ記法へ変換すること
- code が null なら UNKNOWN、message が null なら空文字として扱うこと
入出力例
buildErrorBody(404, "USER_NOT_FOUND", "user 7 was not found") → "{"status":404,"code":"USER_NOT_FOUND","message":"user 7 was not found"}"
buildErrorBody(400, "VALIDATION_FAILED", "name is required") → "{"status":400,"code":"VALIDATION_FAILED","message":"name is required"}"
buildErrorBody(500, "INTERNAL_ERROR", "unexpected "boom" happened") → "{"status":500,"code":"INTERNAL_ERROR","message":"unexpected \"boom\" happened"}"
buildErrorBody(409, "DUPLICATE", "path C:\temp is taken") → "{"status":409,"code":"DUPLICATE","message":"path C:\\temp is taken"}"
buildErrorBody(400, "BAD_REQUEST", "line1
line2") → "{"status":400,"code":"BAD_REQUEST","message":"line1\nline2"}"
buildErrorBody(403, "FORBIDDEN", "") → "{"status":403,"code":"FORBIDDEN","message":""}"