Validator (Space Code)

バリデーションライブラリSwiftiOSmacOS入力値検証FoundationSwift Package Manager

GitHub概要

space-code/validator

Validator is a framework written in Swift that provides functions that can be used to validate the contents of an input value.

スター14
ウォッチ1
フォーク0
作成日:2023年9月18日
言語:Swift
ライセンス:MIT License

トピックス

formswiftswiftuivalidation

スター履歴

space-code/validator Star History
データ取得日時: 2025/10/22 09:50

ライブラリ

Validator (Space Code)

概要

Validator (Space Code)は、Swift向けの軽量で柔軟な入力値検証フレームワークです。iOS、macOS、tvOS、watchOSアプリケーションでユーザー入力やデータの妥当性を確認するための包括的なバリデーション機能を提供します。Swift Package Indexに登録されており、Nikita Vasilevによって開発・保守されています。関数型プログラミングのアプローチを採用し、宣言的で読みやすいバリデーションルールの定義が可能です。

詳細

space-code/validatorは、現代のSwiftアプリケーション開発に特化した検証ライブラリとして設計されています。27コミット、3リリースを経て安定性を高め、アクティブなメンテナンスが継続されています。依存関係ゼロの軽量設計により、プロジェクトサイズを最小限に抑えながら強力なバリデーション機能を実現。Swift 5.5以上をサポートし、async/awaitパターンやResult型との統合により、モダンなSwift開発パラダイムにシームレスに対応します。

主な特徴

  • 軽量設計: 外部依存関係なしでプロジェクトサイズを最小化
  • マルチプラットフォーム対応: iOS、macOS、tvOS、watchOSをサポート
  • 関数型アプローチ: 宣言的で組み合わせ可能なバリデーションルール
  • 型安全性: Swiftの型システムを活用した安全なバリデーション
  • Swift Package Manager対応: 簡単なプロジェクト統合
  • 拡張性: カスタムバリデーターの柔軟な定義

メリット・デメリット

メリット

  • Swift Package Managerによる簡単なインストールと管理
  • 外部依存関係がなく軽量でセキュア
  • すべてのAppleプラットフォームでの一貫した動作
  • 関数型プログラミングパラダイムによる再利用可能なコード
  • Swift標準ライブラリとの自然な統合
  • カスタムバリデーションルールの簡単な実装
  • メモリ効率が良く高速な実行

デメリット

  • 小規模プロジェクトのため、大規模なコミュニティサポートが限定的
  • 複雑なバリデーションシナリオでの包括的なドキュメント不足
  • UIKit/SwiftUIとの専用統合ヘルパーが限定的
  • 国際化(i18n)エラーメッセージの内蔵サポート不足
  • 他の大手バリデーションライブラリと比較して機能セットが限定的

参考ページ

書き方の例

インストールと基本セットアップ

Swift Package Manager

// Package.swift
let package = Package(
    name: "YourProject",
    platforms: [
        .iOS(.v13),
        .macOS(.v10_15),
        .tvOS(.v13),
        .watchOS(.v6)
    ],
    dependencies: [
        .package(url: "https://github.com/space-code/validator", from: "1.0.0")
    ],
    targets: [
        .target(
            name: "YourTarget",
            dependencies: ["Validator"]
        )
    ]
)

Xcodeプロジェクトでの追加

1. Xcode → File → Add Package Dependencies
2. URL: https://github.com/space-code/validator
3. Dependency Rule: Up to Next Major Version
4. Add Package

基本的なバリデーション

import Validator

// 基本的な文字列バリデーション
let emailValidator = Validator<String> { email in
    guard !email.isEmpty else {
        return .failure(ValidationError.empty)
    }
    
    guard email.contains("@") && email.contains(".") else {
        return .failure(ValidationError.invalidFormat)
    }
    
    return .success(email)
}

// バリデーションの実行
let email = "[email protected]"
switch emailValidator.validate(email) {
case .success(let validEmail):
    print("有効なメール: \(validEmail)")
case .failure(let error):
    print("エラー: \(error)")
}

