← Índice

6.5 En forma correcta

Esta traducción fue asistida por IA y revisada superficialmente. Si encuentras errores o algo no suena natural, escríbeme — lo agradezco.

Una petición verbal y una escrita son peticiones distintas. La primera se olvida, se distorsiona, se cuenta mal. La segunda tiene forma: campos, tipos, orden. El receptor sabe exactamente qué se le pasó - no porque lo adivinara, sino porque la forma no deja margen para el error. El servidor sigue respondiendo con cadenas. La forma aún no está definida.

Lo que necesitas

POST /tasks acepta un cuerpo en la petición - el título de la tarea. Ahora mismo el handler no puede verlo. Los cuatro handlers devuelven cadenas, cuando deberían devolver estructuras.

Json<T> funciona en ambas direcciones: como argumento - axum lee el cuerpo de la petición y lo deserializa en el tipo T que sea necesario; como tipo de retorno - axum serializa T en JSON y añade la cabecera de respuesta correcta.

La construcción

crates/api aún no depende de tq-core ni de serde. Añade las dependencias a crates/api/Cargo.toml:

# crates/api/Cargo.toml - CAMBIADO
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tq-core = { path = "../core" }

Ahora actualiza crates/api/src/routes.rs. Se necesita un tipo para el cuerpo de la petición - AddRequest con un campo title. Vive en el mismo archivo, junto al handler que lo usa:

// crates/api/src/routes.rs - CAMBIADO
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> - el mismo destructuring que Path(id): Path<u64>: el wrapper se desenvuelve en el argumento.

Task::new construye una tarea a partir de un id y un título. Los handlers lo devuelven con valores stub - desaparecerán en cuanto se añada almacenamiento real. .unwrap() es aceptable mientras no haya manejo de errores. La forma de la respuesta ya es correcta.

El resultado

$ make serve

En otro 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"}

Cuatro handlers responden con estructuras correctas. Los datos son stubs - todavía no hay almacén. Pero la forma está definida: el cliente sabe qué va a recibir.

make ci pasa: los mismos 38 tests.

El código completo de tq para este capítulo está en 6-the-port/05-in-proper-form/.


Lore: Content-Type

-H "Content-Type: application/json" es una cabecera obligatoria al enviar un cuerpo JSON. axum la lee y sabe cómo parsear el cuerpo. Sin ella, axum devuelve 415 Unsupported Media Type sin llamar al handler.

En la respuesta, axum establece Content-Type: application/json por sí mismo. Las peticiones GET no llevan cuerpo, y Content-Type describe exactamente eso - el cuerpo. Sin cuerpo, la cabecera no es necesaria.