shared-framework 0.0.15

Reusable building blocks for HTTP services — Hyper routing, SeaORM data layer, validation, OpenAPI docs, jobs, queues, cache.
Documentation
//! DTO validation.
//!
//! [`Validate`] is implemented for request DTOs to check their fields, and
//! [`ValidationException`] describes the first failure. The `is_*` helpers check
//! single constraints (UUID, email, pattern, ranges, dates, and more) and the
//! `combine_*` helpers compose checks with and/or/not semantics.
//!
//! ```ignore
//! impl Validate for CreateUser {
//!     fn validate(&self) -> Result<(), ValidationException> {
//!         is_email("email", &self.email, None)?;
//!         is_length("name", &self.name, 1, 100, None)?;
//!         Ok(())
//!     }
//! }
//! let dto: CreateUser = ctx.body::<CreateUser>()?;
//! ```

use regex::Regex;
use serde_json::Value;
use thiserror::Error;

/// Validation failure for one field.
#[derive(Debug, Error)]
#[error("validation failed on field '{field}': {message}")]
pub struct ValidationException {
    /// Name of the field that failed validation.
    pub field: String,
    /// Human-readable reason, or the caller-supplied custom message.
    pub message: String,
}

impl ValidationException {
    /// Creates an exception for `field` with `message`.
    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
        Self { field: field.into(), message: message.into() }
    }
}

/// Validator implemented for DTOs. Called by body parsing; `validate` may also be invoked directly.
pub trait Validate {
    /// Checks the value, returning the first failure as a [`ValidationException`].
    fn validate(&self) -> Result<(), ValidationException>;
}

// ── Constraint helpers ───────────────────────

/// Requires `value` to parse as a UUID.
pub fn is_uuid(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
    if uuid::Uuid::parse_str(value).is_err() {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a valid UUID but was '{value}'")).to_string()));
    }
    Ok(())
}

/// Requires `value` to match a basic `local@domain.tld` email shape.
pub fn is_email(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
    static RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
        Regex::new(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$").unwrap()
    });
    if !RE.is_match(value) {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a valid email but was '{value}'")).to_string()));
    }
    Ok(())
}

/// Requires `value` to match the regex `pattern`. An invalid pattern is itself an error.
pub fn is_match(field: &str, value: &str, pattern: &str, custom: Option<&str>) -> Result<(), ValidationException> {
    let re = Regex::new(pattern).map_err(|e| ValidationException::new(field, e.to_string()))?;
    if !re.is_match(value) {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("value '{value}' does not match pattern {pattern}")).to_string()));
    }
    Ok(())
}

/// Requires a JSON value to be non-empty (non-null, non-empty string/array/object).
pub fn is_not_empty(field: &str, value: &Value, custom: Option<&str>) -> Result<(), ValidationException> {
    let empty = match value {
        Value::Null => true,
        Value::String(s) => s.is_empty(),
        Value::Array(a) => a.is_empty(),
        Value::Object(o) => o.is_empty(),
        _ => false,
    };
    if empty {
        return Err(ValidationException::new(field, custom.unwrap_or("expected a non-empty value but it was empty").to_string()));
    }
    Ok(())
}

/// Requires a string to contain a non-whitespace character.
pub fn is_not_blank(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
    if value.trim().is_empty() {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a non-blank string but was '{value}'")).to_string()));
    }
    Ok(())
}

/// Requires a string's byte length to be within `[min, max]`.
pub fn is_length(field: &str, value: &str, min: usize, max: usize, custom: Option<&str>) -> Result<(), ValidationException> {
    let len = value.len();
    if len < min || len > max {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("length {len} not within [{min}, {max}]")).to_string()));
    }
    Ok(())
}

/// Requires a length/count `len` to be within `[min, max]`.
pub fn is_size(field: &str, len: usize, min: usize, max: usize, custom: Option<&str>) -> Result<(), ValidationException> {
    if len < min || len > max {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("size {len} not within [{min}, {max}]")).to_string()));
    }
    Ok(())
}

/// Requires `value` to equal one of `allowed`.
pub fn is_in(field: &str, value: &str, allowed: &[&str], custom: Option<&str>) -> Result<(), ValidationException> {
    if !allowed.contains(&value) {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("value '{value}' is not one of {:?}", allowed)).to_string()));
    }
    Ok(())
}

/// Requires `value` to parse as a URL with a host.
pub fn is_url(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
    let url = url::Url::parse(value).map_err(|_| ValidationException::new(field, custom.unwrap_or(&format!("expected a valid URL but was '{value}'")).to_string()))?;
    if url.scheme().is_empty() || url.host().is_none() {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a valid URL but was '{value}'")).to_string()));
    }
    Ok(())
}

