Salta ai contenuti

Progetto: API web con Axum in Rust

In questo progetto costruisci un vero server web: una piccola API per le cose da fare (to-do). Altri programmi (un sito, un’app per il telefono, o semplicemente curl nel tuo terminale) le mandano richieste HTTP, e lei risponde con del JSON. È lo stesso genere di cosa che fa girare il «back end» della maggior parte delle app che usi.

Useremo axum, un framework web popolare e amichevole creato dal team di Tokio. Si appoggia su Rust asincrono e su serde, quindi questo progetto mette insieme tante cose che hai imparato: struct, Result, smart pointer e stato condiviso tra thread. Se hai fatto la lista di cose da fare (CLI), riconoscerai l’idea. Questa volta la lista vive dietro un indirizzo web.

Alla fine, il tuo server risponderà a queste richieste:

Metodo e percorso Cosa fa Stato in caso di successo
GET /todos Elenca tutte le cose da fare 200 OK
GET /todos/{id} Restituisce una cosa da fare 200 OK, oppure 404 Not Found
POST /todos Crea una cosa da fare a partire da JSON 201 Created
DELETE /todos/{id} Elimina una cosa da fare 204 No Content, oppure 404 Not Found

In JSON, una cosa da fare ha questo aspetto:

{"id":1,"title":"Buy milk","done":false}

Una nota importante: un server web aspetta richieste all’infinito e ha bisogno di una porta di rete, quindi non può girare nel Rust Playground. Il codice del server in questa pagina è pensato per il tuo computer, con cargo run. Ogni passo qui sotto è stato compilato ed eseguito, e le risposte mostrate sono reali (per questo i dati di esempio restano in inglese, esattamente come li ha restituiti il server). Dove una parte del programma può girare nel Playground, trovi un pulsante «Provalo tu».

Crea un nuovo progetto e aggiungi quattro crate:

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

Il tuo Cargo.toml ora contiene qualcosa del genere (i numeri di versione esatti potrebbero essere più recenti):

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

A cosa serve ognuno:

  • axum gestisce l’HTTP: legge le richieste e invia le risposte.
  • tokio è il runtime asincrono che fa girare tutto. Vedi Async/await.
  • serde e serde_json trasformano le tue struct in JSON e viceversa. Vedi JSON con Serde.

Ti serve anche curl per mandare le richieste. È incluso in macOS, Linux e Windows 10 e successivi. (Su Windows scrivi curl.exe in PowerShell, perché lì curl da solo significa un’altra cosa.)

Sostituisci src/main.rs con questo:

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!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello, world!"
}

Qui lavorano insieme tre pezzi:

  • Un handler è una async fn che risponde a una richiesta. hello restituisce un testo, e axum lo rimanda indietro come risposta.
  • Il router collega i percorsi agli handler. .route("/", get(hello)) significa «quando qualcuno manda una richiesta GET a /, chiama hello».
  • Il listener apre la porta 3000 sul tuo computer (127.0.0.1), e axum::serve inizia a rispondere alle richieste. Gira finché non lo fermi.

Eseguilo con cargo run. Il programma stampa una riga e poi aspetta:

Listening on http://127.0.0.1:3000

Lascialo girare, apri un secondo terminale e mandagli una richiesta:

Terminal window
curl http://127.0.0.1:3000/
Hello, world!

Il server non aggiunge un a capo dopo il testo, quindi il prompt del terminale potrebbe comparire subito dopo Hello, world!. È normale. In questa pagina ogni risposta è mostrata su una riga a sé. Puoi anche aprire http://127.0.0.1:3000/ nel browser. Premi Ctrl + C nel primo terminale per fermare il server. Devi fermarlo e riavviarlo ogni volta che cambi il codice.

Di solito le API rispondono in JSON. In axum avvolgi in Json(...) qualsiasi valore che implementa Serialize di serde, e axum lo trasforma in JSON e imposta l’header content-type giusto:

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!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello() -> &'static str {
"Hello, world!"
}
async fn example_todo() -> Json<Todo> {
let todo = Todo {
id: 1,
title: String::from("Learn axum"),
done: false,
};
Json(todo)
}

Il flag -i dice a curl di mostrare anche la riga di stato e gli header:

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

