Skip to content

Запросы и ответы

В этом разделе мы рассмотрим, как Axum обрабатывает HTTP-запросы и формирует ответы. Мы изучим различные способы доступа к данным запроса и создания кастомизированных ответов, а также познакомимся с механизмами обработки HTTP-заголовков, статус-кодов и других аспектов HTTP-протокола.

Работа с HTTP-запросами

Структура HTTP-запроса

HTTP-запрос состоит из следующих основных компонентов:

  • HTTP-метод (GET, POST, PUT и т.д.)
  • URL-путь и параметры запроса
  • HTTP-заголовки
  • Тело запроса (опционально)

Axum предоставляет различные способы доступа к каждому из этих компонентов.

Использование низкоуровневых экстракторов

Для прямого доступа к компонентам HTTP-запроса в Axum есть несколько встроенных экстракторов:

rust
use axum::{
    extract::{Request, Path, Query},
    http::{Method, HeaderMap, Uri},
};
use serde::Deserialize;

// Получение всего запроса
async fn handler_with_request(req: Request) -> String {
    format!("Получен запрос: {:?}", req)
}

// Извлечение HTTP-метода
async fn handler_with_method(method: Method) -> String {
    format!("HTTP-метод: {}", method)
}

// Доступ к URL
async fn handler_with_uri(uri: Uri) -> String {
    format!("URL-путь: {}", uri.path())
}

// Доступ к заголовкам
async fn handler_with_headers(headers: HeaderMap) -> String {
    let user_agent = headers.get("user-agent")
        .map(|v| v.to_str().unwrap_or_default())
        .unwrap_or_default();
    
    format!("User-Agent: {}", user_agent)
}

// Комбинирование нескольких экстракторов
#[derive(Deserialize)]
struct Params {
    filter: Option<String>,
}

async fn complex_handler(
    method: Method,
    uri: Uri,
    headers: HeaderMap,
    Path(id): Path<u64>,
    Query(params): Query<Params>,
) -> String {
    format!(
        "Метод: {}, Путь: {}, ID: {}, Фильтр: {}, User-Agent: {}",
        method,
        uri.path(),
        id,
        params.filter.unwrap_or_default(),
        headers.get("user-agent")
            .map(|v| v.to_str().unwrap_or_default())
            .unwrap_or_default()
    )
}

Доступ к телу запроса

Axum предоставляет несколько способов доступа к телу запроса:

rust
use axum::{
    extract::{Json, Form, Bytes},
    body::Body,
    http::StatusCode,
};
use serde::Deserialize;

// Извлечение тела запроса как JSON
#[derive(Deserialize)]
struct User {
    name: String,
    email: String,
}

async fn create_user_json(
    Json(user): Json<User>,
) -> String {
    format!("Создан пользователь: {} ({})", user.name, user.email)
}

// Извлечение тела запроса как формы
async fn create_user_form(
    Form(user): Form<User>,
) -> String {
    format!("Создан пользователь из формы: {} ({})", user.name, user.email)
}

// Извлечение сырых байтов
async fn raw_body_handler(
    bytes: Bytes,
) -> String {
    format!("Получено {} байт данных", bytes.len())
}

// Получение всего тела как axum::body::Body
async fn body_handler(
    body: Body,
) -> String {
    let bytes = axum::body::to_bytes(body, usize::MAX)
        .await
        .unwrap_or_default();
    
    format!("Получено {} байт данных", bytes.len())
}

Работа с multipart/form-data

Для обработки загрузки файлов и multipart-форм в Axum обычно используется крейт axum-multipart:

rust
use axum::{
    extract::Multipart,
    http::StatusCode,
};
use std::io::Write;

