🌱

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 を使ってリクエストボディをバリデーションした場合、不正な値が含まれていれば BindExceptionMethodArgumentNotValidException)が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