Skip to main content

shelly_data/
changeset.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ValidationError {
7    pub field: String,
8    pub code: String,
9    pub message: String,
10}
11
12impl ValidationError {
13    pub fn new(
14        field: impl Into<String>,
15        code: impl Into<String>,
16        message: impl Into<String>,
17    ) -> Self {
18        Self {
19            field: field.into(),
20            code: code.into(),
21            message: message.into(),
22        }
23    }
24}
25
26#[derive(Debug, Clone)]
27pub struct Changeset {
28    input: Map<String, Value>,
29    errors: Vec<ValidationError>,
30}
31
32impl Changeset {
33    pub fn from_map(input: Map<String, Value>) -> Self {
34        Self {
35            input,
36            errors: Vec::new(),
37        }
38    }
39
40    pub fn from_value(value: Value) -> Self {
41        let input = value.as_object().cloned().unwrap_or_default();
42        Self::from_map(input)
43    }
44
45    pub fn required(&mut self, fields: &[&str]) -> &mut Self {
46        for field in fields {
47            if self.string(field).is_none() {
48                self.errors.push(ValidationError::new(
49                    *field,
50                    "required",
51                    format!("{field} is required"),
52                ));
53            }
54        }
55        self
56    }
57
58    pub fn string_length(
59        &mut self,
60        field: &str,
61        min: Option<usize>,
62        max: Option<usize>,
63    ) -> &mut Self {
64        let Some(value) = self.string(field) else {
65            return self;
66        };
67        let len = value.chars().count();
68        if let Some(min) = min {
69            if len < min {
70                self.errors.push(ValidationError::new(
71                    field,
72                    "length_min",
73                    format!("{field} must be at least {min} characters."),
74                ));
75            }
76        }
77        if let Some(max) = max {
78            if len > max {
79                self.errors.push(ValidationError::new(
80                    field,
81                    "length_max",
82                    format!("{field} must be at most {max} characters."),
83                ));
84            }
85        }
86        self
87    }
88
89    pub fn string_contains(&mut self, field: &str, needle: &str, message: &str) -> &mut Self {
90        let Some(value) = self.string(field) else {
91            return self;
92        };
93        if !value.contains(needle) {
94            self.errors
95                .push(ValidationError::new(field, "format", message.to_string()));
96        }
97        self
98    }
99
100    pub fn inclusion(&mut self, field: &str, allowed: &[&str], message: &str) -> &mut Self {
101        let Some(value) = self.string(field) else {
102            return self;
103        };
104        if !allowed.contains(&value) {
105            self.errors.push(ValidationError::new(
106                field,
107                "inclusion",
108                message.to_string(),
109            ));
110        }
111        self
112    }
113
114    pub fn number_range(&mut self, field: &str, min: Option<f64>, max: Option<f64>) -> &mut Self {
115        let Some(value) = self.number(field) else {
116            return self;
117        };
118        if let Some(min) = min {
119            if value < min {
120                self.errors.push(ValidationError::new(
121                    field,
122                    "number_min",
123                    format!("{field} must be >= {min}."),
124                ));
125            }
126        }
127        if let Some(max) = max {
128            if value > max {
129                self.errors.push(ValidationError::new(
130                    field,
131                    "number_max",
132                    format!("{field} must be <= {max}."),
133                ));
134            }
135        }
136        self
137    }
138
139    pub fn add_error(
140        &mut self,
141        field: impl Into<String>,
142        code: impl Into<String>,
143        message: impl Into<String>,
144    ) -> &mut Self {
145        self.errors.push(ValidationError::new(field, code, message));
146        self
147    }
148
149    pub fn is_valid(&self) -> bool {
150        self.errors.is_empty()
151    }
152
153    pub fn errors(&self) -> &[ValidationError] {
154        &self.errors
155    }
156
157    pub fn errors_by_field(&self) -> BTreeMap<String, Vec<String>> {
158        let mut out = BTreeMap::<String, Vec<String>>::new();
159        for error in &self.errors {
160            out.entry(error.field.clone())
161                .or_default()
162                .push(error.message.clone());
163        }
164        out
165    }
166
167    pub fn value(&self, field: &str) -> Option<&Value> {
168        self.input.get(field)
169    }
170
171    pub fn string(&self, field: &str) -> Option<&str> {
172        self.value(field).and_then(Value::as_str).and_then(|value| {
173            if value.trim().is_empty() {
174                None
175            } else {
176                Some(value)
177            }
178        })
179    }
180
181    pub fn number(&self, field: &str) -> Option<f64> {
182        self.value(field).and_then(Value::as_f64)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::Changeset;
189    use serde_json::json;
190
191    #[test]
192    fn changeset_collects_validation_errors() {
193        let mut changeset = Changeset::from_value(json!({
194            "name": "A",
195            "email": "missing-at"
196        }));
197        changeset
198            .required(&["name", "email", "plan"])
199            .string_length("name", Some(2), None)
200            .string_contains("email", "@", "email must include @");
201
202        assert!(!changeset.is_valid());
203        let by_field = changeset.errors_by_field();
204        assert!(by_field.contains_key("plan"));
205        assert!(by_field.contains_key("name"));
206        assert!(by_field.contains_key("email"));
207    }
208}