👻

ぼくのかんがえたさいきょうのエラーハンドリング

2022/08/08に公開

みなさん REST API のエラーハンドリングで悩んだことはありませんか?
この記事ではそんな悩みを解消できる(かもしれない)一例として、
私が考案した Kotlin/SpringBoot/REST API で利用できる統一的なエラーハンドリングを紹介してみます。

サンプル

この記事で紹介しているソースコードは↓ですぅ。
https://github.com/ki504178/sample_kotlin_spring_error_handling

環境

  • Mac: M1 Air 12.5
  • IDE: IntelliJ idea
  • JDK: Corretto 17
  • Kotlin: 1.6
  • SpringBoot: 2.7.1

先ず検討したところ

前提として以下のようなシステム構成でした。

  • Frontend(以降 FE)
    • SPA(TypeScript/Next.js/SSR/API Routes)
  • Backend(以降 BE)
    • REST API(Kotlin/SpringBoot)
    • マルチプロジェクト構成で、外部サービスから利用される(提供する API がある)プロジェクトあり

ここで先ず検討したところとして、
内部アプリの FE、外部サービスに提供する API ともに、
エラー発生時のレスポンス構成を同じ形で返せるようにすれば、
内部・外部に寄らない統一的なエラーハンドリングが可能となると考えました。

エラーレスポンスの構成

検討したレスポンス構成は以下のとおりです。

ErrorResponse.kt
/** エラーの共通レスポンス */
@JsonInclude(JsonInclude.Include.NON_NULL)
data class ErrorResponse @JsonCreator constructor(
    /** レスポンスごとに一意な文字列が設定される。 */
    val responseId: String = UUID.randomUUID().toString(),

    /** エラー名称。バリデーションエラー時は「InvalidError」が設定される */
    val errorCause: String,

    /** エラーメッセージ。バリデーションエラー時は設定されない */
    val message: String? = null,

    /** パス・クエリパラメータ、リクエストボディでバリデーションエラーが発生した場合に設定される、レコードごとのエラー詳細リスト。
     * リクエストボディが優先され、左記でエラーの場合はパス・クエリパラメータはチェックされない */
    val invalidErrors: MutableList<InvalidError>? = null //
)

各項目について説明していきます。

  • data class
    • レスポンスは JSON で返す前提として、@JsonCreator を利用しています。
    • また、Nullable なプロパティは不要と判断し、@JsonInclude(JsonInclude.Include.NON_NULL) で除外するようにしています。
    • InvalidError 以下のクラスについても、特別に言及しない限り↑と同じ考え方です。
  • responseId
    • レスポンスごとに一意な ID を生成するようにします。
    • 用途としてはこの記事のサンプルソースでは導入していませんが、
      実際のアプリ開発では運用を考慮してロギングを行うことはほぼ必須となるため、
      エラー発生時の調査用にこの値を出力しておき、FE でも同様にログ出力しておけば、
      調査時にどの API リクエストでエラーが発生したか FE/BE 一貫して調査可能となります。
  • errorCause
    • エラーの名称が設定されます。パターンにより以下の通りになります。
      • InvalidError
        • Controller で発生した Spring のバリデーションエラー
      • HandleableError
        • バリデーションエラーではないが、実装の中で想定されうるエラー
      • ResourceNotFoundException
        • リクエストされたリソースが存在しない(404)
      • SystemException
        • 上記以外のパターンで想定外のエラー
  • message
    • エラーメッセージが設定されます。バリデーションエラー時は InvalidErrors に詳細が設定されるため Null となります。
  • invalidErrors
    • バリデーションエラー発生時に各項目のエラー詳細が設定されます。 InvalidError の詳細については後述
    • @RequestBody で受け取るパラメータは配列オブジェクトを許容するため、オブジェクト単位にエラーを返せるように配列として定義しています。
      Kotlin ではなるべくイミュータブルにすべきですが、後述するハンドリング処理の制約があり MutableList として定義しています。

InvalidErrorの内容

バリデーションエラー時に設定される InvalidError の内容については以下の通りです。

InvalidError.kt
/** バリデーションエラーの内容 */
data class InvalidError @JsonCreator constructor(
    /** リクエストボディの配列順序。配列オブジェクトとしてパラメータが設定されていない場合は 0 固定 */
    val index: Int,
    /** 各項目のエラー内容リスト */
    val itemErrors: MutableList<ItemError>
)

