Skip to content

Валидация данных в Axum

Валидация пользовательских данных является ключевой частью любого веб-приложения. Axum предлагает несколько мощных подходов к проверке входящих данных. В этой документации рассмотрим различные способы валидации данных в Axum и лучшие практики.

Содержание

Введение в валидацию данных

Валидация данных в веб-приложениях обеспечивает:

  1. Безопасность - предотвращает внедрение вредоносных данных
  2. Целостность данных - обеспечивает корректность принимаемой информации
  3. Улучшение пользовательского опыта - предоставляет понятные сообщения об ошибках

В Axum можно реализовать валидацию несколькими способами:

  • Через встроенные механизмы десериализации (serde)
  • Используя библиотеку validator
  • Ручная валидация в обработчиках
  • Создание собственных экстракторов

Валидация с помощью библиотеки validator

Библиотека validator - это популярный инструмент для валидации структур в Rust, который хорошо интегрируется с Axum.

Установка validator

Добавьте в Cargo.toml:

toml
[dependencies]
validator = { version = "0.16", features = ["derive"] }

Базовый пример

rust
use axum::{
    routing::post,
    Router,
    extract::Json,
    http::StatusCode,
    response::IntoResponse,
};
use serde::Deserialize;
use validator::{Validate, ValidationError};

#[derive(Debug, Deserialize, Validate)]
struct CreateUser {
    #[validate(length(min = 3, message = "имя должно содержать минимум 3 символа"))]
    username: String,
    
    #[validate(email(message = "неверный формат email"))]
    email: String,
    
    #[validate(length(min = 8, message = "пароль должен содержать минимум 8 символов"))]
    password: String,
}

async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    // Валидируем данные
    if let Err(errors) = payload.validate() {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("Ошибки валидации: {:?}", errors),
        ));
    }
    
    // Обработка при успешной валидации
    Ok(StatusCode::CREATED)
}

fn app() -> Router {
    Router::new()
        .route("/users", post(create_user))
}

Кастомные валидаторы

Можно создавать собственные валидаторы для более сложных проверок:

rust
use validator::{Validate, ValidationError};

fn validate_strong_password(password: &str) -> Result<(), ValidationError> {
    // Должен содержать хотя бы одну цифру
    if !password.chars().any(|c| c.is_digit(10)) {
        return Err(ValidationError::new("need_digit"));
    }
    
    // Должен содержать хотя бы один специальный символ
    if !password.chars().any(|c| !c.is_alphanumeric()) {
        return Err(ValidationError::new("need_special_char"));
    }
    
    Ok(())
}

#[derive(Debug, Deserialize, Validate)]
struct CreateUser {
    #[validate(length(min = 3))]
    username: String,
    
    #[validate(email)]
    email: String,
    
    #[validate(length(min = 8), custom = "validate_strong_password")]
    password: String,
}

Вложенная валидация

Validator поддерживает вложенные структуры:

rust
#[derive(Debug, Deserialize, Validate)]
struct CreateUser {
    #[validate]
    profile: UserProfile,
    
    #[validate(email)]
    email: String,
}

#[derive(Debug, Deserialize, Validate)]
struct UserProfile {
    #[validate(length(min = 3))]
    username: String,
    
    #[validate(range(min = 13, max = 120))]
    age: u8,
}

Ручная валидация

В некоторых случаях удобно использовать ручную валидацию внутри обработчиков:

rust
async fn create_post(
    Json(payload): Json<CreatePost>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    // Базовые проверки
    if payload.title.trim().is_empty() {
        return Err((
            StatusCode::BAD_REQUEST,
            "Заголовок не может быть пустым".to_string(),
        ));
    }
    
    if payload.content.len() < 10 {
        return Err((
            StatusCode::BAD_REQUEST,
            "Содержание должно быть не менее 10 символов".to_string(),
        ));
    }
    
    // Более сложные проверки
    if payload.tags.len() > 5 {
        return Err((
            StatusCode::BAD_REQUEST,
            "Максимальное количество тегов: 5".to_string(),
        ));
    }
    
    // Проверка на дубликаты тегов
    let mut unique_tags = std::collections::HashSet::new();
    for tag in &payload.tags {
        if !unique_tags.insert(tag) {
            return Err((
                StatusCode::BAD_REQUEST,
                format!("Дублирующийся тег: {}", tag),
            ));
        }
    }
    
    Ok(StatusCode::CREATED)
}

