Перейти к содержимому

Проект: веб-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, ты найдёшь кнопку «Попробуй сам».

Создай новый проект и добавь четыре крейта:

Terminal window
cargo new todo-api
cd todo-api
cargo add [email protected] tokio --features tokio/full
cargo add serde --features derive
cargo 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 там означает другое.)

Замени 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

Оставь её работать, открой второй терминал и отправь запрос:

Terminal window
curl http://127.0.0.1:3000/
Hello, world!

Сервер не добавляет перевод строки после текста, поэтому приглашение терминала может появиться сразу после Hello, world!. Это нормально. На этой странице каждый ответ показан на отдельной строке. Можно также открыть http://127.0.0.1:3000/ в браузере. Чтобы остановить сервер, нажми Ctrl + C в первом терминале. После каждого изменения кода сервер нужно останавливать и запускать заново.

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 показать ещё и строку статуса с заголовками:

Terminal window
curl -i http://127.0.0.1:3000/example
HTTP/1.1 200 OK
content-type: application/json
content-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 23
▶ Попробуй сам

Vec<Todo> превращается в массив JSON. Именно это и будет возвращать GET /todos. Последняя строка — сообщение serde_json: «нет поля title в строке 1, столбце 23».

В пути вроде /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 внутри. С двумя параметрами получается кортеж.

Terminal window
curl http://127.0.0.1:3000/hello/Ana
curl http://127.0.0.1:3000/add/2/3
Hello, Ana!
2 + 3 = 5

А теперь приятный сюрприз. Ты сказал, что a и b имеют тип i32, и axum сам это проверяет. Отправь что-нибудь, что не является числом:

Terminal window
curl -i http://127.0.0.1:3000/add/2/x
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`

Сервер отвечает: «неверный URL — значение x с индексом 1 нельзя разобрать как i32». Твой обработчик даже не запускался. Типы в сигнатуре функции и есть проверка входных данных.

Чтобы создать дело, клиент отправляет 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 отправляет тело:

Terminal window
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:

Terminal window
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 Entity
content-type: text/plain; charset=utf-8
content-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.

До сих пор каждый обработчик выдумывал данные сам. Настоящему 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 /todos
async 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 /todos
async 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 указывают на один и тот же список.

Запусти сервер через cargo run и во втором терминале попробуй каждую точку входа (endpoint). Сначала список пуст:

Terminal window
curl http://127.0.0.1:3000/todos
[]

Создай дело. Обрати внимание на статус 201 Created:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Buy milk"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 40
{"id":1,"title":"Buy milk","done":false}

Создай второе и выведи весь список:

Terminal window
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 и попроси такое, которого нет:

Terminal window
curl http://127.0.0.1:3000/todos/2
curl -i http://127.0.0.1:3000/todos/99
{"id":2,"title":"Learn axum","done":false}
HTTP/1.1 404 Not Found
content-length: 0

Удали дело 1, а потом попробуй удалить его ещё раз:

Terminal window
curl -i -X DELETE http://127.0.0.1:3000/todos/1
curl -i -X DELETE http://127.0.0.1:3000/todos/1
HTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
content-length: 0
Terminal window
curl http://127.0.0.1:3000/todos
[{"id":2,"title":"Learn axum","done":false}]

Некоторые ошибки axum обрабатывает за тебя. Неверный тип в JSON даёт 422 с объяснением проблемы, а метод, который ты не настроил для пути, даёт 405 Method Not Allowed с заголовком, где перечислены рабочие методы:

Terminal window
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/2
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD,DELETE
content-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