Skip to content

Кастомные экстракторы в Axum

Экстракторы в Axum — это компоненты, которые извлекают данные из HTTP-запросов. В этом разделе мы рассмотрим, как создавать собственные (кастомные) экстракторы для специфических сценариев использования.

Содержание

Основы экстракторов в Axum

В Axum экстракторы реализуются через трейт FromRequest, который определяет, как извлекать данные из HTTP-запроса:

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

#[async_trait]
impl<B> FromRequest<B> for MyExtractor
where
    B: Send, // Требование для тела запроса
{
    type Rejection = (StatusCode, String); // Тип, возвращаемый при ошибке
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Логика извлечения данных
        Ok(MyExtractor { /* ... */ })
    }
}

Создание простого экстрактора

Рассмотрим пример создания простого экстрактора, который извлекает пользовательский агент из заголовков:

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

// Определение экстрактора
pub struct UserAgent(pub String);

#[async_trait]
impl<B> FromRequest<B> for UserAgent
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получение заголовка User-Agent
        let user_agent = req
            .headers()
            .get(axum::http::header::USER_AGENT)
            .and_then(|value| value.to_str().ok())
            .map(|s| s.to_string());
            
        match user_agent {
            Some(agent) => Ok(UserAgent(agent)),
            None => Err((
                StatusCode::BAD_REQUEST,
                "Заголовок User-Agent отсутствует".to_string(),
            )),
        }
    }
}

// Использование экстрактора в обработчике
async fn handler(UserAgent(user_agent): UserAgent) -> String {
    format!("Ваш User-Agent: {}", user_agent)
}

Создание асинхронного экстрактора

Для более сложных сценариев, когда извлечение данных требует асинхронных операций (например, запросы к базе данных), можно создать асинхронный экстрактор:

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, RequestParts, State},
    http::StatusCode,
};
use sqlx::PgPool;
use uuid::Uuid;

// Модель пользователя
#[derive(Debug)]
pub struct AuthUser {
    pub id: Uuid,
    pub username: String,
    pub role: String,
}

#[async_trait]
impl<B> FromRequest<B> for AuthUser
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получение токена из заголовка Authorization
        let auth_header = req
            .headers()
            .get(axum::http::header::AUTHORIZATION)
            .and_then(|value| value.to_str().ok());
            
        let token = match auth_header {
            Some(value) if value.starts_with("Bearer ") => value[7..].to_string(),
            _ => return Err((
                StatusCode::UNAUTHORIZED,
                "Отсутствует или некорректный токен авторизации".to_string(),
            )),
        };
        
        // Получение состояния приложения с подключением к БД
        let state = State::<PgPool>::from_request(req)
            .await
            .map_err(|_| (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Ошибка подключения к базе данных".to_string(),
            ))?;
        
        // Поиск пользователя по токену (асинхронный запрос к БД)
        let user = sqlx::query_as!(
            AuthUser,
            "SELECT id, username, role FROM users WHERE auth_token = $1",
            token
        )
        .fetch_optional(&*state)
        .await
        .map_err(|_| (
            StatusCode::INTERNAL_SERVER_ERROR,
            "Ошибка базы данных".to_string(),
        ))?;
        
        match user {
            Some(user) => Ok(user),
            None => Err((
                StatusCode::UNAUTHORIZED,
                "Недействительный токен".to_string(),
            )),
        }
    }
}

// Использование в обработчике
async fn protected_handler(user: AuthUser) -> String {
    format!("Привет, {}! Ваша роль: {}", user.username, user.role)
}

Экстракторы с отказоустойчивостью

Иногда полезно иметь экстрактор, который не прерывает запрос при неудаче, а возвращает Option или значение по умолчанию:

rust
use async_trait::async_trait;
use axum::extract::{FromRequest, RequestParts};

// Экстрактор, который может отсутствовать
pub struct OptionalUserAgent(pub Option<String>);

