メインコンテンツまでスキップ

エラーレスポンス

前の章までの API は、失敗をばらばらの形で返してきました。無い注文の 404、在庫が足りない注文の 409、無い商品の 422 は、本文の無い応答でした。検証の違反の 400 は、Spring Boot の既定のエラー応答で、どの項目が違反したのかを本文に含みませんでした。この章では、失敗の応答を 1 つの形にそろえ、クライアントが失敗の種類と中身を本文から読めるようにします。

この章のコードは tag api-15 にあります。

Problem Details​

HTTP の API の失敗の本文の形を決めた仕様が、RFC 9457 の Problem Details です1。本文は JSON のオブジェクトで、Content-Type は application/problem+json です。決まった項目は次の 5 つです。

項目意味
type問題の種類を表す URI
title問題の種類の短い要約。同じ種類なら発生ごとに変えない
statusHTTP のステータスコード。実際の応答と同じ値にする
detailこの発生に固有の説明
instanceこの発生を指す URI

種類ごとに、項目を足してよいことになっています (拡張の項目)。在庫が足りない問題なら、どの商品が何個足りないかを足せます。クライアントは、知らない拡張の項目を無視しなければなりません。

Spring Framework は、この本文を ProblemDetail というクラスで表します。Spring Framework 7 の ProblemDetail は、type を設定しなければ、JSON に type を出しません2。RFC 9457 では、type が無いことは about:blank と同じ意味です。Spring Framework 6 (Spring Boot 3) は "type": "about:blank" を出していたので、Spring Boot 3 の資料の出力の例とは形が違います。

項目は、detail・instance・status・title・type のアルファベット順に並びます。13 章で見たとおり、Jackson 3 はコンストラクタの外のプロパティをアルファベット順に並べるためです。拡張の項目は、その後に続きます。

問題の種類と type​

RFC 9457 は、クライアントが問題の種類を type で見分けることを求めています (MUST)。detail は人が読むための文で、クライアントが中身を解析して判断に使うことは勧めていません1。

type を付けない (about:blank の) 問題は、ステータスコード以上の意味を持たないものとされます。そのときの title は、ステータスコードの決まった句 (404 なら Not Found) にします。

このアプリでは、3 つの失敗がどれも 409 になります。在庫が足りない注文、取消の注文の取り消し、この章で足す同時の更新の失敗です。ステータスコードだけでは見分けられないので、アプリ固有の問題に独自の type を付けます。拡張の項目 (足りない商品の一覧など) も、type を付けた問題の上で定めます。RFC 9457 は、拡張の項目を問題の種類ごとに定めるものとしているからです。

一方、無い注文の 404 と、想定していない例外の 500 には type を付けません。どちらもステータスコードだけで意味が決まります。RFC 9457 も、どのリソースにも当たる一般的な問題は、素のステータスコードで表すほうがよいとしています。

type の URI は、この章の「問題の種類」の節の、問題ごとの見出しを指します。RFC 9457 は、type の URI を引くと、問題の解き方を説明する文書が返ることを求めているからです (SHOULD)。

app/src/main/kotlin/com/example/shop/web/ProblemType.kt (抜粋)
enum class ProblemType(anchor: String, val status: HttpStatus, val title: String) {
INVALID_REQUEST("invalid-request", HttpStatus.BAD_REQUEST, "入力の値に誤りがあります"),
PRODUCT_NOT_FOUND("product-not-found", HttpStatus.UNPROCESSABLE_CONTENT, "注文した商品がありません"),
INSUFFICIENT_INVENTORY("insufficient-inventory", HttpStatus.CONFLICT, "在庫が足りません"),
ORDER_CANCELLED("order-cancelled", HttpStatus.CONFLICT, "取消の注文は取り消せません"),
CONCURRENT_UPDATE("concurrent-update", HttpStatus.CONFLICT, "ほかの更新と重なりました"),
;

val uri: URI = URI.create("$DOCUMENT#$anchor")

/** この種類の問題の本文を作る。[detail] は、この発生に固有の説明。 */
fun problem(detail: String): ProblemDetail =
ProblemDetail.forStatusAndDetail(status, detail).also {
it.type = uri
it.title = title
}
}