// 数値の範囲バリデーション
let ageValidator = Validator<Int> { age in
    guard age >= 0 else {
        return .failure(ValidationError.tooSmall)
    }
    
    guard age <= 150 else {
        return .failure(ValidationError.tooLarge)
    }
    
    return .success(age)
}

// カスタムエラー型の定義
enum ValidationError: Error, LocalizedError {
    case empty
    case invalidFormat
    case tooSmall
    case tooLarge
    case custom(String)
    
    var errorDescription: String? {
        switch self {
        case .empty:
            return "値が空です"
        case .invalidFormat:
            return "形式が正しくありません"
        case .tooSmall:
            return "値が小さすぎます"
        case .tooLarge:
            return "値が大きすぎます"
        case .custom(let message):
            return message
        }
    }
}

組み合わせバリデーション

// 複数のバリデーターを組み合わせ
struct UserValidator {
    let nameValidator = Validator<String> { name in
        guard !name.trimmingCharacters(in: .whitespaces).isEmpty else {
            return .failure(ValidationError.empty)
        }
        
        guard name.count >= 2 else {
            return .failure(ValidationError.custom("名前は2文字以上である必要があります"))
        }
        
        return .success(name)
    }
    
    let passwordValidator = Validator<String> { password in
        guard password.count >= 8 else {
            return .failure(ValidationError.custom("パスワードは8文字以上である必要があります"))
        }
        
        let hasUppercase = password.rangeOfCharacter(from: .uppercaseLetters) != nil
        let hasLowercase = password.rangeOfCharacter(from: .lowercaseLetters) != nil
        let hasDigits = password.rangeOfCharacter(from: .decimalDigits) != nil
        
        guard hasUppercase && hasLowercase && hasDigits else {
            return .failure(ValidationError.custom("パスワードは大文字、小文字、数字を含む必要があります"))
        }
        
        return .success(password)
    }
    
    func validateUser(name: String, email: String, password: String) -> Result<User, ValidationError> {
        // 各フィールドを個別にバリデーション
        guard case .success(let validName) = nameValidator.validate(name) else {
            return .failure(.custom("名前が無効です"))
        }
        
        guard case .success(let validEmail) = emailValidator.validate(email) else {
            return .failure(.custom("メールアドレスが無効です"))
        }
        
        guard case .success(let validPassword) = passwordValidator.validate(password) else {
            return .failure(.custom("パスワードが無効です"))
        }
        
        return .success(User(name: validName, email: validEmail, password: validPassword))
    }
}

struct User {
    let name: String
    let email: String
    let password: String
}

SwiftUIとの統合

import SwiftUI
import Validator

struct RegistrationForm: View {
    @State private var name = ""
    @State private var email = ""
    @State private var password = ""
    @State private var nameError: String?
    @State private var emailError: String?
    @State private var passwordError: String?
    
    private let userValidator = UserValidator()
    
    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("ユーザー情報")) {
                    VStack(alignment: .leading) {
                        TextField("名前", text: $name)
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                            .onChange(of: name) { newValue in
                                validateName(newValue)
                            }
                        
                        if let error = nameError {
                            Text(error)
                                .foregroundColor(.red)
                                .font(.caption)
                        }
                    }
                    
                    VStack(alignment: .leading) {
                        TextField("メールアドレス", text: $email)
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                            .keyboardType(.emailAddress)
                            .autocapitalization(.none)
                            .onChange(of: email) { newValue in
                                validateEmail(newValue)
                            }
                        
                        if let error = emailError {
                            Text(error)
                                .foregroundColor(.red)
                                .font(.caption)
                        }
                    }
                    
                    VStack(alignment: .leading) {
                        SecureField("パスワード", text: $password)
                            .textFieldStyle(RoundedBorderTextFieldStyle())
                            .onChange(of: password) { newValue in
                                validatePassword(newValue)
                            }
                        
                        if let error = passwordError {
                            Text(error)
                                .foregroundColor(.red)
                                .font(.caption)
                        }
                    }
                }
                
                Section {
                    Button("登録") {
                        submitRegistration()
                    }
                    .disabled(!isFormValid)
                }
            }
            .navigationTitle("ユーザー登録")
        }
    }
    
    private var isFormValid: Bool {
        nameError == nil && emailError == nil && passwordError == nil &&
        !name.isEmpty && !email.isEmpty && !password.isEmpty
    }
    
    private func validateName(_ name: String) {
        switch userValidator.nameValidator.validate(name) {
        case .success:
            nameError = nil
        case .failure(let error):
            nameError = error.localizedDescription
        }
    }
    
    private func validateEmail(_ email: String) {
        switch emailValidator.validate(email) {
        case .success:
            emailError = nil
        case .failure(let error):
            emailError = error.localizedDescription
        }
    }
    
    private func validatePassword(_ password: String) {
        switch userValidator.passwordValidator.validate(password) {
        case .success:
            passwordError = nil
        case .failure(let error):
            passwordError = error.localizedDescription
        }
    }
    
    private func submitRegistration() {
        switch userValidator.validateUser(name: name, email: email, password: password) {
        case .success(let user):
            print("ユーザー登録成功: \(user)")
            // 登録処理を実行
        case .failure(let error):
            print("登録エラー: \(error.localizedDescription)")
        }
    }
}

