Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Экстракторы

Экстрактор (Extractor) — механизм, позволяющий извлечь некое значение из (или на основе) HTTP-запроса и заинжектить его в функцию-обработчик запроса.

Мы уже знакомы с рядом стандартных экстракторов:

  • экстрактор для инжекции аргументов пути — Path
  • экстрактор для инжекции квери-параметров — Query
  • экстрактор для инжекции тела запроса, переданного как JSON-документ — Json

Теперь давайте разберёмся, как экстракторы устроены.

Чтобы создать экстрактор, нужно реализовать один из двух трэйтов: FromRequestParts или FromRequest.

FromRequestParts

Трэйт FromRequestParts объявлен так:

pub trait FromRequestParts<S>: Sized {
    type Rejection: IntoResponse;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection>;
}

Как видим, через аргументы он имеет доступ к заголовочной части HTTP-запроса — Parts и к объекту состояния. Строить свой экстрактор на базе FromRequestParts следует в том случае, если экстрактору необходимо иметь доступ только к заголовочной части HTTP-запроса, а доступ к телу запроса не нужен.


Чтобы понять, как это работает, давайте напишем свою упрощённую версию экстрактора Query, который инжектит квери-параметры в функцию-обработчик.

use std::collections::HashMap;
use axum::{Router, extract::FromRequestParts, http::request::Parts, routing::get};

struct MyQueryParams(HashMap<String, String>);

impl<S: Send + Sync> FromRequestParts<S> for MyQueryParams {
    type Rejection = ();

    async fn from_request_parts(
        parts: &mut Parts, _state: &S
    ) -> Result<Self, Self::Rejection> {
        let mut params = HashMap::new();
        // Получает квери-строку вида key_1=val_1&key_2=val_2
        if let Some(query_string) = parts.uri.query() {
            // По разделителю &, разбиваем квери-строку на подстроки вида key=val
            for pair in query_string.split("&") {
                // вычленяем из строки key=value ключ и значение
                let mut kv = pair.split("=");
                if let Some(k) = kv.next() && let Some(v) = kv.next() {
                    params.insert(k.to_string(), v.to_string());
                }
            }
        }
        Ok(MyQueryParams(params))
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/hello", get(hello));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn hello(MyQueryParams(map): MyQueryParams) -> String {
    format!("Query params: {map:?}")
}

Если запустить сервер (cargo run) и перейти по адресу http://localhost:8080/hello?a=1&b=2&c=3, то мы должны увидеть:

Query params: {"b": "2", "c": "3", "a": "1"}

Теперь давайте рассмотрим довольно распространённый пример: экстрактор, который извлекает ID сессии из HTTP заголовка.

use axum::{ Router, extract::FromRequestParts, routing::get };
use axum::http::{StatusCode, request::Parts};

// Экстрактор ID сессии
struct SessionId(String);

impl<S: Send + Sync> FromRequestParts<S> for SessionId {
    type Rejection = StatusCode;