問題の種類ごとに、type の URI・ステータスコード・title を 1 か所で決めています。DOCUMENT は、この章のページの URL (https://notes.rewheel.dev/docs/kotlin-spring-boot-api/error-responses) です。自分の API に写すときは、自分の API の説明のページの URL に変えてください。

業務の失敗を Problem Details にする​

在庫が足りない、商品が無い、といった業務の失敗は、12 章でサービスが sealed の結果として返すようにしました。6 章の「失敗の表し方を選ぶ」で決めた分け方のとおり、普通に起こる失敗は例外にしていません。そのため、例外を受ける仕組みには届きません。sealed の結果を when で分けているコントローラで、Problem Details の本文を作ります。

app/src/main/kotlin/com/example/shop/order/OrderController.kt (抜粋)
@PostMapping
fun create(@Valid @RequestBody request: CreateOrderRequest): ResponseEntity<*> {
val command = CreateOrderCommand(
contactEmail = request.contactEmail,
lines = request.lines.map { CreateOrderCommand.Line(it.productId, it.quantity) },
)
return when (val result = orderService.create(command)) {
is CreateOrderResult.Created ->
ResponseEntity.created(URI.create("/api/v1/orders/${result.order.id}")).body(OrderResponse.from(result.order))
is CreateOrderResult.ProductNotFound ->
ProblemType.PRODUCT_NOT_FOUND.problem("注文した商品のうち ${result.productIds.size} 件が見つかりません")
.also { it.setProperty("productIds", result.productIds) }
.toResponseEntity()
is CreateOrderResult.InsufficientInventory ->
ProblemType.INSUFFICIENT_INVENTORY.problem("在庫の足りない商品が ${result.shortages.size} 件あります")
.also { it.setProperty("shortages", result.shortages) }
.toResponseEntity()
}
}

setProperty で足した項目は、JSON では ProblemDetail の項目と同じ階層に出ます。shortages の要素は、12 章の Shortage (商品の ID・注文した数量・在庫の数量) です。

toResponseEntity は、ResponseEntity.of(problem).build() で、本文の status と同じステータスコードの応答を作る関数です。ResponseEntity.status(...) に別のステータスコードを書くと、本文の status と応答のステータスコードが食い違います。RFC 9457 は、2 つを同じにすることを求めています。

成功のときの本文は OrderResponse、失敗のときは ProblemDetail なので、関数の戻り値の型を ResponseEntity<*> にしています。toResponseEntity は戻り値の型を ResponseEntity<ProblemDetail> と決めているので、戻り値を ResponseEntity<OrderResponse> と宣言すると、コンパイルで止まります。ResponseEntity.of(problem).build() をコントローラに直接書くと、build() の型引数が宣言に合わせて推論されるので、ResponseEntity<OrderResponse> のままでもコンパイルが通ってしまいます。宣言の型と実際の本文が食い違い、型を見た人が失敗の応答の形を読み違えるので、型を決めた関数を通しています。

取消の注文の取り消しも、同じ書き方で ORDER_CANCELLED の本文にします。無い注文は、type を付けずに ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "注文が見つかりません") で作ります。

例外を Problem Details にする​

検証の違反や、本文を読めない失敗は、Spring MVC が例外として投げます。サービスの中で起きた想定していない例外も、コントローラの外へ出てきます。これらは、@RestControllerAdvice を付けたクラスで受けて、応答に変えます。

@RestControllerAdvice のクラスに @ExceptionHandler を付けた関数を書くと、すべてのコントローラから出た例外のうち、その型の例外を受けます。Spring MVC は、自分の例外を Problem Details に変える ResponseEntityExceptionHandler を用意しているので、これを継承します3。Spring MVC の例外は、この親クラスの既定の処理で Problem Details になり、変えたいものだけを上書きします。

