Skip to main content

pylon_plugin/builtin/
validation.rs

1use std::collections::HashMap;
2
3use crate::{Plugin, PluginError};
4use pylon_auth::AuthContext;
5use serde_json::Value;
6
7/// A field validation rule.
8pub enum FieldRule {
9    /// Minimum string length.
10    MinLength(usize),
11    /// Maximum string length.
12    MaxLength(usize),
13    /// Must match regex pattern (simple contains check).
14    Pattern(String),
15    /// Must be a valid email (contains @ and .).
16    Email,
17    /// Numeric minimum.
18    Min(f64),
19    /// Numeric maximum.
20    Max(f64),
21    /// Must not be empty string.
22    NotEmpty,
23    /// Custom validation function.
24    Custom(
25        String,
26        Box<dyn Fn(&Value) -> Result<(), String> + Send + Sync>,
27    ),
28}
29
30/// Validation rule for an entity.field.
31pub struct EntityRules {
32    pub rules: HashMap<String, Vec<FieldRule>>,
33}
34
35/// Validation plugin. Validates field values on insert and update.
36pub struct ValidationPlugin {
37    entity_rules: HashMap<String, EntityRules>,
38}
39
40impl ValidationPlugin {
41    pub fn new() -> Self {
42        Self {
43            entity_rules: HashMap::new(),
44        }
45    }
46
47    /// Add a rule for entity.field.
48    pub fn add_rule(&mut self, entity: &str, field: &str, rule: FieldRule) {
49        let entity_rules = self
50            .entity_rules
51            .entry(entity.to_string())
52            .or_insert_with(|| EntityRules {
53                rules: HashMap::new(),
54            });
55        entity_rules
56            .rules
57            .entry(field.to_string())
58            .or_default()
59            .push(rule);
60    }
61
62    fn validate_data(&self, entity: &str, data: &Value) -> Result<(), PluginError> {
63        let rules = match self.entity_rules.get(entity) {
64            Some(r) => r,
65            None => return Ok(()),
66        };
67
68        let obj = match data.as_object() {
69            Some(o) => o,
70            None => return Ok(()),
71        };
72
73        for (field_name, field_rules) in &rules.rules {
74            if let Some(value) = obj.get(field_name) {
75                for rule in field_rules {
76                    if let Err(msg) = validate_value(value, rule) {
77                        return Err(PluginError {
78                            code: "VALIDATION_FAILED".into(),
79                            message: format!("{}.{}: {}", entity, field_name, msg),
80                            status: 400,
81                        });
82                    }
83                }
84            }
85        }
86
87        Ok(())
88    }
89}
90
91fn validate_value(value: &Value, rule: &FieldRule) -> Result<(), String> {
92    match rule {
93        FieldRule::MinLength(min) => {
94            if let Some(s) = value.as_str() {
95                if s.len() < *min {
96                    return Err(format!("must be at least {} characters", min));
97                }
98            }
99        }
100        FieldRule::MaxLength(max) => {
101            if let Some(s) = value.as_str() {
102                if s.len() > *max {
103                    return Err(format!("must be at most {} characters", max));
104                }
105            }
106        }
107        FieldRule::Pattern(pattern) => {
108            if let Some(s) = value.as_str() {
109                if !s.contains(pattern.as_str()) {
110                    return Err(format!("must match pattern: {}", pattern));
111                }
112            }
113        }
114        FieldRule::Email => {
115            if let Some(s) = value.as_str() {
116                if !s.contains('@') || !s.contains('.') {
117                    return Err("must be a valid email address".into());
118                }
119            }
120        }
121        FieldRule::Min(min) => {
122            if let Some(n) = value.as_f64() {
123                if n < *min {
124                    return Err(format!("must be at least {}", min));
125                }
126            }
127        }
128        FieldRule::Max(max) => {
129            if let Some(n) = value.as_f64() {
130                if n > *max {
131                    return Err(format!("must be at most {}", max));
132                }
133            }
134        }
135        FieldRule::NotEmpty => {
136            if let Some(s) = value.as_str() {
137                if s.trim().is_empty() {
138                    return Err("must not be empty".into());
139                }
140            }
141        }
142        FieldRule::Custom(_name, validator) => {
143            validator(value)?;
144        }
145    }
146    Ok(())
147}
148
149impl Plugin for ValidationPlugin {
150    fn name(&self) -> &str {
151        "validation"
152    }
153
154    fn before_insert(
155        &self,
156        entity: &str,
157        data: &mut Value,
158        _auth: &AuthContext,
159    ) -> Result<(), PluginError> {
160        self.validate_data(entity, data)
161    }
162
163    fn before_update(
164        &self,
165        entity: &str,
166        _id: &str,
167        data: &mut Value,
168        _auth: &AuthContext,
169    ) -> Result<(), PluginError> {
170        self.validate_data(entity, data)
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn min_length() {
180        let mut plugin = ValidationPlugin::new();
181        plugin.add_rule("User", "displayName", FieldRule::MinLength(3));
182
183        let mut data = serde_json::json!({"displayName": "AB"});
184        let result = plugin.before_insert("User", &mut data, &AuthContext::anonymous());
185        assert!(result.is_err());
186        assert!(result.unwrap_err().message.contains("at least 3"));
187
188        let mut data = serde_json::json!({"displayName": "Alice"});
189        assert!(plugin
190            .before_insert("User", &mut data, &AuthContext::anonymous())
191            .is_ok());
192    }
193
194    #[test]
195    fn max_length() {
196        let mut plugin = ValidationPlugin::new();
197        plugin.add_rule("User", "email", FieldRule::MaxLength(50));
198
199        let mut data = serde_json::json!({"email": "a".repeat(51)});
200        assert!(plugin
201            .before_insert("User", &mut data, &AuthContext::anonymous())
202            .is_err());
203    }
204
205    #[test]
206    fn email_validation() {
207        let mut plugin = ValidationPlugin::new();
208        plugin.add_rule("User", "email", FieldRule::Email);
209
210        let mut bad = serde_json::json!({"email": "notanemail"});
211        assert!(plugin
212            .before_insert("User", &mut bad, &AuthContext::anonymous())
213            .is_err());
214
215        let mut good = serde_json::json!({"email": "alice@example.com"});
216        assert!(plugin
217            .before_insert("User", &mut good, &AuthContext::anonymous())
218            .is_ok());
219    }
220
221    #[test]
222    fn not_empty() {
223        let mut plugin = ValidationPlugin::new();
224        plugin.add_rule("Todo", "title", FieldRule::NotEmpty);
225
226        let mut empty = serde_json::json!({"title": "  "});
227        assert!(plugin
228            .before_insert("Todo", &mut empty, &AuthContext::anonymous())
229            .is_err());
230
231        let mut valid = serde_json::json!({"title": "Buy milk"});
232        assert!(plugin
233            .before_insert("Todo", &mut valid, &AuthContext::anonymous())
234            .is_ok());
235    }
236
237    #[test]
238    fn numeric_min_max() {
239        let mut plugin = ValidationPlugin::new();
240        plugin.add_rule("Product", "price", FieldRule::Min(0.0));
241        plugin.add_rule("Product", "price", FieldRule::Max(10000.0));
242
243        let mut negative = serde_json::json!({"price": -5});
244        assert!(plugin
245            .before_insert("Product", &mut negative, &AuthContext::anonymous())
246            .is_err());
247
248        let mut too_high = serde_json::json!({"price": 99999});
249        assert!(plugin
250            .before_insert("Product", &mut too_high, &AuthContext::anonymous())
251            .is_err());
252
253        let mut valid = serde_json::json!({"price": 29.99});
254        assert!(plugin
255            .before_insert("Product", &mut valid, &AuthContext::anonymous())
256            .is_ok());
257    }
258
259    #[test]
260    fn pattern_match() {
261        let mut plugin = ValidationPlugin::new();
262        plugin.add_rule("User", "website", FieldRule::Pattern("https://".into()));
263
264        let mut bad = serde_json::json!({"website": "http://example.com"});
265        assert!(plugin
266            .before_insert("User", &mut bad, &AuthContext::anonymous())
267            .is_err());
268
269        let mut good = serde_json::json!({"website": "https://example.com"});
270        assert!(plugin
271            .before_insert("User", &mut good, &AuthContext::anonymous())
272            .is_ok());
273    }
274
275    #[test]
276    fn no_rules_for_entity_passes() {
277        let plugin = ValidationPlugin::new();
278        let mut data = serde_json::json!({"anything": "goes"});
279        assert!(plugin
280            .before_insert("Unknown", &mut data, &AuthContext::anonymous())
281            .is_ok());
282    }
283
284    #[test]
285    fn validates_on_update_too() {
286        let mut plugin = ValidationPlugin::new();
287        plugin.add_rule("Todo", "title", FieldRule::NotEmpty);
288
289        let mut data = serde_json::json!({"title": ""});
290        assert!(plugin
291            .before_update("Todo", "t1", &mut data, &AuthContext::anonymous())
292            .is_err());
293    }
294
295    #[test]
296    fn multiple_rules_on_same_field() {
297        let mut plugin = ValidationPlugin::new();
298        plugin.add_rule("User", "displayName", FieldRule::NotEmpty);
299        plugin.add_rule("User", "displayName", FieldRule::MinLength(2));
300        plugin.add_rule("User", "displayName", FieldRule::MaxLength(50));
301
302        let mut empty = serde_json::json!({"displayName": ""});
303        assert!(plugin
304            .before_insert("User", &mut empty, &AuthContext::anonymous())
305            .is_err());
306
307        let mut short = serde_json::json!({"displayName": "A"});
308        assert!(plugin
309            .before_insert("User", &mut short, &AuthContext::anonymous())
310            .is_err());
311
312        let mut valid = serde_json::json!({"displayName": "Alice"});
313        assert!(plugin
314            .before_insert("User", &mut valid, &AuthContext::anonymous())
315            .is_ok());
316    }
317}