    async fn from_request_parts(
        parts: &mut Parts, _state: &S
    ) -> Result<Self, Self::Rejection> {
        // ID сессии передаётся в заголовке sessionid
        if let Some(value) = parts.headers.get("sessionid") {
            if let Ok(string_value) = value.to_str() {
                Ok(SessionId(string_value.to_string()))
            } else {
                Err(StatusCode::BAD_REQUEST)
            }
        } else {
            Err(StatusCode::UNAUTHORIZED)
        }
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/hello", get(hello));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn hello(SessionId(session_id): SessionId) -> String {
    format!("Session ID: {session_id}")
}

Протестируем наш эндпоинт:

$ curl -i http://localhost:8080/hello
HTTP/1.1 401 Unauthorized
content-length: 0
date: Mon, 01 Dec 2025 22:20:47 GMT

$ curl -i -H "sessionid: 1111-1111-1111" http://localhost:8080/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 26
date: Mon, 01 Dec 2025 22:08:03 GMT

Session ID: 1111-1111-1111

С практической точки зрения, будет гораздо удобнее, если в обработчик будет инжектиться не ID сессии, а сразу объект сессии пользователя. Экстракторы имеют доступ не только к заголовку HTTP-запроса, но и к объекту состояния приложения, где мы будем хранить сессию. Давайте перепишем наш экстрактор так, чтобы он сначала извлекал из заголовков запроса ID сессии, а далее из состояния доставал уже сам объект сессии.

use std::{collections::HashMap, sync::Arc};
use axum::{Router, extract::FromRequestParts, routing::get};
use axum::http::{StatusCode, request::Parts};
use tokio::sync::{Mutex, RwLock};

// Данные пользователя, хранимые в сессии
struct SessionData {
    user_name: String,
}

struct AppState {
    // ID сессии -> данные сессии
    sessions: RwLock<HashMap<String, Arc<Mutex<SessionData>>>>,
}

// Экстрактор сессии. Хранит и ID сессии, и объект данных сессии
struct Session(String, Arc<Mutex<SessionData>>);

impl FromRequestParts<Arc<AppState>> for Session {
    type Rejection = StatusCode;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &Arc<AppState>,
    ) -> Result<Self, Self::Rejection> {
        if let Some(value) = parts.headers.get("sessionid") {
            if let Ok(string_value) = value.to_str() {
                let session_id = string_value.to_string();
                let read_guard = state.sessions.read().await;
                if let Some(session) = read_guard.get(&session_id) {
                    Ok(Session(session_id, session.clone()))
                } else {
                    Err(StatusCode::UNAUTHORIZED)
                }
            } else {
                Err(StatusCode::BAD_REQUEST)
            }
        } else {
            Err(StatusCode::UNAUTHORIZED)
        }
    }
}

#[tokio::main]
async fn main() {
    // Предподготовленное хранилище сессий для тестирования нашего экстрактора
    let sessions: RwLock<HashMap<String, Arc<Mutex<SessionData>>>> = {
        let mut data = HashMap::new();
        // тестовая сессия
        data.insert(
            "1111-1111-1111".to_string(),
            Arc::new(Mutex::new(SessionData {user_name: "John Doe".to_string()})),
        );
        RwLock::new(data)
    };
    let app_state = AppState { sessions };

    let app = Router::new()
        .route("/hello", get(hello))
        .with_state(Arc::new(app_state));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn hello(Session(id, session): Session) -> String {
    format!(
        "Session ID: {id}, User name: {}",
        session.lock().await.user_name
    )
}

Проверяем, что если передать ID несуществующей сессии, мы получим в ответ 401-й код.

$ curl -i -H "sessionid: 2222-2222-2222" http://localhost:8080/hello
HTTP/1.1 401 Unauthorized
content-length: 0
date: Mon, 01 Dec 2025 22:56:52 GMT

Теперь проверяем успешный сценарий:

$ curl -i -H "sessionid: 1111-1111-1111" http://localhost:8080/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 47
date: Mon, 01 Dec 2025 22:48:05 GMT

Session ID: 1111-1111-1111, User name: John Doe

FromRequest

Трэйт FromRequest имеет вид:

pub trait FromRequest<S, M = ViaRequest>: Sized {
    type Rejection: IntoResponse;

    async fn from_request(
        req: Request<Body>,
        state: &S,
    ) -> Result<Self, Self::Rejection>
}

Он имеет доступ к объекту Request, который инкапсулирует все данные запроса, включая тело. Экстрактор следует строить на основе FromRequest только в случае, если необходим доступ к телу запроса.


Как вы уже могли догадаться, типы Json и Form<FormData>, которые мы рассматривали в разделе про чтение данных запроса, реализуют FromRequest, что позволяет им инжектиться в функцию обработчик.

Чтобы понять, как работать с FromRequest, давайте напишем экстрактор, который будет иметь возможность десериализовать данные запроса, переданные и в JSON, и в XML формате.

Для начала добавим зависимость serde-xml-rs в файл Cargo.toml:

[package]
name = "test_axum"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["full"]}
axum = "0.8"

serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde-xml-rs = "0.8"

Теперь сама программа — src/main.rs:

use std::io::Cursor;
use axum::{Router, body::{Body, Bytes, to_bytes}, http::StatusCode, routing::post};
use axum::extract::{FromRequest, Request};
use serde::{Deserialize, de::DeserializeOwned};

// Экстрактор для данных запроса
struct AnyFormat<D: DeserializeOwned>(D);

impl<S: Send + Sync, D: DeserializeOwned> FromRequest<S> for AnyFormat<D> {
    type Rejection = (StatusCode, &'static str);

    async fn from_request(
        request: Request<Body>, _state: &S
    ) -> Result<Self, Self::Rejection> {
        // Извлекаем из запроса заголовок запроса и его тело
        let (parts, body) = request.into_parts();
        let Some(content_type) = parts.headers.get("content-type") else {
            return Err((StatusCode::BAD_REQUEST, "Missing content-type"));
        };
        // Вычитываем байты запроса в буфер, максимальный размер которого 100MB
        let body_bytes: Bytes = to_bytes(body, 100 * 1024 * 1024).await.unwrap();

        let result = match content_type.to_str().unwrap() {
            "application/json" => match serde_json::from_slice::<D>(&body_bytes) {
                Ok(entity) => entity,
                Err(_) => return Err((StatusCode::BAD_REQUEST, "Malformed JSON")),
            },
            "application/xml" => {
                let cursor = Cursor::new(body_bytes);
                match serde_xml_rs::from_reader::<'_, D, _>(cursor) {
                    Ok(entity) => entity,
                    Err(_) => return Err((StatusCode::BAD_REQUEST, "Malformed XML")),
                }
            }
            _ => return Err((StatusCode::BAD_REQUEST, "Unsupported format")),
        };
        Ok(AnyFormat(result))
    }
}

#[derive(Debug, Deserialize)]
struct CreateUserRequest {
    name: String,
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/users", post(create_user));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn create_user(AnyFormat(req): AnyFormat<CreateUserRequest>) -> String {
    format!("Received: {req:?}")
}

Теперь, запустив сервер, мы можем на один и тот же эндпоинт http://localhost:8080/users сделать запрос, отправив тело и в формате JSON:

$ curl -i -X POST -H "content-type: application/json" -d '{"name":"John Doe"}' \
    http://localhost:8080/users
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 48
date: Tue, 02 Dec 2025 00:31:16 GMT

Received: CreateUserRequest { name: "John Doe" }

и в формате XML:

$ curl -i -X POST -H "content-type: application/xml" \
    -d '<CreateUserRequest><name>John Doe</name></CreateUserRequest>' \
    http://localhost:8080/users
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 48
date: Tue, 02 Dec 2025 00:56:08 GMT

Received: CreateUserRequest { name: "John Doe" }