Skip to main content

shared_framework/validation/
mod.rs

1//! DTO validation.
2//!
3//! [`Validate`] is implemented for request DTOs to check their fields, and
4//! [`ValidationException`] describes the first failure. The `is_*` helpers check
5//! single constraints (UUID, email, pattern, ranges, dates, and more) and the
6//! `combine_*` helpers compose checks with and/or/not semantics.
7//!
8//! ```ignore
9//! impl Validate for CreateUser {
10//!     fn validate(&self) -> Result<(), ValidationException> {
11//!         is_email("email", &self.email, None)?;
12//!         is_length("name", &self.name, 1, 100, None)?;
13//!         Ok(())
14//!     }
15//! }
16//! let dto: CreateUser = ctx.body::<CreateUser>()?;
17//! ```
18
19use regex::Regex;
20use serde_json::Value;
21use thiserror::Error;
22
23/// Validation failure for one field.
24#[derive(Debug, Error)]
25#[error("validation failed on field '{field}': {message}")]
26pub struct ValidationException {
27    /// Name of the field that failed validation.
28    pub field: String,
29    /// Human-readable reason, or the caller-supplied custom message.
30    pub message: String,
31}
32
33impl ValidationException {
34    /// Creates an exception for `field` with `message`.
35    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
36        Self { field: field.into(), message: message.into() }
37    }
38}
39
40/// Validator implemented for DTOs. Called by body parsing; `validate` may also be invoked directly.
41pub trait Validate {
42    /// Checks the value, returning the first failure as a [`ValidationException`].
43    fn validate(&self) -> Result<(), ValidationException>;
44}
45
46// ── Constraint helpers ───────────────────────
47
48/// Requires `value` to parse as a UUID.
49pub fn is_uuid(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
50    if uuid::Uuid::parse_str(value).is_err() {
51        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a valid UUID but was '{value}'")).to_string()));
52    }
53    Ok(())
54}
55
56/// Requires `value` to match a basic `local@domain.tld` email shape.
57pub fn is_email(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
58    static RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
59        Regex::new(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$").unwrap()
60    });
61    if !RE.is_match(value) {
62        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a valid email but was '{value}'")).to_string()));
63    }
64    Ok(())
65}
66
67/// Requires `value` to match the regex `pattern`. An invalid pattern is itself an error.
68pub fn is_match(field: &str, value: &str, pattern: &str, custom: Option<&str>) -> Result<(), ValidationException> {
69    let re = Regex::new(pattern).map_err(|e| ValidationException::new(field, e.to_string()))?;
70    if !re.is_match(value) {
71        return Err(ValidationException::new(field, custom.unwrap_or(&format!("value '{value}' does not match pattern {pattern}")).to_string()));
72    }
73    Ok(())
74}
75
76/// Requires a JSON value to be non-empty (non-null, non-empty string/array/object).
77pub fn is_not_empty(field: &str, value: &Value, custom: Option<&str>) -> Result<(), ValidationException> {
78    let empty = match value {
79        Value::Null => true,
80        Value::String(s) => s.is_empty(),
81        Value::Array(a) => a.is_empty(),
82        Value::Object(o) => o.is_empty(),
83        _ => false,
84    };
85    if empty {
86        return Err(ValidationException::new(field, custom.unwrap_or("expected a non-empty value but it was empty").to_string()));
87    }
88    Ok(())
89}
90
91/// Requires a string to contain a non-whitespace character.
92pub fn is_not_blank(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
93    if value.trim().is_empty() {
94        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a non-blank string but was '{value}'")).to_string()));
95    }
96    Ok(())
97}
98
99/// Requires a string's byte length to be within `[min, max]`.
100pub fn is_length(field: &str, value: &str, min: usize, max: usize, custom: Option<&str>) -> Result<(), ValidationException> {
101    let len = value.len();
102    if len < min || len > max {
103        return Err(ValidationException::new(field, custom.unwrap_or(&format!("length {len} not within [{min}, {max}]")).to_string()));
104    }
105    Ok(())
106}
107
108/// Requires a length/count `len` to be within `[min, max]`.
109pub fn is_size(field: &str, len: usize, min: usize, max: usize, custom: Option<&str>) -> Result<(), ValidationException> {
110    if len < min || len > max {
111        return Err(ValidationException::new(field, custom.unwrap_or(&format!("size {len} not within [{min}, {max}]")).to_string()));
112    }
113    Ok(())
114}
115
116/// Requires `value` to equal one of `allowed`.
117pub fn is_in(field: &str, value: &str, allowed: &[&str], custom: Option<&str>) -> Result<(), ValidationException> {
118    if !allowed.contains(&value) {
119        return Err(ValidationException::new(field, custom.unwrap_or(&format!("value '{value}' is not one of {:?}", allowed)).to_string()));
120    }
121    Ok(())
122}
123
124/// Requires `value` to parse as a URL with a host.
125pub fn is_url(field: &str, value: &str, custom: Option<&str>) -> Result<(), ValidationException> {
126    let url = url::Url::parse(value).map_err(|_| ValidationException::new(field, custom.unwrap_or(&format!("expected a valid URL but was '{value}'")).to_string()))?;
127    if url.scheme().is_empty() || url.host().is_none() {
128        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a valid URL but was '{value}'")).to_string()));
129    }
130    Ok(())
131}
132
133/// Requires `n` to be strictly positive.
134pub fn is_positive(field: &str, n: f64, custom: Option<&str>) -> Result<(), ValidationException> {
135    if n <= 0.0 {
136        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a positive value but was {n}")).to_string()));
137    }
138    Ok(())
139}
140
141/// Requires `n` to be strictly negative.
142pub fn is_negative(field: &str, n: f64, custom: Option<&str>) -> Result<(), ValidationException> {
143    if n >= 0.0 {
144        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a negative value but was {n}")).to_string()));
145    }
146    Ok(())
147}
148
149/// Requires `n` above `bound` (inclusive when `or_equals` is true).
150pub fn is_greater(field: &str, n: f64, bound: f64, or_equals: bool, custom: Option<&str>) -> Result<(), ValidationException> {
151    let ok = if or_equals { n >= bound } else { n > bound };
152    if !ok {
153        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value >{} {bound} but was {n}", if or_equals { "=" } else { "" })).to_string()));
154    }
155    Ok(())
156}
157
158/// Requires `n` below `bound` (inclusive when `or_equals` is true).
159pub fn is_lesser(field: &str, n: f64, bound: f64, or_equals: bool, custom: Option<&str>) -> Result<(), ValidationException> {
160    let ok = if or_equals { n <= bound } else { n < bound };
161    if !ok {
162        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value <{} {bound} but was {n}", if or_equals { "=" } else { "" })).to_string()));
163    }
164    Ok(())
165}
166
167/// Requires `n` inside `[start, end]` (inclusive) or `(start, end)` (exclusive).
168pub fn is_between(field: &str, n: f64, start: f64, end: f64, inclusive: bool, custom: Option<&str>) -> Result<(), ValidationException> {
169    let ok = if inclusive { n >= start && n <= end } else { n > start && n < end };
170    if !ok {
171        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value within [{start}, {end}] {} but was {n}", if inclusive { "inclusive" } else { "exclusive" })).to_string()));
172    }
173    Ok(())
174}
175
176/// Requires `n` outside `[start, end]`; `inclusive` widens the rejected interval to include the bounds.
177pub fn is_outside(field: &str, n: f64, start: f64, end: f64, inclusive: bool, custom: Option<&str>) -> Result<(), ValidationException> {
178    let ok = if inclusive { n < start || n > end } else { n <= start || n >= end };
179    if !ok {
180        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected value outside [{start}, {end}] {} but was {n}", if inclusive { "inclusive" } else { "exclusive" })).to_string()));
181    }
182    Ok(())
183}
184
185/// Requires `value` to be strictly before `bound`.
186pub fn is_before(field: &str, value: &chrono::DateTime<chrono::Utc>, bound: &chrono::DateTime<chrono::Utc>, custom: Option<&str>) -> Result<(), ValidationException> {
187    if !value.lt(bound) {
188        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a date strictly before {bound} but was {value}")).to_string()));
189    }
190    Ok(())
191}
192
193/// Requires `value` to be strictly after `bound`.
194pub fn is_after(field: &str, value: &chrono::DateTime<chrono::Utc>, bound: &chrono::DateTime<chrono::Utc>, custom: Option<&str>) -> Result<(), ValidationException> {
195    if !value.gt(bound) {
196        return Err(ValidationException::new(field, custom.unwrap_or(&format!("expected a date strictly after {bound} but was {value}")).to_string()));
197    }
198    Ok(())
199}
200
201// ── Combinators ─────────────────────────────────────────────────────────────
202
203/// Requires at least one of `validators` to pass; combines their messages on failure.
204pub fn combine_or(field: &str, validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>], custom: Option<&str>) -> Result<(), ValidationException> {
205    let mut errors = String::new();
206    for v in validators {
207        match v() {
208            Ok(_) => return Ok(()),
209            Err(e) => errors.push_str(&format!("; {}", e.message)),
210        }
211    }
212    Err(ValidationException::new(field, custom.unwrap_or(&format!("value did not satisfy any of {} combined constraints:{errors}", validators.len())).to_string()))
213}
214
215/// Requires every validator in `validators` to pass, returning the first failure.
216pub fn combine_and(validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>]) -> Result<(), ValidationException> {
217    for v in validators {
218        v()?;
219    }
220    Ok(())
221}
222
223/// Requires at least one of `validators` to fail. Succeeds on empty input; fails when all pass.
224pub fn combine_not(field: &str, validators: &[Box<dyn Fn() -> Result<(), ValidationException> + Send + Sync>], custom: Option<&str>) -> Result<(), ValidationException> {
225    let mut all_passed = true;
226    for v in validators {
227        if v().is_err() {
228            all_passed = false;
229            break;
230        }
231    }
232    if all_passed && !validators.is_empty() {
233        return Err(ValidationException::new(field, custom.unwrap_or("value satisfied constraints that must not hold").to_string()));
234    }
235    Ok(())
236}
237
238/// Runs [`Validate::validate`] on `target`, returning the first failure.
239pub fn validate<T: Validate>(target: &T) -> Result<(), ValidationException> {
240    target.validate()
241}