#[async_trait]
impl<B> FromRequest<B> for OptionalUserAgent
where
    B: Send,
{
    type Rejection = (); // Этот экстрактор никогда не отклоняет запрос
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Попытка получить заголовок User-Agent
        let user_agent = req
            .headers()
            .get(axum::http::header::USER_AGENT)
            .and_then(|value| value.to_str().ok())
            .map(|s| s.to_string());
            
        Ok(OptionalUserAgent(user_agent))
    }
}

// Использование в обработчике
async fn optional_agent_handler(
    OptionalUserAgent(user_agent): OptionalUserAgent,
) -> String {
    match user_agent {
        Some(agent) => format!("Ваш User-Agent: {}", agent),
        None => "User-Agent не предоставлен".to_string(),
    }
}

Экстракторы с доступом к состоянию

Создание экстрактора, который использует состояние приложения:

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, RequestParts, State},
    http::StatusCode,
};
use std::sync::{Arc, RwLock};

// Структура конфигурации
#[derive(Clone)]
pub struct AppConfig {
    pub debug_mode: bool,
    pub api_version: String,
}

// Экстрактор для конфигурации
pub struct ExtractConfig(pub AppConfig);

#[async_trait]
impl<B> FromRequest<B> for ExtractConfig
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получение состояния приложения
        let state = State::<Arc<RwLock<AppConfig>>>::from_request(req)
            .await
            .map_err(|_| (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Не удалось получить конфигурацию".to_string(),
            ))?;
        
        // Клонирование конфигурации из состояния
        let config = state.read()
            .map_err(|_| (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Ошибка блокировки".to_string(),
            ))?
            .clone();
        
        Ok(ExtractConfig(config))
    }
}

// Использование в обработчике
async fn config_handler(
    ExtractConfig(config): ExtractConfig,
) -> String {
    format!(
        "Режим отладки: {}, Версия API: {}",
        config.debug_mode,
        config.api_version
    )
}

Экстракторы с валидацией

Создание экстрактора, который валидирует данные при извлечении:

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, RequestParts, Json},
    http::StatusCode,
};
use serde::Deserialize;
use validator::Validate;

// Структура данных с валидацией
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUser {
    #[validate(length(min = 3, max = 50, message = "Username must be between 3 and 50 characters"))]
    pub username: String,
    
    #[validate(email(message = "Invalid email format"))]
    pub email: String,
    
    #[validate(length(min = 8, message = "Password must be at least 8 characters"))]
    pub password: String,
}

// Экстрактор с валидацией
pub struct ValidatedJson<T>(pub T);

#[async_trait]
impl<B, T> FromRequest<B> for ValidatedJson<T>
where
    B: Send,
    T: Validate + Send + 'static,
    Json<T>: FromRequest<B, Rejection = axum::extract::rejection::JsonRejection>,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Извлечение JSON из запроса
        let Json(data) = Json::<T>::from_request(req)
            .await
            .map_err(|err| {
                let message = format!("Ошибка JSON: {}", err);
                (StatusCode::BAD_REQUEST, message)
            })?;
        
        // Валидация данных
        data.validate().map_err(|err| {
            (StatusCode::UNPROCESSABLE_ENTITY, format!("Ошибка валидации: {}", err))
        })?;
        
        Ok(ValidatedJson(data))
    }
}

// Использование в обработчике
async fn create_user_handler(
    ValidatedJson(user): ValidatedJson<CreateUser>,
) -> String {
    format!(
        "Пользователь создан: {} ({})",
        user.username,
        user.email
    )
}

Примеры полезных экстракторов

1. Экстрактор для пагинации

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, Query, RequestParts},
    http::StatusCode,
};
use serde::Deserialize;

// Параметры пагинации
#[derive(Deserialize)]
struct PaginationParams {
    page: Option<usize>,
    per_page: Option<usize>,
}

// Экстрактор пагинации с ограничениями
pub struct Pagination {
    pub page: usize,
    pub per_page: usize,
    pub offset: usize,
}

