use regex::Regex;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
#[error("validation failed on field '{field}': {message}")]
pub struct ValidationException {
pub field: String,
pub message: String,
}
impl ValidationException {
pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self { field: field.into(), message: message.into() }
}
}
pub trait Validate {
fn validate(&self) -> Result<(), ValidationException>;
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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(())
}
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()))
}
pub fn combine_and(validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>]) -> Result<(), ValidationException> {
for v in validators {
v()?;
}
Ok(())
}
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(())
}
pub fn validate<T: Validate>(target: &T) -> Result<(), ValidationException> {
target.validate()
}