🫖

Kotlin × Spring Boot の 400 系の標準例外をハンドリングする

2024/02/01に公開

概要

本記事では、Kotlin × Spring Boot の標準例外について以下の内容を筆者が知る限りでまとめます。

  • Spring Boot の標準例外ハンドラについて
  • Spring Boot の標準例外のハンドリング例

ハンドリング例で紹介したコードは、以下のリポジトリにまとめています。

https://github.com/Msksgm/practice-of-kotlin-spring-boot-client-exception

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 アノテーションを利用することで、キャッチされなかった例外をハンドリングできます。
詳細は以下の実装で説明します。
GenericErrorModelGenericErrorModelErrors は本記事のサンプルコードにおける実装です。折りたたみで後述します。

src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/GlobalExceptionHandleController.kt
/**
 * グローバルに例外ハンドリグし、エラーレスポンスを実現するコントローラクラス
 *
 * 予期しない例外が発生した際に、以下を実施する。
 * - 秘密情報をレスポンスに含めないためのエラーハンドリング
 * - 原因調査のためのログ出力
 *
 */
@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
src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/model/GenericErrorModel.kt
/**
 * GenericErrorModel
 *
 * エラーレスポンスを返す時に利用するモデル
 *
 * @property errors
 */
data class GenericErrorModel(
    @field:Valid
    @field:JsonProperty("errors", required = true) val errors: GenericErrorModelErrors
)
GenericErrorModelErrors
src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/model/GenericErrorModelErrors.kt
/**
 * 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 との接続はありません。
詳細は先述もした以下のサンプルコードを参照してください。

https://github.com/Msksgm/practice-of-kotlin-spring-boot-client-exception

NoResourceFoundException

この例外は先述のとおり、該当エンドポイントがなかった際に発生する例外です。

src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/GlobalExceptionHandleController.kt
    /**
     * 存在しないエンドポイントにリクエストされた時のエラーレスポンスを作成する関数
     *
     * @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 はエンドポイントに定義されていないメソッドでリクエストされた時に利用される例外です。

src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/GlobalExceptionHandleController.kt
    /**
     * 許可されていないメソッドでリクエストを送った時のエラーレスポンスを作成するメソッド
     *
     * @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 リクエストを送った例です。

src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/GlobalExceptionHandleController.kt
    /**
     * エンドポイントが想定していない 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 を送ろうとしたときにも発生します。

src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/GlobalExceptionHandleController.kt
    /**
     * 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 も合わせて実装例を紹介します。

src/main/kotlin/com/example/practiceofkotlinspringbootclientexception/presentation/GlobalExceptionHandleController.kt
    /**
     * リクエストのバリデーションエラーが発生した場合に発生させるエラーレスポンスを作成するメソッド
     *
     * @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 の文字コード設定が必要です。

src/main/resources/ValidationMessages.properties
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 が発生します。
そのときのハンドリング方法については、以下の記事にまとめましたので、そちらを参照してください。

https://zenn.dev/msksgm/articles/20240218-kotlin-spring-handle-constraint-violation

参考

https://sebenkyo.com/2020/08/02/post-1260/

https://gist.github.com/disc99/8e72d9813616d3ab8602de4bd4b35a11

https://qiita.com/growsic/items/c611e29313f09246c2d0

https://b1san-blog.com/post/spring/spring-validation/

まとめ

本記事では Kotlin × Spring Boot の 400 系の標準例外とハンドリングについてまとめました。
Spring Boot はアノテーションで暗黙的に処理される部分が多いので、本記事をきっかけに例外処理をハンドリングする糸口になれば幸いです。
400 系例外でも再現できなかった例外がありますので、今後見つかりましたら追記していきます。

Discussion