Skip to content

Работа с формами

Формы — важный элемент веб-приложений, позволяющий пользователям отправлять данные на сервер. Axum предоставляет удобные инструменты для обработки как обычных HTML-форм, так и мультимедийных форм с загрузкой файлов.

Основы работы с формами

В Axum для извлечения данных из форм используется экстрактор Form<T>, где T — тип данных, в который будут десериализованы данные формы:

rust
use axum::{
    extract::Form,
    routing::post,
    Router,
};
use serde::Deserialize;

#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
    remember_me: Option<bool>,
}

async fn login(Form(form): Form<LoginForm>) -> String {
    format!(
        "Вход: пользователь={}, запомнить={}",
        form.username,
        form.remember_me.unwrap_or(false)
    )
}

let app = Router::new()
    .route("/login", post(login));

По умолчанию Axum ожидает данные в формате application/x-www-form-urlencoded.

Обработка различных типов форм

Формы в формате x-www-form-urlencoded

Это стандартный формат для HTML-форм:

html
<form action="/login" method="post">
  <input type="text" name="username">
  <input type="password" name="password">
  <input type="checkbox" name="remember_me">
  <button type="submit">Войти</button>
</form>

Мультичастные формы (multipart/form-data)

Для работы с мультичастными формами, которые используются для загрузки файлов, необходимо включить функцию multipart в зависимостях Axum:

toml
[dependencies]
axum = { version = "0.7.2", features = ["multipart"] }

Обработка мультичастной формы:

rust
use axum::{
    extract::Multipart,
    routing::post,
    Router,
};
use std::io::Write;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;

async fn upload_file(mut multipart: Multipart) -> String {
    let mut uploaded_files = Vec::new();
    
    // Обрабатываем каждую часть формы
    while let Some(field) = multipart.next_field().await.unwrap() {
        let name = field.name().unwrap_or("unknown").to_string();
        let file_name = field.file_name().unwrap_or("unknown").to_string();
        let content_type = field.content_type().unwrap_or("unknown").to_string();
        
        // Чтение данных поля
        let data = field.bytes().await.unwrap();
        
        // Сохраняем файл
        if !file_name.is_empty() {
            let path = format!("./uploads/{}", file_name);
            let mut file = File::create(&path).await.unwrap();
            file.write_all(&data).await.unwrap();
            uploaded_files.push(file_name);
        }
    }
    
    format!("Загружено файлов: {}", uploaded_files.join(", "))
}

let app = Router::new()
    .route("/upload", post(upload_file));

HTML-форма для загрузки файлов:

html
<form action="/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="files" multiple>
  <button type="submit">Загрузить</button>
</form>

Валидация форм

Для валидации данных форм можно использовать библиотеку validator:

rust
use validator::{Validate, ValidationError};

#[derive(Deserialize, Validate)]
struct RegisterForm {
    #[validate(length(min = 3, max = 20, message = "Имя пользователя должно содержать от 3 до 20 символов"))]
    username: String,
    
    #[validate(email(message = "Некорректный email"))]
    email: String,
    
    #[validate(length(min = 8, message = "Пароль должен содержать не менее 8 символов"))]
    password: String,
    
    #[validate(must_match = "password", message = "Пароли не совпадают")]
    password_confirm: String,
    
    #[validate(custom = "validate_terms")]
    terms_accepted: bool,
}

fn validate_terms(accepted: &bool) -> Result<(), ValidationError> {
    if !accepted {
        return Err(ValidationError::new("terms_not_accepted"));
    }
    Ok(())
}

async fn register(
    Form(form): Form<RegisterForm>,
) -> Result<String, (StatusCode, String)> {
    // Валидация формы
    if let Err(errors) = form.validate() {
        let error_message = errors
            .field_errors()
            .iter()
            .map(|(field, errors)| {
                let message = errors[0].message.clone().unwrap_or_else(|| {
                    format!("Ошибка в поле {}", field)
                });
                format!("{}: {}", field, message)
            })
            .collect::<Vec<_>>()
            .join(", ");
        
        return Err((StatusCode::BAD_REQUEST, error_message));
    }
    
    // При успешной валидации - обработка регистрации
    Ok(format!("Пользователь {} успешно зарегистрирован", form.username))
}

