feat: custom async validation

This commit is contained in:
Max Hohlfeld 2025-06-19 16:21:57 +02:00
parent 0b4248604a
commit 9666932915
9 changed files with 135 additions and 19 deletions

View File

@ -37,6 +37,7 @@ tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tracing-panic = "0.1.2"
rust_xlsxwriter = "0.87.0"
regex = "1.11.1"
[build-dependencies]
built = "0.7.4"

View File

@ -1,11 +1,10 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::{user::NewOrEditUserForm, IdPath},
models::{Area, Function, Role, User, UserChangeset},
utils::ApplicationError,
models::{Function, Role, User, UserChangeset},
utils::{validation::AsyncValidate, ApplicationError},
};
#[actix_web::post("/users/edit/{id}")]
@ -34,9 +33,6 @@ pub async fn post_edit(
}
let area_id = form.area.unwrap_or(user_in_db.area_id);
if Area::read_by_id(pool.get_ref(), area_id).await?.is_none() {
return Err(ApplicationError::Unauthorized);
}
let mut functions = Vec::with_capacity(3);
@ -67,7 +63,7 @@ pub async fn post_edit(
}
}
if let Err(e) = changeset.validate() {
if let Err(e) = changeset.validate_with_pool(pool.get_ref()).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};
@ -214,6 +210,6 @@ mod tests {
};
let response = test_post(&context.db_pool, app, &config, form).await;
assert_eq!(StatusCode::BAD_REQUEST, response.status());
assert_eq!(StatusCode::UNPROCESSABLE_ENTITY, response.status());
}
}

View File

@ -1,12 +1,11 @@
use actix_web::{http::header::LOCATION, web, HttpResponse, Responder};
use garde::Validate;
use sqlx::PgPool;
use crate::{
endpoints::user::NewOrEditUserForm,
mail::Mailer,
models::{Function, Registration, Role, User, UserChangeset},
utils::ApplicationError,
utils::{validation::AsyncValidate, ApplicationError},
};
#[actix_web::post("/users/new")]
@ -58,7 +57,7 @@ pub async fn post_new(
.body("email: an user already exists with the same email"));
}
if let Err(e) = changeset.validate() {
if let Err(e) = changeset.validate_with_pool(pool.get_ref()).await {
return Ok(HttpResponse::UnprocessableEntity().body(e.to_string()));
};

View File

@ -1,24 +1,42 @@
#[cfg(test)]
use fake::{faker::internet::en::SafeEmail, faker::name::en::Name, Dummy};
use garde::Validate;
use sqlx::PgPool;
use super::{Function, Role};
use crate::utils::validation::{email_is_valid, AsyncValidate, AsyncValidateError};
#[derive(Debug, Validate)]
use super::{Area, Function, Role};
#[derive(Debug)]
#[cfg_attr(test, derive(Dummy))]
#[garde(allow_unvalidated)]
pub struct UserChangeset {
#[cfg_attr(test, dummy(faker = "Name()"))]
pub name: String,
#[garde(email)]
#[cfg_attr(test, dummy(faker = "SafeEmail()"))]
pub email: String,
#[cfg_attr(test, dummy(expr = "Role::Staff"))]
pub role: Role,
#[cfg_attr(test, dummy(expr = "vec![Function::Posten]"))]
pub functions: Vec<Function>,
/// check before: must exist and user can create other user for this area
#[cfg_attr(test, dummy(expr = "1"))]
pub area_id: i32,
}
impl AsyncValidate for UserChangeset {
async fn validate_with_pool(&self, pool: &sqlx::PgPool) -> Result<(), AsyncValidateError> {
email_is_valid(&self.email)?;
area_exists(pool, self.area_id).await?;
Ok(())
}
}
async fn area_exists(pool: &PgPool, id: i32) -> Result<(), AsyncValidateError> {
if Area::read_by_id(pool, id).await?.is_none() {
return Err(AsyncValidateError::new(
"Angegebener Bereich für Nutzer existiert nicht!",
));
}
Ok(())
}

View File

@ -1,3 +1,4 @@
mod app_customization;
mod application_error;
pub mod auth;
mod date_time_format;
@ -5,16 +6,16 @@ pub mod event_planning_template;
pub mod manage_commands;
pub mod password_change;
mod template_response_trait;
mod app_customization;
pub mod token_generation;
pub mod validation;
#[cfg(test)]
pub mod test_helper;
pub use app_customization::Customization;
pub use application_error::ApplicationError;
pub use date_time_format::DateTimeFormat;
pub use template_response_trait::TemplateResponse;
pub use app_customization::Customization;
use chrono::{NaiveDate, Utc};

View File

@ -0,0 +1,58 @@
// great inspiration taken from https://github.com/jprochazk/garde/blob/main/garde/src/rules/email.rs
use regex::Regex;
use super::AsyncValidateError;
pub fn email_is_valid(email: &str) -> Result<(), AsyncValidateError> {
if email.is_empty() {
return Err(AsyncValidateError::new("E-Mail ist leer!"));
}
let (user, domain) = email
.split_once('@')
.ok_or(AsyncValidateError::new("E-Mail enthält kein '@'!"))?;
if user.len() > 64 {
return Err(AsyncValidateError::new("Nutzerteil der E-Mail zu lang!"));
}
let user_re = Regex::new(r"(?i-u)^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+\z").unwrap();
if !user_re.is_match(user) {
return Err(AsyncValidateError::new(
"Nutzerteil der E-Mail enthält unerlaubte Zeichen.",
));
}
if domain.len() > 255 {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail ist zu lang.",
));
}
let domain_re = Regex::new(
r"(?i-u)^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$",
)
.unwrap();
if !domain_re.is_match(domain) {
return Err(AsyncValidateError::new(
"Domainteil der E-Mail enthält unerlaubte Zeichen!",
));
}
Ok(())
}
#[test]
pub fn email_validation_works_correctly() {
assert!(email_is_valid("abc@example.com").is_ok());
assert!(email_is_valid("admin.new@example-domain.de").is_ok());
assert!(email_is_valid("admin!new@sub.web.www.example-domain.de").is_ok());
assert!(email_is_valid("admin.domain.de").is_err());
assert!(email_is_valid("admin@web@domain.de").is_err());
assert!(email_is_valid("@domain.de").is_err());
assert!(email_is_valid("user@").is_err());
assert!(email_is_valid("").is_err());
}

View File

@ -0,0 +1,29 @@
use tracing::error;
#[derive(Debug)]
pub struct AsyncValidateError {
message: String,
}
impl std::fmt::Display for AsyncValidateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for AsyncValidateError {}
impl AsyncValidateError {
pub fn new(message: &str) -> Self {
AsyncValidateError {
message: message.to_string(),
}
}
}
impl From<sqlx::Error> for AsyncValidateError {
fn from(value: sqlx::Error) -> Self {
error!(error = %value, "database error while validation input");
AsyncValidateError::new("Datenbankfehler beim Validieren!")
}
}

View File

@ -0,0 +1,7 @@
mod email;
mod error;
mod r#trait;
pub use email::email_is_valid;
pub use error::AsyncValidateError;
pub use r#trait::AsyncValidate;

View File

@ -0,0 +1,7 @@
use sqlx::PgPool;
use super::AsyncValidateError;
pub trait AsyncValidate {
async fn validate_with_pool(&self, pool: &PgPool) -> Result<(), AsyncValidateError>;
}