Проект: веб-API на Rust с Axum
В этом проекте ты напишешь настоящий веб-сервер — небольшое API для списка дел (to-do API). Другие программы (сайт, мобильное приложение или просто curl в терминале) отправляют ему HTTP-запросы, а он отвечает в формате JSON. Примерно так устроен «бэкенд» большинства приложений, которыми ты пользуешься.
Мы возьмём axum — популярный и дружелюбный веб-фреймворк от команды Tokio. Он построен поверх асинхронного Rust и serde, так что в этом проекте сойдётся многое из того, что ты уже знаешь: структуры, Result, умные указатели и общее состояние между потоками. Если ты делал проект Список дел в терминале, идея будет знакомой. Только теперь список дел живёт за веб-адресом.
Что у нас получится
Заголовок раздела «Что у нас получится»Когда закончишь, сервер будет отвечать на такие запросы:
| Метод и путь | Что делает | Статус при успехе |
|---|---|---|
GET /todos |
Список всех дел | 200 OK |
GET /todos/{id} |
Одно дело | 200 OK или 404 Not Found |
POST /todos |
Создать дело из JSON | 201 Created |
DELETE /todos/{id} |
Удалить дело | 204 No Content или 404 Not Found |
В JSON одно дело выглядит так:
{"id":1,"title":"Buy milk","done":false}Одно важное замечание: веб-сервер бесконечно ждёт запросов, и ему нужен сетевой порт, поэтому в Rust Playground он работать не может. Серверный код на этой странице предназначен для твоего компьютера и cargo run. Каждый шаг ниже был скомпилирован и запущен, и показанные ответы настоящие. Поэтому данные в примерах (например, "Buy milk" — «купить молоко») и ответы сервера оставлены на английском, как их напечатала программа. Там, где часть программы может работать в Playground, ты найдёшь кнопку «Попробуй сам».
Создаём проект
Заголовок раздела «Создаём проект»Создай новый проект и добавь четыре крейта:
cargo new todo-apicd todo-apicargo add serde --features derivecargo add serde_jsonТеперь в Cargo.toml будет примерно такое (номера версий могут оказаться новее):
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }Зачем нужен каждый:
- axum занимается HTTP: читает запросы и отправляет ответы.
- tokio — асинхронная среда выполнения (runtime), которая всё это запускает. См. урок Async/Await.
- serde и serde_json превращают твои структуры в JSON и обратно. См. урок JSON и serde.
Ещё тебе понадобится curl, чтобы отправлять запросы. Он есть в macOS, Linux и Windows 10 и новее. (В Windows набирай в PowerShell curl.exe, потому что просто curl там означает другое.)
Шаг 1: Hello, world!
Заголовок раздела «Шаг 1: Hello, world!»Замени src/main.rs на это:
use axum::{routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello() -> &'static str { "Hello, world!"}Здесь работают вместе три детали:
- Обработчик (handler) — это
async fn, которая отвечает на запрос.helloвозвращает текст, и axum отправляет его обратно как ответ. - Маршрутизатор (router) связывает пути с обработчиками.
.route("/", get(hello))означает: «когда кто-то отправитGET-запрос на/, вызовиhello». - Слушатель (listener) открывает порт 3000 на твоём компьютере (
127.0.0.1), аaxum::serveначинает отвечать на запросы. Он работает, пока ты его не остановишь.
Запусти через cargo run. Программа напечатает одну строку и будет ждать («слушаю на…»):
Listening on http://127.0.0.1:3000Оставь её работать, открой второй терминал и отправь запрос:
curl http://127.0.0.1:3000/Hello, world!Сервер не добавляет перевод строки после текста, поэтому приглашение терминала может появиться сразу после Hello, world!. Это нормально. На этой странице каждый ответ показан на отдельной строке. Можно также открыть http://127.0.0.1:3000/ в браузере. Чтобы остановить сервер, нажми Ctrl + C в первом терминале. После каждого изменения кода сервер нужно останавливать и запускать заново.
Шаг 2: возвращаем JSON из структуры
Заголовок раздела «Шаг 2: возвращаем JSON из структуры»API обычно отвечают в JSON. В axum любое значение, которое реализует Serialize из serde, достаточно обернуть в Json(...) — axum превратит его в JSON и выставит правильный заголовок content-type:
use axum::{routing::get, Json, Router};use serde::Serialize;
#[derive(Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[tokio::main]async fn main() { let app = Router::new() .route("/", get(hello)) .route("/example", get(example_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello() -> &'static str { "Hello, world!"}
async fn example_todo() -> Json<Todo> { let todo = Todo { id: 1, title: String::from("Learn axum"), done: false, }; Json(todo)}Флаг -i просит curl показать ещё и строку статуса с заголовками:
curl -i http://127.0.0.1:3000/exampleHTTP/1.1 200 OKcontent-type: application/jsoncontent-length: 42
{"id":1,"title":"Learn axum","done":false}(curl печатает ещё заголовок date, но на этой странице мы его опускаем.) Ключи JSON в точности совпадают с именами полей структуры. Форму этого JSON можно проверить в Playground и без сервера, потому что это обычный serde. Здесь же появляется структура NewTodo, которую мы используем на шаге 4 для входящих данных:
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Debug, Deserialize)]struct NewTodo { title: String,}
fn main() { let todo = Todo { id: 1, title: String::from("Learn axum"), done: false }; println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("Buy milk"), done: true }]; println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"Walk the dog"}"#).unwrap(); println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"Walk the dog"}"#); println!("{}", bad.unwrap_err());}Вывод:
{"id":1,"title":"Learn axum","done":false}[{"id":1,"title":"Learn axum","done":false},{"id":2,"title":"Buy milk","done":true}]NewTodo { title: "Walk the dog" }missing field `title` at line 1 column 23Vec<Todo> превращается в массив JSON. Именно это и будет возвращать GET /todos. Последняя строка — сообщение serde_json: «нет поля title в строке 1, столбце 23».
Шаг 3: параметры пути
Заголовок раздела «Шаг 3: параметры пути»В пути вроде /todos/2 число 2 меняется от запроса к запросу. В axum 0.8 эта часть маршрута отмечается фигурными скобками, {id}, а в обработчике читается экстрактором (extractor) Path:
use axum::{extract::Path, routing::get, Router};
#[tokio::main]async fn main() { let app = Router::new() .route("/hello/{name}", get(hello_name)) .route("/add/{a}/{b}", get(add));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn hello_name(Path(name): Path<String>) -> String { format!("Hello, {name}!")}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String { format!("{a} + {b} = {}", a + b)}Экстрактор — это параметр обработчика, который достаёт из запроса одну деталь. Path(name): Path<String> — это образец (pattern): он распаковывает Path и отдаёт тебе String внутри. С двумя параметрами получается кортеж.
curl http://127.0.0.1:3000/hello/Anacurl http://127.0.0.1:3000/add/2/3Hello, Ana!2 + 3 = 5А теперь приятный сюрприз. Ты сказал, что a и b имеют тип i32, и axum сам это проверяет. Отправь что-нибудь, что не является числом:
curl -i http://127.0.0.1:3000/add/2/xHTTP/1.1 400 Bad Requestcontent-type: text/plain; charset=utf-8content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`Сервер отвечает: «неверный URL — значение x с индексом 1 нельзя разобрать как i32». Твой обработчик даже не запускался. Типы в сигнатуре функции и есть проверка входных данных.
Шаг 4: принимаем JSON через POST
Заголовок раздела «Шаг 4: принимаем JSON через POST»Чтобы создать дело, клиент отправляет JSON в теле запроса. Экстрактор Json читает тело и десериализует его в твою структуру. Клиент присылает только заголовок задачи, поэтому для входных данных заведём отдельную структуру NewTodo:
use axum::{routing::post, Json, Router};use serde::{Deserialize, Serialize};
#[derive(Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Deserialize)]struct NewTodo { title: String,}
#[tokio::main]async fn main() { let app = Router::new().route("/echo", post(echo_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
async fn echo_todo(Json(input): Json<NewTodo>) -> Json<Todo> { let todo = Todo { id: 1, title: input.title, done: false, }; Json(todo)}В маршруте используй post(...) вместо get(...). В curl -X POST задаёт метод, -H добавляет заголовок, а -d отправляет тело:
curl -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"title":"Buy milk"}'{"id":1,"title":"Buy milk","done":false}И снова axum проверяет ввод до того, как запустится твой код. Если в JSON нет title, ты получишь 422 Unprocessable Entity:
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"Buy milk"}'HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 19А если забыть заголовок Content-Type, придёт 415 Unsupported Media Type с сообщением Expected request with `Content-Type: application/json` . Этот заголовок чаще всего забывают при проверке через curl.
Шаг 5: общее состояние для настоящего API
Заголовок раздела «Шаг 5: общее состояние для настоящего API»До сих пор каждый обработчик выдумывал данные сам. Настоящему API нужно помнить дела между запросами. Запросы могут прийти одновременно и обрабатываются в разных потоках, поэтому список должен быть безопасным для совместного использования. Это работа пары, с которой ты познакомился в уроке Многопоточность:
Mutex<Vec<Todo>>следит, чтобы список в каждый момент менял только один запрос.Arc<...>позволяет многим обработчикам владеть указателем на один и тот же список (см. Умные указатели).
Дадим типу короткое имя через type Db = Arc<Mutex<Vec<Todo>>>;, передадим его маршрутизатору через .with_state(db) и будем читать в обработчиках экстрактором State. Для каждого запроса axum клонирует Arc (это дёшево: копируется только указатель).
Вот программа целиком. Замени ею src/main.rs:
use std::sync::{Arc, Mutex};
use axum::{ extract::{Path, State}, http::StatusCode, routing::get, Json, Router,};use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]struct Todo { id: u32, title: String, done: bool,}
#[derive(Deserialize)]struct NewTodo { title: String,}
// «База данных»: список дел, общий для всех запросов.type Db = Arc<Mutex<Vec<Todo>>>;
#[tokio::main]async fn main() { let db: Db = Arc::new(Mutex::new(Vec::new()));
let app = Router::new() .route("/todos", get(list_todos).post(create_todo)) .route("/todos/{id}", get(get_todo).delete(delete_todo)) .with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap(); println!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap();}
// GET /todosasync fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> { let todos = db.lock().unwrap(); Json(todos.clone())}
// GET /todos/{id}async fn get_todo(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> { let todos = db.lock().unwrap(); match todos.iter().find(|todo| todo.id == id) { Some(todo) => Ok(Json(todo.clone())), None => Err(StatusCode::NOT_FOUND), }}
// POST /todosasync fn create_todo(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) { let mut todos = db.lock().unwrap(); let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1; let todo = Todo { id, title: input.title, done: false, }; todos.push(todo.clone()); (StatusCode::CREATED, Json(todo))}
// DELETE /todos/{id}async fn delete_todo(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode { let mut todos = db.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != id); if todos.len() < before { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND }}Разберём новые идеи:
- Один путь, несколько методов.
get(list_todos).post(create_todo)отправляетGET /todosиPOST /todosв разные обработчики. - Коды статуса — это возвращаемые значения.
StatusCodeберётся из модуляhttpв axum. Обработчик может вернутьResult:Ok(Json(todo))превращается в 200 с JSON, аErr(StatusCode::NOT_FOUND)— в пустой ответ 404. Кортеж(StatusCode::CREATED, Json(todo))задаёт и статус, и тело. Если вернуть толькоStatusCode::NO_CONTENT(204), это значит «готово, отправлять нечего». lock()и страж.db.lock().unwrap()ждёт, пока список не освободится от других запросов, а потом даёт тебе страж (guard), с которым можно работать как с обычнымVec: читать и менять. Блокировка снимается, когда страж выходит из области видимости в конце функции.- Новые id. Следующий id на единицу больше самого большого id в списке, а для пустого списка равен 1.
- Удаление.
retainоставляет все дела, чей id не совпадает. Если длина не изменилась, ничего не удалено, и мы возвращаем 404.
Почему std::sync::Mutex, а не асинхронный? Он подходит (и работает быстрее), пока ты не держишь блокировку через .await. Ни один наш обработчик так не делает: заблокировал, поработал со списком, вернул ответ.
Логике со списком и блокировкой веб-сервер не нужен, так что её можно попробовать в Playground. Эти функции делают ту же работу, что и обработчики, но без axum:
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]struct Todo { id: u32, title: String, done: bool,}
type Db = Arc<Mutex<Vec<Todo>>>;
fn create(db: &Db, title: &str) -> Todo { let mut todos = db.lock().unwrap(); let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1; let todo = Todo { id, title: title.to_string(), done: false }; todos.push(todo.clone()); todo}
fn get(db: &Db, id: u32) -> Option<Todo> { let todos = db.lock().unwrap(); todos.iter().find(|todo| todo.id == id).cloned()}
fn delete(db: &Db, id: u32) -> bool { let mut todos = db.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != id); todos.len() < before}
fn main() { let db: Db = Arc::new(Mutex::new(Vec::new())); let second_handle = Arc::clone(&db); // то же делает axum для каждого запроса
println!("{:?}", create(&db, "Buy milk")); println!("{:?}", create(&second_handle, "Learn axum")); println!("{:?}", get(&db, 2)); println!("{:?}", get(&db, 99)); println!("удалили 1? {}", delete(&db, 1)); println!("удалили 1 ещё раз? {}", delete(&db, 1)); println!("осталось: {}", db.lock().unwrap().len());}Вывод:
Todo { id: 1, title: "Buy milk", done: false }Todo { id: 2, title: "Learn axum", done: false }Some(Todo { id: 2, title: "Learn axum", done: false })Noneудалили 1? trueудалили 1 ещё раз? falseосталось: 1Дело, созданное через second_handle, видно и при чтении через db, потому что оба Arc указывают на один и тот же список.
Шаг 6: проверяем через curl
Заголовок раздела «Шаг 6: проверяем через curl»Запусти сервер через cargo run и во втором терминале попробуй каждую точку входа (endpoint). Сначала список пуст:
curl http://127.0.0.1:3000/todos[]Создай дело. Обрати внимание на статус 201 Created:
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 40
{"id":1,"title":"Buy milk","done":false}Создай второе и выведи весь список:
curl -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Learn axum"}'curl http://127.0.0.1:3000/todos{"id":2,"title":"Learn axum","done":false}[{"id":1,"title":"Buy milk","done":false},{"id":2,"title":"Learn axum","done":false}]Получи одно дело по id и попроси такое, которого нет:
curl http://127.0.0.1:3000/todos/2curl -i http://127.0.0.1:3000/todos/99{"id":2,"title":"Learn axum","done":false}HTTP/1.1 404 Not Foundcontent-length: 0Удали дело 1, а потом попробуй удалить его ещё раз:
curl -i -X DELETE http://127.0.0.1:3000/todos/1curl -i -X DELETE http://127.0.0.1:3000/todos/1HTTP/1.1 204 No Content
HTTP/1.1 404 Not Foundcontent-length: 0curl http://127.0.0.1:3000/todos[{"id":2,"title":"Learn axum","done":false}]Некоторые ошибки axum обрабатывает за тебя. Неверный тип в JSON даёт 422 с объяснением проблемы, а метод, который ты не настроил для пути, даёт 405 Method Not Allowed с заголовком, где перечислены рабочие методы:
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title": 42}'curl -i -X PUT http://127.0.0.1:3000/todos/2HTTP/1.1 422 Unprocessable Entitycontent-type: text/plain; charset=utf-8content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12HTTP/1.1 405 Method Not Allowedallow: GET,HEAD,DELETEcontent-length: 0Останови сервер через Ctrl + C и запусти снова — список пуст. Данные живут только в памяти. Как их сохранять — первая из идей ниже.
Частые ошибки
Заголовок раздела «Частые ошибки»Старый синтаксис маршрутов :id. Многие руководства в интернете написаны для axum 0.7, где использовался /todos/:id. В axum 0.8 такой код компилируется, но программа паникует сразу при запуске:
thread 'main' (12345) panicked at src/main.rs:32:10:Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.Паника говорит, что сегменты пути не должны начинаться с :, а для захвата нужно писать {capture}. Исправление: пиши /todos/{id}.
Json перед другими экстракторами. Тело запроса можно прочитать только один раз, поэтому экстрактор, который его читает (Json), должен быть последним параметром. Если написать create_todo(Json(input): Json<NewTodo>, State(db): State<Db>), ошибка не скажет об этом прямо. Важные строки такие:
error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied |31 | .route("/todos", get(list_todos).post(create_todo)) | ---- ^^^^^^^^^^^ unsatisfied trait bound | = note: Consider using `#[axum::debug_handler]` to improve the error messageКогда видишь «Handler is not satisfied» (ограничение трейта Handler не выполнено), проверь порядок параметров и то, что каждый возвращаемый тип axum умеет превратить в ответ.
Забыть -H "Content-Type: application/json" в curl. Сервер ответит 415. Этот заголовок обязателен для экстрактора Json.
Забыть перезапустить. Запущенный сервер — это старая программа. После каждого изменения останавливай его через Ctrl + C и снова выполняй cargo run.
Как развить проект
Заголовок раздела «Как развить проект»Когда базовое API заработает, вот хорошие следующие шаги. Пробуй их по одному.
- Отмечать дело выполненным. Добавь
PATCH /todos/{id}через.patch(...)на втором маршруте. Найди дело черезiter_mut(), поставьdone = trueи верни его. - Проверка ввода. Пустой заголовок задачи не должен проходить. Обработчик может возвращать
Result<(StatusCode, Json<Todo>), (StatusCode, String)>и отправлятьErr((StatusCode::BAD_REQUEST, String::from("title must not be empty"))). Для проверки используйtitle.trim().is_empty(). - Id, которые никогда не повторяются. При правиле «самый большой id плюс один» после удаления самого нового дела его id может появиться снова. Вместо этого храни в состоянии отдельный счётчик — например, структуру с
todos: Vec<Todo>иnext_id: u32внутриMutex. - Сохранение в JSON-файл. Добавь
Todoещё иDeserialize. При запуске читайtodos.jsonчерезstd::fs::read_to_stringиserde_json::from_str(или начинай с пустого списка, если файла нет). После каждого изменения записывай список обратно черезserde_json::to_string_prettyиstd::fs::write. Всё нужное есть в уроках Работа с файлами и JSON и serde. Позже файл можно заменить настоящей базой данных, например SQLite. - Логирование с tower-http. Выполни
cargo add tower-http --features traceиcargo add tracing-subscriber, а потом добавь слой логирования. Теперь сервер печатает строку на каждый запрос — с методом, путём, статусом и временем обработки:
use tower_http::trace::TraceLayer;
// в начале main:tracing_subscriber::fmt() .with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG) .init();
// при сборке маршрутизатора:let app = Router::new() .route("/todos", get(list_todos).post(create_todo)) .route("/todos/{id}", get(get_todo).delete(delete_todo)) .layer(TraceLayer::new_for_http()) .with_state(db);- Заметки о развёртывании.
127.0.0.1принимает запросы только с твоего компьютера. Чтобы принимать запросы с других машин (например, внутри Docker или на сервере), слушай0.0.0.0. Многие хостинги сообщают программе, какой порт использовать, через переменную окруженияPORT; прочитать её можно черезstd::env::var(см. урок Переменные окружения и процессы). Собирай черезcargo build --release— так сервер работает намного быстрее. В результате получится один файл вtarget/release/, который можно скопировать на Linux-сервер или положить в маленький Docker-образ. Для публичного сайта поставь перед ним обратный прокси (reverse proxy), например Caddy или nginx, который будет заниматься HTTPS.
- Обработчик — это
async fn. Маршрутизатор сопоставляет метод и путь с обработчиком.axum::serveзапускает его наTcpListenerизtokio. - Экстракторы достают данные из запроса:
Path— части URL (/todos/{id}),Json— тело (всегда последним), аState— общие данные. - Возвращай
Json(value)для JSON,StatusCodeдля статуса, кортеж — чтобы задать и то и другое, илиResult, чтобы выбрать между успехом и статусом ошибки. Arc<Mutex<Vec<Todo>>>безопасно делит один список между всеми запросами. Заблокируй, поработай и отпусти стража. Не держи его через.await.- Проверяй API через
curl:-iпоказывает статус,-Xзадаёт метод,-Hдобавляет заголовок, а-dотправляет тело.
Проверь себя
Заголовок раздела «Проверь себя»Проверь себя
1. Как в axum 0.8 записать маршрут с id в пути?
2. Обработчик возвращает (StatusCode::CREATED, Json(todo)). Что получит клиент?
3. Зачем общий список обёрнут в Arc<Mutex<...>>?
4. Какой экстрактор должен стоять последним среди параметров обработчика?
5. Ты отправил JSON через curl -X POST, но забыл заголовок Content-Type: application/json. Что ответит axum?
Результат: 0 / 5
Что почитать
Заголовок раздела «Что почитать»- Документация axum — docs.rs (на английском) — официальное руководство с разделами о маршрутизации, обработчиках и ответах.
- Экстракторы axum — docs.rs (на английском) — все встроенные экстракторы и правила их порядка.
- Руководство по Tokio (на английском) — асинхронная среда выполнения, на которой работает axum, включая общее состояние.
- Документация tower-http — docs.rs (на английском) — готовые слои для логирования, CORS, сжатия и тайм-аутов.
- Коды состояния ответа HTTP — MDN (перевод на русский) — что значат 200, 201, 204, 404, 422 и остальные.