Kotlin × Spring Boot の 400 系の標準例外をハンドリングする
概要
本記事では、Kotlin × Spring Boot の標準例外について以下の内容を筆者が知る限りでまとめます。
- Spring Boot の標準例外ハンドラについて
- Spring Boot の標準例外のハンドリング例
ハンドリング例で紹介したコードは、以下のリポジトリにまとめています。
Spring Boot の標準例外ハンドラ概要
DefaultHandlerExceptionResolver について
Spring Boot では Spring Boot の特定の例外が発生したときに、DefaultHandlerExceptionResolverが例外をキャッチしてハンドリングします。
たとえば、存在しないエンドポイントにリクエストを送信した場合、開発者が何も設定しなければ、以下のレスポンスが返ってきます。
これは内部的に後述する NoResourceFoundException
が発生し、DefaultHandlerExceptionResolver
がハンドリングしレスポンスを返しています。
実装者側が何もしなければ以下の JSON 形式でレスポンスが作成されます。
$ curl --location 'http://localhost:8080/not-exists' | jq .
{
"timestamp": "2024-01-27T23:11:11.624+00:00",
"status": 404,
"error": "Not Found",
"path": "/nullable-set-not-null-article-models"
}
DefaultHandlerExceptionResolver がハンドリングする 400 系の標準例外
DefaultHandlerExceptionResolverにハンドリングする例外が定義されています。
下表は 2023 年 1 月時点で記述されている 400 系の例外をまとめました。
例外 | ステータスコード | 概要 |
---|---|---|
HttpRequestMethodNotSupportedException | 405 (SC_METHOD_NOT_ALLOWED) | 設定されていないメソッドでリクエストされたとき |
HttpMediaTypeNotSupportedException | 415 (SC_UNSUPPORTED_MEDIA_TYPE) | content-type が API サーバー側が想定していないものだった場合 |
HttpMediaTypeNotAcceptableException | 406 (SC_NOT_ACCEPTABLE) | 調査中 |
MissingServletRequestParameterException | 400 (SC_BAD_REQUEST) | 調査中 |
MissingServletRequestPartException | 400 (SC_BAD_REQUEST) | 調査中 |
ServletRequestBindingException | 400 (SC_BAD_REQUEST) | 調査中 |
TypeMismatchException | 400 (SC_BAD_REQUEST) | 調査中 |
HttpMessageNotReadableException | 400 (SC_BAD_REQUEST) | JSON のプロパティが不足していたり、型が不正だと発生する。 |
MethodArgumentNotValidException | 400 (SC_BAD_REQUEST) | リクエストパラメータのバリデーションエラーが発生したときに発生させる。 |
HandlerMethodValidationException | 400 (SC_BAD_REQUEST) | 調査中 |
NoHandlerFoundException | 404 (SC_NOT_FOUND) | 調査中。エンドポイントがなかったときには、NoResourceFoundException が発生する |
NoResourceFoundException | 404 (SC_NOT_FOUND) | リソース、エンドポイントが見つからなかったときに例外を発生させる。 |
RestControllerAdvice によるハンドリング
Spring Boot では、 @RestControllerAdvice
アノテーションを利用することで、キャッチされなかった例外をハンドリングできます。
詳細は以下の実装で説明します。
GenericErrorModel
と GenericErrorModelErrors
は本記事のサンプルコードにおける実装です。折りたたみで後述します。
/**
* グローバルに例外ハンドリグし、エラーレスポンスを実現するコントローラクラス
*
* 予期しない例外が発生した際に、以下を実施する。
* - 秘密情報をレスポンスに含めないためのエラーハンドリング
* - 原因調査のためのログ出力
*
*/
@RestControllerAdvice
class GlobalExceptionHandleController {
/**
* 存在しないエンドポイントにリクエストされた時のエラーレスポンスを作成する関数
*
* @param e
* @return 404 エラーのレスポンス
*/
@ExceptionHandler(NoResourceFoundException::class)
fun noResourceFoundExceptionHandler(e: NoResourceFoundException): ResponseEntity<GenericErrorModel> {
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = listOf("該当するエンドポイントがありませんでした")
),
),
HttpStatus.NOT_FOUND
)
}
}
先述したように NoResourceFoundException
は何も設定しなければ、DefaultHandlerExceptionResolver
がハンドリングします。
実装者が独自のレスポンスを返す場合は、@ExceptionHandler(NoResourceFoundException::class)
を定義すれば、自信の設定したレスポンスでレスポンス可能です。ほかの例外も @ExceptionHandler
を用いて例外を指定すればハンドリング可能です。
本記事では DefaultHandlerExceptionResolver
が扱う例外に内容を限定しますが、開発者が定義した例外も @ExceptionHandler
で設定すればキャッチ可能です。
GenericErrorModel
/**
* GenericErrorModel
*
* エラーレスポンスを返す時に利用するモデル
*
* @property errors
*/
data class GenericErrorModel(
@field:Valid
@field:JsonProperty("errors", required = true) val errors: GenericErrorModelErrors
)
GenericErrorModelErrors
/**
* GenericErrorModelErrors
*
* エラーレスポンスの詳細を記述するモデル
*
* @property body エラーの詳細
*/
data class GenericErrorModelErrors(
@field:JsonProperty("body", required = true) val body: List<String>
)
この実装をした後に、再び curl コマンドで実行してみます。
このように、自身が設定したレスポンスになったことがわかります。
$ curl --location 'http://localhost:8080/not-exists' | jq .
{
"errors": {
"body": [
"該当するエンドポイントがありませんでした"
]
}
}
DefaultHandlerExceptionResolver がハンドリングする 400 系の標準例外
例外 | ステータスコード | 概要 |
---|---|---|
HttpRequestMethodNotSupportedException | 405 (SC_METHOD_NOT_ALLOWED) | 設定されていないメソッドでリクエストされたとき |
HttpMediaTypeNotSupportedException | 415 (SC_UNSUPPORTED_MEDIA_TYPE) | content-type が API サーバー側が想定していないものだった場合 |
HttpMediaTypeNotAcceptableException | 406 (SC_NOT_ACCEPTABLE) | 調査中 |
MissingServletRequestParameterException | 400 (SC_BAD_REQUEST) | 調査中 |
MissingServletRequestPartException | 400 (SC_BAD_REQUEST) | 調査中 |
ServletRequestBindingException | 400 (SC_BAD_REQUEST) | 調査中 |
TypeMismatchException | 400 (SC_BAD_REQUEST) | 調査中 |
HttpMessageNotReadableException | 400 (SC_BAD_REQUEST) | JSON のプロパティが不足していたり、型が不正だと発生する。 |
MethodArgumentNotValidException | 400 (SC_BAD_REQUEST) | リクエストパラメータのバリデーションエラーが発生したときに発生させる。パスパラメータ、クエリパラメータでは発生しないことに注意 |
HandlerMethodValidationException | 400 (SC_BAD_REQUEST) | 調査中 |
NoHandlerFoundException | 404 (SC_NOT_FOUND) | 調査中。エンドポイントがなかったときには、NoResourceFoundException が発生する |
NoResourceFoundException | 404 (SC_NOT_FOUND) | リソース、エンドポイントが見つからなかったときに例外を発生させる。 |
標準例外とハンリング例
本項では筆者が再現できたクライアント起因の例外の発生方法とハンドリング方法について記載します。
ほかの例外も発生方法がわかれば、追記していきます。
説明の部分ではハンドリングのメソッドのみを記述します。それらのメソッドは GlobalExceptionHandleController
クラスに実装されていると考えてください。
本項では詳しくは触れませんが、GET と POST が可能なエンドポイントをサンプルコードに用意しています。DB との接続はありません。
詳細は先述もした以下のサンプルコードを参照してください。
NoResourceFoundException
この例外は先述のとおり、該当エンドポイントがなかった際に発生する例外です。
/**
* 存在しないエンドポイントにリクエストされた時のエラーレスポンスを作成する関数
*
* @param e
* @return 404 エラーのレスポンス
*/
@ExceptionHandler(NoResourceFoundException::class)
fun noResourceFoundExceptionHandler(e: NoResourceFoundException): ResponseEntity<GenericErrorModel> {
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = listOf("該当するエンドポイントがありませんでした")
),
),
HttpStatus.NOT_FOUND
)
}
$ curl --location 'http://localhost:8080/not-exists' | jq .
{
"errors": {
"body": [
"該当するエンドポイントがありませんでした"
]
}
}
HttpRequestMethodNotSupportedException
HttpRequestMethodNotSupportedException はエンドポイントに定義されていないメソッドでリクエストされた時に利用される例外です。
/**
* 許可されていないメソッドでリクエストを送った時のエラーレスポンスを作成するメソッド
*
* @param e
* @return 405 エラーのレスポンス
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException::class)
fun noHttpRequestMethodNotSupportedExceptionHandler(
e: HttpRequestMethodNotSupportedException
): ResponseEntity<GenericErrorModel> {
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = listOf("該当エンドポイントで${e.method}メソッドの処理は許可されていません")
),
),
HttpStatus.METHOD_NOT_ALLOWED
)
}
$ curl --location --request PUT 'http://localhost:8080/articles'
{
"errors": {
"body": [
"該当エンドポイントでPUTメソッドの処理は許可されていません"
]
}
}
HttpMediaTypeNotSupportedException
HttpMediaTypeNotSupportedException はエンドポイントが想定していない Content-Type でリクエストされたときの例外です。
以下の例では、GET リクエストのみ許可されているエンドポイントに対して、PUT リクエストを送った例です。
/**
* エンドポイントが想定していない Content-Type でリクエストされた時にエラーレスポンスを作成するメソッド
*
* @param e
* @return 415 エラーのレスポンス
*/
@ExceptionHandler(HttpMediaTypeNotSupportedException::class)
fun noHttpMediaTypeNotSupportedException(
e: HttpMediaTypeNotSupportedException
): ResponseEntity<GenericErrorModel> {
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = listOf("該当エンドポイントで${e.contentType}のリクエストはサポートされていません")
),
),
HttpStatus.UNSUPPORTED_MEDIA_TYPE
)
}
$ curl --location 'http://localhost:8080/articles/already-exists-slug' \
--header 'Content-Type: text/plain' | jq .
{
"errors": {
"body": [
"該当エンドポイントでtext/plainのリクエストはサポートされていません"
]
}
}
HttpMessageNotReadableException
HttpMessageNotReadableException は API スキーマが想定していないリクエストだった時に発生させる例外です。
JSON のフォーマットが不正だったり、null 非許容のリクエストモデルに null を送ろうとしたときにも発生します。
/**
* httpMessageNotReadableExceptionHandler
*
* API スキーマが想定していないリクエストだった場合に発生させるエラーレスポンスを作成する関数
*
* @param e
* @return 400 エラーのレスポンス
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
@Suppress("UnusedParameter")
fun httpMessageNotReadableExceptionHandler(e: HttpMessageNotReadableException): ResponseEntity<GenericErrorModel> {
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = listOf("エンドポイントが想定していない形式または型のリクエストが送られた")
),
),
HttpStatus.BAD_REQUEST
)
}
$ curl --location 'http://localhost:8080/articles' \
--header 'Content-Type: application/json' \
--data '{
"article": {
"title": "new-title",
"body": "new-body",
"description": "new-description"
}' | jq .
MethodArgumentNotValidException
Spring Boot のバリデーションに利用するアノテーション(@field:Size
、@field:NotBlank
など)でバリデーションエラーが発生したときの例外です。
MethodArgumentNotValidException が持つ FieldError
でデフォルトのエラーメッセージを持っており、それらをエラーレスポンスに含めることが可能です。
アノテーション内にエラーメッセージを記述可能ですが、共通化したい場合は resources パッケージ配下に ValidationMessages.properties
配置します。
ValidationMessages.properties
ファイルにバリデーションエラーごとのメッセージを記述すると簡単に共通化可能です。
ValidationMessages.properties
も合わせて実装例を紹介します。
/**
* リクエストのバリデーションエラーが発生した場合に発生させるエラーレスポンスを作成するメソッド
*
* @param e
* @return 400 エラーのレスポンス
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
fun methodArgumentNotValidExceptionHandler(e: MethodArgumentNotValidException): ResponseEntity<GenericErrorModel> {
// エラーメッセージ本文の作成。FieldError の場合は、フィールド名を含める
val messages = e.bindingResult.allErrors.map {
when (it) {
is FieldError -> "${it.field}は${it.defaultMessage}"
else -> it.defaultMessage.toString()
}
}
return ResponseEntity<GenericErrorModel>(
GenericErrorModel(
errors = GenericErrorModelErrors(
body = messages
),
),
HttpStatus.BAD_REQUEST
)
}
以下が、ValidationMessages.properties の実装です。
src/main/resources/ValidationMessages.properties を作成する前に、後述する Intellij IDEA の文字コード設定が必要です。
jakarta.validation.constraints.NotBlank.message=必須です
jakarta.validation.constraints.NotNull.message=nullが許可されていません
jakarta.validation.constraints.Size.message={min}以上{max}以下にしてください
jakarta.validation.constraints.Max.message={value}以下にしてください
jakarta.validation.constraints.Min.message={value}以上にしてください
org.hibernate.validator.constraints.Length.message={min}文字以上{max}文字以下にしてください
リクエストを送ると、以下のように設定したバリデーションエラーが表示されます。
$ curl --location 'http://localhost:8080/articles' \
--header 'Content-Type: application/json' \
--data '{
"article": {
"title": "012345678901234567890123456789123",
"body": "new-body",
"description": "new-description"
}
}'
{
"errors": {
"body": [
"article.titleは0文字以上32文字以下にしてください"
]
}
}
これは、リクエストボディのときのみ発生することに注意してください。
パスパラメータとクエリパラメータに対しては、ConstraintViolationException が発生します。
そのときのハンドリング方法については、以下の記事にまとめましたので、そちらを参照してください。
参考
まとめ
本記事では Kotlin × Spring Boot の 400 系の標準例外とハンドリングについてまとめました。
Spring Boot はアノテーションで暗黙的に処理される部分が多いので、本記事をきっかけに例外処理をハンドリングする糸口になれば幸いです。
400 系例外でも再現できなかった例外がありますので、今後見つかりましたら追記していきます。
Discussion