#[async_trait]
impl<B> FromRequest<B> for Pagination
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Извлечение query-параметров
        let Query(params) = Query::<PaginationParams>::from_request(req)
            .await
            .map_err(|_| {
                (StatusCode::BAD_REQUEST, "Некорректные параметры пагинации".to_string())
            })?;
        
        // Установка значений по умолчанию и применение ограничений
        let page = params.page.unwrap_or(1).max(1);
        let per_page = params.per_page.unwrap_or(20).clamp(1, 100);
        let offset = (page - 1) * per_page;
        
        Ok(Pagination {
            page,
            per_page,
            offset,
        })
    }
}

// Использование экстрактора
async fn list_users(pagination: Pagination) -> String {
    format!(
        "Список пользователей: страница {}, по {} элементов (смещение: {})",
        pagination.page,
        pagination.per_page,
        pagination.offset
    )
}

2. Экстрактор для API-ключа

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, RequestParts, State},
    http::{header, StatusCode},
};

// Структура для API-ключа
pub struct ApiKey(pub String);

#[async_trait]
impl<B> FromRequest<B> for ApiKey
where
    B: Send,
{
    type Rejection = (StatusCode, &'static str);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Сначала проверяем заголовок X-API-Key
        if let Some(api_key) = req
            .headers()
            .get("X-API-Key")
            .and_then(|value| value.to_str().ok())
        {
            return Ok(ApiKey(api_key.to_string()));
        }
        
        // Затем проверяем query-параметр api_key
        if let Some(query) = req.uri().query() {
            if let Some(key_pos) = query.find("api_key=") {
                let key_start = key_pos + 8; // длина "api_key="
                let key_end = query[key_start..]
                    .find('&')
                    .map(|pos| key_start + pos)
                    .unwrap_or(query.len());
                    
                let api_key = &query[key_start..key_end];
                if !api_key.is_empty() {
                    return Ok(ApiKey(api_key.to_string()));
                }
            }
        }
        
        // API-ключ не найден
        Err((StatusCode::UNAUTHORIZED, "API-ключ отсутствует или недействителен"))
    }
}

// Использование экстрактора
async fn api_endpoint(ApiKey(key): ApiKey) -> String {
    format!("Запрос с API-ключом: {}", key)
}

3. Экстрактор для геолокации по IP

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, RequestParts, ConnectInfo},
    http::StatusCode,
};
use std::net::SocketAddr;
use serde::Serialize;

// Структура для информации о геолокации
#[derive(Debug, Serialize)]
pub struct GeoLocation {
    pub country: String,
    pub city: String,
    pub latitude: f64,
    pub longitude: f64,
}

#[async_trait]
impl<B> FromRequest<B> for GeoLocation
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Получение IP-адреса клиента
        let connect_info = ConnectInfo::<SocketAddr>::from_request(req)
            .await
            .map_err(|_| (
                StatusCode::INTERNAL_SERVER_ERROR,
                "Не удалось получить информацию о подключении".to_string(),
            ))?;
        
        let ip = connect_info.ip();
        
        // В реальном приложении здесь был бы запрос к сервису геолокации
        // Для примера возвращаем фиктивные данные
        let geo = match ip.to_string().as_str() {
            "127.0.0.1" => GeoLocation {
                country: "Local".to_string(),
                city: "Localhost".to_string(),
                latitude: 0.0,
                longitude: 0.0,
            },
            _ => GeoLocation {
                country: "Russia".to_string(),
                city: "Moscow".to_string(),
                latitude: 55.7558,
                longitude: 37.6173,
            },
        };
        
        Ok(geo)
    }
}

// Использование экстрактора
async fn geo_handler(geo: GeoLocation) -> String {
    format!(
        "Ваше местоположение: {}, {} (координаты: {}, {})",
        geo.city,
        geo.country,
        geo.latitude,
        geo.longitude
    )
}

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