Обработка ошибок для форм

Axum предоставляет механизмы для обработки ошибок при разборе форм:

rust
use axum::{
    extract::{Form, rejection::FormRejection},
    response::{IntoResponse, Response},
    http::StatusCode,
};
use serde_json::json;

async fn login_with_error_handling(
    result: Result<Form<LoginForm>, FormRejection>,
) -> Response {
    match result {
        Ok(Form(form)) => {
            // Успешная обработка формы
            format!(
                "Вход: пользователь={}, запомнить={}",
                form.username,
                form.remember_me.unwrap_or(false)
            ).into_response()
        },
        Err(err) => {
            // Обработка ошибок
            let error_message = format!("Ошибка при обработке формы: {}", err);
            
            (
                StatusCode::BAD_REQUEST,
                json!({
                    "error": error_message,
                }),
            ).into_response()
        }
    }
}

Загрузка файлов и лимиты

При работе с загрузкой файлов важно устанавливать ограничения для предотвращения DoS-атак:

rust
use tower_http::limit::RequestBodyLimitLayer;
use axum::{
    routing::post,
    Router,
};

let app = Router::new()
    .route("/upload", post(upload_file))
    // Ограничение размера тела запроса (10 МБ)
    .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024));

Для более детального контроля загрузки файлов:

rust
use axum::{
    extract::Multipart,
    routing::post,
    Router,
    http::StatusCode,
};
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use std::path::Path;

async fn upload_with_validation(mut multipart: Multipart) -> Result<String, StatusCode> {
    let mut uploaded_files = Vec::new();
    
    while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
        let name = field.name().unwrap_or("unknown").to_string();
        let file_name = match field.file_name() {
            Some(name) => name.to_string(),
            None => continue, // Пропускаем поля без имени файла
        };
        
        // Проверка расширения файла
        let extension = Path::new(&file_name)
            .extension()
            .and_then(|ext| ext.to_str())
            .unwrap_or("");
            
        let allowed_extensions = ["jpg", "jpeg", "png", "pdf"];
        if !allowed_extensions.contains(&extension.to_lowercase().as_str()) {
            return Err(StatusCode::BAD_REQUEST);
        }
        
        // Проверка размера файла
        let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
        
        const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; // 5 МБ
        if data.len() > MAX_FILE_SIZE {
            return Err(StatusCode::PAYLOAD_TOO_LARGE);
        }
        
        // Безопасное имя файла
        let safe_filename = sanitize_filename::sanitize(&file_name);
        let path = format!("./uploads/{}", safe_filename);
        
        // Сохранение файла
        let mut file = File::create(&path).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        file.write_all(&data).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        
        uploaded_files.push(safe_filename);
    }
    
    if uploaded_files.is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    
    Ok(format!("Загружено файлов: {}", uploaded_files.join(", ")))
}

Комбинирование форм с другими экстракторами

Формы часто используются вместе с другими экстракторами, такими как State:

rust
use axum::{
    extract::{Form, State},
    routing::post,
    Router,
};
use serde::Deserialize;
use std::sync::{Arc, Mutex};

#[derive(Clone)]
struct AppState {
    users: Arc<Mutex<Vec<User>>>,
}

#[derive(Deserialize)]
struct RegisterForm {
    username: String,
    email: String,
    password: String,
}

#[derive(Clone)]
struct User {
    id: u64,
    username: String,
    email: String,
    password_hash: String, // В реальном приложении храните хеш, а не пароль!
}

async fn register(
    State(state): State<AppState>,
    Form(form): Form<RegisterForm>,
) -> String {
    // Хеширование пароля (в реальном приложении используйте bcrypt или argon2)
    let password_hash = format!("fake_hash_{}", form.password);
    
    let mut users = state.users.lock().unwrap();
    
    let user = User {
        id: users.len() as u64 + 1,
        username: form.username,
        email: form.email,
        password_hash,
    };
    
    users.push(user.clone());
    
    format!("Пользователь {} успешно зарегистрирован", user.username)
}

