dog_schema_validator/
lib.rs1use 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}