Собственные типы валидации

Можно создавать новые типы для валидации определенных данных:

rust
use std::str::FromStr;
use axum::{
    async_trait,
    extract::{FromRequest, RequestParts},
    http::StatusCode,
};

/// Тип для валидного username (только буквы и цифры, 3-20 символов)
#[derive(Debug)]
pub struct Username(pub String);

impl FromStr for Username {
    type Err = String;
    
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // Проверка длины
        if s.len() < 3 || s.len() > 20 {
            return Err("Имя пользователя должно содержать от 3 до 20 символов".to_string());
        }
        
        // Проверка разрешенных символов
        if !s.chars().all(|c| c.is_alphanumeric() || c == '_') {
            return Err("Имя может содержать только буквы, цифры и подчеркивания".to_string());
        }
        
        Ok(Username(s.to_string()))
    }
}

#[async_trait]
impl<B> FromRequest<B> for Username
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        let path = req.extract::<axum::extract::Path<String>>().await
            .map_err(|_| (StatusCode::BAD_REQUEST, "Неверный параметр пути".to_string()))?;
            
        path.0.parse::<Username>()
            .map_err(|e| (StatusCode::BAD_REQUEST, e))
    }
}

// Использование в обработчике
async fn get_user_profile(username: Username) -> impl IntoResponse {
    format!("Профиль пользователя: {}", username.0)
}

Валидация в различных экстракторах

Валидация в Path параметрах

rust
#[derive(Debug, Deserialize, Validate)]
struct UserParams {
    #[validate(regex = "^[a-zA-Z0-9_]+$")]
    username: String,
}

async fn get_user(
    Path(params): Path<UserParams>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    if let Err(errors) = params.validate() {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("Неверное имя пользователя: {:?}", errors),
        ));
    }
    
    // Получение информации о пользователе
    Ok(format!("Данные пользователя: {}", params.username))
}

Валидация в Query параметрах

rust
#[derive(Debug, Deserialize, Validate)]
struct PaginationParams {
    #[validate(range(min = 1, max = 100))]
    limit: Option<u32>,
    
    #[validate(range(min = 0))]
    offset: Option<u32>,
    
    #[validate(length(min = 1, max = 50))]
    search: Option<String>,
}

async fn list_items(
    Query(params): Query<PaginationParams>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
    if let Err(errors) = params.validate() {
        return Err((
            StatusCode::BAD_REQUEST,
            format!("Неверные параметры запроса: {:?}", errors),
        ));
    }
    
    // Получение списка элементов с учетом пагинации
    let limit = params.limit.unwrap_or(10);
    let offset = params.offset.unwrap_or(0);
    
    Ok(format!("Список элементов (лимит: {}, смещение: {})", limit, offset))
}

Обработка ошибок валидации

Для улучшения UX важно возвращать детальные и понятные сообщения об ошибках:

rust
use axum::{
    response::{IntoResponse, Response},
    http::StatusCode,
    Json,
};
use serde::Serialize;
use validator::ValidationErrors;

#[derive(Debug, Serialize)]
struct ValidationErrorResponse {
    errors: Vec<ValidationErrorDetail>,
}

#[derive(Debug, Serialize)]
struct ValidationErrorDetail {
    field: String,
    message: String,
}

impl IntoResponse for ValidationErrors {
    fn into_response(self) -> Response {
        let mut error_details = Vec::new();
        
        for (field, errors) in self.field_errors() {
            for error in errors {
                let message = error.message
                    .as_ref()
                    .map(|m| m.to_string())
                    .unwrap_or_else(|| "Ошибка валидации".to_string());
                    
                error_details.push(ValidationErrorDetail {
                    field: field.to_string(),
                    message,
                });
            }
        }
        
        let response = ValidationErrorResponse {
            errors: error_details,
        };
        
        (StatusCode::BAD_REQUEST, Json(response)).into_response()
    }
}

// Использование в обработчике
async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Result<impl IntoResponse, impl IntoResponse> {
    payload.validate()?;
    
    // Создание пользователя...
    Ok(StatusCode::CREATED)
}

Примеры комплексной валидации

Форма регистрации с проверкой паролей

rust
#[derive(Debug, Deserialize, Validate)]
struct Registration {
    #[validate(length(min = 3, max = 30))]
    username: String,
    