(curl stampa anche un header date, che in questa pagina omettiamo.) Le chiavi JSON sono esattamente i nomi dei campi della struct. Puoi controllare la forma di quel JSON nel Playground, senza server, perché è serde puro. Questa è anche la struct NewTodo che useremo al passo 4 per i dati in arrivo:

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("Learn axum"), done: false };
println!("{}", serde_json::to_string(&todo).unwrap());
let list = vec![todo.clone(), Todo { id: 2, title: String::from("Buy milk"), done: true }];
println!("{}", serde_json::to_string(&list).unwrap());
let input: NewTodo = serde_json::from_str(r#"{"title":"Walk the dog"}"#).unwrap();
println!("{:?}", input);
let bad = serde_json::from_str::<NewTodo>(r#"{"name":"Walk the dog"}"#);
println!("{}", bad.unwrap_err());
}

Output:

{"id":1,"title":"Learn axum","done":false}
[{"id":1,"title":"Learn axum","done":false},{"id":2,"title":"Buy milk","done":true}]
NewTodo { title: "Walk the dog" }
missing field `title` at line 1 column 23
▶ Provalo tu

L’ultima riga è l’errore di serde (in inglese): manca il campo title alla riga 1, colonna 23. Un Vec<Todo> diventa un array JSON. È esattamente quello che restituirà GET /todos.

In un percorso come /todos/2, il 2 cambia da una richiesta all’altra. In axum 0.8 segni quella parte della route con le parentesi graffe, {id}, e la leggi nell’handler con l’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!("Listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
async fn hello_name(Path(name): Path<String>) -> String {
format!("Hello, {name}!")
}
async fn add(Path((a, b)): Path<(i32, i32)>) -> String {
format!("{a} + {b} = {}", a + b)
}

Un extractor è un parametro dell’handler che tira fuori un pezzo della richiesta. Path(name): Path<String> è un pattern: scarta il Path e ti dà la String che c’è dentro. Con due parametri ottieni una tupla.

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

Ecco una bella sorpresa. Hai detto che a e b sono i32, quindi axum lo controlla per te. Manda qualcosa che non è un numero:

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`

Il messaggio dice che il valore x in posizione 1 non si può convertire in i32. Il tuo handler non è nemmeno partito. I tipi nella firma della funzione sono la validazione.

Per creare una cosa da fare, il client manda del JSON nel corpo (body) della richiesta. L’extractor Json legge il corpo e lo deserializza nella tua struct. Il client manda solo un titolo, quindi per l’input usiamo una struct separata, NewTodo:

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!("Listening on 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)
}

Nella route usa post(...) invece di get(...). Con curl, -X POST imposta il metodo, -H aggiunge un header e -d manda il corpo:

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

Di nuovo, axum controlla l’input prima che parta il tuo codice. Se al JSON manca title, ottieni 422 Unprocessable Entity:

Terminal window
curl -i -X POST http://127.0.0.1:3000/echo -H "Content-Type: application/json" -d '{"name":"Buy milk"}'
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 19

E se ti dimentichi l’header Content-Type, ottieni 415 Unsupported Media Type con il messaggio Expected request with `Content-Type: application/json` . Quell’header è la cosa che si dimentica più spesso quando si fanno prove con curl.

Finora ogni handler si inventava i suoi dati. Una vera API deve ricordarsi le cose da fare tra una richiesta e l’altra. Le richieste possono arrivare contemporaneamente e vengono gestite su thread diversi, quindi la lista deve essere sicura da condividere. È il lavoro della coppia che hai conosciuto nella pagina Concorrenza:

  • Mutex<Vec<Todo>> fa in modo che una sola richiesta alla volta modifichi la lista.
  • Arc<...> permette a tanti handler di possedere un puntatore a quella stessa lista (vedi Smart pointer).

Diamo al tipo un nome breve con type Db = Arc<Mutex<Vec<Todo>>>;, lo passiamo al router con .with_state(db) e lo leggiamo negli handler con l’extractor State. axum clona l’Arc per ogni richiesta (costa poco: copia solo un puntatore).

Ecco il programma completo. Sostituisci src/main.rs con questo:

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,
}
// Il «database»: una lista di cose da fare che ogni richiesta può condividere.
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!("Listening on 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
}
}

Vediamo le idee nuove:

  • Un percorso, più metodi. get(list_todos).post(create_todo) manda GET /todos e POST /todos a handler diversi.
  • I codici di stato sono valori restituiti. StatusCode viene dal modulo http di axum. Un handler può restituire un Result: Ok(Json(todo)) diventa un 200 con JSON, ed Err(StatusCode::NOT_FOUND) diventa un 404 vuoto. Una tupla (StatusCode::CREATED, Json(todo)) imposta lo stato e il corpo. Restituire solo StatusCode::NO_CONTENT (204) significa «fatto, niente da rimandare».
  • lock() e la guardia. db.lock().unwrap() aspetta che nessun’altra richiesta stia usando la lista, poi ti dà una guardia (guard) che puoi leggere e modificare come un normale Vec. Il lock viene rilasciato quando la guardia esce dallo scope alla fine della funzione.
  • Nuovi id. Il prossimo id è uno in più dell’id più grande nella lista, oppure 1 se la lista è vuota.
  • Eliminare. retain tiene tutte le cose da fare il cui id non corrisponde. Se la lunghezza non è cambiata, non è stato eliminato niente, quindi restituiamo 404.

Perché std::sync::Mutex e non uno asincrono? Va bene (ed è più veloce) finché non tieni il lock attraverso un .await. Nessuno dei nostri handler lo fa: prendono il lock, lavorano con la lista e restituiscono il risultato.

La logica di lista e lock non ha bisogno di un server web, quindi puoi provarla nel Playground. Queste funzioni fanno lo stesso lavoro degli handler, senza 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); // quello che fa axum per ogni richiesta
println!("{:?}", create(&db, "Buy milk"));
println!("{:?}", create(&second_handle, "Learn axum"));
println!("{:?}", get(&db, 2));
println!("{:?}", get(&db, 99));
println!("eliminato 1? {}", delete(&db, 1));
println!("eliminato di nuovo 1? {}", delete(&db, 1));
println!("rimasti: {}", db.lock().unwrap().len());
}

Output:

Todo { id: 1, title: "Buy milk", done: false }
Todo { id: 2, title: "Learn axum", done: false }
Some(Todo { id: 2, title: "Learn axum", done: false })
None
eliminato 1? true
eliminato di nuovo 1? false
rimasti: 1
▶ Provalo tu

La cosa da fare creata tramite second_handle compare quando leggi tramite db, perché entrambi gli Arc puntano alla stessa lista.

Avvia il server con cargo run e, in un secondo terminale, prova ogni endpoint. All’inizio la lista è vuota:

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

Crea una cosa da fare. Nota lo stato 201 Created:

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

Creane una seconda ed elencale tutte:

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

Chiedine una per id, e chiedine una che non esiste:

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

Elimina la cosa da fare 1, poi prova a eliminarla di nuovo:

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":"Learn axum","done":false}]

axum gestisce anche alcuni errori al posto tuo. Un tipo sbagliato nel JSON dà un 422 che spiega il problema, e un metodo che non hai previsto per un percorso dà 405 Method Not Allowed, con un header che elenca i metodi che funzionano:

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

Il primo messaggio dice che title doveva essere una stringa e invece è arrivato il numero intero 42. Ferma il server con Ctrl + C e riavvialo: la lista è vuota. I dati vivono solo in memoria. Salvarli è la prima idea qui sotto.

Usare la vecchia sintassi :id per le route. Molti tutorial online sono stati scritti per axum 0.7, che usava /todos/:id. In axum 0.8 questo compila, ma il programma va in panic appena parte:

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.

Il messaggio dice che i segmenti del percorso non devono iniziare con : e che per catturare un valore devi usare {capture}. La soluzione: scrivi /todos/{id}.

Mettere Json prima degli altri extractor. Il corpo della richiesta si può leggere una volta sola, quindi l’extractor che lo legge (Json) deve essere l’ultimo parametro. Se scrivi create_todo(Json(input): Json<NewTodo>, State(db): State<Db>), l’errore non lo dice in modo diretto. Le righe importanti sono:

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

Quando vedi «Handler is not satisfied» (il vincolo di trait Handler non è soddisfatto), controlla l’ordine dei parametri, e controlla che ogni tipo restituito sia qualcosa che axum sa trasformare in una risposta.

Dimenticare -H "Content-Type: application/json" in curl. Il server risponde 415. L’header è obbligatorio per l’extractor Json.

Dimenticare di riavviare. Il server in esecuzione è il vecchio programma. Fermalo con Ctrl + C e lancia di nuovo cargo run dopo ogni modifica.

Quando l’API di base funziona, ecco dei buoni passi successivi. Provali uno alla volta.

  • Segnare una cosa da fare come completata. Aggiungi PATCH /todos/{id} con .patch(...) sulla seconda route. Trova la cosa da fare con iter_mut(), imposta done = true e restituiscila.
  • Validazione. Un titolo vuoto non dovrebbe essere ammesso. Un handler può restituire Result<(StatusCode, Json<Todo>), (StatusCode, String)>, e rimandare Err((StatusCode::BAD_REQUEST, String::from("il titolo non può essere vuoto"))). Per controllare, usa title.trim().is_empty().
  • Id che non si ripetono mai. Con «id più grande più uno», eliminare la cosa da fare più recente permette di riusare il suo id. Tieni invece un contatore separato nel tuo stato, per esempio una struct con todos: Vec<Todo> e next_id: u32 dentro il Mutex.
  • Persistenza con un file JSON. Fai derivare a Todo anche Deserialize. All’avvio, leggi todos.json con std::fs::read_to_string e serde_json::from_str (oppure parti vuoto se il file non esiste). Dopo ogni modifica, riscrivi la lista con serde_json::to_string_pretty e std::fs::write. Le pagine I/O su file e JSON con Serde hanno tutto quello che ti serve. Più avanti puoi sostituire il file con un vero database come SQLite (vedi Database).
  • Log con tower-http. Esegui cargo add tower-http --features trace e cargo add tracing-subscriber, poi aggiungi uno strato (layer) di logging. Ora il server stampa una riga per ogni richiesta, con metodo, percorso, stato e quanto tempo ha impiegato:
use tower_http::trace::TraceLayer;
// all'inizio di main:
tracing_subscriber::fmt()
.with_max_level(tracing_subscriber::filter::LevelFilter::DEBUG)
.init();
// quando costruisci il 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);
  • Note sul deploy. 127.0.0.1 accetta richieste solo dal tuo computer. Per accettare richieste da altre macchine (per esempio dentro Docker o su un server), fai il bind su 0.0.0.0. Molti servizi di hosting dicono al tuo programma quale porta usare tramite una variabile d’ambiente PORT, che puoi leggere con std::env::var (vedi Ambiente e processi). Compila con cargo build --release per un server molto più veloce. Il risultato è un singolo file in target/release/ che puoi copiare su un server Linux, o mettere in una piccola immagine Docker. Per un sito pubblico, mettilo dietro un reverse proxy (come Caddy o nginx) che gestisca l’HTTPS.
  • Un handler è una async fn. Un router associa un metodo e un percorso a un handler. axum::serve lo fa girare su un TcpListener di tokio.
  • Gli extractor prendono dati dalla richiesta: Path per parti dell’URL (/todos/{id}), Json per il corpo (sempre per ultimo) e State per i dati condivisi.
  • Restituisci Json(valore) per il JSON, StatusCode per uno stato, una tupla per impostarli entrambi, oppure un Result per scegliere tra successo e uno stato di errore.
  • Arc<Mutex<Vec<Todo>>> condivide in sicurezza una lista tra tutte le richieste. Prendi il lock, usalo e lascia che la guardia venga rilasciata. Non tenerlo attraverso un .await.
  • Prova la tua API con curl: -i mostra lo stato, -X imposta il metodo, -H aggiunge un header e -d manda un corpo.

Verifica veloce

1. In axum 0.8, come scrivi una route con un id nel percorso?

2. Un handler restituisce (StatusCode::CREATED, Json(todo)). Cosa riceve il client?

3. Perché la lista condivisa è avvolta in Arc<Mutex<...>>?

4. Quale extractor deve venire per ultimo tra i parametri di un handler?

5. Fai curl -X POST con del JSON ma dimentichi l'header Content-Type: application/json. Cosa risponde axum?

Punteggio: 0 / 5