1. Используйте информативные сообщения об ошибках

rust
// Плохо
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
    match get_token(req) {
        Some(token) => Ok(MyExtractor { token }),
        None => Err((StatusCode::UNAUTHORIZED, "Unauthorized")),
    }
}

// Хорошо
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
    match get_token(req) {
        Some(token) => Ok(MyExtractor { token }),
        None => Err((
            StatusCode::UNAUTHORIZED,
            "Заголовок Authorization отсутствует или имеет неверный формат".to_string(),
        )),
    }
}

2. Разделяйте сложные экстракторы на более простые

rust
// Плохо (один большой экстрактор)
pub struct UserData {
    pub user_id: Uuid,
    pub token: String,
    pub permissions: Vec<String>,
}

// Хорошо (композиция экстракторов)
pub struct AuthToken(pub String);
pub struct UserInfo { pub id: Uuid, pub name: String }
pub struct UserPermissions(pub Vec<String>);

async fn handler(
    AuthToken(token): AuthToken,
    user: UserInfo,
    UserPermissions(permissions): UserPermissions,
) -> impl IntoResponse {
    // Обработка с использованием всех извлеченных данных
}

3. Используйте дженерики для повторно используемых экстракторов

rust
use async_trait::async_trait;
use axum::extract::{FromRequest, RequestParts};
use serde::de::DeserializeOwned;
use validator::Validate;

// Дженерик-экстрактор для валидируемых данных
pub struct Validated<T>(pub T);

#[async_trait]
impl<B, T> FromRequest<B> for Validated<T>
where
    B: Send,
    T: DeserializeOwned + Validate + Send + 'static,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Реализация...
    }
}

// Использование с разными типами
async fn create_user(Validated(user): Validated<CreateUser>) -> impl IntoResponse {
    // ...
}

async fn create_post(Validated(post): Validated<CreatePost>) -> impl IntoResponse {
    // ...
}

4. Кэшируйте результаты для тяжелых экстракторов

rust
use async_trait::async_trait;
use axum::{
    extract::{FromRequest, RequestParts, FromRequestParts},
    http::StatusCode,
    Extension,
};
use std::sync::Arc;

// Тяжелый экстрактор с кэшированием
pub struct HeavyExtractor {
    pub data: Arc<Vec<String>>,
}

#[async_trait]
impl<B> FromRequest<B> for HeavyExtractor
where
    B: Send,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Проверяем, есть ли уже результат в расширениях
        if let Ok(Extension(cached)) = Extension::<HeavyExtractor>::from_request(req).await {
            return Ok(cached);
        }
        
        // Если нет, выполняем тяжелую операцию
        let data = perform_heavy_operation().await?;
        let extractor = HeavyExtractor {
            data: Arc::new(data),
        };
        
        // Сохраняем результат в расширениях для последующих экстракторов
        req.extensions_mut().insert(extractor.clone());
        
        Ok(extractor)
    }
}

// Вспомогательная функция
async fn perform_heavy_operation() -> Result<Vec<String>, (StatusCode, String)> {
    // Имитация тяжелой асинхронной операции
    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    Ok(vec!["data1".to_string(), "data2".to_string()])
}

5. Используйте трейты для абстракции общего поведения

rust
use async_trait::async_trait;

// Трейт для объектов, которые можно аутентифицировать
#[async_trait]
pub trait Authenticable {
    async fn authenticate(&self, token: &str) -> Result<(), String>;
    fn get_user_id(&self) -> Uuid;
    fn get_permissions(&self) -> Vec<String>;
}

// Экстрактор, использующий трейт
pub struct AuthenticatedUser<T: Authenticable>(pub T);

#[async_trait]
impl<B, T> FromRequest<B> for AuthenticatedUser<T>
where
    B: Send,
    T: Authenticable + Send + 'static,
{
    type Rejection = (StatusCode, String);
    
    async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
        // Логика аутентификации с использованием трейта
    }
}

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