各項目について説明します。

  • index
    • リクエストボディの配列順序が設定されます。
      • 例えば以下のようなパラメータがリクエストボディに設定された場合、

        リクエストボディ
        [
           { "hoge": "1", "fuga": 1 },
           { "hoge": "2", "fuga": 2 }
        ]
        

        hoge: "1" のオブジェクトでバリデーションエラーが発生した場合は 0
        hoge: "2" の場合は 1 になるイメージです。

    • 配列オブジェクトとしてパラメータが設定されていない場合は 0 固定となります。
  • itemErrors
    • 各項目(フィールド)ごとに発生したバリデーションエラーの詳細が配列オブジェクトとして設定されます。 ItemError の詳細については後述
    • こちらも InvalidErrors と同じく、後述する実装の都合で MutablList として定義しています。

ItemErrorの内容

項目(フィールド)のバリデーションエラー詳細は以下のとおりです。

ItemError.kt
/** 項目ごとのバリデーションエラーの内容 */
@JsonInclude(JsonInclude.Include.NON_NULL)
data class ItemError @JsonCreator constructor(
    /** 名称 */
    val name: String,
    /** エラータイプ */
    val type: String? = null,
    /** エラーメッセージ */
    val message: String? = null,
    /** List<T>の場合の T 要素のバリデーションエラー内容 */
    var listErrors: MutableList<ListError>? = null
)

各項目について説明します。

  • name
    • JSON のフィールド名が設定されます。
    • リクエストボディのフィールドとして、プリミティブ型・List 型ではないクラス型が存在し、
      そのクラスの中のフィールドでバリデーションエラーが発生した場合は フィールド名.クラス内のフィールド名 となります。
  • type
    • バリデーションエラーの種別が設定されます。
    • 例えば、@NotBlank をバリデーションとして設定していた場合、NotBlank が設定されます。
  • message
    • バリデーションエラーメッセージが設定されます。
  • listErrors
    • List<T>T でバリデーションエラーが発生した場合のみ設定されます。ListError の内容については後述
    • こちらも InvalidErrors と同じく、後述する実装の都合で MutablList として定義しています。

ListErrorの内容

List<T>T のバリデーションエラー詳細は以下のとおりです。

ListError.kt
/** List<T> の T のバリデーションエラーの内容 */
@JsonInclude(JsonInclude.Include.NON_NULL)
data class ListError @JsonCreator constructor(
    /** List<T> の T の順序 */
    val index: Int,
    /** ItemErrorと同じ */
    val type: String,
    /** ItemErrorと同じ */
    val message: String
)

各項目について説明します。

  • index
    • バリデーションエラーが発生した List<T>T 位置が設定されます。
    • 例えば [1, 2, 3]3 でエラーが発生した場合は、2 が設定されます。
  • type, message

エラーハンドリング

ここまでで検討したエラーレスポンス構成に合わせて、
発生するエラーパターンに合わせたハンドリングを実装していきます。

Controller

Spring で補足できるすべてのエラーを1つの Controller でハンドリングする方針とします。
実装は以下です。

ErrorRestController.kt
/**
 * すべてのエラーを補足する共通エラーハンドラ
 */
@RestController
@RequestMapping("\${server.error.path:\${error.path:/error}}") // ここでエラー発生時のルートパスにマッピングしておけばこのControllerですべて補足できる
class ErrorRestController(
    private val errorAttributes: ErrorAttributes,
    private val messageSource: MessageSource
) : AbstractErrorController(errorAttributes) {
    companion object {
        private const val INVALID_ERROR = "InvalidError"
    }

    // バリデーションエラーハンドラ 詳細は後述
    private val formErrorHandler = FormErrorHandler()
    private val parameterErrorHandler = ParameterErrorHandler()

    /** 404レスポンス */
    private val notFound = ResponseEntity.status(HttpStatus.NOT_FOUND)
        .body(
            ErrorResponse(
                errorCause = "ResourceNotFound",
                message = "Not Found"
            )
        )

    @RequestMapping
    fun handleError(request: HttpServletRequest): ResponseEntity<*>? {
        // 補足できるエラーかチェックし、そうでなければ存在しないリソースへのリクエストとして 404 エラーを返す
        val error = errorAttributes.getError(ServletWebRequest(request)) ?: return notFound

        // リクエストしたクライアント側起因のエラーはハンドリングできないため Null を返す
        if (error.cause is ClientAbortException) return null

        val status = getStatus(request)
        val body = when (error) {
            // @RequestBody でバリデーションエラーが発生
            is BindException -> {
                ErrorResponse(
                    errorCause = INVALID_ERROR,
                    invalidErrors = formErrorHandler.handleFormException(error, messageSource)
                )
            }
            // @PathVariable, @ReuqestParam でバリデーションエラーが発生
            is ConstraintViolationException -> {
                ErrorResponse(
                    errorCause = INVALID_ERROR,
                    invalidErrors = mutableListOf(parameterErrorHandler.handleParameterException(error))
                )
            }
            // @ReuqestParam(require = true)(デフォルト true) でパラメータ指定無し
            is MissingServletRequestParameterException -> {
                ErrorResponse(
                    errorCause = INVALID_ERROR,
                    invalidErrors = mutableListOf(parameterErrorHandler.handleParameterException(error))
                )
            }
            // バリデーションエラーではないエラーが発生
            is HandleableException -> {
                ErrorResponse(
                    errorCause = "HandleableError",
                    message = error.message
                )
            }
            // 想定外の例外が発生
            else -> {
                val reqMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE)
                ErrorResponse(
                    errorCause = "SystemException",
                    message = if (reqMessage is String && reqMessage.isNotBlank()) reqMessage else error.message
                )
            }
        }

        return ResponseEntity.status(status).body(body)
    }
}