    #[validate(email)]
    email: String,
    
    #[validate(length(min = 8), custom = "validate_strong_password")]
    password: String,
    
    password_confirmation: String,
    
    #[validate(range(min = 18, message = "Вы должны быть старше 18 лет"))]
    age: u8,
    
    #[validate(must_match = "terms_agreed", message = "Вы должны согласиться с условиями использования")]
    terms_agreed: bool,
}

// Валидатор для проверки совпадения паролей
impl Validate for Registration {
    fn validate(&self) -> Result<(), ValidationErrors> {
        // Сначала используем стандартную валидацию через аннотации
        let result = <Self as validator::Validate>::validate(self);
        
        // Затем добавляем свою логику
        if self.password != self.password_confirmation {
            let mut errors = result.err().unwrap_or_else(ValidationErrors::new);
            errors.add("password_confirmation", ValidationError {
                code: "passwords_must_match".into(),
                message: Some("Пароли не совпадают".into()),
                params: std::collections::HashMap::new(),
            });
            return Err(errors);
        }
        
        result
    }
}

Валидация зависимых полей

rust
#[derive(Debug, Deserialize, Validate)]
struct DiscountRequest {
    #[validate(range(min = 0, max = 100))]
    discount_percentage: f32,
    
    min_purchase_amount: Option<f32>,
    
    start_date: Option<chrono::NaiveDate>,
    end_date: Option<chrono::NaiveDate>,
}

impl Validate for DiscountRequest {
    fn validate(&self) -> Result<(), ValidationErrors> {
        let mut errors = <Self as validator::Validate>::validate(self)
            .err()
            .unwrap_or_else(ValidationErrors::new);
        
        // Проверка: если скидка больше 20%, обязательно указывать минимальную сумму покупки
        if self.discount_percentage > 20.0 && self.min_purchase_amount.is_none() {
            errors.add("min_purchase_amount", ValidationError {
                code: "required_for_large_discount".into(),
                message: Some("Для скидки более 20% укажите минимальную сумму заказа".into()),
                params: std::collections::HashMap::new(),
            });
        }
        
        // Проверка: конечная дата должна быть позже начальной
        if let (Some(start), Some(end)) = (self.start_date, self.end_date) {
            if end <= start {
                errors.add("end_date", ValidationError {
                    code: "invalid_date_range".into(),
                    message: Some("Дата окончания должна быть позже даты начала".into()),
                    params: std::collections::HashMap::new(),
                });
            }
        }
        
        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }
}

Лучшие практики

  1. Разделение валидационной логики и бизнес-логики

    • Используйте библиотеку validator для отделения правил валидации от бизнес-логики
    • Применяйте аннотации для простых проверок и кастомные функции для сложных случаев
  2. Валидация на всех уровнях

    • Проверяйте данные не только при десериализации, но и при взаимодействии с базой данных
    • Внедряйте типы, гарантирующие правильность данных внутри вашего приложения
  3. Структурированные сообщения об ошибках

    • Возвращайте детальные JSON ответы о проблемах в валидации
    • Используйте понятные пользователю сообщения (не технические описания)
  4. Тестирование валидации

    • Пишите автоматические тесты для проверки сценариев с некорректными данными
    • Проверяйте граничные значения и специальные случаи
  5. Контекстуальная валидация

    • Учитывайте бизнес-правила, которые могут зависеть от контекста (например, роли пользователя)
    • Используйте извлечение State в Axum для доступа к контексту приложения при валидации
rust
// Пример контекстуальной валидации с учетом роли пользователя
async fn update_user(
    State(app_state): State<AppState>,
    auth: AuthUser,
    Json(payload): Json<UpdateUser>,
) -> Result<impl IntoResponse, impl IntoResponse> {
    // Базовая валидация
    payload.validate()?;
    
    // Контекстуальная валидация: только админы могут менять определенные поля
    if payload.role.is_some() && !auth.is_admin() {
        return Err((
            StatusCode::FORBIDDEN, 
            Json(json!({ "error": "Только администраторы могут изменять роли пользователей" }))
        ));
    }
    
    // Продолжение обработки...
    Ok(StatusCode::OK)
}

Изложенные выше подходы к валидации позволяют создавать надежные, безопасные и удобные для пользователя веб-приложения на базе Axum. Комбинируя различные техники валидации, вы можете обеспечить целостность данных и улучшить пользовательский опыт.