app/src/main/kotlin/com/example/shop/web/ApiExceptionHandler.kt (抜粋)
@RestControllerAdvice
class ApiExceptionHandler(private val trackingIds: TrackingIdGenerator) : ResponseEntityExceptionHandler() {

Spring Boot にも、spring.mvc.problemdetails.enabled を true にすると、同じ親クラスの handler を登録する設定があります。ただし、その handler は、アプリが ResponseEntityExceptionHandler の bean を持たないときだけ作られます4。このアプリは自分の handler を持つので、この設定は書きません。

入力の誤り​

入力の誤りの 400 は、どの値がどう誤っているかを errors の配列で返します。要素は、本文の中の誤りなら pointer、クエリやパスの値の誤りなら parameter で位置を示し、detail で内容を書きます。pointer は、JSON の中の位置を #/lines/0/quantity のような文字列で表す JSON Pointer の書き方です5。RFC 9457 の例も、検証の誤りをこの形 (errors の要素ごとに detail と pointer) で表しています1。

検証の違反は、handleMethodArgumentNotValid を上書きして変えます。

app/src/main/kotlin/com/example/shop/web/ApiExceptionHandler.kt (抜粋)
override fun handleMethodArgumentNotValid(
ex: MethodArgumentNotValidException,
headers: HttpHeaders,
status: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any>? {
// クラスに付けた制約の違反は項目を持たないので、本文の全体 (#) を指す。
val errors = ex.bindingResult.allErrors.map { error ->
val pointer = if (error is FieldError) jsonPointer(error.field.split('.', '[', ']').filter { it.isNotEmpty() }) else "#"
RequestError(pointer = pointer, detail = error.defaultMessage.orEmpty())
}
return handleExceptionInternal(ex, invalidRequest(errors), headers, status, request)
}

違反の位置 (lines[0].quantity) を .・[・] で区切り、JSON Pointer (#/lines/0/quantity) にします。detail には、11 章で注釈に書いたメッセージが入ります。

違反の並べ方は、invalidRequest で決めています。

app/src/main/kotlin/com/example/shop/web/RequestError.kt (抜粋)
fun invalidRequest(errors: List<RequestError>): ProblemDetail =
ProblemType.INVALID_REQUEST.problem("入力の値の誤りが ${errors.size} 件あります").also {
it.setProperty("errors", errors.sortedWith(compareBy<RequestError>({ it.pointer ?: it.parameter }, { it.detail })))
}

Bean Validation は、違反を順を持たない集合 (Set) で返します6。並べ替えずに返すと、同じリクエストでも errors の順が実行ごとに変わります。位置で並べ、同じ位置に違反が 2 つあるときは、detail で並べます。

同じ商品の明細の違反を、明細のリストの違反にする​

11 章では、同じ商品の明細が重なる注文を、@get:AssertTrue を付けた計算のプロパティ hasDistinctProducts で断りました。この形だと、違反の位置がプロパティの名前の hasDistinctProducts になります。JSON に無い名前なので、JSON Pointer にできません。そこで、明細のリスト (lines) そのものに付ける制約を作りました。

app/src/main/kotlin/com/example/shop/order/DistinctProducts.kt (抜粋)
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [DistinctProductsValidator::class])
annotation class DistinctProducts(
val message: String = "同じ商品の注文明細が重なっています",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
)

class DistinctProductsValidator : ConstraintValidator<DistinctProducts, List<CreateOrderLineRequest>> {
// Bean Validation の制約は null を正しいとみなす決まり。null の誤りは @NotEmpty が出す。
override fun isValid(value: List<CreateOrderLineRequest>?, context: ConstraintValidatorContext): Boolean =
value == null || value.distinctBy { it.productId }.size == value.size
}

制約の注釈は、@Constraint で検証する処理のクラスを指し、message・groups・payload の 3 つの属性を持つ決まりです。CreateOrderRequest では、lines に @DistinctProducts を付け、hasDistinctProducts を消しました。違反の位置が lines になり、応答では #/lines を指します。

@Target(AnnotationTarget.FIELD) は、この注釈をフィールドに付けるものと決める指定です。コンストラクタの val に付けると、11 章で見た -Xannotation-default-target=param-property の設定のもとで、ほかの制約の注釈と同じくフィールドに付き、Bean Validation が読みます。

本文を読めない失敗​

11 章の練習問題で見たとおり、productId の無い明細は、検証の前に Jackson の読み取りで失敗します。Spring MVC が投げる例外も、検証の違反の MethodArgumentNotValidException とは別の HttpMessageNotReadableException です。11 章で予告したとおり、こちらも同じ形の応答にします。

app/src/main/kotlin/com/example/shop/web/ApiExceptionHandler.kt (抜粋)
val mismatch = generateSequence<Throwable>(ex) { it.cause }.filterIsInstance<MismatchedInputException>().firstOrNull()
val error = if (mismatch == null) {
RequestError(pointer = "#", detail = "本文を JSON として読めません")
} else {
RequestError(
pointer = jsonPointer(mismatch.path.map { it.propertyName ?: it.index.toString() }),
detail = if (mismatch is InvalidNullException) "値を入れてください" else "値の形が正しくありません",
)
}

Jackson が値を読めなかったときの例外 (MismatchedInputException) は、読めなかった位置を、プロパティの名前と配列の位置の並びとして持っています。これを JSON Pointer にします。null を許さない項目が無いときの例外は InvalidNullException の仲間なので、「値を入れてください」にします。JSON の文法の誤りのように、位置を持たない失敗は、本文の全体 (#) を指します。

クエリやパスの値を、引数の型 (Int や UUID) に変えられなかったときも、handleTypeMismatch を上書きして、parameter に引数の名前を入れた同じ形にしています。14 章の一覧の、許可リストに無い並びや範囲の外の件数も、コントローラで同じ形の本文を返すように直しました。

同じ行の更新の重なり​

12 章で見たとおり、同時に 2 つの注文が同じ在庫を引き当てると、後の保存が楽観ロックの失敗 (OptimisticLockingFailureException) になります。明細の順が逆の注文が重なり、それぞれ 1 件目の在庫を更新してから 2 件目に進んだときは、PostgreSQL が片方をデッドロックとして止め、Spring は既定の変換でこれを PessimisticLockingFailureException にします。2 つの例外は、どちらも ConcurrencyFailureException の子です。この型の子には、SQLState 40001 (serialization_failure) から Spring の既定の変換が作る CannotAcquireLockException もあり、同じく 409 になります。この型を @ExceptionHandler で受けて、409 の応答にします。

app/src/main/kotlin/com/example/shop/web/ApiExceptionHandler.kt (抜粋)
@ExceptionHandler(ConcurrencyFailureException::class)
fun handleConcurrencyFailure(ex: ConcurrencyFailureException, request: WebRequest): ResponseEntity<Any>? {
val body = ProblemType.CONCURRENT_UPDATE.problem("ほかの処理と同じデータの更新が重なりました。もう一度送ってください。")
return handleExceptionInternal(ex, body, HttpHeaders(), HttpStatus.CONFLICT, request)
}

409 は、リクエストがリソースの今の状態と衝突して完了できず、利用者が衝突を解いて送り直せる場面のためのステータスコードです7。楽観ロックの失敗は、読んでから書くまでのあいだに、ほかの処理が在庫を先に変えたという、今の状態との衝突です。デッドロックで止められた注文も、ほかの注文と同じ在庫の行を取り合った結果で、書き込みはトランザクションが戻しています。どちらも、同じリクエストを送り直せば、新しい在庫の数で通るか、在庫不足の 409 になります。ほかの更新とまた重なれば、concurrent-update の 409 がもう一度返ります。

detail には、例外のメッセージを入れずに、決まった文を書いています。例外のメッセージに何が入っているかは、章末の練習問題で確かめます。同時の注文をどちらも正しく処理する方法は、後の部で扱います。

想定していない例外と追跡 ID​

ここまでのどれにも当たらない例外は、プログラムの誤りか、データベースの障害のような想定していない失敗です。6 章の分け方の 1 行目に当たる、check の失敗 (IllegalStateException) もここに入ります。これらは 500 にします。

500 の本文には、例外のメッセージやスタックトレースを入れません。OWASP の手引きは、想定していないエラーでは一般的な応答を返し、詳しい情報はサーバー側のログに残すよう勧めています8。ASVS (OWASP の検証の基準) も、一般的なメッセージを返すことと、捕まえなかった例外をすべて受ける最後の handler を置くことを求めています9。

代わりに、500 を返すたびに 追跡 ID を作り、応答と、例外のログの両方に載せます。利用者から問い合わせを受けたら、追跡 ID でログを探します。

app/src/main/kotlin/com/example/shop/web/ApiExceptionHandler.kt (抜粋)
/** ほかのどの handler も受けなかった例外。本文と追跡 ID は [handleExceptionInternal] で作る。 */
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception, request: WebRequest): ResponseEntity<Any>? =
handleExceptionInternal(ex, null, HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request)
app/src/main/kotlin/com/example/shop/web/ApiExceptionHandler.kt (抜粋)
override fun handleExceptionInternal(
ex: Exception,
body: Any?,
headers: HttpHeaders,
statusCode: HttpStatusCode,
request: WebRequest,
): ResponseEntity<Any>? {
if (statusCode.value() != HttpStatus.INTERNAL_SERVER_ERROR.value()) {
return super.handleExceptionInternal(ex, body, headers, statusCode, request)
}
val trackingId = trackingIds.newTrackingId()
logger.error("想定していない例外 (追跡 ID: $trackingId)", ex)
val problem = ProblemDetail.forStatusAndDetail(
statusCode,
"サーバーで想定していないエラーが起きました。問い合わせのときは instance の値を伝えてください。",
).also { it.instance = URI.create("urn:uuid:$trackingId") }
return super.handleExceptionInternal(ex, problem, headers, statusCode, request)
}

@ExceptionHandler(Exception::class) は、すべての例外に当たります。それでも、検証の違反のような Spring MVC の例外は、親クラスの handler が受けます。同じ advice の中で複数の handler が当たるときは、投げられた例外の型に近い handler が選ばれるからです10。

追跡 ID は、500 の本文と追跡 ID を作る処理を、handleExceptionInternal に置いて作っています。handleUnexpected に置かないのは、Spring MVC の例外の一部 (応答の本文を書き出せなかった失敗など) を、親クラスが自分で 500 にして返すからです。どの handler が作った応答も、最後に handleExceptionInternal を通るので、ここで 500 を見れば、この advice が返す 500 をすべて扱えます。

追跡 ID は instance に urn:uuid:<追跡 ID> の形で入れます。RFC 9457 の instance は、問題のその発生を指す URI です。引けない URI のときは、サーバーにとって意味があり、クライアントには中身の分からない識別子になります1。追跡 ID の役目そのものです。ほかの問題の instance には、Spring が既定でリクエストのパスを入れます。

追跡 ID を作る処理は、時計や ID の採番と同じく bean (TrackingIdGenerator) にし、本番ではランダムな UUID を返します。テストでは決まった値を返すものに差し替え、応答とログの値を確かめます。

この advice が扱えるのは、Spring MVC (DispatcherServlet) がリクエストを処理するあいだに起きた例外です。405 や 415 のように、コントローラの関数を呼ぶ前に Spring MVC が断る例外もここに入ります。DispatcherServlet より前にあるサーブレットのフィルタの中で起きた例外は、この advice に届きません。1 つのリクエストのログをまとめて追うための ID (相関 ID) は、後の部で扱います。

既定のまま返すもの​

Spring MVC の例外のうち、入力の誤りの 3 つ (検証の違反・本文を読めない失敗・型の変換の失敗) のほかの 4xx は、親クラスの既定の本文のまま返します。対応の無いメソッドの 405、受けない Content-Type の 415、対応の無いパスの 404 などです。type は付かず、detail は英語です (Method 'DELETE' is not supported. など)。対応の無いパスの 404 の detail は No static resource api/v1/nothing. のように、Spring MVC がそのパスを静的なファイルとして探して見つからなかったことを書きます。

Spring Framework の文書は、Spring MVC の例外の本文を、実装の詳細を見せない形に変えるのがよいとしています3。これらの detail が示すのは、クライアントが送ったメソッド・Content-Type・パスで、例外のメッセージやサーバーの中の値は含まないので、既定のままにしています。

問題の種類​

このアプリの type の URI は、次の見出しを指します。クライアントは type で種類を見分け、detail は人に見せる文として扱います。

入力の値に誤りがあります​

  • ステータスコード: 400
  • 拡張の項目: errors (誤りの一覧)。要素は、本文の中の誤りなら pointer (JSON Pointer)、クエリやパスの値の誤りなら parameter (名前) と、detail (内容)
  • クライアントの対処: errors が示す値を直して、送り直します。同じ値のまま送り直しても、同じ応答になります

注文した商品がありません​

  • ステータスコード: 422
  • 拡張の項目: productIds (見つからなかった商品の ID の一覧)
  • クライアントの対処: 商品の一覧 API で、今ある商品を確かめます。productIds の商品を注文から外して送り直します

在庫が足りません​

  • ステータスコード: 409
  • 拡張の項目: shortages (在庫の足りない明細の一覧)。要素は productId (商品の ID)・requested (注文した数量)・available (その時点の在庫の数量)
  • クライアントの対処: 数量を available 以下にするか、その商品を外して送り直します。在庫の数量は、ほかの注文で変わります

取消の注文は取り消せません​

  • ステータスコード: 409
  • 拡張の項目: なし
  • クライアントの対処: 注文はすでに取り消されているので、することはありません。注文の状態は、取得の API で確かめられます

ほかの更新と重なりました​

  • ステータスコード: 409
  • 拡張の項目: なし
  • クライアントの対処: 同じリクエストを送り直します。送り直した時点の在庫の数量で処理し、足りなければ在庫不足 (insufficient-inventory) になります。ほかの更新とまた重なれば、もう一度この問題になります

テスト​

業務の失敗と入力の誤りの応答は、次の節の curl の例で、実際の応答と 1 文字ずつ比べています。同じ行の更新の重なりと 500 は、curl では起こせません。12 章で、途中の例外で書き込みが戻ることを確かめたテストと同じく、在庫の repository を差し替えて起こします。

app/src/test/kotlin/com/example/shop/web/ApiExceptionHandlerTest.kt (抜粋)
@Test
fun `捕まえなかった例外は 500 にし、追跡 ID を instance とログに載せ、例外の中身は本文に入れない`(output: CapturedOutput) {
inventories.failure = IllegalStateException("在庫の行を読めない (テストで投げる例外)")

createOrder()
.expectStatus().isEqualTo(500)
.expectHeader().contentType(MediaType.APPLICATION_PROBLEM_JSON)
.expectBody().json(INTERNAL_ERROR_BODY, JsonCompareMode.STRICT)
assertTrue(output.out.contains("想定していない例外 (追跡 ID: $TRACKING_ID)"))
assertTrue(output.out.contains("java.lang.IllegalStateException: 在庫の行を読めない (テストで投げる例外)"))
}

inventories は、本物の repository に委譲し、failure に入れた例外を save で投げる、テスト用の repository です。JsonCompareMode.STRICT は、期待した JSON に無い項目が本文にあると失敗にします。例外のメッセージが本文に紛れ込んでいないことも、これで確かめられます。CapturedOutput は、テストの間に標準出力に出たもの (ログを含む) を受け取る、Spring Boot のテストの部品です。

同じテストのクラスで、Spring MVC が自分で 500 を返す例外 (文字列から変換できない型の引数) も、同じ本文と追跡 ID になることを確かめています。デッドロックは、PostgreSQL が返す SQLState (40P01) を持つ SQLException を、アプリの JdbcTemplate と同じ変換で Spring の例外にしてから投げ、409 になることを確かめています。

curl で試す​

アプリを起動して、失敗の応答を見ます。手元の DB が初期データだけの状態から打つことを前提にしています (手元の DB を確かめる、戻す)。

在庫の足りない注文から始めます。-w に %{content_type} を足して、応答の Content-Type も出します。

curl -s -w '\n%{http_code} %{content_type}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 2},
{"productId": "0199f3a8-0000-7000-8000-000000000004", "quantity": 5}
]
}'
結果
{
"detail" : "在庫の足りない商品が 1 件あります",
"instance" : "/api/v1/orders",
"status" : 409,
"title" : "在庫が足りません",
"type" : "https://notes.rewheel.dev/docs/kotlin-spring-boot-api/error-responses#insufficient-inventory",
"shortages" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000004",
"requested" : 5,
"available" : 3
} ]
}
409 application/problem+json

