Валидация данных в Axum
Валидация пользовательских данных является ключевой частью любого веб-приложения. Axum предлагает несколько мощных подходов к проверке входящих данных. В этой документации рассмотрим различные способы валидации данных в Axum и лучшие практики.
Содержание
- Введение в валидацию данных
- Валидация с помощью библиотеки validator
- Ручная валидация
- Собственные типы валидации
- Валидация в различных экстракторах
- Обработка ошибок валидации
- Примеры комплексной валидации
- Лучшие практики
Введение в валидацию данных
Валидация данных в веб-приложениях обеспечивает:
- Безопасность - предотвращает внедрение вредоносных данных
- Целостность данных - обеспечивает корректность принимаемой информации
- Улучшение пользовательского опыта - предоставляет понятные сообщения об ошибках
В Axum можно реализовать валидацию несколькими способами:
- Через встроенные механизмы десериализации (serde)
- Используя библиотеку validator
- Ручная валидация в обработчиках
- Создание собственных экстракторов
Валидация с помощью библиотеки validator
Библиотека validator - это популярный инструмент для валидации структур в Rust, который хорошо интегрируется с Axum.
Установка validator
Добавьте в Cargo.toml:
[dependencies]
validator = { version = "0.16", features = ["derive"] }
Базовый пример
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))
}
Кастомные валидаторы
Можно создавать собственные валидаторы для более сложных проверок:
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 поддерживает вложенные структуры:
#[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,
}
Ручная валидация
В некоторых случаях удобно использовать ручную валидацию внутри обработчиков:
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)
}
Собственные типы валидации
Можно создавать новые типы для валидации определенных данных:
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 параметрах
#[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 параметрах
#[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 важно возвращать детальные и понятные сообщения об ошибках:
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)
}
Примеры комплексной валидации
Форма регистрации с проверкой паролей
#[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
}
}
Валидация зависимых полей
#[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)
}
}
}
Лучшие практики
Разделение валидационной логики и бизнес-логики
- Используйте библиотеку validator для отделения правил валидации от бизнес-логики
- Применяйте аннотации для простых проверок и кастомные функции для сложных случаев
Валидация на всех уровнях
- Проверяйте данные не только при десериализации, но и при взаимодействии с базой данных
- Внедряйте типы, гарантирующие правильность данных внутри вашего приложения
Структурированные сообщения об ошибках
- Возвращайте детальные JSON ответы о проблемах в валидации
- Используйте понятные пользователю сообщения (не технические описания)
Тестирование валидации
- Пишите автоматические тесты для проверки сценариев с некорректными данными
- Проверяйте граничные значения и специальные случаи
Контекстуальная валидация
- Учитывайте бизнес-правила, которые могут зависеть от контекста (например, роли пользователя)
- Используйте извлечение State в Axum для доступа к контексту приложения при валидации
// Пример контекстуальной валидации с учетом роли пользователя
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. Комбинируя различные техники валидации, вы можете обеспечить целостность данных и улучшить пользовательский опыт.