Ir al contenido

Proyecto: API web con Axum en Rust

En este proyecto vas a construir un servidor web de verdad: una pequeña API de tareas (to-do). Otros programas (una página web, una app del móvil o simplemente curl en tu terminal) le envían peticiones HTTP, y ella responde con JSON. Es lo mismo que hace funcionar el “back end” de la mayoría de las apps que usas.

Usaremos axum, un framework web popular y amigable creado por el equipo de Tokio. Funciona sobre Rust asíncrono y serde, así que este proyecto junta mucho de lo que ya has aprendido: structs, Result, smart pointers (punteros inteligentes) y estado compartido entre hilos. Si hiciste el proyecto de la lista de tareas en la terminal, reconocerás la idea. Esta vez la lista de tareas vive detrás de una dirección web.

Cuando termines, tu servidor responderá a estas peticiones:

Método y ruta Qué hace Estado si todo va bien
GET /todos Lista todas las tareas 200 OK
GET /todos/{id} Devuelve una tarea 200 OK, o 404 Not Found
POST /todos Crea una tarea a partir de JSON 201 Created
DELETE /todos/{id} Borra una tarea 204 No Content, o 404 Not Found

Una tarea se ve así en JSON:

{"id":1,"title":"Comprar leche","done":false}

Una nota importante: un servidor web espera peticiones para siempre y necesita un puerto de red, así que no puede ejecutarse en el Playground de Rust. El código del servidor de esta página está pensado para tu propio ordenador, con cargo run. Cada paso de abajo se compiló y se ejecutó, y las respuestas que se muestran son reales. Donde una parte del programa sí puede ejecutarse en el Playground, encontrarás un botón para probarla.

Crea un proyecto nuevo y añade cuatro crates:

Terminal window
cargo new todo-api
cd todo-api
cargo add [email protected] tokio --features tokio/full
cargo add serde --features derive
cargo add serde_json

Ahora tu Cargo.toml tiene algo así (puede que los números de versión exactos sean más nuevos):

[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Qué hace cada uno:

  • axum se encarga de HTTP: lee las peticiones y envía las respuestas.
  • tokio es el runtime asíncrono que lo ejecuta todo. Consulta Async/Await.
  • serde y serde_json convierten tus structs en JSON y al revés. Consulta JSON con Serde.

También necesitarás curl para enviar peticiones. Viene incluido en macOS, Linux y Windows 10 o posterior. (En Windows, escribe curl.exe en PowerShell, porque allí curl a secas significa otra cosa.)

Sustituye src/main.rs por esto:

use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/", get(hello));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Escuchando en http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"¡Hola, mundo!"
}

Aquí trabajan juntas tres piezas:

  • Un handler (manejador) es una async fn que responde a una petición. hello devuelve un texto, y axum lo envía como respuesta.
  • El router (enrutador) conecta rutas con handlers. .route("/", get(hello)) significa “cuando alguien envíe una petición GET a /, llama a hello”.
  • El listener abre el puerto 3000 en tu propio ordenador (127.0.0.1), y axum::serve empieza a responder peticiones. Sigue funcionando hasta que lo detengas.

Ejecútalo con cargo run. El programa imprime una línea y se queda esperando:

Escuchando en http://127.0.0.1:3000

Déjalo funcionando, abre una segunda terminal y envíale una petición:

Terminal window
curl http://127.0.0.1:3000/
¡Hola, mundo!

El servidor no añade un salto de línea después del texto, así que puede que el prompt de tu terminal aparezca justo detrás de ¡Hola, mundo!. Es normal. En esta página, cada respuesta se muestra en su propia línea. También puedes abrir http://127.0.0.1:3000/ en tu navegador. Pulsa Ctrl + C en la primera terminal para detener el servidor. Tienes que detenerlo y volver a arrancarlo cada vez que cambies el código.

Las APIs suelen responder con JSON. En axum, envuelves en Json(...) cualquier valor que implemente el Serialize de serde, y axum lo convierte en JSON y pone la cabecera content-type correcta:

use axum::{routing::get, Json, Router};
use serde::Serialize;
#[derive(Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello))
.route("/example", get(example_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Escuchando en http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"¡Hola, mundo!"
}
async fn example_todo() -> Json<Todo> {
let todo = Todo {
id: 1,
title: String::from("Aprender axum"),
done: false,
};
Json(todo)
}

La opción -i le dice a curl que muestre también la línea de estado y las cabeceras:

Terminal window
curl -i http://127.0.0.1:3000/example
HTTP/1.1 200 OK
content-type: application/json
content-length: 45
{"id":1,"title":"Aprender axum","done":false}

(curl también imprime una cabecera date, que en esta página omitimos.) Las claves del JSON son exactamente los nombres de los campos del struct. Puedes comprobar esa forma del JSON en el Playground, sin servidor, porque es serde normal y corriente. Aquí también aparece el struct NewTodo que usaremos en el paso 4 para los datos que llegan:

use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Debug, Deserialize)]
struct NewTodo {
title: String,
}
fn main() {
let todo = Todo { id: 1, title: String::from("Aprender axum"), done: false };
println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("Comprar leche"), done: true }];
println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"Pasear al perro"}"#).unwrap();
println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"Pasear al perro"}"#);
println!("{}", bad.unwrap_err());
}

Salida:

{"id":1,"title":"Aprender axum","done":false}
[{"id":1,"title":"Aprender axum","done":false},{"id":2,"title":"Comprar leche","done":true}]
NewTodo { title: "Pasear al perro" }
missing field `title` at line 1 column 26
▶ Pruébalo tú mismo

Un Vec<Todo> se convierte en un array JSON. Eso es exactamente lo que devolverá GET /todos. La última línea es el mensaje de error de serde (en inglés): falta el campo title.

En una ruta como /todos/2, el 2 cambia de una petición a otra. En axum 0.8 marcas esa parte de la ruta con llaves, {id}, y la lees en el handler con el extractor Path:

use axum::{extract::Path, routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/hello/{name}", get(hello_name))
.route("/add/{a}/{b}", get(add));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Escuchando en http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello_name(Path(name): Path<String>) -> String {
format!("¡Hola, {name}!")
}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String {
format!("{a} + {b} = {}", a + b)
}

Un extractor es un parámetro del handler que saca una pieza de la petición. Path(name): Path<String> es un patrón: desenvuelve el Path y te da el String que hay dentro. Con dos parámetros, obtienes una tupla.

Terminal window
curl http://127.0.0.1:3000/hello/Ana
curl http://127.0.0.1:3000/add/2/3
¡Hola, Ana!
2 + 3 = 5

Aquí viene una sorpresa agradable. Dijiste que a y b son i32, así que axum lo comprueba por ti. Envía algo que no sea un número:

Terminal window
curl -i http://127.0.0.1:3000/add/2/x
HTTP/1.1 400 Bad Request
content-type: text/plain; charset=utf-8
content-length: 68
Invalid URL: Cannot parse value at index 1 with value `x` to a `i32`

El mensaje (en inglés) dice que la URL no es válida porque x no se puede convertir en un i32. Tu handler ni siquiera llegó a ejecutarse. Los tipos de la firma de tu función son la validación.

Para crear una tarea, el cliente envía JSON en el cuerpo (body) de la petición. El extractor Json lee el cuerpo y lo deserializa en tu struct. El cliente solo envía un título, así que usamos un struct aparte, NewTodo, para la entrada:

use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
title: String,
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/echo", post(echo_todo));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Escuchando en http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn echo_todo(Json(input): Json<NewTodo>) -> Json<Todo> {
let todo = Todo {
id: 1,
title: input.title,
done: false,
};
Json(todo)
}

Usa post(...) en lugar de get(...) en la ruta. Con curl, -X POST elige el método, -H añade una cabecera y -d envía el cuerpo:

Terminal window
curl -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"title":"Comprar leche"}'
{"id":1,"title":"Comprar leche","done":false}

Otra vez, axum comprueba la entrada antes de que se ejecute tu código. Si al JSON le falta title, obtienes 422 Unprocessable Entity:

Terminal window
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"Comprar leche"}'
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 99
Failed to deserialize the JSON body into the target type: missing field `title` at line 1 column 24

El mensaje dice que no se pudo deserializar el cuerpo JSON porque falta el campo title. Y si olvidas la cabecera Content-Type, obtienes 415 Unsupported Media Type con el mensaje Expected request with `Content-Type: application/json` . Esa cabecera es lo que más se olvida al probar con curl.

Paso 5: Estado compartido para una API de tareas de verdad

Sección titulada «Paso 5: Estado compartido para una API de tareas de verdad»

