Lezione 3 di 6 · 25 min di lettura

JSON

Il formato con cui si scambiano dati quasi tutti i programmi del mondo. Il pacchetto gleam_json, leggere JSON con i decoder, gli errori di sintassi e di forma, null e Option, e scrivere JSON a partire dai tuoi tipi.

La lingua franca dei dati

Quando due programmi devono scambiarsi dati, un server e un browser, un’app e il suo backend, un programma e il file dove salva le impostazioni, quasi sempre lo fanno in JSON:

json
{
  "title": "Dune",
  "year": 1965,
  "tags": ["sci-fi", "classic"],
  "rating": null
}

Il JSON è testo, e ha pochi ingredienti: oggetti tra graffe (coppie chiave-valore, con le chiavi sempre tra virgolette), array tra quadre, stringhe, numeri, true, false e null. Il nome viene da JavaScript Object Notation, perché nasce dalla sintassi degli oggetti di JavaScript; ma oggi lo parlano tutti i linguaggi.

Dettagli nerd Perché trasformare i dati in testo? (la serializzazione)

Dentro un programma, un record Book è una struttura in memoria: una tupla con dentro dei puntatori a un binario, a una lista, e così via. Quella struttura ha senso solo dentro quel programma, in quel momento: gli indirizzi di memoria non significano niente per un altro computer, e spariscono quando il programma termina.

Per spedire un dato o salvarlo, bisogna trasformarlo in una sequenza di byte che si possa rileggere altrove: si chiama serializzazione (e l’operazione inversa, deserializzazione). Il JSON è un formato di serializzazione testuale: meno compatto di un formato binario, ma leggibile da un essere umano, e capito ovunque.

Il pacchetto gleam_json

La libreria standard non legge né scrive JSON: serve il pacchetto gleam_json. Dalla cartella exercises:

terminale
gleam add gleam_json

Il modulo si chiama gleam/json, e fa due cose: json.parse trasforma testo JSON in valori Gleam, usando un decoder della lezione precedente; le funzioni json.object, json.string e compagnia costruiscono JSON a partire dai tuoi valori.

Leggere JSON

Leggere un JSON è decodificarlo: json.parse(from: testo, using: decoder) controlla la sintassi e poi applica il decoder, esattamente come decode.run.

src/read_json.gleam
import gleam/dynamic/decode
import gleam/io
import gleam/json
import gleam/option.{type Option}
import gleam/string

pub type Book {
  Book(title: String, year: Int, tags: List(String), rating: Option(Float))
}

fn book_decoder() -> decode.Decoder(Book) {
  use title <- decode.field("title", decode.string)
  use year <- decode.field("year", decode.int)
  use tags <- decode.field("tags", decode.list(decode.string))
  use rating <- decode.field("rating", decode.optional(decode.float))
  decode.success(Book(title:, year:, tags:, rating:))
}

pub fn main() -> Nil {
  let text =
    "{
  \"title\": \"Dune\",
  \"year\": 1965,
  \"tags\": [\"sci-fi\", \"classic\"],
  \"rating\": null
}"
  io.println(string.inspect(json.parse(text, book_decoder())))
}
output
Ok(Book("Dune", 1965, ["sci-fi", "classic"], None))

Il testo JSON è una normale stringa Gleam, su più righe, con le virgolette interne scritte \" (lezione 1.6). Nei programmi veri arriva da un file o dalla rete, e le virgolette non vanno protette; lo faremo nella prossima lezione.

Il campo rating vale null, e il decoder decode.optional(decode.float) lo trasforma in None: in JSON null vuol dire “nessun valore”, e in Gleam è Option a dirlo. Un voto presente, come 4.5, diventerebbe Some(4.5). Se un oggetto JSON ha campi in più, che il decoder non chiede, vengono semplicemente ignorati.

Quando il JSON è sbagliato

json.parse può fallire in due modi molto diversi: il testo non è JSON valido, oppure è JSON valido ma non ha la forma che il decoder si aspetta. Il tipo json.DecodeError li distingue:

src/broken_json.gleam
import gleam/dynamic/decode
import gleam/io
import gleam/json
import gleam/string

pub fn main() -> Nil {
  let decoder = decode.list(decode.int)
  io.println(string.inspect(json.parse("[1, 2, 3]", decoder)))
  io.println(string.inspect(json.parse("[1, 2, 3", decoder)))
  io.println(string.inspect(json.parse("[1, two, 3]", decoder)))
  io.println(string.inspect(json.parse("[1, 2.5, 3]", decoder)))
}
output
Ok([1, 2, 3])
Error(UnexpectedEndOfInput)
Error(UnexpectedByte("0x77"))
Error(UnableToDecode([DecodeError("Int", "Float", ["1"])]))
  • UnexpectedEndOfInput: il testo finisce prima del previsto (manca la ]).
  • UnexpectedByte("0x77"): a un certo punto c’è un carattere che non può stare lì. 0x77 è il codice del carattere, in esadecimale: è la w di two. E perché non la t? Perché una t in quel punto va ancora bene: il parser pensa che stia arrivando true, e si accorge dell’errore solo alla lettera dopo. (Le stringhe, in JSON, vogliono le virgolette.)
  • UnableToDecode(...): il JSON è corretto, ma non è quello che il decoder vuole. Dentro ci sono i DecodeError della lezione precedente: all’indice 1 c’è un Float, non un Int.

Quiz

json.parse("{\"year\": \"1965\"}", decoder), con un decoder che vuole year intero. Che errore ottieni?

Scrivere JSON

