← Índice

8.3 La negativa con gracia

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

A un artesano al que se le hace una pregunta sin respuesta puede decir “no lo sé” - o desmayarse. Lo primero es útil. Lo segundo asusta a quienes le rodean y no explica nada. El servidor actualmente elige lo segundo: una petición de una tarea que no existe acaba en pánico, una conexión cerrada y silencio. El cliente espera. No vendrá ninguna respuesta.

Lo que necesitas

get_task y done reciben un id de la URL. Si no existe ninguna tarea con ese número - .unwrap() entra en pánico. El cliente recibe una conexión cerrada: sin código, sin explicación.

HTTP tiene una palabra para “no”. Para un recurso que no existe - 404. Para una petición incorrecta (un título vacío en add) - 400. Axum devolverá el código correcto si el tipo de respuesta implementa IntoResponse. Result<Json<Task>, ApiError> axum lo entiende: Ok - el cliente recibe la tarea, Err - lo que ApiError decida devolver.

La construcción

En crates/api/src/routes.rs, nuevas importaciones y un tipo de error:

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};

enum ApiError {
    NotFound,
    BadRequest(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        match self {
            ApiError::NotFound => (StatusCode::NOT_FOUND, "not found").into_response(),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
        }
    }
}

IntoResponse es un trait de axum: cualquier cosa que lo implemente puede devolverse desde un handler. La tupla (StatusCode, &str) ya lo implementa; ApiError simplemente delega en ella a través de match.

Se actualizan tres handlers - add, get_task y done:

async fn add(State(state): State<AppState>, Json(req): Json<AddRequest>) -> Result<Json<Task>, ApiError> {
    let mut guard = state.store.lock().unwrap();
    let id = guard.add(req.title.as_str()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
    persistence::save(guard.all(), &state.data_path).unwrap();
    Ok(Json(guard.get(id).unwrap().clone()))
}

async fn get_task(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let guard = state.store.lock().unwrap();
    let task = guard.get(id).map_err(|_| ApiError::NotFound)?;
    Ok(Json(task.clone()))
}

async fn done(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<Task>, ApiError> {
    let mut guard = state.store.lock().unwrap();
    let task = guard.get_mut(id).map_err(|_| ApiError::NotFound)?;
    task.complete();
    let task = task.clone();
    persistence::save(guard.all(), &state.data_path).unwrap();
    Ok(Json(task))
}

.map_err(|_| ApiError::NotFound) convierte un TqError en un ApiError. ? devuelve el error pronto - el mismo ? del capítulo 2.3, ahora dentro de un handler async. guard.get(id).unwrap() en add tras un .add() exitoso no entrará en pánico: la tarea acaba de crearse.

Para que guard.add() en add pueda devolver un error, la validación de título vacío se mueve a TaskStore::add en crates/core/src/store/mod.rs - donde le corresponde estar:

fn add(&mut self, item: impl Into<Task>) -> TqResult<u64> {
    let mut task = item.into();
    if task.title.trim().is_empty() {
        return Err(TqError::EmptyTitle);
    }
    let id = self.next_id;
    // ...
}

Ahora ni la CLI ni la API pueden crear una tarea con un título vacío. La CLI se simplifica como resultado; los cambios están en el código de ejemplo de este capítulo. list y router no cambian.

El resultado

$ make serve

En otro terminal:

$ curl -s -w "\ncode: %{http_code}\n" localhost:3000/tasks/99
not found
code: 404

$ curl -s -w "\ncode: %{http_code}\n" -X POST localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":""}'
task title cannot be empty
code: 400

$ curl -s -w "\ncode: %{http_code}\n" localhost:3000/tasks/1
{"id":1,"title":"buy milk","status":"Todo","created_at":"2026-06-20T10:00:00Z"}
code: 200

El servidor dice “no” - y explica por qué. El cliente recibe un código y un cuerpo, no silencio.

El código completo de tq para este capítulo está en 8-ready/03-graceful-refusal/.