マーカーは、注文した数量 (requested) が在庫の数量 (available) より多いので、在庫不足です。ノートは足りているので、shortages には入りません。

11 章と同じ、空のメールアドレスと数量 0 の注文を送ります。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 0}
]
}'
結果
{
"detail" : "入力の値の誤りが 2 件あります",
"instance" : "/api/v1/orders",
"status" : 400,
"title" : "入力の値に誤りがあります",
"type" : "https://notes.rewheel.dev/docs/kotlin-spring-boot-api/error-responses#invalid-request",
"errors" : [ {
"pointer" : "#/contactEmail",
"detail" : "連絡先メールアドレスを入れてください"
}, {
"pointer" : "#/lines/0/quantity",
"detail" : "数量は 1 以上にしてください"
} ]
}
400

11 章では日時を含む既定の応答だったので、結果をテストで照合できませんでした。この応答は日時を含まないので、記事の結果とテストの応答を 1 文字ずつ比べています。

同じ商品の明細が重なる注文は、明細のリストの位置 (#/lines) を指します。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 2},
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 1}
]
}'
結果
{
"detail" : "入力の値の誤りが 1 件あります",
"instance" : "/api/v1/orders",
"status" : 400,
"title" : "入力の値に誤りがあります",
"type" : "https://notes.rewheel.dev/docs/kotlin-spring-boot-api/error-responses#invalid-request",
"errors" : [ {
"pointer" : "#/lines",
"detail" : "同じ商品の注文明細が重なっています"
} ]
}
400

