6.5 По форме
Заявка на словах и заявка на бумаге - разные заявки. Первую забудут, исказят, перескажут не так. Вторая имеет форму: поля, типы, порядок. Получивший знает, что именно передали - не потому что догадался, а потому что форма не позволяет ошибиться. Сервер пока отвечает строками. Форма не задана.
Что нужно
POST /tasks принимает тело запроса - название задачи. Сейчас обработчик его не видит.
Все четыре обработчика возвращают строки, хотя должны возвращать структуры.
Json<T> работает в обоих направлениях: как аргумент - axum читает тело запроса и
десериализует его в тот тип T что нужен на входе; как тип возврата -
axum сериализует T в JSON и добавляет правильный заголовок ответа.
Сборка
crates/api пока не зависит от tq-core и serde. Добавьте зависимости в
crates/api/Cargo.toml:
# crates/api/Cargo.toml - CHANGED
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tq-core = { path = "../core" }
Теперь обновите crates/api/src/routes.rs. Нужен тип для тела запроса - AddRequest
с полем title. Он живёт в том же файле, рядом с обработчиком, который его использует:
// crates/api/src/routes.rs - CHANGED
use axum::{
Json, Router,
extract::Path,
routing::{get, patch, post},
};
use serde::Deserialize;
use tq_core::task::Task;
#[derive(Deserialize)]
struct AddRequest {
title: String,
}
async fn add(Json(req): Json<AddRequest>) -> Json<Task> {
Json(Task::new(1, &req.title).unwrap())
}
async fn list() -> Json<Vec<Task>> {
Json(vec![])
}
async fn get_task(Path(id): Path<u64>) -> Json<Task> {
Json(Task::new(id, "stub").unwrap())
}
async fn done(Path(id): Path<u64>) -> Json<Task> {
let mut task = Task::new(id, "stub").unwrap();
task.complete();
Json(task)
}
pub fn router() -> Router {
Router::new()
.route("/tasks", post(add).get(list))
.route("/tasks/{id}", get(get_task))
.route("/tasks/{id}/done", patch(done))
}
Json(req): Json<AddRequest> - то же деструктурирование что и Path(id): Path<u64>:
обёртка раскрывается в аргументе.
Task::new строит задачу из id и названия. Обработчики возвращают его
с заглушечными значениями - они исчезнут когда мы добавим реальное хранилище.
.unwrap() допустим пока нет обработки ошибок. Форма ответа уже правильная.
Результат
$ make serve
В другом терминале:
$ curl -X POST localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"buy milk"}'
{"id":1,"title":"buy milk","status":"Todo","created_at":"2025-06-09T10:00:00Z"}
$ curl localhost:3000/tasks
[]
$ curl localhost:3000/tasks/42
{"id":42,"title":"stub","status":"Todo","created_at":"2025-06-09T10:00:00Z"}
$ curl -X PATCH localhost:3000/tasks/7/done
{"id":7,"title":"stub","status":"Done","created_at":"2025-06-09T10:00:00Z"}
Четыре обработчика отвечают правильными структурами. Данные заглушки - хранилища нет. Но форма задана: клиент знает что получит.
make ci проходит: те же 38 тестов.
Полный код
tqдля этой главы -6-the-port/05-in-proper-form/.
Lore: Content-Type
-H "Content-Type: application/json" - обязательный заголовок при отправке JSON-тела.
axum видит его и знает как читать тело. Без него вернёт 415 Unsupported Media Type,
не вызвав обработчик.
В ответе axum выставляет Content-Type: application/json сам. GET-запросы тела не
имеют, а Content-Type описывает именно тело - без тела заголовок не нужен.