Accord

ScalaのためのDSLベースのバリデーションライブラリ。直感的な構文と強力な型安全性を提供

import { Tabs, TabItem } from "@/components/ui/Tabs"; import { Alert, AlertDescription } from "@/components/ui/Alert";

概要

AccordはScalaのための強力なバリデーションライブラリで、DSL(Domain Specific Language)を使用して直感的かつ型安全なバリデーションルールを定義できます。Wixによって開発され、Scalaの型システムを最大限に活用して、コンパイル時の安全性と実行時の柔軟性を両立しています。

主な特徴

  • DSLベースの直感的な構文: 自然言語に近い形でバリデーションルールを記述
  • 型安全性: Scalaの型システムを活用した安全なバリデーション
  • コンビネータ: 複雑なバリデーションルールを簡単に組み合わせ可能
  • エラーメッセージのカスタマイズ: 詳細なエラー情報の提供
  • ケースクラスとの統合: Scalaのケースクラスと自然に統合

インストール

```scala libraryDependencies += "com.wix" %% "accord-core" % "0.7.6" ``` ```xml com.wix accord-core_2.13 0.7.6 ``` Scala 2.11、2.12、2.13をサポートしています。使用するScalaバージョンに応じて適切なアーティファクトを選択してください。

基本的な使い方

インポート

import com.wix.accord._
import com.wix.accord.dsl._

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

case class Person(name: String, age: Int)

implicit val personValidator = validator[Person] { p =>
  p.name is notEmpty
  p.age is between(0, 120)
}

// バリデーションの実行
val person = Person("Alice", 25)
val result = validate(person)

result match {
  case Success => println("Valid person")
  case Failure(violations) => 
    violations.foreach(v => println(s"${v.path}: ${v.constraint}"))
}

DSLベースのバリデーション

基本的なバリデータ

case class User(
  username: String,
  email: String,
  age: Int,
  score: Double
)

implicit val userValidator = validator[User] { u =>
  u.username is notEmpty
  u.username has size > 3
  u.email is notEmpty
  u.email matches """^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})$"""
  u.age is between(18, 100)
  u.score is between(0.0, 100.0)
}

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

case class Address(
  street: String,
  city: String,
  zipCode: String
)

case class Customer(
  name: String,
  email: String,
  address: Address
)

implicit val addressValidator = validator[Address] { a =>
  a.street is notEmpty
  a.city is notEmpty
  a.zipCode matches """^\d{3}-\d{4}$"""
}

implicit val customerValidator = validator[Customer] { c =>
  c.name is notEmpty
  c.email matches """^[^\s@]+@[^\s@]+\.[^\s@]+$"""
  c.address is valid  // ネストしたバリデータを使用
}

コンビネータとバリデータ

条件付きバリデーション

case class Order(
  id: String,
  status: String,
  paymentMethod: Option[String],
  deliveryDate: Option[String]
)

implicit val orderValidator = validator[Order] { o =>
  o.id is notEmpty
  o.status is in("pending", "confirmed", "shipped", "delivered")
  
  // statusがconfirmed以上の場合、paymentMethodが必須
  if (o.status != "pending") {
    o.paymentMethod is notEmpty
  }
  
  // statusがshippedの場合、deliveryDateが必須
  if (o.status == "shipped") {
    o.deliveryDate is notEmpty
  }
}

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

case class Team(
  name: String,
  members: List[Person],
  scores: Set[Int]
)

implicit val teamValidator = validator[Team] { t =>
  t.name is notEmpty
  t.members is notEmpty
  t.members.each is valid  // 各メンバーをバリデート
  t.members has size <= 10
  t.scores.each is between(0, 100)
}

カスタムバリデータ

単純なカスタムバリデータ

// パスワード強度のバリデータ
def strongPassword: Validator[String] = new Validator[String] {
  def apply(password: String): Result = {
    val hasUpperCase = password.exists(_.isUpper)
    val hasLowerCase = password.exists(_.isLower)
    val hasDigit = password.exists(_.isDigit)
    val hasSpecial = password.exists(c => "!@#$%^&*".contains(c))
    val isLongEnough = password.length >= 8
    
    if (hasUpperCase && hasLowerCase && hasDigit && hasSpecial && isLongEnough) {
      Success
    } else {
      Failure(Set(RuleViolation(
        password,
        "must contain uppercase, lowercase, digit, special character and be at least 8 characters",
        None
      )))
    }
  }
}

case class Account(username: String, password: String)

implicit val accountValidator = validator[Account] { a =>
  a.username is notEmpty
  a.password is strongPassword
}

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

// 日本の郵便番号バリデータ
def japaneseZipCode: Validator[String] = 
  validator[String](_ matches """^\d{3}-\d{4}$""")

// 電話番号バリデータ(国コード付き)
def phoneNumber(countryCode: String): Validator[String] = new Validator[String] {
  def apply(phone: String): Result = {
    val pattern = countryCode match {
      case "JP" => """^0\d{1,4}-\d{1,4}-\d{4}$"""
      case "US" => """^\d{3}-\d{3}-\d{4}$"""
      case _ => """^\+?\d{10,15}$"""
    }
    
    if (phone.matches(pattern)) Success
    else Failure(Set(RuleViolation(
      phone,
      s"must be a valid $countryCode phone number",
      None
    )))
  }
}