非同期バリデーション

import Foundation
import Validator

// 非同期バリデーションの例(メールアドレスの重複チェック)
class AsyncEmailValidator {
    func validateEmailUniqueness(_ email: String) async -> Result<String, ValidationError> {
        // 基本的なフォーマットチェック
        guard case .success(let validEmail) = emailValidator.validate(email) else {
            return .failure(.invalidFormat)
        }
        
        // サーバーでの重複チェック(模擬)
        do {
            let isUnique = try await checkEmailUniqueness(validEmail)
            return isUnique ? .success(validEmail) : .failure(.custom("このメールアドレスは既に使用されています"))
        } catch {
            return .failure(.custom("サーバーエラーが発生しました"))
        }
    }
    
    private func checkEmailUniqueness(_ email: String) async throws -> Bool {
        // 実際のAPIコールを模擬
        try await Task.sleep(nanoseconds: 1_000_000_000) // 1秒待機
        
        // 模擬的な重複チェック
        let usedEmails = ["[email protected]", "[email protected]"]
        return !usedEmails.contains(email)
    }
}

// 使用例
Task {
    let asyncValidator = AsyncEmailValidator()
    let result = await asyncValidator.validateEmailUniqueness("[email protected]")
    
    switch result {
    case .success(let email):
        print("有効で利用可能なメールアドレス: \(email)")
    case .failure(let error):
        print("エラー: \(error.localizedDescription)")
    }
}

カスタムバリデーター

// 日本の郵便番号バリデーター
struct JapanesePostalCodeValidator {
    static let validator = Validator<String> { postalCode in
        let cleaned = postalCode.replacingOccurrences(of: "-", with: "")
        
        guard cleaned.count == 7 else {
            return .failure(ValidationError.custom("郵便番号は7桁である必要があります"))
        }
        
        guard cleaned.allSatisfy({ $0.isNumber }) else {
            return .failure(ValidationError.custom("郵便番号は数字のみ使用可能です"))
        }
        
        return .success(postalCode)
    }
}

// 日本の電話番号バリデーター
struct JapanesePhoneValidator {
    static let validator = Validator<String> { phoneNumber in
        let cleaned = phoneNumber.replacingOccurrences(of: "-", with: "")
        
        // 一般的な日本の電話番号パターン
        let patterns = [
            "^0[1-9][0-9]{8}$",      // 固定電話 (10桁)
            "^0[789]0[0-9]{8}$",     // 携帯電話 (11桁)
            "^050[0-9]{8}$"          // IP電話 (11桁)
        ]
        
        let isValid = patterns.contains { pattern in
            NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: cleaned)
        }
        
        guard isValid else {
            return .failure(ValidationError.custom("有効な日本の電話番号ではありません"))
        }
        
        return .success(phoneNumber)
    }
}