async fn upload_handler(mut multipart: Multipart) -> Result<String, StatusCode> {
    let mut uploaded_files = 0;
    let mut total_bytes = 0;
    
    while let Some(field) = multipart.next_field().await.map_err(|_| StatusCode::BAD_REQUEST)? {
        let name = field.name().unwrap_or_default().to_string();
        let file_name = field.file_name().unwrap_or_default().to_string();
        
        let data = field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?;
        total_bytes += data.len();
        
        // Пример сохранения файла
        if !file_name.is_empty() {
            let path = format!("./uploads/{}", file_name);
            let mut file = std::fs::File::create(&path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            file.write_all(&data).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
            uploaded_files += 1;
        }
    }
    
    Ok(format!("Загружено {} файлов, общий размер: {} байт", uploaded_files, total_bytes))
}

Формирование HTTP-ответов

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

В Axum все, что возвращает хендлер, должно реализовывать типаж IntoResponse. Это позволяет гибко формировать HTTP-ответы:

rust
use axum::{
    response::IntoResponse,
    http::{StatusCode, HeaderMap, HeaderValue},
    Json,
};
use serde::Serialize;

// Простой текстовый ответ
async fn text_response() -> &'static str {
    "Это текстовый ответ"
}

// Ответ с JSON-данными
#[derive(Serialize)]
struct ApiResponse {
    message: String,
    data: Vec<String>,
}

async fn json_response() -> Json<ApiResponse> {
    Json(ApiResponse {
        message: "Успешно".to_string(),
        data: vec!["один".to_string(), "два".to_string()],
    })
}

// Ответ со статус-кодом
async fn not_found() -> StatusCode {
    StatusCode::NOT_FOUND
}

// Ответ со статус-кодом и телом
async fn bad_request() -> (StatusCode, &'static str) {
    (StatusCode::BAD_REQUEST, "Некорректный запрос")
}

// Комплексный ответ с заголовками
async fn complex_response() -> impl IntoResponse {
    let mut headers = HeaderMap::new();
    headers.insert("X-Custom-Header", HeaderValue::from_static("custom-value"));
    headers.insert("Cache-Control", HeaderValue::from_static("max-age=3600"));
    
    (
        StatusCode::OK,
        headers,
        Json(ApiResponse {
            message: "Расширенный ответ".to_string(),
            data: vec!["с".to_string(), "заголовками".to_string()],
        }),
    )
}

Реализация IntoResponse для кастомных типов

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

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

// Кастомный тип ответа API
#[derive(Serialize)]
struct ApiSuccessResponse<T>
where
    T: Serialize,
{
    success: bool,
    data: T,
    message: String,
}

// Кастомный тип ответа с ошибкой
#[derive(Serialize)]
struct ApiErrorResponse {
    success: bool,
    error: String,
    code: String,
}

// Enum для представления результата API
enum ApiResult<T>
where
    T: Serialize,
{
    Success(T, String),
    Error(StatusCode, String, String),
}

impl<T> IntoResponse for ApiResult<T>
where
    T: Serialize,
{
    fn into_response(self) -> Response {
        match self {
            ApiResult::Success(data, message) => {
                let response = ApiSuccessResponse {
                    success: true,
                    data,
                    message,
                };
                
                (StatusCode::OK, Json(response)).into_response()
            },
            ApiResult::Error(status, error, code) => {
                let response = ApiErrorResponse {
                    success: false,
                    error,
                    code,
                };
                
                (status, Json(response)).into_response()
            }
        }
    }
}

// Использование кастомного типа ответа
async fn api_handler(Path(id): Path<u64>) -> ApiResult<User> {
    match find_user(id).await {
        Some(user) => ApiResult::Success(user, "Пользователь найден".to_string()),
        None => ApiResult::Error(
            StatusCode::NOT_FOUND,
            "Пользователь не найден".to_string(),
            "USER_NOT_FOUND".to_string(),
        ),
    }
}

Управление заголовками ответа

Axum позволяет гибко управлять HTTP-заголовками ответа:

rust
use axum::{
    response::{IntoResponse, Response},
    http::{StatusCode, header, HeaderMap, HeaderValue},
};