Per la direzione opposta, gleam/json ha una funzione per ogni tipo di valore JSON: json.string, json.int, json.float, json.bool, json.null(). Poi json.object riceve una lista di coppie chiave-valore, e json.array(from: lista, of: funzione) trasforma ogni elemento di una lista Gleam. Il risultato è un valore di tipo json.Json, che json.to_string trasforma in testo.

Di solito si scrive una funzione encoder per ogni tipo, simmetrica al decoder:

src/write_json.gleam
import gleam/io
import gleam/json
import gleam/option.{type Option, None, Some}

pub type Book {
  Book(title: String, year: Int, tags: List(String), rating: Option(Float))
}

fn book_to_json(book: Book) -> json.Json {
  json.object([
    #("title", json.string(book.title)),
    #("year", json.int(book.year)),
    #("tags", json.array(book.tags, json.string)),
    #("rating", json.nullable(book.rating, json.float)),
  ])
}

pub fn main() -> Nil {
  let dune = Book("Dune", 1965, ["sci-fi", "classic"], Some(4.5))
  let emma = Book("Emma", 1815, [], None)
  io.println(json.to_string(book_to_json(dune)))
  io.println(json.to_string(json.array([dune, emma], book_to_json)))
}
output
{"title":"Dune","year":1965,"tags":["sci-fi","classic"],"rating":4.5}
[{"title":"Dune","year":1965,"tags":["sci-fi","classic"],"rating":4.5},{"title":"Emma","year":1815,"tags":[],"rating":null}]

json.nullable è il contrario di decode.optional: Some(4.5) diventa 4.5, None diventa null. json.to_string produce JSON compatto, senza spazi né a capo: è fatto per le macchine, non per gli occhi.

Un decoder e un encoder per lo stesso tipo sono una coppia: con i due insieme, un valore Gleam può fare il giro completo, testo → Book → testo, senza perdere niente. Un buon test da scrivere, per un tipo importante, è proprio questo.

Esercizio · sul tuo computer

Il meteo

Crea src/weather.gleam. Parti da questo JSON, una lista di letture di temperatura (nota che quella di Oslo e quella di Lima non hanno decimali):

json
[
  {"city": "Rome", "temperature": 21.5},
  {"city": "Oslo", "temperature": 4},
  {"city": "Cairo", "temperature": 30.5},
  {"city": "Lima", "temperature": 16}
]

Definisci un tipo Reading(city: String, temperature: Float) e il suo decoder, che deve accettare temperature intere e con la virgola. Leggi la lista con json.parse, poi stampa la città più calda, e un oggetto JSON con la città più calda e la temperatura media:

output
Warmest: Cairo
{"warmest":"Cairo","average":18.0}

Se il JSON non si legge, stampa Invalid data.

Mostra una soluzione (prima prova da solo!)
src/weather.gleam
import gleam/dynamic/decode
import gleam/int
import gleam/io
import gleam/json
import gleam/list

pub type Reading {
  Reading(city: String, temperature: Float)
}

fn number() -> decode.Decoder(Float) {
  decode.one_of(decode.float, or: [decode.int |> decode.map(int.to_float)])
}

fn reading_decoder() -> decode.Decoder(Reading) {
  use city <- decode.field("city", decode.string)
  use temperature <- decode.field("temperature", number())
  decode.success(Reading(city:, temperature:))
}

pub fn main() -> Nil {
  let text =
    "[
  {\"city\": \"Rome\", \"temperature\": 21.5},
  {\"city\": \"Oslo\", \"temperature\": 4},
  {\"city\": \"Cairo\", \"temperature\": 30.5},
  {\"city\": \"Lima\", \"temperature\": 16}
]"
  case json.parse(text, decode.list(reading_decoder())) {
    Ok([first, ..rest]) -> report(first, rest)
    Ok([]) -> io.println("No readings")
    Error(_) -> io.println("Invalid data")
  }
}

fn report(first: Reading, rest: List(Reading)) -> Nil {
  let warmest =
    list.fold(rest, first, fn(best, reading) {
      case reading.temperature >. best.temperature {
        True -> reading
        False -> best
      }
    })
  let readings = [first, ..rest]
  let total = list.fold(readings, 0.0, fn(sum, r) { sum +. r.temperature })
  let average = total /. int.to_float(list.length(readings))
  io.println("Warmest: " <> warmest.city)
  json.object([
    #("warmest", json.string(warmest.city)),
    #("average", json.float(average)),
  ])
  |> json.to_string
  |> io.println
}

Il pattern Ok([first, ..rest]) fa due cose in una: controlla che la lettura sia andata bene e che la lista non sia vuota. Così report riceve per forza almeno una lettura, e il fold per la più calda può partire dalla prima.

Ricapitolando

  • Il JSON è testo con oggetti, array, stringhe, numeri, booleani e null: il formato più comune per scambiare dati.
  • Serve il pacchetto gleam_json (gleam add gleam_json), modulo gleam/json.
  • json.parse(from: testo, using: decoder) legge; l’errore è UnexpectedEndOfInput, UnexpectedByte, UnexpectedSequence (sintassi) o UnableToDecode (forma).
  • I numeri senza decimali sono Int: decode.one_of con decode.map(int.to_float) accetta entrambi.
  • null si legge con decode.optional e si scrive con json.nullable: in Gleam è Option.
  • json.object, json.array, json.string… costruiscono un json.Json; json.to_string lo trasforma in testo.
  • Per ogni tipo importante: un decoder e un encoder, uno lo specchio dell’altro.

Nella prossima lezione il JSON uscirà dalle stringhe: leggeremo e scriveremo file, e daremo istruzioni al programma dalla riga di comando.