← Table of contents

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 tq code for this chapter is in 6-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.