ぼくのかんがえたさいきょうのエラーハンドリング
みなさん 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 ともに、
エラー発生時のレスポンス構成を同じ形で返せるようにすれば、
内部・外部に寄らない統一的なエラーハンドリングが可能となると考えました。
エラーレスポンスの構成
検討したレスポンス構成は以下のとおりです。
/** エラーの共通レスポンス */
@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
以下のクラスについても、特別に言及しない限り↑と同じ考え方です。
- レスポンスは JSON で返す前提として、
-
responseId
- レスポンスごとに一意な ID を生成するようにします。
- 用途としてはこの記事のサンプルソースでは導入していませんが、
実際のアプリ開発では運用を考慮してロギングを行うことはほぼ必須となるため、
エラー発生時の調査用にこの値を出力しておき、FE でも同様にログ出力しておけば、
調査時にどの API リクエストでエラーが発生したか FE/BE 一貫して調査可能となります。
-
errorCause
- エラーの名称が設定されます。パターンにより以下の通りになります。
-
InvalidError
- Controller で発生した Spring のバリデーションエラー
-
HandleableError
- バリデーションエラーではないが、実装の中で想定されうるエラー
-
ResourceNotFoundException
- リクエストされたリソースが存在しない(404)
-
SystemException
- 上記以外のパターンで想定外のエラー
-
- エラーの名称が設定されます。パターンにより以下の通りになります。
-
message
- エラーメッセージが設定されます。バリデーションエラー時は
InvalidErrors
に詳細が設定されるため Null となります。
- エラーメッセージが設定されます。バリデーションエラー時は
-
invalidErrors
- バリデーションエラー発生時に各項目のエラー詳細が設定されます。
InvalidError
の詳細については後述 -
@RequestBody
で受け取るパラメータは配列オブジェクトを許容するため、オブジェクト単位にエラーを返せるように配列として定義しています。
Kotlin ではなるべくイミュータブルにすべきですが、後述するハンドリング処理の制約がありMutableList
として定義しています。
- バリデーションエラー発生時に各項目のエラー詳細が設定されます。
InvalidErrorの内容
バリデーションエラー時に設定される InvalidError
の内容については以下の通りです。
/** バリデーションエラーの内容 */
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の内容
項目(フィールド)のバリデーションエラー詳細は以下のとおりです。
/** 項目ごとのバリデーションエラーの内容 */
@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
のバリデーションエラー詳細は以下のとおりです。
/** 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
- ItemErrorの項目と同じです。
エラーハンドリング
ここまでで検討したエラーレスポンス構成に合わせて、
発生するエラーパターンに合わせたハンドリングを実装していきます。
Controller
Spring で補足できるすべてのエラーを1つの Controller でハンドリングする方針とします。
実装は以下です。
/**
* すべてのエラーを補足する共通エラーハンドラ
*/
@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
が発生する
- バリデーションエラーとなった場合は
それぞれに対応するエラーハンドラの実装は以下のとおりです。
/** パス・クエリパラメータのエラーハンドラ */
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 バリデーション
/** リクエストボディでバリデーションエラーが発生した場合に利用するハンドラ */
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
@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
@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
@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 実装
/**
* 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
@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
@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
}
}
エラーハンドリング全体
@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