Hasta ahora, cada handler se inventaba sus datos. Una API real necesita recordar las tareas entre peticiones. Las peticiones pueden llegar a la vez y se atienden en hilos distintos, así que la lista tiene que poder compartirse de forma segura. Ese es el trabajo de la pareja que conociste en la página de concurrencia:

  • Mutex<Vec<Todo>> se asegura de que solo una petición cambie la lista cada vez.
  • Arc<...> permite que muchos handlers tengan un puntero a esa misma lista (consulta Smart pointers).

Le damos al tipo un nombre corto con type Db = Arc<Mutex<Vec<Todo>>>;, se lo pasamos al router con .with_state(db) y lo leemos en los handlers con el extractor State. axum clona el Arc en cada petición (es barato: solo copia un puntero).

Aquí tienes el programa completo. Sustituye src/main.rs por él:

use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize)]
struct Todo {
id: u32,
title: String,
done: bool,
}
#[derive(Deserialize)]
struct NewTodo {
title: String,
}
// La "base de datos": una lista de tareas que todas las peticiones comparten.
type Db = Arc<Mutex<Vec<Todo>>>;
#[tokio::main]
async fn main() {
let db: Db = Arc::new(Mutex::new(Vec::new()));
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).delete(delete_todo))
.with_state(db);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("Escuchando en http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
// GET /todos
async fn list_todos(State(db): State<Db>) -> Json<Vec<Todo>> {
let todos = db.lock().unwrap();
Json(todos.clone())
}
// GET /todos/{id}
async fn get_todo(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Todo>, StatusCode> {
let todos = db.lock().unwrap();
match todos.iter().find(|todo| todo.id == id) {
Some(todo) => Ok(Json(todo.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
// POST /todos
async fn create_todo(State(db): State<Db>, Json(input): Json<NewTodo>) -> (StatusCode, Json<Todo>) {
let mut todos = db.lock().unwrap();
let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
let todo = Todo {
id,
title: input.title,
done: false,
};
todos.push(todo.clone());
(StatusCode::CREATED, Json(todo))
}
// DELETE /todos/{id}
async fn delete_todo(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != id);
if todos.len() < before {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}

Repasemos las ideas nuevas:

  • Una ruta, varios métodos. get(list_todos).post(create_todo) manda GET /todos y POST /todos a handlers distintos.
  • Los códigos de estado son valores de retorno. StatusCode viene del módulo http de axum. Un handler puede devolver un Result: Ok(Json(todo)) se convierte en un 200 con JSON, y Err(StatusCode::NOT_FOUND) en un 404 vacío. Una tupla (StatusCode::CREATED, Json(todo)) pone el estado y el cuerpo. Devolver solo StatusCode::NO_CONTENT (204) significa “hecho, no hay nada que devolver”.
  • lock() y el guard. db.lock().unwrap() espera hasta que ninguna otra petición esté usando la lista y luego te da un guard (guardián) que puedes leer y cambiar como un Vec normal. El candado se libera cuando el guard sale del ámbito, al final de la función.
  • Ids nuevos. El siguiente id es uno más que el id más grande de la lista, o 1 si la lista está vacía.
  • Borrar. retain se queda con todas las tareas cuyo id no coincide. Si la longitud no cambió, no se borró nada, así que devolvemos 404.

¿Por qué std::sync::Mutex y no uno asíncrono? Está bien (y es más rápido) siempre que no mantengas el candado a través de un .await. Ninguno de nuestros handlers lo hace: bloquean, trabajan con la lista y devuelven.

La lógica de la lista y el candado no necesita un servidor web, así que puedes probarla en el Playground. Estas funciones hacen el mismo trabajo que los handlers, sin axum:

use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
struct Todo {
id: u32,
title: String,
done: bool,
}
type Db = Arc<Mutex<Vec<Todo>>>;
fn create(db: &Db, title: &str) -> Todo {
let mut todos = db.lock().unwrap();
let id = todos.iter().map(|todo| todo.id).max().unwrap_or(0) + 1;
let todo = Todo { id, title: title.to_string(), done: false };
todos.push(todo.clone());
todo
}
fn get(db: &Db, id: u32) -> Option<Todo> {
let todos = db.lock().unwrap();
todos.iter().find(|todo| todo.id == id).cloned()
}
fn delete(db: &Db, id: u32) -> bool {
let mut todos = db.lock().unwrap();
let before = todos.len();
todos.retain(|todo| todo.id != id);
todos.len() < before
}
fn main() {
let db: Db = Arc::new(Mutex::new(Vec::new()));
let second_handle = Arc::clone(&db); // lo que hace axum en cada petición
println!("{:?}", create(&db, "Comprar leche"));
println!("{:?}", create(&second_handle, "Aprender axum"));
println!("{:?}", get(&db, 2));
println!("{:?}", get(&db, 99));
println!("¿borrada la 1? {}", delete(&db, 1));
println!("¿borrada la 1 otra vez? {}", delete(&db, 1));
println!("quedan: {}", db.lock().unwrap().len());
}

Salida:

Todo { id: 1, title: "Comprar leche", done: false }
Todo { id: 2, title: "Aprender axum", done: false }
Some(Todo { id: 2, title: "Aprender axum", done: false })
None
¿borrada la 1? true
¿borrada la 1 otra vez? false
quedan: 1
▶ Pruébalo tú mismo

La tarea creada a través de second_handle aparece cuando lees a través de db, porque los dos Arc apuntan a la misma lista.

Arranca el servidor con cargo run y, en una segunda terminal, prueba todos los endpoints. Al principio la lista está vacía:

Terminal window
curl http://127.0.0.1:3000/todos
[]

Crea una tarea. Fíjate en el estado 201 Created:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Comprar leche"}'
HTTP/1.1 201 Created
content-type: application/json
content-length: 45
{"id":1,"title":"Comprar leche","done":false}

Crea una segunda tarea y lístalas todas:

Terminal window
curl -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title":"Aprender axum"}'
curl http://127.0.0.1:3000/todos
{"id":2,"title":"Aprender axum","done":false}
[{"id":1,"title":"Comprar leche","done":false},{"id":2,"title":"Aprender axum","done":false}]

Pide una por su id, y pide una que no existe:

Terminal window
curl http://127.0.0.1:3000/todos/2
curl -i http://127.0.0.1:3000/todos/99
{"id":2,"title":"Aprender axum","done":false}
HTTP/1.1 404 Not Found
content-length: 0

Borra la tarea 1 e intenta borrarla otra vez:

Terminal window
curl -i -X DELETE http://127.0.0.1:3000/todos/1
curl -i -X DELETE http://127.0.0.1:3000/todos/1
HTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
content-length: 0
Terminal window
curl http://127.0.0.1:3000/todos
[{"id":2,"title":"Aprender axum","done":false}]

axum también gestiona algunos errores por ti. Un tipo equivocado en el JSON da un 422 que explica el problema, y un método que no configuraste para una ruta da 405 Method Not Allowed, con una cabecera que lista los métodos que sí funcionan:

Terminal window
curl -i -X POST http://127.0.0.1:3000/todos -H "Content-Type: application/json" -d '{"title": 42}'
curl -i -X PUT http://127.0.0.1:3000/todos/2
HTTP/1.1 422 Unprocessable Entity
content-type: text/plain; charset=utf-8
content-length: 130
Failed to deserialize the JSON body into the target type: title: invalid type: integer `42`, expected a string at line 1 column 12
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD,DELETE
content-length: 0

El 422 dice que title tiene un tipo no válido: llegó el entero 42 y se esperaba un texto. Detén el servidor con Ctrl + C y vuelve a arrancarlo: la lista está vacía. Los datos solo viven en memoria. Guardarlos es la primera idea de más abajo.

Usar la sintaxis antigua :id en las rutas. Muchos tutoriales de internet se escribieron para axum 0.7, que usaba /todos/:id. En axum 0.8 esto compila, pero el programa hace panic nada más arrancar:

thread 'main' (12345) panicked at src/main.rs:32:10:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.

El mensaje dice que los segmentos de la ruta no pueden empezar por : y que uses {capture}. La solución: escribe /todos/{id}.

Poner Json antes que otros extractores. El cuerpo de la petición solo se puede leer una vez, así que el extractor que lo lee (Json) tiene que ser el último parámetro. Si escribes create_todo(Json(input): Json<NewTodo>, State(db): State<Db>), el error no lo dice directamente. Las líneas importantes son:

error[E0277]: the trait bound `fn(Json<NewTodo>, State<...>) -> ... {create_todo}: Handler<_, ...>` is not satisfied
|
31 | .route("/todos", get(list_todos).post(create_todo))
| ---- ^^^^^^^^^^^ unsatisfied trait bound
|
= note: Consider using `#[axum::debug_handler]` to improve the error message

Cuando veas “Handler is not satisfied” (no se cumple el trait Handler), revisa el orden de tus parámetros, y comprueba que cada tipo de retorno sea algo que axum sepa convertir en una respuesta.

Olvidar -H "Content-Type: application/json" en curl. El servidor responde 415. El extractor Json necesita esa cabecera.

Olvidar reiniciar. El servidor que está funcionando es el programa antiguo. Detenlo con Ctrl + C y vuelve a hacer cargo run después de cada cambio.

Cuando la API básica funcione, aquí tienes buenos pasos siguientes. Pruébalos de uno en uno.

  • Marcar una tarea como hecha. Añade PATCH /todos/{id} con .patch(...) en la segunda ruta. Busca la tarea con iter_mut(), pon done = true y devuélvela.
  • Validación. No debería permitirse un título vacío. Un handler puede devolver Result<(StatusCode, Json<Todo>), (StatusCode, String)> y responder con Err((StatusCode::BAD_REQUEST, String::from("el título no puede estar vacío"))). Usa title.trim().is_empty() para comprobarlo.
  • Ids que nunca se repiten. Con “el id más grande más uno”, borrar la tarea más reciente permite que su id se vuelva a usar. Guarda en su lugar un contador aparte en tu estado, por ejemplo un struct con todos: Vec<Todo> y next_id: u32 dentro del Mutex.
  • Persistencia con un archivo JSON. Deriva también Deserialize en Todo. Al arrancar, lee todos.json con std::fs::read_to_string y serde_json::from_str (o empieza con la lista vacía si el archivo no existe). Después de cada cambio, vuelve a escribir la lista con serde_json::to_string_pretty y std::fs::write. Las páginas de lectura y escritura de archivos y JSON con Serde tienen todo lo que necesitas. Más adelante puedes cambiar el archivo por una base de datos de verdad, como SQLite.
  • Logging con tower-http. Ejecuta cargo add tower-http --features trace y cargo add tracing-subscriber, y añade una capa (layer) de logging. Ahora el servidor imprime una línea por cada petición, con el método, la ruta, el estado y cuánto tardó (consulta Logging en Rust):
use tower_http::trace::TraceLayer;
// al principio de main:
tracing_subscriber::fmt()
.with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG)
.init();
// al construir el router:
let app = Router::new()
.route("/todos", get(list_todos).post(create_todo))
.route("/todos/{id}", get(get_todo).delete(delete_todo))
.layer(TraceLayer::new_for_http())
.with_state(db);
  • Notas para desplegarlo. 127.0.0.1 solo acepta peticiones de tu propio ordenador. Para aceptar peticiones de otras máquinas (por ejemplo dentro de Docker o en un servidor), usa 0.0.0.0 en su lugar. Muchos servicios de hosting le dicen a tu programa qué puerto usar mediante una variable de entorno PORT, que puedes leer con std::env::var (consulta Entorno y procesos). Compila con cargo build --release para tener un servidor mucho más rápido. El resultado es un único archivo en target/release/ que puedes copiar a un servidor Linux o meter en una imagen pequeña de Docker. Para un sitio público, ponlo detrás de un proxy inverso (como Caddy o nginx) que se encargue de HTTPS.
  • Un handler es una async fn. Un router asocia un método y una ruta con un handler. axum::serve lo ejecuta sobre un TcpListener de tokio.
  • Los extractores sacan datos de la petición: Path para partes de la URL (/todos/{id}), Json para el cuerpo (siempre el último) y State para los datos compartidos.
  • Devuelve Json(value) para JSON, StatusCode para un estado, una tupla para poner los dos, o un Result para elegir entre éxito y un estado de error.
  • Arc<Mutex<Vec<Todo>>> comparte una lista de forma segura entre todas las peticiones. Bloquéala, úsala y deja que el guard se destruya. No mantengas el candado a través de un .await.
  • Prueba tu API con curl: -i muestra el estado, -X elige el método, -H añade una cabecera y -d envía un cuerpo.

Comprueba lo aprendido

1. En axum 0.8, ¿cómo escribes una ruta con un id en el camino?

2. Un handler devuelve (StatusCode::CREATED, Json(todo)). ¿Qué recibe el cliente?

3. ¿Por qué la lista compartida va envuelta en Arc<Mutex<...>>?

4. ¿Qué extractor tiene que ir el último en los parámetros de un handler?

5. Haces curl -X POST con algo de JSON pero olvidas la cabecera Content-Type: application/json. ¿Qué responde axum?

Puntuación: 0 / 5