use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationError {
pub field: String,
pub code: String,
pub message: String,
}
impl ValidationError {
pub fn new(
field: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct Changeset {
input: Map<String, Value>,
errors: Vec<ValidationError>,
}
impl Changeset {
pub fn from_map(input: Map<String, Value>) -> Self {
Self {
input,
errors: Vec::new(),
}
}
pub fn from_value(value: Value) -> Self {
let input = value.as_object().cloned().unwrap_or_default();
Self::from_map(input)
}
pub fn required(&mut self, fields: &[&str]) -> &mut Self {
for field in fields {
if self.string(field).is_none() {
self.errors.push(ValidationError::new(
*field,
"required",
format!("{field} is required"),
));
}
}
self
}
pub fn string_length(
&mut self,
field: &str,
min: Option<usize>,
max: Option<usize>,
) -> &mut Self {
let Some(value) = self.string(field) else {
return self;
};
let len = value.chars().count();
if let Some(min) = min {
if len < min {
self.errors.push(ValidationError::new(
field,
"length_min",
format!("{field} must be at least {min} characters."),
));
}
}
if let Some(max) = max {
if len > max {
self.errors.push(ValidationError::new(
field,
"length_max",
format!("{field} must be at most {max} characters."),
));
}
}
self
}
pub fn string_contains(&mut self, field: &str, needle: &str, message: &str) -> &mut Self {
let Some(value) = self.string(field) else {
return self;
};
if !value.contains(needle) {
self.errors
.push(ValidationError::new(field, "format", message.to_string()));
}
self
}
pub fn inclusion(&mut self, field: &str, allowed: &[&str], message: &str) -> &mut Self {
let Some(value) = self.string(field) else {
return self;
};
if !allowed.contains(&value) {
self.errors.push(ValidationError::new(
field,
"inclusion",
message.to_string(),
));
}
self
}
pub fn number_range(&mut self, field: &str, min: Option<f64>, max: Option<f64>) -> &mut Self {
let Some(value) = self.number(field) else {
return self;
};
if let Some(min) = min {
if value < min {
self.errors.push(ValidationError::new(
field,
"number_min",
format!("{field} must be >= {min}."),
));
}
}
if let Some(max) = max {
if value > max {
self.errors.push(ValidationError::new(
field,
"number_max",
format!("{field} must be <= {max}."),
));
}
}
self
}
pub fn add_error(
&mut self,
field: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> &mut Self {
self.errors.push(ValidationError::new(field, code, message));
self
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn errors(&self) -> &[ValidationError] {
&self.errors
}
pub fn errors_by_field(&self) -> BTreeMap<String, Vec<String>> {
let mut out = BTreeMap::<String, Vec<String>>::new();
for error in &self.errors {
out.entry(error.field.clone())
.or_default()
.push(error.message.clone());
}
out
}
pub fn value(&self, field: &str) -> Option<&Value> {
self.input.get(field)
}
pub fn string(&self, field: &str) -> Option<&str> {
self.value(field).and_then(Value::as_str).and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
}
pub fn number(&self, field: &str) -> Option<f64> {
self.value(field).and_then(Value::as_f64)
}
}
#[cfg(test)]
mod tests {
use super::Changeset;
use serde_json::json;
#[test]
fn changeset_collects_validation_errors() {
let mut changeset = Changeset::from_value(json!({
"name": "A",
"email": "missing-at"
}));
changeset
.required(&["name", "email", "plan"])
.string_length("name", Some(2), None)
.string_contains("email", "@", "email must include @");
assert!(!changeset.is_valid());
let by_field = changeset.errors_by_field();
assert!(by_field.contains_key("plan"));
assert!(by_field.contains_key("name"));
assert!(by_field.contains_key("email"));
}
}