← Оглавление

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 описывает именно тело - без тела заголовок не нужен.