qubit-validator 0.1.2

Type-safe validation traits and registries for Qubit applications
Documentation

Qubit Validator (rs-validator)

Rust CI Coverage Crates.io Rust License 中文文档

qubit-validator supplies reusable typed rules, rule binding, and structured validation reports for Rust applications. Consider a POST /users endpoint: the server must reject a blank username or a malformed email before writing to the user store. This crate is where those checks live as Validator implementations with stable IDs. For applications that use field annotations, those annotations only name rule IDs; the optional qubit-model-metadata integration binds registered rules and walks the object. Keeping the rules in one registry avoids copying the same checks into every handler.

A user registration flow

The frontend sends a username and email. The REST handler parses the request into User and calls the registration service. At startup, the service registers and binds app.non_blank and app.email. For each request, it applies the bound rules to the username and email fields and records their outcomes in a report: invalid input returns field paths and stable codes without a repository write. Only after every intended check completes successfully should a complete report with no failures permit a write; a partial report attached to an execution error is diagnostic evidence only. The HTTP layer can map rejection to a client error and storage or validation execution failure to a server error; HTTP routing and persistence are application code. Apps that declare rules on model fields can use the optional qubit-model-metadata integration described below.

Installation

Rust 1.94 or newer is required. Direct rules and a local registry need only this crate:

[dependencies]
qubit-validator = "0.1"

Quick start

Add the dependency above to your application's Cargo.toml, then save this complete program as src/main.rs. It follows a user registration flow: register rules, bind them at application startup, and check each field before writing to the repository. CountingRepository makes the rejection and write branches observable; a service can replace it with its own repository.

use std::cell::Cell;
use std::error::Error;
use std::fmt;
use std::sync::Arc;

use qubit_validator::{
    ArgumentReader, BoundValidationContext, InputType, PreparedValidator,
    RegistrationSource, ValidationPath, ValidationReport, ValidationValue, Validator,
    ValidatorDescriptor, ValidatorId, ValidatorRegistration, ValidatorRegistry, ValidatorSignature,
    ViolationCode, ViolationDraft, prepare_text_validator,
};

#[derive(Debug)]
struct RuleError(&'static str);
impl fmt::Display for RuleError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.0)
    }
}
impl Error for RuleError {}

struct NonBlank;
impl Validator<str> for NonBlank {
    type Error = RuleError;
    fn validate(&self, value: &str, _: &()) -> Result<(), Self::Error> {
        if value.trim().is_empty() {
            Err(RuleError("text is blank"))
        } else {
            Ok(())
        }
    }
}

fn prepare_non_blank(
    _reader: &mut ArgumentReader<'_>,
) -> Result<Arc<dyn PreparedValidator>, qubit_validator::BindError> {
    Ok(prepare_text_validator(NonBlank, |_| {
        ViolationDraft::new(ViolationCode::new("text.blank"))
    }))
}
static NON_BLANK_SIGNATURES: &[ValidatorSignature] = &[ValidatorSignature::new(
    InputType::Text,
    &[],
    prepare_non_blank,
)];
static NON_BLANK_DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(NON_BLANK_SIGNATURES);
static NON_BLANK_REGISTRATION: ValidatorRegistration = ValidatorRegistration::new(
    ValidatorId::new("app.non_blank"),
    &NON_BLANK_DESCRIPTOR,
    RegistrationSource::new("registration", "rules", file!(), line!()),
);

struct AsciiEmail;
impl Validator<str> for AsciiEmail {
    type Error = RuleError;
    fn validate(&self, value: &str, _: &()) -> Result<(), Self::Error> {
        let mut parts = value.split('@');
        let local = parts.next().ok_or(RuleError("invalid email shape"))?;
        let domain = parts.next().ok_or(RuleError("invalid email shape"))?;
        if parts.next().is_some()
            || value.len() > 254
            || local.is_empty()
            || local.len() > 64
            || !value.is_ascii()
            || value.chars().any(char::is_whitespace)
            || !domain.contains('.')
            || domain.split('.').any(str::is_empty)
        {
            Err(RuleError("invalid ASCII email shape"))
        } else {
            Ok(())
        }
    }
}
fn prepare_email(
    _reader: &mut ArgumentReader<'_>,
) -> Result<Arc<dyn PreparedValidator>, qubit_validator::BindError> {
    Ok(prepare_text_validator(AsciiEmail, |_| {
        ViolationDraft::new(ViolationCode::new("text.email"))
    }))
}
static EMAIL_SIGNATURES: &[ValidatorSignature] =
    &[ValidatorSignature::new(InputType::Text, &[], prepare_email)];
static EMAIL_DESCRIPTOR: ValidatorDescriptor = ValidatorDescriptor::new(EMAIL_SIGNATURES);
static EMAIL_REGISTRATION: ValidatorRegistration = ValidatorRegistration::new(
    ValidatorId::new("app.email"),
    &EMAIL_DESCRIPTOR,
    RegistrationSource::new("registration", "rules", file!(), line!()),
);

struct User {
    username: String,
    email: String,
}
struct CountingRepository(Cell<usize>);
impl CountingRepository {
    fn insert(&self, _user: &User) {
        self.0.set(self.0.get() + 1);
    }
}

