Spring Boot × Kotlin で柔軟な Enum バリデーションを実現する方法
リクエストのEnumバリデーションを柔軟に行う方法
Spring Web(Kotlin)で REST API を作っていると、リクエストボディ内の文字列が enum
に一致しているかどうかを検証したくなることがあります。
たとえば、mime_type
に "image/jpeg"
や "image/png"
など特定の値だけを許容したいといったケースです。
本記事では、jakarta.validation.Constraint
を使って、任意の enum
によるバリデーションを簡単に実装できるカスタムアノテーションの作り方をご紹介します。
🎯 目的:Enumバリデーションで不正な入力を防ぐ
以下のように UNKNOWN
などの不正な値が送られた場合、各フィールドごとにバリデーションエラーを返す実装を目指します。
% curl -s 'http://localhost:8080/v1/documents/upload' \
-H 'X-User-Credential: test-user-key' \
-H 'Content-Type: application/json' \
-d '{
"base64_image": "iVBRK5CYII=",
"mime_type": "UNKNOWN",
"image_side": "UNKNOWN"
}' | jq
{
"code": "400",
"message": "Validation Failed",
"fields": [
{
"field": "image_side",
"error": "must be a valid 'ImageSide'"
},
{
"field": "mime_type",
"error": "must be a valid 'MimeType'"
}
]
}
📘 想定する OpenAPI 仕様
以下のリクエストボディを想定しています
type: object
required:
- base64_image
- mime_type
- image_side
properties:
base64_image:
type: string
example: "iVBRK5CYII="
description: Base64 encoded image data to be uploaded
mime_type:
type: string
enum:
- image/jpeg
- image/png
example: "image/jpeg"
description: MIME type of the image
image_side:
type: string
enum:
- FRONT
- BACK
example: "FRONT"
description: Specifies whether the image is the front or back side
🧱 Enum定義:外部値に対応したMimeType列挙
まずは MimeType を定義します。ここでは name ではなく value に "image/jpeg" などを定義します。
enum class MimeType(
val value: String,
val externalCode: String,
) {
JPEG("image/jpeg", "jpg"),
PNG("image/png", "png"),
;
companion object {
private val valueToMimeTypeMap = entries.associateBy { it.value }
fun fromValue(value: String) =
valueToMimeTypeMap[value] ?: throw RuntimeException("invalid mime type value: $value")
}
}
🛠️ カスタムバリデーションアノテーションの定義
次に、任意の enum に対してバリデーションできる汎用アノテーション @EnumConstraint
を作成します。
import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import jakarta.validation.Payload
import kotlin.reflect.KClass
@Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [EnumValidator::class])
annotation class EnumConstraint(
val enumClass: KClass<out Enum<*>>,
val enumField: String = "name",
val message: String = "must be a valid enum value",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
)
class EnumValidator : ConstraintValidator<EnumConstraint, String> {
private lateinit var acceptedValues: Set<String>
override fun initialize(annotation: EnumConstraint) {
val enumConstants = annotation.enumClass.java.enumConstants
acceptedValues = when (annotation.enumField) {
"name" -> enumConstants.map { it.name }.toSet()
else -> {
val field = annotation.enumClass.java.getDeclaredField(annotation.enumField)
field.isAccessible = true
enumConstants.map { field.get(it)?.toString().orEmpty() }.toSet()
}
}
}
override fun isValid(
value: String?,
context: ConstraintValidatorContext
): Boolean {
return value == null || acceptedValues.contains(value)
}
}
このバリデーションアノテーションは、指定した enumClass に含まれる値だけを許容します。
デフォルトでは enum.name をチェックします。
MimeType のように value フィールドを使う場合は、enumField = "value"
を指定します
💡 実際の使用例(ControllerとRequest)
作成したアノテーションを使ってリクエストボディをバリデーションできます。
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import org.springframework.http.HttpStatus
import org.springframework.validation.annotation.Validated
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@RestController
@Validated
class DocumentController(
private val service: DocumentService,
) {
@PostMapping("/v1/documents/upload")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun uploadImage(
@Valid @RequestBody request: DocumentUploadRequest
) {
service.uploadImage(
request.base64Image!!,
MimeType.fromValue(request.mimeType!!),
ImageSide.valueOf(request.imageSide!!)
)
}
}
data class DocumentUploadRequest(
@field:NotBlank val base64Image: String?,
@field:NotNull @EnumConstraint(
enumClass = MimeType::class,
enumField = "value",
message = "must be a valid 'MimeType'"
) val mimeType: String?,
@field:NotNull @EnumConstraint(
enumClass = ImageSide::class,
message = "must be a valid 'ImageSide'"
) val imageSide: String?
)
✏️ 補足:enum.name を使うパターン
たとえば以下のように、列挙子の name(FRONT, BACK)をそのまま使う場合は enumField
の指定は不要です。
enum class ImageSide(
val code: String,
) {
FRONT("0"),
BACK("1"),
}
✅ まとめ
このように @EnumConstraint
を使えば、Spring MVC のリクエストバリデーションにおいて柔軟に enum を扱うことができ、入力値の制約をコード上で明確に管理でき、仕様違反を防ぐ堅牢なAPI を実現できます。
🧩 補足:バリデーションエラーの整形とハンドリング
上記のように @Valid
を使ってリクエストボディをバリデーションした場合、不正な値が含まれていれば BindException
(MethodArgumentNotValidException
)がthrowされます。
このエラーをハンドリングし、API利用者にわかりやすい形式でエラーレスポンスを返すには、@RestControllerAdvice
を使った共通のExceptionHandler 実装が便利です。
以下はフィールドごとのエラーを "field" と "error" のペアで返す例です。
package com.example.demo
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.BindException
import org.springframework.validation.FieldError
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ExceptionHandler(
private val objectMapper: ObjectMapper,
) {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException::class)
fun bindExceptionHandler(ex: BindException) = ResponseEntity(
ValidationErrorResponse(
code = "400",
message = "Validation Failed",
fields = ex.bindingResult.allErrors.map {
val fieldName = if (it is FieldError) it.field else it.objectName
val formattedFieldName = objectMapper.propertyNamingStrategy.nameForField(null, null, fieldName)
FieldErrorResponse(formattedFieldName, it.defaultMessage ?: "Binding Error")
}), HttpStatus.BAD_REQUEST
)
}
data class ValidationErrorResponse(
val code: String,
val message: String,
val fields: List<FieldErrorResponse>,
)
data class FieldErrorResponse(
val field: String,
val error: String,
)
このハンドラにより、OpenAPI仕様で想定した以下のようなレスポンス形式を自動的に返すことができます:
{
"code": "400",
"message": "Validation Failed",
"fields": [
{
"field": "mime_type",
"error": "must be a valid 'MimeType'"
},
{
"field": "image_side",
"error": "must be a valid 'ImageSide'"
}
]
}
🧱 ライブラリ依存の追加
上記バリデーション処理を有効にするために、build.gradle.kts などのビルドファイルに以下の依存を追加します
implementation("org.springframework.boot:spring-boot-starter-validation")
⚙️ application.properties の設定
レスポンスでのフィールド名をスネークケースで統一するため、以下の設定を application.properties に追加します
spring.jackson.property-naming-strategy=SNAKE_CASE
Discussion