Path/Queryパラメータのバリデーション

Path/Query パラメータのバリデーションとして以下の2パターンがあります。

  • @PathVariable, @RequestParam を設定したフィールドに、@Max などのバリデーションを設定
    • バリデーションエラーとなった場合は ConstraintViolationException が発生する
  • 必須の @RequestParam のパラメータ指定が無い
    • バリデーションエラーとなった場合は MissingServletRequestParameterException が発生する

それぞれに対応するエラーハンドラの実装は以下のとおりです。

ParameterErrorHandler.kt
/** パス・クエリパラメータのエラーハンドラ */
class ParameterErrorHandler {
    /**
     * パス・クエリパラメータでバリデーションエラーとなった場合に利用するハンドラ
     * 前提として、パラメータはプリミティブ型であること
     */
    fun handleParameterException(ex: ConstraintViolationException) = InvalidError(
        0,
        ex.constraintViolations.map { err ->
            ItemError(
                // Path/Queryだと「xxx.プロパティ名」となるため、 末尾 . から先をフィールド名とする
                lastDotAfter(err.propertyPath.toString()),
                InvalidErrorTypeEnum.type(err.constraintDescriptor.annotation.annotationClass.simpleName ?: "Unknown"),
                err.message
            )
        }.toMutableList()
    )

    /**
     * 必須のクエリパラメータが存在しない場合に利用するハンドラ
     * 前提として、パラメータはプリミティブ型であること
     */
    fun handleParameterException(ex: MissingServletRequestParameterException) = InvalidError(
        0,
        mutableListOf(
            ItemError(
                ex.parameterName,
                // 必須エラーの場合だけであるため、固定で「Required」とする
                InvalidErrorTypeEnum.Required.name,
                ex.localizedMessage
            )
        )
    )

    /** 最後の . から先の文字列を取得 */
    private fun lastDotAfter(value: String) = value.substring(value.lastIndexOf(".") + 1)
}

RequestBodyパラメータのバリデーション

RequestBody パラメータのバリデーションエラーハンドラの実装は以下のとおりです。
少し長いソースコードなので折りたたみます。

RequestBody バリデーション
FormErrorHandler.kt
/** リクエストボディでバリデーションエラーが発生した場合に利用するハンドラ */
class FormErrorHandler {
    companion object {
        private val MESSAGE_LOCALE = Locale.getDefault()

        /** リクエストボディのフィールドネスト数上限 */
        private const val REQ_BODY_NESTED_SIZE_OVER = 2

        // FieldErrorパース用
        private const val LEFT_BRACKET = '['
        private const val RIGHT_BRACKET = ']'
        private const val DOT = '.'
    }