fn validate_and_insert(
    user: &User,
    non_blank: &qubit_validator::BoundValidator,
    email: &qubit_validator::BoundValidator,
    repository: &CountingRepository,
) -> Result<ValidationReport, Box<dyn Error>> {
    let mut report = ValidationReport::new();
    for (index, (field, value, rule)) in [
        ("username", user.username.as_str(), non_blank),
        ("email", user.email.as_str(), email),
    ]
    .into_iter()
    .enumerate()
    {
        let outcome = rule.validate(
            ValidationValue::Text(value),
            &BoundValidationContext::new(&[]),
        )?;
        report.record_outcome(index, ValidationPath::root().with_field(field), outcome)?;
    }
    if !report.is_truncated() && report.is_valid() {
        repository.insert(user);
    }
    Ok(report)
}

fn main() -> Result<(), Box<dyn Error>> {
    // Register and bind once during application startup.
    let registry =
        ValidatorRegistry::from_registrations([&NON_BLANK_REGISTRATION, &EMAIL_REGISTRATION])?;
    let non_blank = registry.bind("app.non_blank", InputType::Text, &[])?;
    let email = registry.bind("app.email", InputType::Text, &[])?;
    let repository = CountingRepository(Cell::new(0));

    let invalid = User {
        username: "   ".into(),
        email: "wrong".into(),
    };
    let report = validate_and_insert(&invalid, &non_blank, &email, &repository)?;
    assert!(!report.is_truncated());
    assert!(!report.is_valid());
    assert_eq!(report.violations().len(), 2);
    assert_eq!(report.violations()[0].code().as_str(), "text.blank");
    assert_eq!(report.violations()[0].path().render(), "username");
    assert_eq!(report.violations()[1].code().as_str(), "text.email");
    assert_eq!(report.violations()[1].path().render(), "email");
    assert_eq!(repository.0.get(), 0);

    let valid = User {
        username: "ada".into(),
        email: "ada@example.com".into(),
    };
    let report = validate_and_insert(&valid, &non_blank, &email, &repository)?;
    assert!(!report.is_truncated());
    assert!(report.is_valid());
    assert_eq!(repository.0.get(), 1);
    Ok(())
}

Run cargo run. The invalid request report contains username: text.blank and email: text.email, and the repository count stays at zero. The valid request is written once. The email rule checks a demonstration ASCII email shape; it does not verify mailbox ownership.

Model annotations

qubit-validator defines, prepares, registers, and executes rules. Object field traversal is provided by qubit-model-metadata. The former #[Model] and ValidationPlan example belongs to this optional advanced path.

For a checkout with the layout below, an application can use these local dependencies. This example is not required to get started with qubit-validator:

<checkout>/rust-common/{rs-validator,rs-model-metadata,rs-reflect,rs-validation-rules}
<checkout>/app

In <checkout>/app/Cargo.toml:

[dependencies]
qubit-validator = { version = "0.1", path = "../rust-common/rs-validator" }
qubit-model-metadata = { path = "../rust-common/rs-model-metadata", features = ["validation"] }
qubit-model-derive = { path = "../rust-common/rs-model-metadata/derive" }
qubit-reflect = { path = "../rust-common/rs-reflect" }

In the current checkout, both qubit-model-metadata and qubit-model-derive set publish = false. See the model validation user guide.

What it provides

  • Validator<T, C> for direct typed rules with domain-specific errors.
  • ValidatorSignature, ValidatorDescriptor, and ValidatorRegistry for selecting and preparing configured rules by stable ID; BoundValidator checks the chosen input and dependency shape.
  • ValidationOutcome, Violation, and ValidationReport for separating invalid values from binding or execution failures and collecting caller-visible field results.
  • Adapters and ArgumentReader for reusable rules with parameters or named dependencies. Optional inventory discovers linked custom registrations.

qubit-validator does not inspect model fields or schedule rules. The Quick Start shows the direct path: bind rules at startup, validate caller-selected fields, and assemble a report before deciding whether to save. Applications that want field annotations and automatic object traversal can use the optional qubit-model-metadata integration described in Model annotations. See the validator user guide for the bind-and-report API and the model validation guide for plan construction, supported access shapes, partial reports, and limits.

Learn more

Testing

Default cargo fmt --check may report differences from the project format; do not apply its full-repository diff.

# Run tests with the default feature set
cargo test

# Run tests with all declared features
cargo test --all-features

# Project CI checks
./.infra/bin/ci-check.sh

# Check code coverage
./.infra/bin/coverage.sh

License

Copyright (c) 2025 - 2026. Haixing Hu. All rights reserved.

Licensed under the Apache License, Version 2.0. See LICENSE for the full license text.

Contributing

Contributions are welcome. Please follow the Rust API guidelines, keep public API documentation and tests current, and run ./.infra/bin/align-ci.sh to format code and ./.infra/bin/ci-check.sh to satisfy CI requirements before submitting a pull request.

Author

Haixing Hu - Qubit Co. Ltd.

Repository: https://github.com/qubit-ltd/rs-validator