Skip to main content

architect_sdk/service/
validation.rs

1//! Request validation from config rules.
2
3use crate::config::ValidationRule;
4use crate::error::AppError;
5use regex::Regex;
6use serde_json::Value;
7use std::collections::HashMap;
8
9pub struct RequestValidator;
10
11impl RequestValidator {
12    /// Validate body against per-column rules. All required fields must be present.
13    pub fn validate(
14        body: &HashMap<String, Value>,
15        rules: &HashMap<String, ValidationRule>,
16    ) -> Result<(), AppError> {
17        for (col, rule) in rules {
18            let val = body.get(col);
19            if rule.required == Some(true) && (val.is_none() || val == Some(&Value::Null)) {
20                return Err(AppError::Validation(format!("{} is required", col)));
21            }
22            if let Some(v) = val {
23                validate_field(col, v, rule)?;
24            }
25        }
26        Ok(())
27    }
28
29    /// Validate only the fields present in body (for PATCH). Required is not enforced for missing fields.
30    pub fn validate_partial(
31        body: &HashMap<String, Value>,
32        rules: &HashMap<String, ValidationRule>,
33    ) -> Result<(), AppError> {
34        for (col, v) in body {
35            if let Some(rule) = rules.get(col) {
36                validate_field(col, v, rule)?;
37            }
38        }
39        Ok(())
40    }
41
42    /// Like `validate` but collects all errors instead of stopping at the first.
43    /// Returns a vec of (field, message) pairs.
44    pub fn validate_collecting(
45        body: &HashMap<String, Value>,
46        rules: &HashMap<String, ValidationRule>,
47    ) -> Vec<(String, String)> {
48        let mut errors = Vec::new();
49        for (col, rule) in rules {
50            let val = body.get(col);
51            if rule.required == Some(true) && (val.is_none() || val == Some(&Value::Null)) {
52                errors.push((col.clone(), format!("{} is required", col)));
53                continue;
54            }
55            if let Some(v) = val {
56                if let Err(AppError::Validation(msg)) = validate_field(col, v, rule) {
57                    errors.push((col.clone(), msg));
58                }
59            }
60        }
61        errors
62    }
63}
64
65fn validate_field(col: &str, v: &Value, rule: &ValidationRule) -> Result<(), AppError> {
66    if v.is_null() {
67        return Ok(());
68    }
69    if let Some(format) = &rule.format {
70        validate_format(col, v, format)?;
71    }
72    if let Some(max) = rule.max_length {
73        if let Some(s) = v.as_str() {
74            if s.len() > max as usize {
75                return Err(AppError::Validation(format!(
76                    "{} must be at most {} characters",
77                    col, max
78                )));
79            }
80        }
81    }
82    if let Some(min) = rule.min_length {
83        if let Some(s) = v.as_str() {
84            if s.len() < min as usize {
85                return Err(AppError::Validation(format!(
86                    "{} must be at least {} characters",
87                    col, min
88                )));
89            }
90        }
91    }
92    if let Some(ref pattern) = rule.pattern {
93        let re = Regex::new(pattern)
94            .map_err(|_| AppError::Validation(format!("invalid pattern for {}", col)))?;
95        if let Some(s) = v.as_str() {
96            if !re.is_match(s) {
97                return Err(AppError::Validation(format!(
98                    "{} does not match required pattern",
99                    col
100                )));
101            }
102        }
103    }
104    if let Some(ref allowed) = rule.allowed {
105        let mut ok = false;
106        for a in allowed {
107            if value_eq(v, a) {
108                ok = true;
109                break;
110            }
111        }
112        if !ok {
113            return Err(AppError::Validation(format!(
114                "{} must be one of: {:?}",
115                col,
116                allowed.iter().take(5).collect::<Vec<_>>()
117            )));
118        }
119    }
120    if let Some(min) = rule.minimum {
121        if let Some(n) = v.as_f64() {
122            if n < min {
123                return Err(AppError::Validation(format!(
124                    "{} must be at least {}",
125                    col, min
126                )));
127            }
128        }
129    }
130    if let Some(max) = rule.maximum {
131        if let Some(n) = v.as_f64() {
132            if n > max {
133                return Err(AppError::Validation(format!(
134                    "{} must be at most {}",
135                    col, max
136                )));
137            }
138        }
139    }
140    Ok(())
141}
142
143fn value_eq(a: &Value, b: &Value) -> bool {
144    match (a, b) {
145        (Value::String(s), Value::String(t)) => s == t,
146        (Value::Number(n), Value::Number(m)) => n.as_f64() == m.as_f64(),
147        _ => a == b,
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::config::ValidationRule;
155    use serde_json::json;
156
157    fn rule(f: impl FnOnce(&mut ValidationRule)) -> ValidationRule {
158        let mut r = ValidationRule::default();
159        f(&mut r);
160        r
161    }
162
163    fn body(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, Value> {
164        pairs
165            .iter()
166            .map(|(k, v)| (k.to_string(), v.clone()))
167            .collect()
168    }
169
170    fn rules_map(pairs: &[(&str, ValidationRule)]) -> HashMap<String, ValidationRule> {
171        pairs
172            .iter()
173            .map(|(k, v)| (k.to_string(), v.clone()))
174            .collect()
175    }
176
177    // --- required ---
178
179    #[test]
180    fn required_field_present_passes() {
181        let rules = rules_map(&[("name", rule(|r| r.required = Some(true)))]);
182        let b = body(&[("name", json!("Alice"))]);
183        assert!(RequestValidator::validate(&b, &rules).is_ok());
184    }
185
186    #[test]
187    fn required_field_missing_fails() {
188        let rules = rules_map(&[("name", rule(|r| r.required = Some(true)))]);
189        let b = body(&[]);
190        assert!(RequestValidator::validate(&b, &rules).is_err());
191    }
192
193    #[test]
194    fn required_field_null_fails() {
195        let rules = rules_map(&[("name", rule(|r| r.required = Some(true)))]);
196        let b = body(&[("name", json!(null))]);
197        assert!(RequestValidator::validate(&b, &rules).is_err());
198    }
199
200    #[test]
201    fn optional_field_absent_passes() {
202        let rules = rules_map(&[("bio", rule(|_| {}))]);
203        let b = body(&[]);
204        assert!(RequestValidator::validate(&b, &rules).is_ok());
205    }
206
207    // --- partial validation ---
208
209    #[test]
210    fn partial_skips_missing_required() {
211        let rules = rules_map(&[("name", rule(|r| r.required = Some(true)))]);
212        let b = body(&[]); // name absent — OK for PATCH
213        assert!(RequestValidator::validate_partial(&b, &rules).is_ok());
214    }
215
216    #[test]
217    fn partial_validates_present_field() {
218        let rules = rules_map(&[("email", rule(|r| r.format = Some("email".into())))]);
219        let b = body(&[("email", json!("not-an-email"))]);
220        assert!(RequestValidator::validate_partial(&b, &rules).is_err());
221    }
222
223    // --- format: email ---
224
225    #[test]
226    fn email_valid() {
227        let rules = rules_map(&[("email", rule(|r| r.format = Some("email".into())))]);
228        let b = body(&[("email", json!("user@example.com"))]);
229        assert!(RequestValidator::validate(&b, &rules).is_ok());
230    }
231
232    #[test]
233    fn email_invalid_no_at() {
234        let rules = rules_map(&[("email", rule(|r| r.format = Some("email".into())))]);
235        let b = body(&[("email", json!("notanemail"))]);
236        assert!(RequestValidator::validate(&b, &rules).is_err());
237    }
238
239    // --- format: uuid ---
240
241    #[test]
242    fn uuid_valid() {
243        let rules = rules_map(&[("id", rule(|r| r.format = Some("uuid".into())))]);
244        let b = body(&[("id", json!("550e8400-e29b-41d4-a716-446655440000"))]);
245        assert!(RequestValidator::validate(&b, &rules).is_ok());
246    }
247
248    #[test]
249    fn uuid_invalid() {
250        let rules = rules_map(&[("id", rule(|r| r.format = Some("uuid".into())))]);
251        let b = body(&[("id", json!("not-a-uuid"))]);
252        assert!(RequestValidator::validate(&b, &rules).is_err());
253    }
254
255    // --- max_length / min_length ---
256
257    #[test]
258    fn max_length_pass() {
259        let rules = rules_map(&[("bio", rule(|r| r.max_length = Some(10)))]);
260        let b = body(&[("bio", json!("hello"))]);
261        assert!(RequestValidator::validate(&b, &rules).is_ok());
262    }
263
264    #[test]
265    fn max_length_fail() {
266        let rules = rules_map(&[("bio", rule(|r| r.max_length = Some(3)))]);
267        let b = body(&[("bio", json!("toolong"))]);
268        assert!(RequestValidator::validate(&b, &rules).is_err());
269    }
270
271    #[test]
272    fn min_length_pass() {
273        let rules = rules_map(&[("code", rule(|r| r.min_length = Some(3)))]);
274        let b = body(&[("code", json!("abc"))]);
275        assert!(RequestValidator::validate(&b, &rules).is_ok());
276    }
277
278    #[test]
279    fn min_length_fail() {
280        let rules = rules_map(&[("code", rule(|r| r.min_length = Some(5)))]);
281        let b = body(&[("code", json!("hi"))]);
282        assert!(RequestValidator::validate(&b, &rules).is_err());
283    }
284
285    // --- pattern ---
286
287    #[test]
288    fn pattern_match_passes() {
289        let rules = rules_map(&[("zip", rule(|r| r.pattern = Some(r"^\d{5}$".into())))]);
290        let b = body(&[("zip", json!("12345"))]);
291        assert!(RequestValidator::validate(&b, &rules).is_ok());
292    }
293
294    #[test]
295    fn pattern_no_match_fails() {
296        let rules = rules_map(&[("zip", rule(|r| r.pattern = Some(r"^\d{5}$".into())))]);
297        let b = body(&[("zip", json!("abc"))]);
298        assert!(RequestValidator::validate(&b, &rules).is_err());
299    }
300
301    // --- allowed ---
302
303    #[test]
304    fn allowed_values_pass() {
305        let rules = rules_map(&[(
306            "status",
307            rule(|r| r.allowed = Some(vec![json!("active"), json!("inactive")])),
308        )]);
309        let b = body(&[("status", json!("active"))]);
310        assert!(RequestValidator::validate(&b, &rules).is_ok());
311    }
312
313    #[test]
314    fn allowed_values_fail() {
315        let rules = rules_map(&[(
316            "status",
317            rule(|r| r.allowed = Some(vec![json!("active"), json!("inactive")])),
318        )]);
319        let b = body(&[("status", json!("pending"))]);
320        assert!(RequestValidator::validate(&b, &rules).is_err());
321    }
322
323    // --- minimum / maximum ---
324
325    #[test]
326    fn minimum_passes() {
327        let rules = rules_map(&[("age", rule(|r| r.minimum = Some(0.0)))]);
328        let b = body(&[("age", json!(5))]);
329        assert!(RequestValidator::validate(&b, &rules).is_ok());
330    }
331
332    #[test]
333    fn minimum_fails() {
334        let rules = rules_map(&[("age", rule(|r| r.minimum = Some(18.0)))]);
335        let b = body(&[("age", json!(10))]);
336        assert!(RequestValidator::validate(&b, &rules).is_err());
337    }
338
339    #[test]
340    fn maximum_passes() {
341        let rules = rules_map(&[("score", rule(|r| r.maximum = Some(100.0)))]);
342        let b = body(&[("score", json!(99))]);
343        assert!(RequestValidator::validate(&b, &rules).is_ok());
344    }
345
346    #[test]
347    fn maximum_fails() {
348        let rules = rules_map(&[("score", rule(|r| r.maximum = Some(100.0)))]);
349        let b = body(&[("score", json!(101))]);
350        assert!(RequestValidator::validate(&b, &rules).is_err());
351    }
352
353    // --- null value skips field-level checks ---
354
355    #[test]
356    fn null_value_skips_format_check() {
357        let rules = rules_map(&[("email", rule(|r| r.format = Some("email".into())))]);
358        let b = body(&[("email", json!(null))]);
359        // null is not required, and null skips format validation
360        assert!(RequestValidator::validate(&b, &rules).is_ok());
361    }
362
363    // --- validate_collecting ---
364
365    #[test]
366    fn collecting_returns_all_errors() {
367        let rules = rules_map(&[
368            ("name", rule(|r| r.required = Some(true))),
369            ("email", rule(|r| r.required = Some(true))),
370        ]);
371        let b = body(&[]);
372        let errors = RequestValidator::validate_collecting(&b, &rules);
373        assert_eq!(errors.len(), 2);
374    }
375
376    #[test]
377    fn collecting_returns_empty_on_success() {
378        let rules = rules_map(&[("name", rule(|r| r.required = Some(true)))]);
379        let b = body(&[("name", json!("Alice"))]);
380        let errors = RequestValidator::validate_collecting(&b, &rules);
381        assert!(errors.is_empty());
382    }
383}
384
385fn validate_format(col: &str, v: &Value, format: &str) -> Result<(), AppError> {
386    match format.to_lowercase().as_str() {
387        "email" => {
388            if let Some(s) = v.as_str() {
389                if !s.contains('@') || s.len() < 3 {
390                    return Err(AppError::Validation(format!(
391                        "{} must be a valid email",
392                        col
393                    )));
394                }
395            }
396        }
397        "uuid" => {
398            if let Some(s) = v.as_str() {
399                if uuid::Uuid::parse_str(s).is_err() {
400                    return Err(AppError::Validation(format!(
401                        "{} must be a valid UUID",
402                        col
403                    )));
404                }
405            }
406        }
407        _ => {}
408    }
409    Ok(())
410}