// Добавление заголовков через кортеж
async fn with_headers() -> impl IntoResponse {
    (
        StatusCode::OK,
        [
            (header::CONTENT_TYPE, "text/plain"),
            (header::CACHE_CONTROL, "max-age=3600"),
            ("X-Custom-Header", "custom-value"),
        ],
        "Ответ с заголовками"
    )
}

// Модификация заголовков через HeaderMap
async fn with_header_map() -> impl IntoResponse {
    let body = "Ответ с заголовками через HeaderMap";
    
    let mut headers = HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"));
    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("max-age=3600"));
    headers.insert("X-Custom-Header", HeaderValue::from_static("custom-value"));
    
    (StatusCode::OK, headers, body)
}

// Добавление конкретных заголовков через extensions
async fn set_cookies() -> impl IntoResponse {
    let mut response = Response::builder()
        .status(StatusCode::OK)
        .body("Ответ с куками")
        .unwrap();
    
    let headers = response.headers_mut();
    headers.insert(
        header::SET_COOKIE,
        HeaderValue::from_str("session=abc123; Path=/; HttpOnly; SameSite=Lax").unwrap(),
    );
    headers.insert(
        header::SET_COOKIE,
        HeaderValue::from_str("theme=dark; Path=/; Max-Age=31536000").unwrap(),
    );
    
    response
}

Потоковые ответы

Axum поддерживает потоковую передачу ответов, что полезно для больших файлов или SSE (Server-Sent Events):

rust
use axum::{
    response::{IntoResponse, Response, sse::{Event, Sse}},
    extract::Path,
    body::StreamBody,
};
use futures::{Stream, stream};
use tokio::fs::File;
use tokio_util::io::ReaderStream;
use std::{convert::Infallible, time::Duration};

// Потоковая передача файла
async fn stream_file(Path(filename): Path<String>) -> Result<impl IntoResponse, StatusCode> {
    let path = format!("./files/{}", filename);
    let file = File::open(path).await.map_err(|_| StatusCode::NOT_FOUND)?;
    
    // Преобразование файла в поток
    let stream = ReaderStream::new(file);
    let body = StreamBody::new(stream);
    
    let headers = [
        (header::CONTENT_TYPE, "application/octet-stream"),
        (header::CONTENT_DISPOSITION, &format!("attachment; filename=\"{}\"", filename)),
    ];
    
    Ok((headers, body))
}

// Server-Sent Events (SSE)
async fn sse_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
    let stream = stream::repeat_with(|| {
        let timestamp = chrono::Utc::now().to_rfc3339();
        Event::default().data(format!("Current time: {}", timestamp))
    })
    .map(Ok)
    .throttle(Duration::from_secs(1));
    
    Sse::new(stream)
}

Обработка ошибок

Базовая обработка ошибок

Axum поддерживает возврат Result<T, E> из хендлеров, где T и E реализуют IntoResponse:

rust
use axum::{
    response::IntoResponse,
    http::StatusCode,
    Json,
};
use serde::{Serialize, Deserialize};

#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}