productId の無い明細は、Jackson の読み取りで落ちる失敗ですが、検証の違反と同じ形で返ります。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"quantity": 1}
]
}'
結果
{
"detail" : "入力の値の誤りが 1 件あります",
"instance" : "/api/v1/orders",
"status" : 400,
"title" : "入力の値に誤りがあります",
"type" : "https://notes.rewheel.dev/docs/kotlin-spring-boot-api/error-responses#invalid-request",
"errors" : [ {
"pointer" : "#/lines/0/productId",
"detail" : "値を入れてください"
} ]
}
400

一覧の件数に数でない値を渡すと、クエリの値の誤りとして parameter で位置を示します。

curl -s -w '\n%{http_code}\n' 'http://localhost:8080/api/v1/products?limit=abc'
結果
{
"detail" : "入力の値の誤りが 1 件あります",
"instance" : "/api/v1/products",
"status" : 400,
"title" : "入力の値に誤りがあります",
"type" : "https://notes.rewheel.dev/docs/kotlin-spring-boot-api/error-responses#invalid-request",
"errors" : [ {
"parameter" : "limit",
"detail" : "値の形が正しくありません"
} ]
}
400

最後に、無い注文を取得します。type を付けない問題なので、type の項目は無く、title はステータスコードの句の Not Found です。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000099
結果
{
"detail" : "注文が見つかりません",
"instance" : "/api/v1/orders/019a1c00-0000-7000-8000-000000000099",
"status" : 404,
"title" : "Not Found"
}
404