/// Requires `n` to be strictly positive.
pub fn is_positive(field: &str, n: f64, custom: Option<&str>) -> Result<(), ValidationException> {
    if n <= 0.0 {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a positive value but was {n}")).to_string()));
    }
    Ok(())
}

/// Requires `n` to be strictly negative.
pub fn is_negative(field: &str, n: f64, custom: Option<&str>) -> Result<(), ValidationException> {
    if n >= 0.0 {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a negative value but was {n}")).to_string()));
    }
    Ok(())
}

/// Requires `n` above `bound` (inclusive when `or_equals` is true).
pub fn is_greater(field: &str, n: f64, bound: f64, or_equals: bool, custom: Option<&str>) -> Result<(), ValidationException> {
    let ok = if or_equals { n >= bound } else { n > bound };
    if !ok {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value >{} {bound} but was {n}", if or_equals { "=" } else { "" })).to_string()));
    }
    Ok(())
}

/// Requires `n` below `bound` (inclusive when `or_equals` is true).
pub fn is_lesser(field: &str, n: f64, bound: f64, or_equals: bool, custom: Option<&str>) -> Result<(), ValidationException> {
    let ok = if or_equals { n <= bound } else { n < bound };
    if !ok {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value <{} {bound} but was {n}", if or_equals { "=" } else { "" })).to_string()));
    }
    Ok(())
}

/// Requires `n` inside `[start, end]` (inclusive) or `(start, end)` (exclusive).
pub fn is_between(field: &str, n: f64, start: f64, end: f64, inclusive: bool, custom: Option<&str>) -> Result<(), ValidationException> {
    let ok = if inclusive { n >= start && n <= end } else { n > start && n < end };
    if !ok {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value within [{start}, {end}] {} but was {n}", if inclusive { "inclusive" } else { "exclusive" })).to_string()));
    }
    Ok(())
}

/// Requires `n` outside `[start, end]`; `inclusive` widens the rejected interval to include the bounds.
pub fn is_outside(field: &str, n: f64, start: f64, end: f64, inclusive: bool, custom: Option<&str>) -> Result<(), ValidationException> {
    let ok = if inclusive { n < start || n > end } else { n <= start || n >= end };
    if !ok {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value outside [{start}, {end}] {} but was {n}", if inclusive { "inclusive" } else { "exclusive" })).to_string()));
    }
    Ok(())
}

/// Requires `value` to be strictly before `bound`.
pub fn is_before(field: &str, value: &chrono::DateTime<chrono::Utc>, bound: &chrono::DateTime<chrono::Utc>, custom: Option<&str>) -> Result<(), ValidationException> {
    if !value.lt(bound) {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a date strictly before {bound} but was {value}")).to_string()));
    }
    Ok(())
}

/// Requires `value` to be strictly after `bound`.
pub fn is_after(field: &str, value: &chrono::DateTime<chrono::Utc>, bound: &chrono::DateTime<chrono::Utc>, custom: Option<&str>) -> Result<(), ValidationException> {
    if !value.gt(bound) {
        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a date strictly after {bound} but was {value}")).to_string()));
    }
    Ok(())
}

// ── Combinators ─────────────────────────────────────────────────────────────

/// Requires at least one of `validators` to pass; combines their messages on failure.
pub fn combine_or(field: &str, validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>], custom: Option<&str>) -> Result<(), ValidationException> {
    let mut errors = String::new();
    for v in validators {
        match v() {
            Ok(_) => return Ok(()),
            Err(e) => errors.push_str(&format!("; {}", e.message)),
        }
    }
    Err(ValidationException::new(field, custom.unwrap_or(&format!("value did not satisfy any of {} combined constraints:{errors}", validators.len())).to_string()))
}

/// Requires every validator in `validators` to pass, returning the first failure.
pub fn combine_and(validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>]) -> Result<(), ValidationException> {
    for v in validators {
        v()?;
    }
    Ok(())
}

/// Requires at least one of `validators` to fail. Succeeds on empty input; fails when all pass.
pub fn combine_not(field: &str, validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>], custom: Option<&str>) -> Result<(), ValidationException> {
    let mut all_passed = true;
    for v in validators {
        if v().is_err() {
            all_passed = false;
            break;
        }
    }
    if all_passed && !validators.is_empty() {
        return Err(ValidationException::new(field, custom.unwrap_or("value satisfied constraints that must not hold").to_string()));
    }
    Ok(())
}

/// Runs [`Validate::validate`] on `target`, returning the first failure.
pub fn validate<T: Validate>(target: &T) -> Result<(), ValidationException> {
    target.validate()
}