    /**
     * リクエストボディのエラーハンドラ
     * BindExceptionはFormバリデーションエラーとし、FieldErrorを各項目のエラーとして抽出する
     */
    fun handleFormException(ex: BindException, messageSource: MessageSource): MutableList<InvalidError> {
        val nestedOverError = nestedOverErrors(ex.fieldErrors)
        if (nestedOverError != null) return mutableListOf(nestedOverError)

        val invalidErrors: MutableList<InvalidError> = mutableListOf()
        ex.fieldErrors
            .sortedBy { error -> error.field }
            .forEach { error ->
                val fieldFullName = fieldFullName(error.field)
                val fieldSplitList = fieldSplitList(fieldFullName)

                val field = field(fieldSplitList)
                val type = InvalidErrorTypeEnum.type(error.code ?: "")
                val message = messageSource.getMessage(error, MESSAGE_LOCALE)

                val invalidError = invalidError(fieldFullName, invalidErrors)
                val listError = listError(field, type, message)

                val fieldExcludedIndex = fieldExcludeIndex(field, listError)
                val addedItemError =
                    invalidError.itemErrors.firstOrNull { err -> err.name == fieldExcludedIndex }

                if (null == addedItemError) {
                    val itemError = itemError(fieldExcludedIndex, type, message, listError)
                    invalidError.itemErrors.add(itemError)
                } else {
                    if (null == listError) {
                        invalidError.itemErrors.add(ItemError(fieldExcludedIndex, type, message))
                    } else {
                        if (null == addedItemError.listErrors) {
                            addedItemError.listErrors = mutableListOf(listError)
                        } else {
                            addedItemError.listErrors!!.add(listError)
                        }
                    }
                }

                if (!invalidErrors.any { err -> err.index == invalidError.index }) {
                    invalidErrors.add(invalidError)
                }
            }

        return invalidErrors
    }

    /** リクエストボディのネスト数上限をオーバーしているフィールドをバリデーションエラーとして返す */
    private fun nestedOverErrors(fieldErrors: List<FieldError>): InvalidError? {
        val nestedOverItemErrors = fieldErrors.map { err -> fieldFullName((err.field)) }
            .filter { fieldFullName ->
                fieldSplitList(fieldFullName).size > REQ_BODY_NESTED_SIZE_OVER
            }.map { fieldFullName ->
                ItemError(fieldFullName, InvalidErrorTypeEnum.ReqBodyNestedOver.name)
            }
        if (nestedOverItemErrors.isEmpty()) return null

        return InvalidError(0, nestedOverItemErrors.toMutableList())
    }

    /** FieldError.fieldからValidListを利用した場合に設定されるフィールド名を除外する */
    private fun fieldFullName(field: String) = if (field.startsWith(ValidList.WRAP_LIST_FIELDS_NAME)) {
        field.substringAfter(ValidList.WRAP_LIST_FIELDS_NAME)
    } else field

    /** フルフィールド名から先頭の []. を除き、 . で分割したリストを取得 */
    private fun fieldSplitList(fieldFullName: String): List<String> {
        val fullNameExcludedIndex = if (fieldFullName.startsWith(LEFT_BRACKET)) {
            fieldFullName.substringAfter(DOT)
        } else fieldFullName

        return fullNameExcludedIndex.split(DOT)
    }

    /** 分割したフィールド名をフォーマット */
    private fun field(fieldSplitList: List<String>) = if (fieldSplitList.size == REQ_BODY_NESTED_SIZE_OVER) {
        "${fieldSplitList.first()}.${fieldSplitList.last()}"
    } else fieldSplitList.first()

    /** ValidList<T>のTでエラーの場合は、フィールド名から [] を除外 */
    private fun fieldExcludeIndex(field: String, listError: ListError?) = if (null == listError) field
    else field.substringBefore(LEFT_BRACKET)

    /** ItemErrorを返す */
    private fun itemError(field: String, type: String, message: String, listError: ListError?) =
        if (null == listError) ItemError(field, type, message)
        else ItemError(field, listErrors = mutableListOf(listError))

    /** List<T>のTに対するエラーの場合はListErrorとして返す。違うならnull */
    private fun listError(field: String, type: String, message: String): ListError? {
        if (!(field.contains(LEFT_BRACKET) && field.contains(RIGHT_BRACKET))) return null

        val listFieldIndex = field.substringAfter(LEFT_BRACKET).substringBefore(RIGHT_BRACKET)
            .toIntOrNull() ?: return null

        return ListError(listFieldIndex, type, message)
    }

    /** フィールドごとのInvalidErrorを返す */
    private fun invalidError(fieldFullName: String, invalidErrors: MutableList<InvalidError>): InvalidError {
        // ValidList<Form>をパラメータとしている場合、[n].フィールド名 となるため、n をindexとして設定する
        val indexStr = if (fieldFullName.startsWith(LEFT_BRACKET)) fieldFullName.substring(1, 2)
        else ""
        val index = indexStr.toIntOrNull() ?: 0

        return invalidErrors.firstOrNull { err -> err.index == index }
            ?: InvalidError(index, mutableListOf())
    }
}

Controllerに組み込んでみる

ここまでで実装したエラーハンドラを利用する前提として、
検証用の Controller を実装してみます。
テストを実行してみると、想定した通りのレスポンスが返されることが検証できます。

  • 検証用の Controller
