by_loco/
validation.rs

1//! This module provides utility functions for handling validation errors for
2//! structs. It useful if you want to validate model before insert to Database.
3//!
4//! # Example:
5//!
6//! In the following example you can see how you can validate a user model
7//! ```rust,ignore
8//! use loco_rs::prelude::*;
9//! pub use myapp::_entities::users::ActiveModel;
10//!
11//! // Validation structure
12//! #[derive(Debug, Validate, Deserialize)]
13//! pub struct Validator {
14//!     #[validate(length(min = 2, message = "Name must be at least 2 characters long."))]
15//!     pub name: String,
16//! }
17//!
18//! impl Validatable for ActiveModel {
19//!   fn validator(&self) -> Box<dyn Validate> {
20//!     Box::new(Validator {
21//!         name: self.name.as_ref().to_owned(),
22//!     })
23//!   }
24//! }
25//!
26//! /// Override `before_save` function and run validation to make sure that we insert valid data.
27//! #[async_trait::async_trait]
28//! impl ActiveModelBehavior for ActiveModel {
29//!     async fn before_save<C>(self, _db: &C, insert: bool) -> Result<Self, DbErr>
30//!     where
31//!         C: ConnectionTrait,
32//!     {
33//!         {
34//!             self.validate()?;
35//!             Ok(self)
36//!         }
37//!     }
38//! }
39//! ```
40
41use std::ops::{Deref, DerefMut};
42
43#[cfg(feature = "with-db")]
44use sea_orm::DbErr;
45use serde::{Deserialize, Serialize};
46use validator::{Validate, ValidationError, ValidationErrors};
47
48// this is a line-serialization type. it is used as an intermediate format
49// to hold validation error data when we transform from
50// validation::ValidationErrors to DbErr and encode all information in json.
51#[derive(Debug, Deserialize, Serialize)]
52#[allow(clippy::module_name_repetitions)]
53pub struct ModelValidationMessage {
54    pub code: String,
55    pub message: Option<String>,
56}
57
58/// Validate the given email
59///
60/// # Errors
61///
62/// Return an error in case the email is invalid.
63#[deprecated(
64    since = "0.15.1",
65    note = "Use the builtin email validator from `validator`"
66)]
67pub fn is_valid_email(email: &str) -> Result<(), ValidationError> {
68    if email.contains('@') {
69        Ok(())
70    } else {
71        Err(ValidationError::new("invalid email"))
72    }
73}
74
75///
76/// <DbErr conversion hack>
77///
78/// Convert `ModelValidationErrors` (pretty) into a `DbErr` (ugly) for database
79/// handling.
80///
81/// Because `DbErr` is used in model hooks and we implement the hooks
82/// in the trait, we MUST use `DbErr`, so we need to "hide" a _representation_
83/// of the error in `DbErr::Custom`, so that it can be unpacked later down the
84/// stream, in the central error response handler.
85#[derive(Debug, thiserror::Error)]
86#[error("Model validation failed: {0}")]
87pub struct ModelValidationErrors(ValidationErrors);
88
89impl Deref for ModelValidationErrors {
90    type Target = ValidationErrors;
91
92    fn deref(&self) -> &Self::Target {
93        &self.0
94    }
95}
96
97impl DerefMut for ModelValidationErrors {
98    fn deref_mut(&mut self) -> &mut Self::Target {
99        &mut self.0
100    }
101}
102
103#[cfg(feature = "with-db")]
104impl From<ModelValidationErrors> for DbErr {
105    fn from(errors: ModelValidationErrors) -> Self {
106        into_db_error(&errors)
107    }
108}
109
110#[cfg(feature = "with-db")]
111#[must_use]
112pub fn into_db_error(errors: &ModelValidationErrors) -> sea_orm::DbErr {
113    use std::collections::BTreeMap;
114
115    let errors = &errors.0;
116    let error_data: BTreeMap<String, Vec<ModelValidationMessage>> = errors
117        .field_errors()
118        .iter()
119        .map(|(field, field_errors)| {
120            let errors = field_errors
121                .iter()
122                .map(|err| ModelValidationMessage {
123                    code: err.code.to_string(),
124                    message: err.message.as_ref().map(std::string::ToString::to_string),
125                })
126                .collect();
127            ((*field).to_string(), errors)
128        })
129        .collect();
130    let json_errors = serde_json::to_value(error_data);
131    match json_errors {
132        Ok(errors_json) => sea_orm::DbErr::Custom(errors_json.to_string()),
133        Err(err) => sea_orm::DbErr::Custom(format!(
134            "[before_save] could not parse validation errors. err: {err}"
135        )),
136    }
137}
138
139/// Implement `Validatable` for `ActiveModel` when you want it to have a
140/// `validate()` function.
141pub trait Validatable {
142    /// Perform validation
143    ///
144    /// # Errors
145    ///
146    /// This function will return an error if there are validation errors
147    fn validate(&self) -> Result<(), ModelValidationErrors> {
148        self.validator().validate().map_err(ModelValidationErrors)
149    }
150    fn validator(&self) -> Box<dyn Validate>;
151}
152
153#[cfg(test)]
154mod tests {
155
156    use insta::assert_debug_snapshot;
157    use rstest::rstest;
158    use serde::Deserialize;
159    use validator::Validate;
160
161    use super::*;
162
163    #[derive(Debug, Validate, Deserialize)]
164    pub struct TestValidator {
165        #[validate(length(min = 4, message = "Invalid min characters long."))]
166        pub name: String,
167    }
168
169    #[rstest]
170    #[case("test@example.com", true)]
171    #[case("invalid-email", false)]
172    fn can_validate_email(#[case] test_name: &str, #[case] expected: bool) {
173        assert_eq!(is_valid_email(test_name).is_ok(), expected);
174    }
175
176    #[cfg(feature = "with-db")]
177    #[rstest]
178    #[case("foo")]
179    #[case("foo-bar")]
180    fn can_validate_into_db_error(#[case] name: &str) {
181        let data = TestValidator {
182            name: name.to_string(),
183        };
184
185        assert_debug_snapshot!(
186            format!("struct-[{name}]"),
187            data.validate()
188                .map_err(|e| into_db_error(&ModelValidationErrors(e)))
189        );
190    }
191}