dog_schema_validator/
lib.rs

1use serde::de::DeserializeOwned;
2use serde_json::{json, Value};
3use validator::Validate;
4
5use dog_schema::SchemaErrors;
6
7fn friendly_message(code: &str) -> Option<&'static str> {
8    match code {
9        "required" => Some("is required"),
10        "email" => Some("must be a valid email"),
11        "length" => Some("has invalid length"),
12        "range" => Some("is out of range"),
13        "url" => Some("must be a valid URL"),
14        _ => None,
15    }
16}
17
18fn join_path(prefix: &str, field: &str) -> String {
19    if prefix.is_empty() {
20        field.to_string()
21    } else {
22        format!("{prefix}.{field}")
23    }
24}
25
26fn join_index(prefix: &str, idx: usize) -> String {
27    format!("{prefix}[{idx}]")
28}
29
30fn push_validation_errors(out: &mut SchemaErrors, prefix: &str, errs: &validator::ValidationErrors) {
31    for (field, kind) in errs.errors() {
32        match kind {
33            validator::ValidationErrorsKind::Field(field_errors) => {
34                let key = join_path(prefix, field);
35                for e in field_errors {
36                    let msg = e
37                        .message
38                        .as_ref()
39                        .map(|m| m.to_string())
40                        .or_else(|| friendly_message(&e.code).map(|m| m.to_string()))
41                        .unwrap_or_else(|| e.code.to_string());
42                    out.push_field(&key, msg);
43                }
44            }
45            validator::ValidationErrorsKind::Struct(struct_errs) => {
46                let next = join_path(prefix, field);
47                push_validation_errors(out, &next, struct_errs.as_ref());
48            }
49            validator::ValidationErrorsKind::List(list_errs) => {
50                let base = join_path(prefix, field);
51                for (idx, nested) in list_errs {
52                    let next = join_index(&base, *idx);
53                    push_validation_errors(out, &next, nested.as_ref());
54                }
55            }
56        }
57    }
58}
59
60fn validator_errors_to_schema_errors(errs: &validator::ValidationErrors) -> SchemaErrors {
61    let mut out = SchemaErrors::default();
62
63    push_validation_errors(&mut out, "", errs);
64
65    out
66}
67
68pub fn validate<T>(data: &Value, error_message: &str) -> anyhow::Result<T>
69where
70    T: DeserializeOwned + Validate,
71{
72    let parsed: T = serde_json::from_value(data.clone())
73        .map_err(|e| dog_schema::unprocessable(error_message, json!({"_schema": [e.to_string()]})))?;
74
75    parsed
76        .validate()
77        .map_err(|e| validator_errors_to_schema_errors(&e).into_unprocessable_anyhow(error_message))?;
78
79    Ok(parsed)
80}
81
82#[cfg(test)]
83mod tests {
84    use dog_core::errors::DogError;
85    use serde::Deserialize;
86    use serde_json::json;
87    use validator::Validate;
88
89    use super::validate;
90
91    #[derive(Debug, Deserialize, Validate)]
92    struct Profile {
93        #[validate(length(min = 2, message = "display_name must be at least 2 chars"))]
94        display_name: String,
95    }
96
97    #[derive(Debug, Deserialize, Validate)]
98    struct Tag {
99        #[validate(email(message = "tag email must be valid"))]
100        email: String,
101    }
102
103    #[derive(Debug, Deserialize, Validate)]
104    struct User {
105        #[validate(nested)]
106        profile: Profile,
107
108        #[validate(nested)]
109        tags: Vec<Tag>,
110    }
111
112    #[test]
113    fn nested_and_list_errors_are_flattened_with_paths() {
114        let data = json!({
115            "profile": {"display_name": "x"},
116            "tags": [{"email": "not-an-email"}]
117        });
118
119        let err = validate::<User>(&data, "Users schema validation failed").unwrap_err();
120        let dog = DogError::from_anyhow(&err).expect("must be DogError");
121        let errors = dog.errors.as_ref().unwrap();
122
123        assert_eq!(errors["profile.display_name"][0], "display_name must be at least 2 chars");
124        assert_eq!(errors["tags[0].email"][0], "tag email must be valid");
125    }
126}