// Инициализация состояния
let state = AppState {
    users: Arc::new(Mutex::new(Vec::new())),
};

// Регистрация маршрута
let app = Router::new()
    .route("/register", post(register))
    .with_state(state);

Формы и CSRF-защита

Для защиты от CSRF-атак можно использовать токены:

rust
use axum::{
    extract::{Form, State},
    routing::{get, post},
    response::{Html, IntoResponse},
    Router,
    http::{header, StatusCode},
};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use rand::{thread_rng, Rng};
use rand::distributions::Alphanumeric;

// Состояние с CSRF-токенами
#[derive(Clone)]
struct AppState {
    tokens: Arc<Mutex<HashMap<String, String>>>,
}

// Форма входа с CSRF-токеном
#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
    csrf_token: String,
}

// Генерация CSRF-токена
fn generate_csrf_token() -> String {
    thread_rng()
        .sample_iter(&Alphanumeric)
        .take(32)
        .map(char::from)
        .collect()
}

// Страница с формой входа
async fn login_page(State(state): State<AppState>) -> impl IntoResponse {
    // Генерация и сохранение токена
    let token = generate_csrf_token();
    let session_id = generate_csrf_token(); // В реальной жизни это был бы ID сессии
    
    state.tokens.lock().unwrap().insert(session_id.clone(), token.clone());
    
    // HTML-форма с CSRF-токеном
    let html = format!(
        r#"
        <!DOCTYPE html>
        <html>
        <head><title>Вход</title></head>
        <body>
            <form action="/login" method="post">
                <input type="hidden" name="csrf_token" value="{}">
                <div>
                    <label>Имя пользователя:</label>
                    <input type="text" name="username">
                </div>
                <div>
                    <label>Пароль:</label>
                    <input type="password" name="password">
                </div>
                <button type="submit">Войти</button>
            </form>
        </body>
        </html>
        "#,
        token
    );
    
    // Установка cookie с идентификатором сессии
    (
        [(header::SET_COOKIE, format!("session_id={}; Path=/; HttpOnly", session_id))],
        Html(html)
    )
}

// Обработка отправки формы
async fn process_login(
    State(state): State<AppState>,
    cookie: Option<axum::extract::TypedHeader<axum::headers::Cookie>>,
    Form(form): Form<LoginForm>,
) -> Result<String, StatusCode> {
    // Получение сессии из cookie
    let session_id = match cookie
        .and_then(|cookie| cookie.get("session_id").map(|id| id.to_string()))
    {
        Some(id) => id,
        None => return Err(StatusCode::BAD_REQUEST),
    };
    
    // Проверка CSRF-токена
    let mut tokens = state.tokens.lock().unwrap();
    let stored_token = tokens.remove(&session_id);
    
    match stored_token {
        Some(token) if token == form.csrf_token => {
            // Токен валиден, обрабатываем вход
            Ok(format!("Вход выполнен для пользователя: {}", form.username))
        },
        _ => {
            // Недействительный токен
            Err(StatusCode::FORBIDDEN)
        }
    }
}

// Создание приложения
let state = AppState {
    tokens: Arc::new(Mutex::new(HashMap::new())),
};

let app = Router::new()
    .route("/login", get(login_page).post(process_login))
    .with_state(state);

Примеры типичных задач

Обработка формы контакта

rust
use axum::{
    extract::Form,
    response::Html,
    routing::{get, post},
    Router,
};
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Validate)]
struct ContactForm {
    #[validate(length(min = 2, max = 100, message = "Имя должно содержать от 2 до 100 символов"))]
    name: String,
    
    #[validate(email(message = "Некорректный email"))]
    email: String,
    
    #[validate(length(min = 10, message = "Сообщение должно содержать не менее 10 символов"))]
    message: String,
}

