6.5 In Proper Form
A verbal request and a written one are different requests. The first will be forgotten, distorted, retold wrong. The second has form: fields, types, order. The recipient knows exactly what was passed - not because they guessed, but because the form leaves no room for error. The server is still answering with strings. The form has not been defined.
What You Need
POST /tasks accepts a request body - the task title. Right now the handler cannot see it.
All four handlers return strings, though they should return structures.
Json<T> works in both directions: as an argument - axum reads the request body and
deserializes it into whatever type T is needed; as a return type - axum serializes T
into JSON and adds the correct response header.
The Build
crates/api does not yet depend on tq-core or serde. Add the dependencies to
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" }
Now update crates/api/src/routes.rs. A type is needed for the request body -
AddRequest with a title field. It lives in the same file, next to the handler that uses
it:
// 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> - the same destructuring as Path(id): Path<u64>: the
wrapper is unwrapped in the argument.
Task::new builds a task from an id and a title. The handlers return it with stub values -
those will disappear once real storage is added. .unwrap() is acceptable while error
handling is absent. The response shape is already correct.
The Result
$ make serve
In another terminal:
$ 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"}
Four handlers respond with correct structures. The data is stubbed - there is no store yet. But the form is defined: the client knows what it will receive.
make ci passes: the same 38 tests.
The complete
tqcode for this chapter is in6-the-port/05-in-proper-form/.
Lore: Content-Type
-H "Content-Type: application/json" is a required header when sending a JSON body. axum
reads it and knows how to parse the body. Without it, axum returns 415 Unsupported Media Type without calling the handler.
In the response, axum sets Content-Type: application/json itself. GET requests carry
no body, and Content-Type describes exactly that - the body. Without a body, the header
is not needed.