Skip to main content

Crate koruma

Crate koruma 

Source
Expand description

§koruma

Build Status Codecov mdBook llms.txt llms-full.txt Docs Crates.io

koruma is a per-field validation framework focused on:

  1. Type Safety: Strongly typed validation error structs generated at compile time.
  2. Ergonomics: Derive macros and validator attributes that minimize boilerplate.
  3. Developer Experience: Optional constructors, nested/newtype validation, and i18n with Project Fluent.

§Installation

[dependencies]
koruma = { version = "*" }

§Feature flags

  • derive (default): enables derive/attribute macros (Koruma, KorumaAllDisplay, #[validator]).
  • fluent: enables localized error support for KorumaAllFluent (use with es-fluent).

§koruma-collection

Docs Crates.io Crowdin

A curated set of validators built on top of koruma, organized by domain: string, format, numeric, collection, and general-purpose validators.

[dependencies]
koruma-collection = { version = "*", features = ["full"] }

§Usage

§1. Declare validators (generic + type-specific)

use koruma::{Validate, validator};
use std::fmt;

#[validator]
#[derive(Clone, Debug)]
pub struct NumberRangeValidation<T: PartialOrd + Copy + fmt::Display + Clone> {
    min: T,
    max: T,
    #[koruma(value)]
    actual: T,
}

impl<T: PartialOrd + Copy + fmt::Display> Validate<T> for NumberRangeValidation<T> {
    fn validate(&self, value: &T) -> bool {
        *value >= self.min && *value <= self.max
    }
}

impl<T: PartialOrd + Copy + fmt::Display + Clone> fmt::Display for NumberRangeValidation<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "Value {} must be between {} and {}",
            self.actual, self.min, self.max
        )
    }
}

#[validator]
#[derive(Clone, Debug)]
pub struct StringLengthValidation {
    min: usize,
    max: usize,
    #[koruma(value)]
    input: String,
}

impl Validate<String> for StringLengthValidation {
    fn validate(&self, value: &String) -> bool {
        let len = value.chars().count();
        len >= self.min && len <= self.max
    }
}

impl fmt::Display for StringLengthValidation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "String length {} must be between {} and {} characters",
            self.input.chars().count(),
            self.min,
            self.max
        )
    }
}

#[validator] generates with_value(...) on the builder and a getter on the validator type with the same name as the #[koruma(value)] field. That field is expected to stay private; use the generated getter for reads.

If a validator does not need to retain the failing input, you can opt out of capture on an Option<T> value field:

#[validator]
pub struct RequiredValidation<T> {
    #[koruma(value, skip_capture)]
    actual: Option<T>,
}

skip_capture keeps the stored field at its default None during derived validation, which avoids clone requirements for presence-only validators. If your validator still derives traits like Clone or Debug through that field, use a manual impl like general::RequiredValidation.

§2. Use #[derive(Koruma)] on a struct + individual validator getters

Validators in #[koruma(...)] use the same builder syntax you use in Rust code:

#[koruma(NumberRangeValidation::<_>::builder().min(0).max(100))]
use koruma::{Koruma, KorumaAllDisplay, Validate};

#[derive(Koruma, KorumaAllDisplay)]
pub struct Item {
    #[koruma(NumberRangeValidation::<_>::builder().min(0).max(100))]
    pub age: i32,

    #[koruma(StringLengthValidation::builder().min(1).max(67))]
    pub name: String,

    // No #[koruma(...)] attribute -> not validated
    pub internal_id: u64,
}

let item = Item {
    age: 150,
    name: "".to_string(),
    internal_id: 1,
};

match item.validate() {
    Ok(()) => println!("Item is valid!"),
    Err(errors) => {
        if let Some(age_err) = errors.age().number_range_validation() {
            println!("age failed: {}", age_err);
        }

        if let Some(name_err) = errors.name().string_length_validation() {
            println!("name failed: {}", name_err);
        }
    },
}

For per-element validation, each(...) supports Vec<T>, borrowed slices like &[T], arrays like [T; N], and optional variants of those:

#[derive(Koruma)]
pub struct Order {
    #[koruma(each(NumberRangeValidation::<_>::builder().min(1).max(5)))]
    pub quantities: Vec<i32>,
}

#[derive(Koruma)]
pub struct BorrowedOrder<'a> {
    #[koruma(each(NumberRangeValidation::<_>::builder().min(1).max(5)))]
    pub quantities: &'a [i32],
}

§3. Use all() getter (KorumaAllDisplay)

if let Err(errors) = item.validate() {
    for failed in errors.age().all() {
        println!("age validator: {}", failed);
    }

    for failed in errors.name().all() {
        println!("name validator: {}", failed);
    }
}

§4. Use all() getter with localized messages (KorumaAllFluent)

[dependencies]
koruma = { version = "*", features = ["derive", "fluent"] }
es-fluent = { version = "*", features = ["derive"] }

This setup assumes:

  • koruma is built with derive + fluent.
  • your application owns an es-fluent localizer, such as EmbeddedI18n.
  • a locale is selected on that localizer before rendering messages.

Rendering is explicit: KorumaAllFluent produces FluentMessage values, and your application chooses the localizer used to turn them into strings. The examples expose a small i18n::localize(...) helper around an app-owned EmbeddedI18n; an application can instead pass that localizer through its own state.

use es_fluent::EsFluent;
use koruma::{Koruma, KorumaAllFluent, Validate, validator};

#[validator]
#[derive(Clone, Debug, EsFluent)]
pub struct IsEvenNumberValidation<
    T: Clone + Copy + std::fmt::Display + std::ops::Rem<Output = T> + From<u8> + PartialEq,
> {
    #[koruma(value)]
    #[fluent(value(|x: &T| x.to_string()))]
    actual: T,
}

impl<T: Copy + std::fmt::Display + std::ops::Rem<Output = T> + From<u8> + PartialEq> Validate<T>
    for IsEvenNumberValidation<T>
{
    fn validate(&self, value: &T) -> bool {
        *value % T::from(2u8) == T::from(0u8)
    }
}

#[validator]
#[derive(Clone, Debug, EsFluent)]
pub struct NonEmptyStringValidation {
    #[koruma(value)]
    input: String,
}

impl Validate<String> for NonEmptyStringValidation {
    fn validate(&self, value: &String) -> bool {
        !value.is_empty()
    }
}

#[derive(Koruma, KorumaAllFluent)]
pub struct User {
    #[koruma(IsEvenNumberValidation::<_>::builder())]
    pub id: i32,

    #[koruma(NonEmptyStringValidation::builder())]
    pub username: String,
}

let user = User {
    id: 3,
    username: "".to_string(),
};

if let Err(errors) = user.validate() {
    if let Some(id_err) = errors.id().is_even_number_validation() {
        println!("{}", i18n::localize(id_err));
    }

    if let Some(username_err) = errors.username().non_empty_string_validation() {
        println!("{}", i18n::localize(username_err));
    }

    for failed in errors.id().all() {
        println!("{}", i18n::localize(failed));
    }

    for failed in errors.username().all() {
        println!("{}", i18n::localize(failed));
    }
}

