Lezione 5 di 7 · 25 min di lettura
Un'API JSON
Un server che parla con altri programmi. Rispondere in JSON, leggere il JSON che arriva con require_json e un decoder, e scegliere il codice di stato giusto per ogni errore, da 415 a 422.
Server per programmi
Finora il server ha risposto a un browser, cioè a una persona: pagine HTML da guardare. Ma moltissimi server parlano con altri programmi: l’app sul telefono che chiede i messaggi nuovi, una pagina web che aggiorna un pezzo senza ricaricarsi, un altro server che chiede il meteo. A un programma l’HTML non serve: servono dati, e quasi sempre in JSON (lezione 8.3).
Un server che risponde a programmi, con regole precise su quali richieste accetta e cosa restituisce, si chiama API (Application Programming Interface, “interfaccia per programmi”). Per convenzione i suoi percorsi iniziano spesso con /api, così nello stesso server convivono le pagine per le persone e i dati per i programmi.
Rispondere in JSON
Scrivere la risposta lo sai già fare: un encoder della lezione 8.3 costruisce un json.Json, json.to_string lo trasforma in testo, e wisp.json_response(testo, codice) crea la risposta con l’intestazione content-type: application/json; charset=utf-8. Il pacchetto gleam_json è nel progetto exercises dal modulo 8.
Leggere il JSON che arriva
Per la direzione opposta c’è il middleware wisp.require_json. Controlla che la richiesta dichiari content-type: application/json, legge il corpo, controlla che sia JSON valido, e passa al resto un valore Dynamic: dati di forma sconosciuta, da verificare con un decoder (lezione 8.2). Tutto insieme, una piccola API per i libri:
import gleam/dynamic/decode
import gleam/http.{Get, Post}
import gleam/int
import gleam/io
import gleam/json
import gleam/list
import wisp.{type Request, type Response}
import wisp/simulate
pub type Book {
Book(title: String, year: Int)
}
fn book_decoder() -> decode.Decoder(Book) {
use title <- decode.field("title", decode.string)
use year <- decode.field("year", decode.int)
decode.success(Book(title:, year:))
}
fn book_to_json(book: Book) -> json.Json {
json.object([
#("title", json.string(book.title)),
#("year", json.int(book.year)),
])
}
const books = [Book("Dune", 1965), Book("Emma", 1815)]
pub fn handle_request(request: Request) -> Response {
case wisp.path_segments(request), request.method {
["api", "books"], Get -> list_books()
["api", "books"], Post -> create_book(request)
["api", "books"], _ -> wisp.method_not_allowed([Get, Post])
_, _ -> wisp.not_found()
}
}
fn list_books() -> Response {
json.array(books, book_to_json)
|> json.to_string
|> wisp.json_response(200)
}
fn create_book(request: Request) -> Response {
use data <- wisp.require_json(request)
case decode.run(data, book_decoder()) {
Ok(book) ->
book_to_json(book)
|> json.to_string
|> wisp.json_response(201)
Error(_) -> wisp.unprocessable_content()
}
}
pub fn main() -> Nil {
let ulysses =
json.object([#("title", json.string("Ulysses")), #("year", json.int(1922))])
let no_year = json.object([#("title", json.string("Ulysses"))])
[
simulate.request(Get, "/api/books"),
simulate.request(Post, "/api/books") |> simulate.json_body(ulysses),
simulate.request(Post, "/api/books") |> simulate.json_body(no_year),
simulate.request(Post, "/api/books") |> simulate.string_body("Ulysses"),
simulate.request(Post, "/api/books")
|> simulate.string_body("{\"title\": ")
|> simulate.header("content-type", "application/json"),
]
|> list.each(fn(request) {
let response = handle_request(request)
io.println(
int.to_string(response.status) <> " " <> simulate.read_body(response),
)
})
}200 [{"title":"Dune","year":1965},{"title":"Emma","year":1815}]
201 {"title":"Ulysses","year":1922}
422 Unprocessable content
415 Unsupported media type
400 Bad request: Invalid JSONIl routing qui guarda percorso e metodo insieme, con un case su due valori (lezione 2.5): ogni ramo è una coppia percorso, metodo. È un’alternativa al case annidato della lezione 9.3, comoda quando le strade sono poche.
Le cinque richieste finte mostrano i cinque esiti possibili di un POST:
| Codice | Quando | Chi risponde |
|---|---|---|
201 Created | Tutto giusto: il libro è “creato”, e la risposta lo restituisce | create_book |
422 Unprocessable Content | JSON valido, ma della forma sbagliata: manca year | il case sul decoder |
415 Unsupported Media Type | Il corpo non dichiara di essere JSON | require_json |
400 Bad Request | Dichiara di essere JSON, ma non lo è (è troncato) | require_json |
405 Method Not Allowed | Un metodo diverso da GET e POST | il terzo ramo |
La differenza tra 400 e 422 è la stessa della lezione 8.3 tra un errore di sintassi e un errore di forma: il primo vuol dire “non capisco cosa hai scritto”, il secondo “ho capito, ma non è quello che mi serve”. Un client che riceve codici precisi sa cosa correggere.
simulate.json_body costruisce una richiesta con un corpo JSON e l’intestazione giusta. simulate.string_body invece dichiara content-type: text/plain: per questo la quarta richiesta riceve 415, e per provare il JSON rotto della quinta abbiamo dovuto sovrascrivere l’intestazione con simulate.header.
Dettagli nerd Come fa il server a sapere cosa c'è nel corpo? (i tipi MIME)
Un corpo è una fila di byte, e dai byte da soli non si capisce se sono un’immagine, una pagina HTML o del JSON. Per questo HTTP ha l’intestazione content-type, che contiene un tipo MIME: un’etichetta standard nella forma categoria/formato. text/html è una pagina, text/plain testo semplice, application/json il JSON, image/png un’immagine PNG, application/x-www-form-urlencoded un modulo HTML.
Dopo il tipo possono esserci dei parametri, come ; charset=utf-8, che dice con quale codifica sono scritti i caratteri (lezione 1.4). Il nome MIME viene dalle email (Multipurpose Internet Mail Extensions), dove è nato per allegare file ai messaggi: il web l’ha preso in prestito.
Dal terminale
Con un modulo books_server che avvia books_api.handle_request (lo schema è sempre quello di library_server), puoi chiamare l’API con curl. Per mandare JSON servono due opzioni: -d con il corpo, e -H con l’intestazione giusta:
curl -i -H 'content-type: application/json' -d '{"title": "Ulysses", "year": 1922}' localhost:8000/api/booksHTTP/1.1 201 Created
content-type: application/json; charset=utf-8
content-length: 31
date: Mon, 28 Sep 2026 13:55:29 GMT
connection: keep-alive
{"title":"Ulysses","year":1922}Se dimentichi -H, curl dichiara che il corpo è un modulo HTML (application/x-www-form-urlencoded, quello che usa di solito con -d), e l’API risponde giustamente 415 Unsupported Media Type. È l’errore più comune quando si prova un’API a mano.
Quiz
Un client manda POST /api/books con l'intestazione content-type: application/json e il corpo {"title": "Dune", "year": "1965"}. Cosa risponde l'API della lezione?
Esercizio · sul tuo computer
Il convertitore
Crea src/converter_api.gleam: un’API che accetta solo POST, legge un JSON come {"celsius": 21.5} e risponde {"fahrenheit": ...}, con la formula celsius * 9 / 5 + 32. La temperatura può arrivare con o senza decimali (100 e 100.0 vanno bene tutti e due); se celsius manca o non è un numero, 422.
Nel main, prova tre corpi: 21.5, 100 (intero), e la stringa "hot", e stampa codice e corpo delle risposte:
200 {"fahrenheit":70.7}
200 {"fahrenheit":212.0}
422 Unprocessable contentSuggerimenti: il decoder che accetta interi e float l’hai scritto nella lezione 8.3 (decode.one_of con decode.map(int.to_float)). E per leggere un solo campo, senza un tipo apposta, c’è decode.at(["celsius"], decoder).
Mostra una soluzione (prima prova da solo!)
import gleam/dynamic/decode
import gleam/http.{Post}
import gleam/int
import gleam/io
import gleam/json
import gleam/list
import wisp.{type Request, type Response}
import wisp/simulate
fn number() -> decode.Decoder(Float) {
decode.one_of(decode.float, or: [decode.int |> decode.map(int.to_float)])
}
pub fn handle_request(request: Request) -> Response {
use <- wisp.require_method(request, Post)
use data <- wisp.require_json(request)
case decode.run(data, decode.at(["celsius"], number())) {
Ok(celsius) ->
json.object([#("fahrenheit", json.float(celsius *. 9.0 /. 5.0 +. 32.0))])
|> json.to_string
|> wisp.json_response(200)
Error(_) -> wisp.unprocessable_content()
}
}
pub fn main() -> Nil {
[
json.object([#("celsius", json.float(21.5))]),
json.object([#("celsius", json.int(100))]),
json.object([#("celsius", json.string("hot"))]),
]
|> list.each(fn(body) {
let response =
simulate.request(Post, "/convert")
|> simulate.json_body(body)
|> handle_request
io.println(
int.to_string(response.status) <> " " <> simulate.read_body(response),
)
})
}decode.at scende lungo un percorso di chiavi e applica il decoder a quello che trova: comodo quando ti serve un solo valore. Se la richiesta avesse più campi, un tipo con il suo decoder, come Book, sarebbe più chiaro.
Ricapitolando
- Un’API è un server per programmi: riceve e restituisce dati, di solito JSON, spesso su percorsi che iniziano con
/api. wisp.json_response(testo, codice)risponde con JSON; il testo si costruisce con un encoder ejson.to_string.use data <- wisp.require_json(request)legge il corpo:415se non dichiaraapplication/json,400se non è JSON valido, altrimenti unDynamicda passare a un decoder.- Se il decoder fallisce,
wisp.unprocessable_content(), cioè422; se va bene e hai creato qualcosa,201. - Il tipo MIME nell’intestazione
content-typedice che cosa c’è nel corpo. - Da curl:
-H 'content-type: application/json' -d '...'. Senza-H, è un modulo, e arriva415. - Per le prove:
simulate.json_body(richiesta, json).
Per ora i libri sono una costante, e il POST non salva niente. Nella prossima lezione il server avrà una memoria, condivisa da tutti gli utenti, e vedremo come fa la BEAM a servirne tanti insieme.