Работа с формами
Формы — важный элемент веб-приложений, позволяющий пользователям отправлять данные на сервер. Axum предоставляет удобные инструменты для обработки как обычных HTML-форм, так и мультимедийных форм с загрузкой файлов.
Основы работы с формами
В Axum для извлечения данных из форм используется экстрактор Form<T>
, где T
— тип данных, в который будут десериализованы данные формы:
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-форм:
<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:
[dependencies]
axum = { version = "0.7.2", features = ["multipart"] }
Обработка мультичастной формы:
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-форма для загрузки файлов:
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="files" multiple>
<button type="submit">Загрузить</button>
</form>
Валидация форм
Для валидации данных форм можно использовать библиотеку validator
:
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 предоставляет механизмы для обработки ошибок при разборе форм:
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-атак:
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));
Для более детального контроля загрузки файлов:
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:
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-атак можно использовать токены:
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);
Примеры типичных задач
Обработка формы контакта
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));
Загрузка и управление изображениями
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-формами и загрузкой файлов. Основные преимущества:
- Типобезопасная десериализация данных форм через Serde
- Поддержка как обычных форм, так и multipart/form-data
- Простая валидация и преобразование данных
- Хорошая поддержка загрузки и обработки файлов
При работе с формами и загрузкой файлов важно:
- Правильно валидировать все входные данные
- Устанавливать ограничения на размер и тип загружаемых файлов
- Использовать CSRF-защиту для форм, меняющих состояние
- Обрабатывать потенциальные ошибки и возвращать понятные сообщения пользователю
Правильное использование форм позволяет создавать интерактивные и безопасные веб-приложения на базе Axum.