// Простая обработка ошибок
async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, StatusCode> {
    if id == 42 {
        let user = User {
            id,
            name: "Джон Доу".to_string(),
        };
        Ok(Json(user))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

Кастомные типы ошибок

Для более детальной обработки ошибок удобно создать собственный тип ошибки:

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

#[derive(Debug)]
enum AppError {
    NotFound(String),
    InvalidInput(String),
    DatabaseError(String),
    Unauthorized(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let (message, _) = self.get_details();
        write!(f, "{}", message)
    }
}

impl AppError {
    fn get_details(&self) -> (String, StatusCode) {
        match self {
            AppError::NotFound(message) => (message.clone(), StatusCode::NOT_FOUND),
            AppError::InvalidInput(message) => (message.clone(), StatusCode::BAD_REQUEST),
            AppError::DatabaseError(message) => (message.clone(), StatusCode::INTERNAL_SERVER_ERROR),
            AppError::Unauthorized(message) => (message.clone(), StatusCode::UNAUTHORIZED),
        }
    }
}

#[derive(Serialize)]
struct ErrorResponse {
    success: bool,
    error: String,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (message, status) = self.get_details();
        
        // Логирование ошибки
        tracing::error!("{}: {}", status, message);
        
        // Формирование JSON-ответа
        let body = Json(ErrorResponse {
            success: false,
            error: message,
        });
        
        (status, body).into_response()
    }
}

// Использование в хендлере
async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Result<Json<User>, AppError> {
    // Валидация
    if payload.name.is_empty() {
        return Err(AppError::InvalidInput("Имя не может быть пустым".to_string()));
    }
    
    // Создание пользователя в БД
    match db::create_user(&payload).await {
        Ok(user) => Ok(Json(user)),
        Err(e) => Err(AppError::DatabaseError(format!("Ошибка БД: {}", e))),
    }
}

Обработка ошибок с использованием thiserror и anyhow

Для более удобной работы с ошибками можно использовать популярные крейты thiserror и anyhow:

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

#[derive(Error, Debug)]
enum ApiError {
    #[error("Ресурс не найден: {0}")]
    NotFound(String),
    
    #[error("Неверные входные данные: {0}")]
    BadRequest(String),
    
    #[error("Ошибка авторизации: {0}")]
    Unauthorized(String),
    
    #[error("Внутренняя ошибка сервера")]
    Internal(#[from] anyhow::Error),
    
    #[error("Ошибка базы данных: {0}")]
    Database(#[from] sqlx::Error),
}

#[derive(Serialize)]
struct ErrorPayload {
    message: String,
    code: String,
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, code, message) = match &self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg),
            ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg),
            ApiError::Internal(_) => (
                StatusCode::INTERNAL_SERVER_ERROR, 
                "INTERNAL_ERROR", 
                "Внутренняя ошибка сервера"
            ),
            ApiError::Database(_) => (
                StatusCode::INTERNAL_SERVER_ERROR, 
                "DATABASE_ERROR", 
                "Ошибка базы данных"
            ),
        };

        // Детальное логирование для внутренних ошибок
        if let ApiError::Internal(ref e) = self {
            tracing::error!("Внутренняя ошибка: {:?}", e);
        }
        
        let payload = ErrorPayload {
            message: message.to_string(),
            code: code.to_string(),
        };
        
        (status, Json(payload)).into_response()
    }
}

// Использование в хендлере
async fn get_user(
    Path(id): Path<i64>,
    State(state): State<AppState>,
) -> Result<Json<User>, ApiError> {
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(id)
        .fetch_optional(&state.pool)
        .await?;  // Автоматически конвертируется в ApiError::Database
    
    user.map(Json)
        .ok_or_else(|| ApiError::NotFound(format!("Пользователь с ID {} не найден", id)))
}

Управление кодами состояния

Определение кодов состояния (статус-кодов)

Axum предоставляет удобный способ установки HTTP-статус-кодов через тип StatusCode:

rust
use axum::{
    http::StatusCode,
    response::IntoResponse,
};

async fn created() -> (StatusCode, &'static str) {
    (StatusCode::CREATED, "Ресурс успешно создан")
}

async fn accepted() -> (StatusCode, &'static str) {
    (StatusCode::ACCEPTED, "Запрос принят в обработку")
}

async fn no_content() -> StatusCode {
    StatusCode::NO_CONTENT
}

// Перенаправление
async fn redirect() -> impl IntoResponse {
    (
        StatusCode::FOUND,
        [(header::LOCATION, "/new-location")],
        "Перенаправление..."
    )
}

Обработка различных кодов состояния

Axum также позволяет обрабатывать различные статус-коды на основе логики приложения:

rust
async fn conditional_response(
    Query(params): Query<Params>,
) -> impl IntoResponse {
    match params.action.as_deref() {
        Some("create") => (StatusCode::CREATED, "Ресурс создан"),
        Some("update") => (StatusCode::OK, "Ресурс обновлен"),
        Some("delete") => (StatusCode::NO_CONTENT, ""),
        Some("move") => (
            StatusCode::FOUND,
            [(header::LOCATION, "/new-location")],
            "Перенаправление..."
        ),
        _ => (StatusCode::BAD_REQUEST, "Неизвестное действие"),
    }
}

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

Структурирование API-ответов

Для обеспечения согласованности API рекомендуется использовать стандартные форматы ответов:

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

// Общая структура успешного ответа
#[derive(Serialize)]
struct ApiResponse<T> {
    success: bool,
    data: T,
    message: Option<String>,
}

// Общая структура ответа с ошибкой
#[derive(Serialize)]
struct ApiErrorResponse {
    success: bool,
    error: String,
    code: String,
}

// Функция-помощник для успешных ответов
fn success<T: Serialize>(data: T, message: Option<String>) -> Json<ApiResponse<T>> {
    Json(ApiResponse {
        success: true,
        data,
        message,
    })
}

// Функция-помощник для ответов с ошибкой
fn error(status: StatusCode, error: String, code: String) -> impl IntoResponse {
    let response = ApiErrorResponse {
        success: false,
        error,
        code,
    };
    
    (status, Json(response))
}

// Использование в хендлерах
async fn get_user(Path(id): Path<u64>) -> impl IntoResponse {
    match find_user(id).await {
        Some(user) => (
            StatusCode::OK,
            success(user, Some("Пользователь найден".to_string()))
        ),
        None => error(
            StatusCode::NOT_FOUND,
            "Пользователь не найден".to_string(),
            "USER_NOT_FOUND".to_string()
        ),
    }
}

Валидация запросов

Рекомендуется валидировать входящие данные перед их обработкой:

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

#[derive(Debug, Deserialize, Validate)]
struct CreateUser {
    #[validate(length(min = 3, message = "Имя должно содержать минимум 3 символа"))]
    name: 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, impl IntoResponse> {
    // Валидация входных данных
    if let Err(validation_errors) = payload.validate() {
        let error_message = validation_errors
            .field_errors()
            .iter()
            .map(|(field, errors)| {
                let error_message = errors[0].message.clone().unwrap_or_else(|| {
                    format!("Поле '{}' содержит ошибки", field)
                });
                error_message.to_string()
            })
            .collect::<Vec<_>>()
            .join(", ");
        
        return Err(error(
            StatusCode::BAD_REQUEST,
            error_message,
            "VALIDATION_ERROR".to_string()
        ));
    }
    
    // Обработка данных...
    Ok((
        StatusCode::CREATED,
        success(
            User { id: 1, name: payload.name, email: payload.email },
            Some("Пользователь создан".to_string())
        )
    ))
}

Обработка больших нагрузок

Для приложений с высокой нагрузкой важно оптимизировать работу с запросами и ответами:

rust
use std::time::Duration;
use tower::timeout::TimeoutLayer;
use tower_http::{
    compression::CompressionLayer,
    limit::RequestBodyLimitLayer,
};

// Настройка приложения для высокой нагрузки
let app = Router::new()
    .route("/api/users", get(list_users))
    // Ограничение размера тела запроса (10 МБ)
    .layer(RequestBodyLimitLayer::new(10 * 1024 * 1024))
    // Сжатие ответов
    .layer(CompressionLayer::new())
    // Таймаут запросов
    .layer(TimeoutLayer::new(Duration::from_secs(30)));

Заключение

В этом разделе мы рассмотрели различные аспекты работы с HTTP-запросами и ответами в Axum. Мы изучили, как извлекать данные из запросов, формировать и кастомизировать ответы, обрабатывать ошибки и управлять HTTP-заголовками и статус-кодами.

Axum предоставляет мощные и типобезопасные абстракции для работы с HTTP, которые позволяют создавать надежные веб-приложения и API с чистым и поддерживаемым кодом.

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