§Newtype pattern (#[koruma(newtype)], optional try_new / newtype(try_from))

Use #[koruma(newtype)], adding try_new and newtype(try_from) as needed, when you want:

  • newtype - transparent error access to the inner field’s error (Deref for non-optional fields, Option<&InnerError> accessors for Option<Newtype> fields)
  • try_new - a checked constructor function (fn try_new(value: Inner) -> Result<Self, Error>)
  • newtype(try_from) - a TryFrom<Inner> impl for checked conversions from the inner type

You can layer derive_more traits on top for additional wrapper ergonomics (e.g., Deref to inner value).

use es_fluent::EsFluent;
use koruma::{Koruma, KorumaAllFluent, Validate};

#[derive(Clone, Koruma, KorumaAllFluent)]
#[koruma(try_new, newtype)]
pub struct Email {
    #[koruma(NonEmptyStringValidation::builder())]
    pub value: String,
}

#[derive(Koruma, KorumaAllFluent)]
pub struct SignupForm {
    #[koruma(NonEmptyStringValidation::builder())]
    pub username: String,

    #[koruma(newtype)]
    pub email: Email,
}

#[derive(Koruma, KorumaAllFluent)]
pub struct OptionalSignupForm {
    #[koruma(newtype)]
    pub email: Option<Email>,
}

let form = SignupForm {
    username: "".to_string(),
    email: Email {
        value: "".to_string(),
    },
};
if let Err(errors) = form.validate() {
    if let Some(username_err) = errors.username().non_empty_string_validation() {
        println!("username failed: {}", i18n::localize(username_err));
    }
    if let Some(email_err) = errors.email().non_empty_string_validation() {
        println!("email failed: {}", i18n::localize(email_err));
    }

    for failed in errors.email().all() {
        println!("email validator: {}", i18n::localize(failed));
    }
}

let optional_form = OptionalSignupForm { email: None };
assert!(optional_form.validate().is_ok());

let invalid_optional_form = OptionalSignupForm {
    email: Some(Email {
        value: "".to_string(),
    }),
};
if let Err(errors) = invalid_optional_form.validate()
    && let Some(email_errors) = errors.email()
    && let Some(email_err) = email_errors.non_empty_string_validation()
{
    println!("optional email failed: {}", i18n::localize(email_err));
}

// Constructor-time validation path
if let Err(errors) = Email::try_new("".to_string()) {
    if let Some(email_err) = errors.non_empty_string_validation() {
        println!("email::try_new failed: {}", i18n::localize(email_err));
    }
    for failed in errors.all() {
        println!("email::try_new validator: {}", i18n::localize(failed));
    }
}

§Unnamed newtype (tuple struct)

The same pattern works with tuple structs:

use es_fluent::EsFluent;
use koruma::{Koruma, KorumaAllFluent, Validate};

#[derive(Clone, Koruma, KorumaAllFluent)]
#[koruma(try_new, newtype)]
pub struct Username(#[koruma(NonEmptyStringValidation::builder())] pub String);

#[derive(Koruma, KorumaAllFluent)]
pub struct LoginForm {
    #[koruma(newtype)]
    pub username: Username,
}

let login = LoginForm {
    username: Username("".to_string()),
};
if let Err(errors) = login.validate() {
    if let Some(username_err) = errors.username().non_empty_string_validation() {
        println!("username failed: {}", i18n::localize(username_err));
    }
}

if let Ok(username) = Username::try_new("alice".to_string()) {
    println!("username created: {}", username.0);
}

§TryFrom integration (#[koruma(newtype(try_from))])

Add try_from inside newtype(...) to generate a TryFrom<Inner> impl:

use std::convert::TryFrom;
use es_fluent::EsFluent;
use koruma::{Koruma, KorumaAllFluent, Validate};

#[derive(Clone, Koruma, koruma::KorumaAllFluent)]
#[koruma(newtype(try_from))]
pub struct Only67u8(#[koruma(Only67Validation::<_>::builder())] u8);

match Only67u8::try_from(69) {
    Ok(n) => println!("{}!", n.0),
    Err(errors) => {
        for failed in errors.all() {
            println!("validation failed: {}", i18n::localize(failed));
        }
    }
}

Traits§

BuilderWithValue
Trait for validator builders that can receive the value being validated.
NewtypeValidation
Marker trait for newtype structs (single-field wrappers) that derive Koruma.
Validate
Trait for types that can validate a value of type T.
ValidateExt
Trait for structs that derive Koruma and have a validate() method.
ValidationError
Trait for validation error structs that have no errors.

Attribute Macros§

validator
Attribute macro for validator structs.

Derive Macros§

Koruma
Derive macro for generating validation error structs and validate methods.
KorumaAllDisplay
Derive macro for implementing Display on the all() validator enums.
KorumaAllFluent
Derive macro for implementing FluentMessage on the all() validator enums.