kotlin-validation

KotlinのためのタイプセーフでDSLベースのバリデーションライブラリ

概要

kotlin-validationは、Kotlinのための表現力豊かでタイプセーフなバリデーションDSLを提供するライブラリです。このライブラリは、ドメインクラスをバリデーションロジックから分離し、クリーンアーキテクチャの原則に従った設計を可能にします。

主な特徴

  • タイプセーフなDSL: Kotlinの型システムを活用した安全なバリデーション定義
  • 流暢なAPI: 読みやすく、書きやすいメソッドチェーン
  • カスタムバリデータ: 独自のバリデーションルールを簡単に追加
  • エラーメッセージのカスタマイズ: 詳細なエラー情報の提供
  • 複合バリデーション: 複数のプロパティを跨いだバリデーション
  • 非同期バリデーション: suspendファンクションを使用した非同期検証

インストール

Gradle (Kotlin DSL)

dependencies {
    implementation("ch.fhnw:kotlin-validation:1.0.0")
}

Gradle (Groovy)

dependencies {
    implementation 'ch.fhnw:kotlin-validation:1.0.0'
}

Maven

<dependency>
    <groupId>ch.fhnw</groupId>
    <artifactId>kotlin-validation</artifactId>
    <version>1.0.0</version>
</dependency>

基本的な使い方

シンプルなバリデーション

data class User(
    val name: String,
    val email: String,
    val age: Int
)

val userValidator = validator<User> {
    User::name {
        notBlank()
        minLength(3)
        maxLength(50)
    }
    
    User::email {
        notBlank()
        email()
    }
    
    User::age {
        min(18)
        max(120)
    }
}

// バリデーションの実行
val user = User("太郎", "[email protected]", 25)
val result = userValidator.validate(user)

if (result.isValid) {
    println("バリデーション成功")
} else {
    result.errors.forEach { error ->
        println("${error.property}: ${error.message}")
    }
}

バリデータクラスの作成

class UserValidator : Validator<User>() {
    init {
        rules {
            property(User::name) {
                notBlank() message "名前は必須です"
                minLength(3) message "名前は3文字以上で入力してください"
                pattern("^[ぁ-んァ-ヶー一-龠]+$") message "名前は日本語で入力してください"
            }
            
            property(User::email) {
                notBlank() message "メールアドレスは必須です"
                email() message "有効なメールアドレスを入力してください"
            }
            
            property(User::age) {
                notNull() message "年齢は必須です"
                min(18) message "18歳以上である必要があります"
                max(120) message "年齢が不正です"
            }
        }
    }
}

バリデーションルール

文字列バリデーション

validator<Product> {
    Product::name {
        notBlank()              // 空白文字のみでないことを検証
        notEmpty()              // 空でないことを検証
        minLength(5)            // 最小文字数
        maxLength(100)          // 最大文字数
        pattern("[A-Za-z0-9]+") // 正規表現パターン
        alphanumeric()          // 英数字のみ
        alphabetic()            // アルファベットのみ
        numeric()               // 数字のみ
        email()                 // メールアドレス形式
        url()                   // URL形式
        creditCard()            // クレジットカード番号形式
        phoneNumber()           // 電話番号形式
    }
}

数値バリデーション

validator<Order> {
    Order::quantity {
        min(1)                  // 最小値
        max(100)                // 最大値
        between(1, 100)         // 範囲内
        positive()              // 正の数
        negative()              // 負の数
        nonNegative()           // 0以上
        nonPositive()           // 0以下
        multipleOf(5)           // 倍数
    }
    
    Order::price {
        min(0.01)
        scale(2)                // 小数点以下の桁数
        precision(10)           // 全体の桁数
    }
}

コレクションバリデーション

validator<ShoppingCart> {
    ShoppingCart::items {
        notEmpty()              // 空でない
        minSize(1)              // 最小要素数
        maxSize(10)             // 最大要素数
        unique()                // 重複なし
        
        each {                  // 各要素に対するバリデーション
            CartItem::productId {
                notNull()
                positive()
            }
            
            CartItem::quantity {
                between(1, 99)
            }
        }
    }
}

日付・時刻バリデーション

validator<Event> {
    Event::startDate {
        notNull()
        future()                // 未来の日付
        past()                  // 過去の日付
        after(LocalDate.now())  // 特定日付より後
        before(endDate)         // 特定日付より前
        between(start, end)     // 期間内
    }
    
    Event::duration {
        min(Duration.ofMinutes(30))
        max(Duration.ofHours(8))
    }
}

カスタムバリデータ

シンプルなカスタムバリデータ

fun ValidationRules<String>.hiragana() = addRule { value ->
    if (value.matches(Regex("^[ぁ-ん]+$"))) {
        Valid
    } else {
        Invalid("ひらがなで入力してください")
    }
}

// 使用例
validator<Person> {
    Person::furigana {
        notBlank()
        hiragana()
    }
}

パラメータ付きカスタムバリデータ

fun <T : Comparable<T>> ValidationRules<T>.between(
    min: T, 
    max: T
) = addRule { value ->
    when {
        value < min -> Invalid("$min 以上の値を入力してください")
        value > max -> Invalid("$max 以下の値を入力してください")
        else -> Valid
    }
}

非同期カスタムバリデータ

suspend fun ValidationRules<String>.uniqueEmail(
    userRepository: UserRepository
) = addAsyncRule { email ->
    val exists = userRepository.existsByEmail(email)
    if (exists) {
        Invalid("このメールアドレスは既に使用されています")
    } else {
        Valid
    }
}

高度な使い方

条件付きバリデーション

