1use std::collections::HashMap;
2
3use crate::{Plugin, PluginError};
4use pylon_auth::AuthContext;
5use serde_json::Value;
6
7pub enum FieldRule {
9 MinLength(usize),
11 MaxLength(usize),
13 Pattern(String),
15 Email,
17 Min(f64),
19 Max(f64),
21 NotEmpty,
23 Custom(
25 String,
26 Box<dyn Fn(&Value) -> Result<(), String> + Send + Sync>,
27 ),
28}
29
30pub struct EntityRules {
32 pub rules: HashMap<String, Vec<FieldRule>>,
33}
34
35pub 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 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}