Path/Query 検証用の Controller
MockParameterErrorHandlerController.kt
@RestController
@RequestMapping("/test/form_error_test")
internal class MockFormErrorController {
    /** 単一オブジェクトのリクエストボディ検証用 */
    @PostMapping
    @ResponseStatus(value = HttpStatus.CREATED)
    fun register(
        @Validated
        @RequestBody
        form: MockFormErrorForm
    ) = println(form)

    /** 複数オブジェクトのリクエストボディ検証用 */
    @PostMapping("/registers")
    @ResponseStatus(value = HttpStatus.CREATED)
    fun registers(
        @Validated
        @RequestBody
        forms: ValidList<@Valid MockFormErrorForm>
    ) = println(forms)

    /** フィールドのネスト数上限をオーバーしたリクエストボディ検証用 */
    @PutMapping
    @ResponseStatus(value = HttpStatus.CREATED)
    fun nestedOver(
        @Validated
        @RequestBody
        form: MockFormErrorNestedOverForm
    ) = println(form)
}

data class MockFormErrorForm(
    @field: NotBlank
    @field: Length(min = 1, max = 10)
    val name: String,

    @field: Email
    val email: String,

    @field: Valid // TODO: なぜか List 内部要素のバリデーションが効かない
    @field: Size(min = 1, max = 3)
    val hogeList: List<@Max(3) Int>,

    @field: Valid
    @field: NonNull
    val nest: Nest
)

data class Nest(
    @field: NotNull
    @field: Max(4)
    val id: Number,

    @field: NotBlank
    val value: String,

    @field: Valid // TODO: なぜか List 内部要素のバリデーションが効かない
    @field: Size(max = 2)
    val list: List<@NotBlank String>
)

@Validated
data class MockFormErrorNestedOverForm(
    @field: Valid
    @field: NotNull
    val nestedOver: NestedOver
)

data class NestedOver(
    @field: Valid
    @field: NotNull
    val nest: Nest
)
RequestBody 検証用の Controller
MockFormErrorHandlerController.kt
@RestController
@RequestMapping("/test/form_error_test")
internal class MockFormErrorController {
    /** 単一オブジェクトのリクエストボディ検証用 */
    @PostMapping
    @ResponseStatus(value = HttpStatus.CREATED)
    fun register(
        @Validated
        @RequestBody
        form: MockFormErrorForm
    ) = println(form)

    /** 複数オブジェクトのリクエストボディ検証用 */
    @PostMapping("/registers")
    @ResponseStatus(value = HttpStatus.CREATED)
    fun registers(
        @Validated
        @RequestBody
        forms: ValidList<@Valid MockFormErrorForm>
    ) = println(forms)

    /** フィールドのネスト数上限をオーバーしたリクエストボディ検証用 */
    @PutMapping
    @ResponseStatus(value = HttpStatus.CREATED)
    fun nestedOver(
        @Validated
        @RequestBody
        form: MockFormErrorNestedOverForm
    ) = println(form)
}

data class MockFormErrorForm(
    @field: NotBlank
    @field: Length(min = 1, max = 10)
    val name: String,

    @field: Email
    val email: String,

    @field: Valid // TODO: なぜか List 内部要素のバリデーションが効かない
    @field: Size(min = 1, max = 3)
    val hogeList: List<@Max(3) Int>,

    @field: Valid
    @field: NonNull
    val nest: Nest
)

data class Nest(
    @field: NotNull
    @field: Max(4)
    val id: Number,

    @field: NotBlank
    val value: String,

    @field: Valid // TODO: なぜか List 内部要素のバリデーションが効かない
    @field: Size(max = 2)
    val list: List<@NotBlank String>
)

@Validated
data class MockFormErrorNestedOverForm(
    @field: Valid
    @field: NotNull
    val nestedOver: NestedOver
)

data class NestedOver(
    @field: Valid
    @field: NotNull
    val nest: Nest
)
エラーハンドリング全体検証用の Controller
MockErrorRestController.kt
@RestController
@RequestMapping("/test")
@Validated
class MockErrorRestController {
    /** HandleableError検証用 */
    @GetMapping
    fun handleable(): Nothing = throw SampleException("検証用")

    /** SystemException検証用 */
    @PostMapping
    fun system(): Nothing = throw IllegalArgumentException("検証用")

    /** Path/Query/RequestBody同時にバリデーションエラーした場合の検証用 */
    @PutMapping
    @RequestMapping("/{id}")
    fun pathQueryBody(
        @PathVariable
        @Length(max = 1)
        id: String,
        @RequestParam
        @Max(1)
        num: Int,
        @Validated
        @RequestBody
        form: MockErrorRestControllerForm
    ) = println()
}