validator<User> {
    User::phoneNumber {
        // 国コードが日本の場合のみ検証
        whenValue { user -> user.countryCode == "JP" } then {
            pattern("^0[0-9]{9,10}$")
        }
    }
    
    User::postalCode {
        // アドレスが入力されている場合は必須
        when { user -> user.address.isNotBlank() } then {
            notBlank()
            pattern("^[0-9]{3}-[0-9]{4}$")
        }
    }
}

複合バリデーション

validator<PasswordChangeRequest> {
    // 複数のプロパティを跨いだバリデーション
    crossValidation {
        val password = it.newPassword
        val confirmation = it.passwordConfirmation
        
        if (password != confirmation) {
            addError("passwordConfirmation", "パスワードが一致しません")
        }
        
        if (password == it.currentPassword) {
            addError("newPassword", "新しいパスワードは現在のパスワードと異なる必要があります")
        }
    }
}

ネストされたオブジェクトのバリデーション

data class Address(
    val street: String,
    val city: String,
    val postalCode: String
)

data class Company(
    val name: String,
    val address: Address,
    val employees: List<Employee>
)

val companyValidator = validator<Company> {
    Company::name {
        notBlank()
        maxLength(100)
    }
    
    // ネストされたオブジェクトのバリデーション
    Company::address validates addressValidator
    
    // コレクション内の各要素のバリデーション
    Company::employees {
        notEmpty()
        each validates employeeValidator
    }
}

グループバリデーション

interface Create
interface Update

validator<User> {
    User::id {
        onGroup<Update> {
            notNull()
            positive()
        }
    }
    
    User::email {
        notBlank()
        email()
        onGroup<Create> {
            uniqueEmail(userRepository)
        }
    }
    
    User::password {
        onGroup<Create> {
            notBlank()
            minLength(8)
            strongPassword()
        }
    }
}

// グループを指定してバリデーション実行
val createResult = userValidator.validate(user, Create::class)
val updateResult = userValidator.validate(user, Update::class)

エラーハンドリング

カスタムエラーメッセージ

validator<Product> {
    Product::price {
        min(0) message "価格は0円以上で入力してください"
        max(1_000_000) message { value ->
            "価格は100万円以下で入力してください(入力値: ${value}円)"
        }
    }
}

エラーの集約

val result = validator.validate(data)

// すべてのエラーを取得
val allErrors = result.errors

// プロパティごとのエラーを取得
val nameErrors = result.errorsFor("name")

// エラーメッセージのマップを作成
val errorMap = result.errors
    .groupBy { it.property }
    .mapValues { entry -> 
        entry.value.map { it.message }
    }

例外としてのエラー

// バリデーションエラーを例外として投げる
try {
    validator.validateOrThrow(user)
} catch (e: ValidationException) {
    e.errors.forEach { error ->
        logger.error("Validation failed: ${error.property} - ${error.message}")
    }
}

Spring Frameworkとの統合

@RestController
@RequestMapping("/api/users")
class UserController(
    private val userValidator: Validator<User>
) {
    @PostMapping
    fun createUser(@RequestBody user: User): ResponseEntity<*> {
        val result = userValidator.validate(user)
        
        if (!result.isValid) {
            return ResponseEntity.badRequest().body(
                mapOf("errors" to result.errors)
            )
        }
        
        // ユーザー作成処理
        return ResponseEntity.ok(user)
    }
}

// バリデータをBeanとして登録
@Configuration
class ValidatorConfig {
    @Bean
    fun userValidator() = validator<User> {
        // バリデーションルールの定義
    }
}

テスト

class UserValidatorTest {
    private val validator = UserValidator()
    
    @Test
    fun `有効なユーザーデータの検証`() {
        val user = User(
            name = "山田太郎",
            email = "[email protected]",
            age = 30
        )
        
        val result = validator.validate(user)
        
        assertTrue(result.isValid)
        assertTrue(result.errors.isEmpty())
    }
    
    @Test
    fun `無効なメールアドレスの検証`() {
        val user = User(
            name = "山田太郎",
            email = "invalid-email",
            age = 30
        )
        
        val result = validator.validate(user)
        
        assertFalse(result.isValid)
        assertEquals(1, result.errors.size)
        assertEquals("email", result.errors[0].property)
        assertEquals("有効なメールアドレスを入力してください", result.errors[0].message)
    }
}

パフォーマンス最適化

// バリデータのキャッシュ
object ValidatorCache {
    private val cache = ConcurrentHashMap<KClass<*>, Validator<*>>()
    
    @Suppress("UNCHECKED_CAST")
    fun <T : Any> getValidator(klass: KClass<T>): Validator<T> {
        return cache.getOrPut(klass) {
            createValidator(klass)
        } as Validator<T>
    }
}

// 遅延評価
validator<Order> {
    Order::items {
        // 大量のアイテムがある場合、遅延評価で効率化
        lazy {
            notEmpty()
            maxSize(1000)
        }
    }
}

ベストプラクティス

  1. バリデータの再利用: 同じ型に対するバリデータは一度作成して再利用する
  2. 適切なエラーメッセージ: ユーザーが理解しやすい具体的なメッセージを提供
  3. 早期リターン: 最初のエラーで停止するオプションを活用してパフォーマンスを向上
  4. テスタビリティ: バリデーションロジックを独立したクラスに分離してテストしやすくする
  5. 国際化対応: エラーメッセージの多言語対応を考慮した設計
// エラーメッセージの国際化
validator<User> {
    User::name {
        notBlank() message { 
            i18n("validation.user.name.required") 
        }
        minLength(3) message { 
            i18n("validation.user.name.minLength", 3) 
        }
    }
}