Skip to main content

cratestack_core/
validators.rs

1//! Field-level validators.
2//!
3//! Standalone helpers invoked from generated `validate` methods on
4//! Create / Update input structs. Each returns `Ok(())` on success or
5//! a redacted [`CoolError::Validation`] whose public message names
6//! the field but never echoes the rejected value (so PII does not
7//! leak via 422 bodies).
8
9use crate::Decimal;
10use crate::error::CoolError;
11
12#[cfg(test)]
13mod tests;
14
15pub fn validate_length(
16    field: &'static str,
17    value: &str,
18    min: Option<usize>,
19    max: Option<usize>,
20) -> Result<(), CoolError> {
21    let len = value.chars().count();
22    if let Some(min) = min
23        && len < min
24    {
25        return Err(CoolError::Validation(format!(
26            "field '{field}' length {len} is below minimum {min}",
27        )));
28    }
29    if let Some(max) = max
30        && len > max
31    {
32        return Err(CoolError::Validation(format!(
33            "field '{field}' length {len} exceeds maximum {max}",
34        )));
35    }
36    Ok(())
37}
38
39pub fn validate_range_i64(
40    field: &'static str,
41    value: i64,
42    min: Option<i64>,
43    max: Option<i64>,
44) -> Result<(), CoolError> {
45    if let Some(min) = min
46        && value < min
47    {
48        return Err(CoolError::Validation(format!(
49            "field '{field}' is below minimum {min}",
50        )));
51    }
52    if let Some(max) = max
53        && value > max
54    {
55        return Err(CoolError::Validation(format!(
56            "field '{field}' exceeds maximum {max}",
57        )));
58    }
59    Ok(())
60}
61
62/// Decimal-typed `@range` enforcement. The parser accepts integer
63/// bounds (`@range(min: 0, max: 100)`) on both Int and Decimal
64/// fields; the i64 bounds are promoted to Decimal here so monetary
65/// fields can declare the same shape as integer counters. Banks
66/// routinely write things like `amount Decimal @range(min: 0)` to
67/// forbid negative amounts at the framework layer — without this,
68/// the validator silently no-ops and out-of-range values reach the
69/// database.
70pub fn validate_range_decimal(
71    field: &'static str,
72    value: &Decimal,
73    min: Option<i64>,
74    max: Option<i64>,
75) -> Result<(), CoolError> {
76    if let Some(min) = min {
77        let bound = Decimal::from(min);
78        if *value < bound {
79            return Err(CoolError::Validation(format!(
80                "field '{field}' is below minimum {min}",
81            )));
82        }
83    }
84    if let Some(max) = max {
85        let bound = Decimal::from(max);
86        if *value > bound {
87            return Err(CoolError::Validation(format!(
88                "field '{field}' exceeds maximum {max}",
89            )));
90        }
91    }
92    Ok(())
93}
94
95/// Pragmatic email check: requires exactly one `@`, non-empty local
96/// and domain parts, at least one `.` in the domain, and no
97/// whitespace. Not a full RFC 5322 grammar — that grammar admits
98/// forms (quoted local parts, IP literals) banks rarely accept
99/// anyway. Reject early; let real KYC flows do deeper validation.
100pub fn validate_email(field: &'static str, value: &str) -> Result<(), CoolError> {
101    let trimmed = value.trim();
102    if trimmed.is_empty()
103        || trimmed.chars().any(char::is_whitespace)
104        || trimmed.chars().filter(|c| *c == '@').count() != 1
105    {
106        return Err(CoolError::Validation(format!(
107            "field '{field}' is not a valid email address",
108        )));
109    }
110    let (local, domain) = trimmed.split_once('@').unwrap();
111    if local.is_empty() || domain.is_empty() || !domain.contains('.') {
112        return Err(CoolError::Validation(format!(
113            "field '{field}' is not a valid email address",
114        )));
115    }
116    Ok(())
117}
118
119pub fn validate_uri(field: &'static str, value: &str) -> Result<(), CoolError> {
120    if url::Url::parse(value).is_err() {
121        return Err(CoolError::Validation(format!(
122            "field '{field}' is not a valid URI",
123        )));
124    }
125    Ok(())
126}
127
128/// ISO 4217 currency codes are 3 ASCII uppercase letters. We do not
129/// enforce the registered set here — that table churns and is
130/// downstream policy. Banks typically pin allowed currencies via a
131/// separate allow-list anyway.
132pub fn validate_iso4217(field: &'static str, value: &str) -> Result<(), CoolError> {
133    if value.len() != 3 || !value.chars().all(|c| c.is_ascii_uppercase()) {
134        return Err(CoolError::Validation(format!(
135            "field '{field}' must be a 3-letter uppercase ISO 4217 code",
136        )));
137    }
138    Ok(())
139}