前の章の curl の例のうち、本文の無い失敗の応答だったもの (10 章の 404 と 422、12 章の 409 と 404、14 章の 400) も、api-15 では Problem Details の本文を返します。それぞれの章の記事は、その章の tag の結果を載せたままです。

本番で効く注意点​

type の URI は、一度出したら変えない​

クライアントは type の文字列で問題の種類を見分けるので、URI は問題の種類の名前です。説明のページを別の URL へ移したくなっても、type の値は変えず、古い URL から新しいページへ転送します。RFC 9457 も、後から解決できる URI に替えると種類の同一性が変わり、クライアントを壊す変更になると書いています1。

type の無い応答を about:blank として読む​

クライアントの側で type を比べるときは、type が無いことを about:blank として扱います。Spring Framework 6 の時代のクライアントが "type": "about:blank" の文字列の一致で判断していると、Spring Framework 7 に上げたサーバーの応答で判断を誤ります。

まとめ​

  • 失敗の応答は、Problem Details (RFC 9457) の形 (application/problem+json) にそろえます。Spring は ProblemDetail で表します
  • 同じステータスコードに複数の意味がある問題には独自の type を付け、拡張の項目はその上で定めます。type の URI は問題の説明のページを指します。ステータスコードだけで意味が決まる問題は type を付けません
  • 業務の失敗 (sealed の結果) は、コントローラで ProblemDetail にします。ResponseEntity.of で、本文と応答のステータスコードをそろえます
  • 例外は、ResponseEntityExceptionHandler を継承した @RestControllerAdvice で受けます。入力の誤りは、検証の違反・本文を読めない失敗・型の変換の失敗を、errors の同じ形にそろえ、位置と説明の順に並べます
  • 同じ行の更新の重なり (楽観ロックの失敗とデッドロック) は、ConcurrencyFailureException で受けて 409 にし、例外のメッセージは本文に入れません
  • 500 の本文には例外の中身を入れず、追跡 ID を作って instance とログに載せます。handleExceptionInternal で、この advice が返すすべての 500 を扱います