async fn contact_form() -> Html<&'static str> {
    Html(
        r#"
        <!DOCTYPE html>
        <html>
        <head><title>Контактная форма</title></head>
        <body>
            <h1>Свяжитесь с нами</h1>
            <form action="/contact" method="post">
                <div>
                    <label>Имя:</label>
                    <input type="text" name="name" required>
                </div>
                <div>
                    <label>Email:</label>
                    <input type="email" name="email" required>
                </div>
                <div>
                    <label>Сообщение:</label>
                    <textarea name="message" required></textarea>
                </div>
                <button type="submit">Отправить</button>
            </form>
        </body>
        </html>
        "#
    )
}

async fn submit_contact(
    Form(form): Form<ContactForm>,
) -> Result<Html<String>, (StatusCode, String)> {
    // Валидация формы
    if let Err(errors) = form.validate() {
        let error_message = errors
            .field_errors()
            .iter()
            .map(|(field, errors)| {
                let message = errors[0].message.clone().unwrap_or_else(|| {
                    format!("Ошибка в поле {}", field)
                });
                format!("{}: {}", field, message)
            })
            .collect::<Vec<_>>()
            .join("<br>");
        
        return Err((StatusCode::BAD_REQUEST, error_message));
    }
    
    // В реальном приложении здесь отправка email, сохранение в БД и т.д.
    
    // Возвращаем страницу успеха
    Ok(Html(format!(
        r#"
        <!DOCTYPE html>
        <html>
        <head><title>Сообщение отправлено</title></head>
        <body>
            <h1>Спасибо за сообщение!</h1>
            <p>Уважаемый(ая) {}, мы получили ваше сообщение и ответим на него в ближайшее время.</p>
            <p><a href="/">Вернуться на главную</a></p>
        </body>
        </html>
        "#,
        form.name
    )))
}

let app = Router::new()
    .route("/contact", get(contact_form).post(submit_contact));

Загрузка и управление изображениями

rust
use axum::{
    extract::{Multipart, State},
    routing::{get, post},
    response::{Html, IntoResponse},
    Router,
    http::{StatusCode, header},
};
use tokio::{fs::File, io::AsyncWriteExt};
use std::path::Path;
use std::sync::{Arc, Mutex};
use uuid::Uuid;

// Состояние для хранения информации о загруженных файлах
#[derive(Clone)]
struct AppState {
    images: Arc<Mutex<Vec<ImageInfo>>>,
}

struct ImageInfo {
    id: String,
    filename: String,
    content_type: String,
}

// Форма загрузки изображений
async fn upload_form() -> Html<&'static str> {
    Html(
        r#"
        <!DOCTYPE html>
        <html>
        <head><title>Загрузка изображений</title></head>
        <body>
            <h1>Загрузка изображений</h1>
            <form action="/upload" method="post" enctype="multipart/form-data">
                <div>
                    <label>Выберите изображения:</label>
                    <input type="file" name="images" multiple accept="image/*">
                </div>
                <button type="submit">Загрузить</button>
            </form>
        </body>
        </html>
        "#
    )
}

