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:

src/books_api.gleam
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),
    )
  })
}
output
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 JSON

Il 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:

CodiceQuandoChi risponde
201 CreatedTutto giusto: il libro è “creato”, e la risposta lo restituiscecreate_book
422 Unprocessable ContentJSON valido, ma della forma sbagliata: manca yearil case sul decoder
415 Unsupported Media TypeIl corpo non dichiara di essere JSONrequire_json
400 Bad RequestDichiara di essere JSON, ma non lo è (è troncato)require_json
405 Method Not AllowedUn metodo diverso da GET e POSTil 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:

terminale
curl -i -H 'content-type: application/json' -d '{"title": "Ulysses", "year": 1922}' localhost:8000/api/books
output
HTTP/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:

output
200 {"fahrenheit":70.7}
200 {"fahrenheit":212.0}
422 Unprocessable content

Suggerimenti: 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!)
src/converter_api.gleam
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 e json.to_string.
  • use data <- wisp.require_json(request) legge il corpo: 415 se non dichiara application/json, 400 se non è JSON valido, altrimenti un Dynamic da 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-type dice che cosa c’è nel corpo.
  • Da curl: -H 'content-type: application/json' -d '...'. Senza -H, è un modulo, e arriva 415.
  • 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.