// 複数のカスタムバリデーターを組み合わせ
struct ContactInfoValidator {
    func validateContact(postalCode: String, phoneNumber: String) -> [String: ValidationError] {
        var errors: [String: ValidationError] = [:]
        
        if case .failure(let error) = JapanesePostalCodeValidator.validator.validate(postalCode) {
            errors["postalCode"] = error
        }
        
        if case .failure(let error) = JapanesePhoneValidator.validator.validate(phoneNumber) {
            errors["phoneNumber"] = error
        }
        
        return errors
    }
}

テストとバリデーション

import XCTest
@testable import YourApp

class ValidatorTests: XCTestCase {
    
    func testEmailValidation() {
        // 有効なメールアドレス
        let validEmails = [
            "[email protected]",
            "[email protected]",
            "[email protected]"
        ]
        
        for email in validEmails {
            XCTAssertTrue(
                emailValidator.validate(email).isSuccess,
                "'\(email)' should be valid"
            )
        }
        
        // 無効なメールアドレス
        let invalidEmails = [
            "",
            "invalid",
            "@example.com",
            "test@",
            "test.example.com"
        ]
        
        for email in invalidEmails {
            XCTAssertTrue(
                emailValidator.validate(email).isFailure,
                "'\(email)' should be invalid"
            )
        }
    }
    
    func testUserValidation() {
        let validator = UserValidator()
        
        // 有効なユーザーデータ
        let result = validator.validateUser(
            name: "田中太郎",
            email: "[email protected]",
            password: "Password123"
        )
        
        XCTAssertTrue(result.isSuccess, "Valid user data should pass validation")
        
        // 無効なパスワード
        let invalidResult = validator.validateUser(
            name: "田中太郎",
            email: "[email protected]",
            password: "weak"
        )
        
        XCTAssertTrue(invalidResult.isFailure, "Weak password should fail validation")
    }
    
    func testJapanesePostalCode() {
        let validator = JapanesePostalCodeValidator.validator
        
        // 有効な郵便番号
        XCTAssertTrue(validator.validate("123-4567").isSuccess)
        XCTAssertTrue(validator.validate("1234567").isSuccess)
        
        // 無効な郵便番号
        XCTAssertTrue(validator.validate("12345").isFailure)
        XCTAssertTrue(validator.validate("123-456a").isFailure)
        XCTAssertTrue(validator.validate("123456789").isFailure)
    }
}

// Result拡張でテストを簡単に
extension Result {
    var isSuccess: Bool {
        switch self {
        case .success:
            return true
        case .failure:
            return false
        }
    }
    
    var isFailure: Bool {
        return !isSuccess
    }
}

パフォーマンス考慮事項

// パフォーマンス最適化のベストプラクティス
class OptimizedValidator {
    // バリデーターの再利用
    private static let emailValidator = createEmailValidator()
    private static let phoneValidator = createPhoneValidator()
    
    // 重い処理は一度だけ作成
    private static func createEmailValidator() -> Validator<String> {
        return Validator<String> { email in
            // 複雑なバリデーションロジック
            // ...
            return .success(email)
        }
    }
    
    // キャッシュを活用した重複チェック
    private var validationCache: [String: Bool] = [:]
    private let cacheQueue = DispatchQueue(label: "validation.cache", attributes: .concurrent)
    
    func validateWithCache(_ input: String) -> Result<String, ValidationError> {
        return cacheQueue.sync {
            if let cached = validationCache[input] {
                return cached ? .success(input) : .failure(.invalidFormat)
            }
            
            let result = Self.emailValidator.validate(input)
            
            cacheQueue.async(flags: .barrier) {
                self.validationCache[input] = result.isSuccess
            }
            
            return result
        }
    }
    
    // 大量データの並列処理
    func validateBatch(_ inputs: [String]) async -> [Result<String, ValidationError>] {
        await withTaskGroup(of: (Int, Result<String, ValidationError>).self) { group in
            for (index, input) in inputs.enumerated() {
                group.addTask {
                    let result = Self.emailValidator.validate(input)
                    return (index, result)
                }
            }
            
            var results = Array<Result<String, ValidationError>?>(repeating: nil, count: inputs.count)
            
            for await (index, result) in group {
                results[index] = result
            }
            
            return results.compactMap { $0 }
        }
    }
}