エラーハンドリング

詳細なエラー情報の取得

case class Registration(
  username: String,
  email: String,
  password: String,
  age: Int
)

implicit val registrationValidator = validator[Registration] { r =>
  r.username as "ユーザー名" is notEmpty
  r.username as "ユーザー名" has size >= 4
  r.email as "メールアドレス" matches """^[^\s@]+@[^\s@]+\.[^\s@]+$"""
  r.password as "パスワード" has size >= 8
  r.age as "年齢" is >= 18
}

val invalidReg = Registration("abc", "invalid-email", "short", 15)
val result = validate(invalidReg)

result match {
  case Success => println("登録成功")
  case Failure(violations) =>
    violations.foreach { violation =>
      println(s"${violation.path}: ${violation.constraint}")
    }
}

エラーメッセージのカスタマイズ

implicit val customMessageValidator = validator[Person] { p =>
  p.name as "名前" should notEmpty as "名前は必須項目です"
  p.age as "年齢" should (be >= 20) as "20歳以上である必要があります"
}

実践的な例

APIリクエストのバリデーション

case class CreateUserRequest(
  username: String,
  email: String,
  password: String,
  profile: UserProfile
)

case class UserProfile(
  firstName: String,
  lastName: String,
  bio: Option[String],
  tags: List[String]
)

implicit val userProfileValidator = validator[UserProfile] { p =>
  p.firstName is notEmpty
  p.lastName is notEmpty
  p.bio.each has size <= 500
  p.tags has size <= 10
  p.tags.each has size between(1, 20)
}

implicit val createUserRequestValidator = validator[CreateUserRequest] { r =>
  r.username has size between(4, 20)
  r.username matches """^[a-zA-Z0-9_]+$"""
  r.email matches """^[^\s@]+@[^\s@]+\.[^\s@]+$"""
  r.password has size >= 8
  r.profile is valid
}

// Play FrameworkやAkka HTTPでの使用例
def createUser(request: CreateUserRequest): Future[Either[String, User]] = {
  validate(request) match {
    case Success => 
      // ユーザー作成処理
      Future.successful(Right(User(request.username, request.email)))
    case Failure(violations) =>
      val errors = violations.map(v => s"${v.path}: ${v.constraint}").mkString(", ")
      Future.successful(Left(s"Validation failed: $errors"))
  }
}

ドメインモデルのバリデーション

// 商品在庫管理システム
case class Product(
  id: String,
  name: String,
  price: BigDecimal,
  stock: Int,
  categories: Set[String]
)

case class Inventory(
  products: List[Product],
  lastUpdated: java.time.Instant
)

implicit val productValidator = validator[Product] { p =>
  p.id matches """^PRD-\d{6}$"""
  p.name is notEmpty
  p.price is > BigDecimal(0)
  p.stock is >= 0
  p.categories is notEmpty
  p.categories has size <= 5
}

implicit val inventoryValidator = validator[Inventory] { inv =>
  inv.products is notEmpty
  inv.products.each is valid
  // 重複する商品IDがないことを確認
  inv.products.map(_.id).distinct.size is equalTo(inv.products.size)
}

ベストプラクティス

1. バリデータの再利用

// 共通バリデータの定義
object CommonValidators {
  val emailValidator = validator[String](_ matches """^[^\s@]+@[^\s@]+\.[^\s@]+$""")
  val nonEmptyString = validator[String](_ is notEmpty)
  val positiveNumber = validator[Int](_ is > 0)
}

// 使用例
case class Contact(name: String, email: String, age: Int)

implicit val contactValidator = validator[Contact] { c =>
  c.name is nonEmptyString
  c.email is emailValidator
  c.age is positiveNumber
}

2. エラーメッセージの国際化

trait ValidationMessages {
  def required(field: String): String
  def invalidFormat(field: String): String
  def tooShort(field: String, min: Int): String
}

object JapaneseMessages extends ValidationMessages {
  def required(field: String) = s"${field}は必須項目です"
  def invalidFormat(field: String) = s"${field}の形式が正しくありません"
  def tooShort(field: String, min: Int) = s"${field}は${min}文字以上である必要があります"
}

3. テスト駆動開発

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class PersonValidatorSpec extends AnyFlatSpec with Matchers {
  "PersonValidator" should "accept valid person" in {
    val validPerson = Person("Alice", 25)
    validate(validPerson) shouldBe Success
  }
  
  it should "reject person with empty name" in {
    val invalidPerson = Person("", 25)
    validate(invalidPerson) match {
      case Failure(violations) =>
        violations.exists(_.path.toString.contains("name")) shouldBe true
      case Success => fail("Should have failed")
    }
  }
}

まとめ

Accordは、Scalaアプリケーションにおいて型安全で表現力豊かなバリデーションを実現する優れたライブラリです。DSLベースの直感的な構文により、複雑なバリデーションルールも読みやすく保守しやすいコードで表現できます。

Accordが適している場面

  • ドメインモデルの検証が必要なアプリケーション
  • RESTful APIのリクエスト/レスポンスバリデーション
  • フォームデータの検証
  • 設定ファイルの検証
  • ビジネスルールの実装

Scalaの型システムとAccordのDSLを組み合わせることで、堅牢で保守性の高いアプリケーションを構築できます。