data class MockErrorRestControllerForm(
    @field: NotBlank
    val mockId: String
)
ValidList 実装
ValidList.kt
/**
* Controllerのメソッド引数でList<Form>として受け取るためのラッパー
* List<Form>で受け取るとバリデーションが効かないため、左記の形式で受け取る場合はこれを利用する
*/
class ValidList<E> @JsonCreator constructor(vararg args: E) {
companion object {
const val WRAP_LIST_FIELDS_NAME = "wrapListFields"
}

@JsonValue
@Valid
var wrapListFields: List<E>? = null
val getWrapListFields: List<E>?
get() = wrapListFields

init {
this.wrapListFields = args.asList()
}
}
  • テストコード
    • 単体テストでは JUnit ではなく kotest を利用しています。
      • JUnit はそこそこ書いたことありましたが、徐に試した kotest の方が好みでした(どうでもよい感想
Path/Query
ParameterErrorHandlerTest.kt
@Suppress("NonAsciiCharacters")
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class ParameterErrorHandlerTest(
    @Autowired
    val restTemplate: TestRestTemplate
) : FunSpec() {
    companion object {
        private const val INVALID_ERROR = "InvalidError"
        private const val ROOT_URI = "/test/param_error_test"
    }

    // data class でJackson使うための設定
    val mapper = ObjectMapper().registerKotlinModule()

    init {
        test("正常なパラメータ指定はOKとなること") {
            val ret = restRequest("$ROOT_URI/path/i", HttpMethod.GET)

            ret.statusCode shouldBe HttpStatus.OK
        }

        test("必須のパスパラメータを指定していない場合、ResourceNotFoundとなること") {
            val ret = restRequest("$ROOT_URI/path", HttpMethod.GET)

            ret.statusCode shouldBe HttpStatus.NOT_FOUND
        }

        test("必須のクエリパラメータを指定していない場合、バリデーションエラーとなること") {
            val ret = restRequest(ROOT_URI, HttpMethod.GET)

            val body = commonAssertion(ret)
            body.invalidErrors should {
                it shouldNotBe null
                it!!.size shouldBe 1
            }

            body.invalidErrors!!.first() should {
                it.index shouldBe 0
                it.itemErrors.size shouldBe 1
                it.itemErrors.first() should { itemError ->
                    itemError.name shouldBe "mail"
                    itemError.type shouldBe "Required"
                    itemError.listErrors shouldBe null
                }
            }
        }

        test("クエリパラメータが正常でない場合、バリデーションエラーとなること") {
            val ret = restRequest("$ROOT_URI?mail=invalid_query", HttpMethod.GET)

            val body = commonAssertion(ret)
            body.invalidErrors should {
                it shouldNotBe null
                it!!.size shouldBe 1
            }

            body.invalidErrors!!.first() should {
                it.index shouldBe 0
                it.itemErrors.size shouldBe 1
                it.itemErrors.first() should { itemError ->
                    itemError.name shouldBe "mail"
                    itemError.type shouldBe "Email"
                    itemError.listErrors shouldBe null
                }
            }
        }

        test("パス・クエリパラメータが正常でない場合、バリデーションエラーとなりそれぞれの項目でエラー内容が返されること") {
            val ret = restRequest("$ROOT_URI/path_query/id?hoge=&fuga=4", HttpMethod.GET)

            val body = commonAssertion(ret)
            body.invalidErrors should {
                it shouldNotBe null
                it!!.size shouldBe 1
            }

            body.invalidErrors!!.first() should {
                it.index shouldBe 0
                it.itemErrors.size shouldBe 3
                it.itemErrors should { itemErrors ->
                    itemErrors.firstOrNull() { err ->
                        err.name == "id" && err.type == "Length"
                    } shouldNotBe null
                    itemErrors.firstOrNull() { err ->
                        err.name == "hoge" && err.type == "NotBlank"
                    } shouldNotBe null
                    itemErrors.firstOrNull() { err ->
                        err.name == "fuga" && err.type == "Max"
                    } shouldNotBe null
                }
            }
        }
    }

    /** APIリクエスト */
    private fun restRequest(uri: String, method: HttpMethod): ResponseEntity<String> {
        val url = URI(uri)
        val headers = HttpHeaders()
        headers.contentType = MediaType.APPLICATION_JSON

        return restTemplate.exchange(url, method, null, String::class.java)
    }

    /** バリデーションエラーの共通チェック */
    private fun commonAssertion(ret: ResponseEntity<String>): ErrorResponse {
        Assertions.assertTrue(ret.hasBody())

        val body = mapper.readValue(ret.body, ErrorResponse::class.java)
        println(body)
        Assertions.assertNotNull(body.responseId)
        Assertions.assertEquals(INVALID_ERROR, body.errorCause)
        Assertions.assertNull(body.message)

        return body
    }
}
RequestBody
FormErrorHandlerTest.kt
@Suppress("NonAsciiCharacters")
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class FormErrorHandlerTest(
    @Autowired
    val restTemplate: TestRestTemplate
) : FunSpec() {
    companion object {
        private const val INVALID_ERROR = "InvalidError"
        private const val ROOT_URI = "/test/form_error_test"
    }

    // data class でJackson使うための設定
    val mapper = ObjectMapper().registerKotlinModule()

    init {
        test("正常なレスポンスの場合はエラーレスポンスが返らないこと") {
            val nest = Nest(3, "1", listOf("a", "1"))
            val form = MockFormErrorForm(
                "鈴木 一郎", "example@example.com", listOf(1), nest
            )
            val ret = restRequest(ROOT_URI, HttpMethod.POST, mapper.writeValueAsString(form))

            ret.statusCode shouldBe HttpStatus.CREATED
            ret.body shouldBe null
        }

        test("RequestBodyのフィールドネスト数上限を超えていた場合、ネスト数オーバーとしてバリデーションエラーとなること") {
            val nest = Nest(5, "", listOf("a", "1", "3"))
            val nestedOver = NestedOver(nest)
            val form = MockFormErrorNestedOverForm(nestedOver)
            val ret = restRequest(ROOT_URI, HttpMethod.PUT, mapper.writeValueAsString(form))

            val body = commonAssertion(ret)
            body.invalidErrors should {
                it shouldNotBe null
                it!!.size shouldBe 1
            }

            val invalidError = body.invalidErrors!!.first()
            invalidError.index shouldBe 0

            val itemErrors = invalidError.itemErrors
            itemErrors.size shouldBe 3

            itemErrors.forExactly(3) {
                it.type shouldBe "ReqBodyNestedOver"
            }
        }

        // TODO: なぜかList<T>のT要素のバリデーションが効かないため一旦保留して落ちるテストケースのままとする
        test("単一オブジェクトでエラーとなった場合、InvalidError 1件でエラー内容がパラメータに指定した通りである") {
            val nest = Nest(5, "", listOf("a", ""))
            val form = MockFormErrorForm(
                "  ", "not_mail_address", listOf(1, 4, 4, 3), nest
            )
            val ret = restRequest(ROOT_URI, HttpMethod.POST, mapper.writeValueAsString(form))

            val body = commonAssertion(ret)
            body.invalidErrors should {
                it shouldNotBe null
                it!!.size shouldBe 1
            }

            val invalidError = body.invalidErrors!!.first()
            invalidError.index shouldBe 0

            val itemErrors = invalidError.itemErrors
            itemErrors.size shouldBe 6

            itemErrors.filter { err -> err.name == "name" } should {
                it.size shouldBe 1
                it.firstOrNull { err -> err.type == "NotBlank" } shouldNotBe null
            }

            itemErrors.filter { err -> err.name == "email" } should {
                it.size shouldBe 1
                it.firstOrNull { err -> err.type == "Email" } shouldNotBe null
            }

            itemErrors.filter { err -> err.name == "hogeList" } should {
                it shouldNotBe null
                it.size shouldBe 1
                it.first().type shouldBe null
                it.first().name shouldBe null
                it.firstOrNull { err -> err.type == "Size" } shouldNotBe null
                it.first().listErrors!! should { listErrors ->
                    listErrors.size shouldBe 2
                    listErrors.firstOrNull { err ->
                        err.index == 1 && err.type == "Max"
                    } shouldNotBe null
                    listErrors.firstOrNull { err ->
                        err.index == 2 && err.type == "Max"
                    } shouldNotBe null
                }
            }

            itemErrors.filter { err -> err.name.startsWith("nest") } should {
                it shouldNotBe null
                it.size shouldBe 3
                it.firstOrNull { err -> err.name == "nest.id" && err.type == "Max" } shouldNotBe null
                it.firstOrNull { err -> err.name == "nest.value" && err.type == "NotBlank" } shouldNotBe null
                it.filter { err -> err.name == "nest.list" } should { nestList ->
                    nestList shouldNotBe null
                    nestList.size shouldBe 1
                    nestList.first().listErrors shouldNotBe null
                    nestList.first().listErrors!! should { listErrors ->
                        listErrors.size shouldBe 1
                        listErrors.firstOrNull { err ->
                            err.index == 1 && err.type == "NotBlank"
                        } shouldNotBe null
                    }
                }
            }
        }

        // TODO: ↑のTODO解消後に配列オブジェクトのテストケースを実装する
    }

    /** APIリクエスト */
    private fun restRequest(uri: String, method: HttpMethod, formJson: String?): ResponseEntity<String> {
        val url = URI(uri)
        val headers = HttpHeaders()
        headers.contentType = MediaType.APPLICATION_JSON
        val entity = HttpEntity<String>(formJson, headers)
        println(entity.body)

        return restTemplate.exchange(url, method, entity, String::class.java)
    }

    /** バリデーションエラーの共通チェック */
    private fun commonAssertion(ret: ResponseEntity<String>): ErrorResponse {
        Assertions.assertTrue(ret.hasBody())

        val body = mapper.readValue(ret.body, ErrorResponse::class.java)
        println(body)
        Assertions.assertNotNull(body.responseId)
        Assertions.assertEquals(INVALID_ERROR, body.errorCause)
        Assertions.assertNull(body.message)

        return body
    }
}
エラーハンドリング全体
ErrorRestControllerTest.kt
@Suppress("NonAsciiCharacters")
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
internal class ErrorRestControllerTest(
    @Autowired
    val restTemplate: TestRestTemplate
) : FunSpec() {
    companion object {
        private const val ROOT_URI = "/test"
    }

    // data class でJackson使うための設定
    val mapper = ObjectMapper().registerKotlinModule()

    init {
        test("HandleableExceptionを継承したExceptionがthrowされた場合、HandleableErrorとしてレスポンスされること") {
            val ret = restRequest(ROOT_URI, HttpMethod.GET)
            val body = mapper.readValue(ret.body, ErrorResponse::class.java)

            body.responseId shouldNotBe null
            body.errorCause shouldBe "HandleableError"
            body.message shouldBe "検証用"
        }
        test("想定外のExceptionがthrowされた場合、SystemExceptionとしてレスポンスされること") {
            val ret = restRequest(ROOT_URI, HttpMethod.POST)
            val body = mapper.readValue(ret.body, ErrorResponse::class.java)

            body.responseId shouldNotBe null
            body.errorCause shouldBe "SystemException"
            body.message shouldBe "検証用"
        }
        test("Path/Query/RequestBodyすべてでバリデーションエラーとなる場合、RequestBodyが優先されてPath/Queryのバリデーションエラーは発生しないこと") {
            val form = MockErrorRestControllerForm("")
            val ret = restRequest("$ROOT_URI/12?num=2", HttpMethod.PUT, mapper.writeValueAsString(form))
            val body = mapper.readValue(ret.body, ErrorResponse::class.java)

            body.responseId shouldNotBe null
            body.errorCause shouldBe "InvalidError"
            body.invalidErrors shouldNotBe null
            body.invalidErrors?.size shouldBe 1

            body.invalidErrors!!.first() should {
                it.itemErrors.size shouldBe 1
                it.itemErrors.first() should { itemError ->
                    itemError.name shouldBe "mockId"
                    itemError.type shouldBe "NotBlank"
                }
            }
        }
        test("未定義のURIでは、ResourceNotFoundとなること") {
            val ret = restRequest("${ROOT_URI}/not/found", HttpMethod.GET)

            ret.statusCode shouldBe HttpStatus.NOT_FOUND

            val body = mapper.readValue(ret.body, ErrorResponse::class.java)

            body.responseId shouldNotBe null
            body.errorCause shouldBe "ResourceNotFound"
            body.message shouldBe "Not Found"
        }
    }

    /** APIリクエスト */
    private fun restRequest(uri: String, method: HttpMethod, formJson: String? = null): ResponseEntity<String> {
        val url = URI(uri)
        val headers = HttpHeaders()
        headers.contentType = MediaType.APPLICATION_JSON
        val entity = if (formJson == null) null else HttpEntity<String>(formJson, headers)
        println(entity?.body)

        return restTemplate.exchange(url, method, entity, String::class.java)
    }
}

ぼくのかんがえたさいきょうのえらーはんどりんぐ

ここまで読んでいただきありがとうございます。

RequestBody に対するエラーハンドリングは納得のいく実装とはなりませんでしたが、
この方針と実装により、エラーハンドリングを統一できました。
何か気になる点などあるかたおりましたらコメントいただけると助かります。

この記事が少しでもみなさんの助けになれば幸いです。

Discussion