// Обработка загрузки
async fn handle_upload(
    State(state): State<AppState>,
    mut multipart: Multipart,
) -> Result<impl IntoResponse, StatusCode> {
    let mut uploaded_images = Vec::new();
    
    // Создаем директорию, если не существует
    tokio::fs::create_dir_all("./uploads").await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    
    // Обрабатываем каждое поле формы
    while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
        // Пропускаем поля, не содержащие файлы
        let file_name = match field.file_name() {
            Some(name) => name.to_string(),
            None => continue,
        };
        
        // Получаем тип контента
        let content_type = field.content_type()
            .map(|ct| ct.to_string())
            .unwrap_or_else(|| "application/octet-stream".to_string());
        
        // Проверяем, что это изображение
        if !content_type.starts_with("image/") {
            continue;
        }
        
        // Чтение данных
        let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
        
        // Проверка размера
        const MAX_SIZE: usize = 10 * 1024 * 1024; // 10 МБ
        if data.len() > MAX_SIZE {
            return Err(StatusCode::PAYLOAD_TOO_LARGE);
        }
        
        // Генерируем уникальный ID и имя файла
        let id = Uuid::new_v4().to_string();
        let extension = Path::new(&file_name)
            .extension()
            .and_then(|ext| ext.to_str())
            .unwrap_or("bin");
            
        let new_filename = format!("{}.{}", id, extension);
        let path = format!("./uploads/{}", new_filename);
        
        // Сохраняем файл
        let mut file = File::create(&path).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        file.write_all(&data).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
        
        // Сохраняем информацию о файле
        let image_info = ImageInfo {
            id,
            filename: new_filename,
            content_type,
        };
        
        state.images.lock().unwrap().push(image_info);
        uploaded_images.push(id);
    }
    
    if uploaded_images.is_empty() {
        return Err(StatusCode::BAD_REQUEST);
    }
    
    // Возвращаем страницу с результатами
    let html = format!(
        r#"
        <!DOCTYPE html>
        <html>
        <head><title>Изображения загружены</title></head>
        <body>
            <h1>Изображения успешно загружены</h1>
            <p>Количество загруженных изображений: {}</p>
            <p><a href="/gallery">Просмотреть галерею</a></p>
        </body>
        </html>
        "#,
        uploaded_images.len()
    );
    
    Ok(Html(html))
}

// Просмотр галереи изображений
async fn gallery(State(state): State<AppState>) -> Html<String> {
    let images = state.images.lock().unwrap();
    
    let mut html = String::from(
        r#"
        <!DOCTYPE html>
        <html>
        <head>
            <title>Галерея изображений</title>
            <style>
                .gallery {
                    display: flex;
                    flex-wrap: wrap;
                    gap: 10px;
                }
                .image {
                    width: 200px;
                    height: 200px;
                    object-fit: cover;
                }
            </style>
        </head>
        <body>
            <h1>Галерея изображений</h1>
            <div class="gallery">
        "#
    );
    
    for image in images.iter() {
        html.push_str(&format!(
            r#"<img src="/images/{}" alt="Изображение" class="image">"#,
            image.id
        ));
    }
    
    html.push_str(
        r#"
            </div>
            <p><a href="/upload">Загрузить еще</a></p>
        </body>
        </html>
        "#
    );
    
    Html(html)
}

// Отдача изображений
async fn serve_image(
    State(state): State<AppState>,
    axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
    // Находим информацию о файле
    let images = state.images.lock().unwrap();
    let image_info = images.iter()
        .find(|img| img.id == id)
        .ok_or(StatusCode::NOT_FOUND)?;
    
    // Читаем файл
    let path = format!("./uploads/{}", image_info.filename);
    let file_content = tokio::fs::read(&path).await.map_err(|_| StatusCode::NOT_FOUND)?;
    
    // Возвращаем файл с правильным Content-Type
    Ok((
        [(header::CONTENT_TYPE, &image_info.content_type)],
        file_content,
    ))
}

// Создание приложения
let state = AppState {
    images: Arc::new(Mutex::new(Vec::new())),
};

let app = Router::new()
    .route("/upload", get(upload_form).post(handle_upload))
    .route("/gallery", get(gallery))
    .route("/images/:id", get(serve_image))
    .with_state(state);

Заключение

Axum предоставляет гибкие и удобные инструменты для работы с HTML-формами и загрузкой файлов. Основные преимущества:

  1. Типобезопасная десериализация данных форм через Serde
  2. Поддержка как обычных форм, так и multipart/form-data
  3. Простая валидация и преобразование данных
  4. Хорошая поддержка загрузки и обработки файлов

При работе с формами и загрузкой файлов важно:

  1. Правильно валидировать все входные данные
  2. Устанавливать ограничения на размер и тип загружаемых файлов
  3. Использовать CSRF-защиту для форм, меняющих состояние
  4. Обрабатывать потенциальные ошибки и возвращать понятные сообщения пользователю

Правильное использование форм позволяет создавать интерактивные и безопасные веб-приложения на базе Axum.