次に読む​

前の章は 一覧 API です。

次章 テスト戦略 では、ここまでに書いたテストを整理し、層ごとのテストとモックの使い方を扱います。

練習問題​

楽観ロックの失敗の例外のメッセージを、そのまま detail に入れたとします。応答に何が出ますか

解答例

在庫の行の ID (商品の ID)、読んだときの版の番号、テーブルの名前が出ます。

OptimisticLockMessageTest.kt (抜粋)
val read = inventoryRepository.findById(marker).orElseThrow()

// 同じ在庫を読んだ 2 つの注文のうち、先の注文が 1 本を引き当てて保存する。
inventoryRepository.save(read.allocate(1))
// 後の注文は、先の保存の前に読んだ在庫 (版 0) のまま保存しようとする。
val exception = assertThrows<OptimisticLockingFailureException> { inventoryRepository.save(read.allocate(2)) }

assertEquals(
"Failed to update versioned entity with id '$marker' (version '0') in table [\"inventories\"]; " +
"Was the entity updated or deleted concurrently?",
exception.message,
)

テーブルの名前は、データベースの作りを外に見せます。RFC 9457 も、スタックトレースのような実装の詳細を HTTP の応答で見せないよう勧めています1。例外のメッセージは開発者がログで読むためのもので、クライアントに返す文ではありません。

このテストは、12 章の 同時の注文は、片方が失敗する で説明した楽観ロックの失敗を、古い版の在庫を保存し直して起こしています。2 つの注文を同時に送る代わりです。

@ResponseStatus(HttpStatus.NOT_FOUND) を付けた例外のクラスを作り、コントローラから投げました。応答のステータスコードは何になりますか

解答例

404 でなく、500 になります。

ResponseStatusAnnotationTest.kt (抜粋)
@Test
fun `@ResponseStatus を付けた例外は 500 になる`() {
client.get().uri("/exercises/ch15/annotated")
.exchange()
.expectStatus().isEqualTo(500)
}

@Test
fun `ResponseStatusException は指定した 404 になる`() {
client.get().uri("/exercises/ch15/response-status-exception")
.exchange()
.expectStatus().isNotFound()
.expectBody().jsonPath("$.detail").isEqualTo("ResponseStatusException で 404 を指定した")
}

Spring MVC は、例外を応答に変える仕組みを順に試します。@ExceptionHandler を探す仕組みが最初で、@ResponseStatus を読む仕組みはその後です11。この章の advice は @ExceptionHandler(Exception::class) を持つので、@ResponseStatus を付けた例外も先に受けて、500 にしてしまいます。

ステータスコードを例外で指定したいときは、ResponseStatusException を投げます。ResponseStatusException は、ResponseEntityExceptionHandler が受ける例外の 1 つなので、指定したステータスコードの Problem Details になります。


Footnotes​

  1. 出典: RFC 9457 (Problem Details for HTTP APIs)。3 節 (例と項目)・3.1.1 節 (type)・3.1.5 節 (instance)・3.2 節 (拡張の項目) を引いています。4 節 (新しい種類の定め方)・4.2.1 節 (about:blank)・5 節 (セキュリティ) も引いています。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. 出典: Spring Framework の ProblemDetail のソース (7.0.10。6.2.12 では type の既定値が about:blank)。 ↩

  3. 出典: Spring Framework 7.0.10 の文書の Error Responses。 ↩ ↩2

  4. 出典: Spring Boot 4.1.2 の WebMvcAutoConfiguration のソース (@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class))。 ↩

  5. 出典: RFC 6901 (JavaScript Object Notation (JSON) Pointer) の 3 節と 6 節。 ↩

  6. 出典: Jakarta Validation 3.1.1 の Validator のソース (validate は Set を返す)。 ↩

  7. 出典: RFC 9110 (HTTP Semantics) の 15.5.10 節。 ↩

  8. 出典: OWASP の Error Handling Cheat Sheet。 ↩

  9. 出典: OWASP の ASVS 5.0.0 の V16 Security Logging and Error Handling の 16.5.1 と 16.5.4。 ↩

  10. 出典: Spring Framework 7.0.10 の文書の Exceptions。該当の文は "the ExceptionDepthComparator is used to sort exceptions based on their depth from the thrown exception type"。 ↩

  11. 出典: Spring Framework 7.0.10 の WebMvcConfigurationSupport の addDefaultHandlerExceptionResolvers。ExceptionHandlerExceptionResolver・ResponseStatusExceptionResolver・DefaultHandlerExceptionResolver の順に登録します。 ↩