API REST in Go: serializzazione JSON
Le API REST comunicano quasi sempre attraverso JSON, e Go offre nella libreria standard il pacchetto encoding/json, potente e flessibile. Finora lo abbiamo usato in modo elementare; in questa puntata ne esploriamo le funzionalità con attenzione, perché una gestione accurata della serializzazione è ciò che distingue un'API curata da una approssimativa. Vedremo i tag delle struct, la gestione dei valori opzionali e nulli, la codifica delle date, la serializzazione personalizzata e costruiremo delle funzioni di supporto per uniformare tutte le risposte del nostro catalogo.
Marshal e Unmarshal
Le due operazioni fondamentali sono la marshalling, ovvero la conversione di un valore Go in JSON, e la sua inversa, l'unmarshalling. Le funzioni json.Marshal e json.Unmarshal operano su slice di byte, mentre json.NewEncoder e json.NewDecoder lavorano direttamente su stream come il corpo di una richiesta o di una risposta. Nel contesto di un server HTTP gli stream sono la scelta migliore, perché evitano di materializzare l'intero payload in memoria.
book := Book{ID: 1, Title: "Se una notte d'inverno", Author: "Calvino", Year: 1979}
// Marshalling: da struct a JSON
data, err := json.Marshal(book)
if err != nil {
log.Fatal(err)
}
// data contiene: {"id":1,"title":"Se una notte d'inverno","author":"Calvino","year":1979}
// Unmarshalling: da JSON a struct
var decoded Book
if err := json.Unmarshal(data, &decoded); err != nil {
log.Fatal(err)
}
I tag delle struct
Per impostazione predefinita il pacchetto usa i nomi dei campi Go come chiavi JSON, ma i nomi Go seguono la convenzione con l'iniziale maiuscola, obbligatoria perché il campo sia esportato e quindi visibile al pacchetto json. Poiché le API REST adottano tradizionalmente chiavi in minuscolo, controlliamo la corrispondenza tramite i tag, annotazioni testuali poste accanto alla dichiarazione del campo.
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Year int `json:"year"`
ISBN string `json:"isbn,omitempty"`
Description string `json:"-"`
PublishedAt time.Time `json:"published_at"`
}
Ogni tag merita un commento. La chiave json:"id" mappa il campo ID sulla chiave id. L'opzione omitempty applicata a ISBN fa sì che il campo venga omesso dal JSON prodotto quando ha il valore zero della sua tipologia, in questo caso la stringa vuota: è utile per non ingombrare la risposta con campi privi di contenuto. Il tag speciale json:"-" applicato a Description esclude completamente il campo dalla serializzazione, tecnica preziosa per nascondere dati interni che non vogliamo esporre nell'API.
La trappola di omitempty
L'opzione omitempty è comoda ma nasconde un'insidia. Considera un campo numerico come Year: il suo valore zero è lo zero numerico. Se marcassimo il campo con omitempty, un libro pubblicato in un ipotetico anno zero, o più realisticamente un campo lasciato non valorizzato, verrebbe omesso, rendendo impossibile per il client distinguere tra "assente" e "pari a zero". Lo stesso vale per i booleani, dove false è il valore zero. Quando questa distinzione conta, la soluzione idiomatica è usare un puntatore.
type BookUpdate struct {
// Un puntatore permette di distinguere tra "campo assente"
// (puntatore nil) e "campo presente con valore zero".
Title *string `json:"title,omitempty"`
Year *int `json:"year,omitempty"`
}
Con questa modellazione, se il client invia {"year": 0} il puntatore Year punterà a un intero pari a zero, mentre se il campo è del tutto assente il puntatore resterà nil. Questa distinzione diventa fondamentale nelle operazioni di aggiornamento parziale tramite PATCH, dove dobbiamo modificare solo i campi effettivamente inviati.
Codificare le date
Il tipo time.Time implementa già le interfacce di serializzazione del pacchetto json e viene codificato nel formato RFC 3339, uno standard universalmente riconosciuto per le date in JSON. Un valore come 2024-05-20T14:30:00Z è quindi prodotto automaticamente. Se invece dovessimo interfacciarci con un sistema che pretende un formato diverso, potremmo definire un tipo personalizzato che implementa le interfacce Marshaler e Unmarshaler.
// Date rappresenta una data senza componente oraria, serializzata
// nel formato "AAAA-MM-GG".
type Date struct {
time.Time
}
const dateLayout = "2006-01-02"
// MarshalJSON produce la rappresentazione JSON personalizzata.
func (d Date) MarshalJSON() ([]byte, error) {
// Racchiudiamo la data formattata tra virgolette, come richiede JSON
formatted := "\"" + d.Format(dateLayout) + "\""
return []byte(formatted), nil
}
// UnmarshalJSON interpreta una stringa JSON come data.
func (d *Date) UnmarshalJSON(data []byte) error {
// Rimuoviamo le virgolette che delimitano la stringa JSON
text := strings.Trim(string(data), "\"")
parsed, err := time.Parse(dateLayout, text)
if err != nil {
return err
}
d.Time = parsed
return nil
}
Il valore 2006-01-02 non è arbitrario: è il layout di riferimento che Go usa per definire i formati temporali, corrispondente a una data memorabile. Ogni componente di quella data mappa un elemento del formato desiderato. Implementando le due interfacce, il nostro tipo Date viene serializzato e deserializzato automaticamente ovunque compaia, senza ulteriori interventi.
Funzioni di supporto per le risposte
Fino a questo punto abbiamo ripetuto in ogni gestore lo stesso schema: impostare l'intestazione, il codice di stato e codificare il JSON. Questa ripetizione è fonte di errori e incoerenze. Conviene estrarre il comportamento in due funzioni di supporto, una per le risposte di successo e una per gli errori, da riusare in tutta l'applicazione.
// writeJSON scrive un valore come risposta JSON con il codice di stato dato.
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
// Se la codifica fallisce dopo l'invio dell'header non possiamo piu
// cambiare il codice di stato: registriamo l'errore nei log.
if err := json.NewEncoder(w).Encode(payload); err != nil {
log.Printf("errore nella codifica della risposta: %v", err)
}
}
// ErrorResponse rappresenta un errore restituito in formato JSON.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
// writeError scrive una risposta di errore uniforme.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, ErrorResponse{
Error: http.StatusText(status),
Message: message,
})
}
Ora i gestori diventano più puliti e coerenti. Riscriviamo il gestore che recupera un libro sfruttando le nuove funzioni.
func getBook(store *BookStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "identificatore non valido")
return
}
book, ok := store.Get(id)
if !ok {
writeError(w, http.StatusNotFound, "libro non trovato")
return
}
writeJSON(w, http.StatusOK, book)
}
}
Il codice è ora molto più leggibile, e ogni risposta di errore ha la stessa struttura, un requisito importante per i client che consumano l'API. L'uso di http.StatusText ci fornisce automaticamente la descrizione testuale standard del codice di stato, evitando di scriverla a mano.
Serializzare le collezioni
Un dettaglio spesso trascurato riguarda le liste vuote. In Go, una slice non inizializzata ha valore nil, e json.Marshal la codifica come null anziché come un array vuoto []. Per un'API questa differenza è fastidiosa, perché i client si aspettano sempre un array quando interrogano una collezione. La soluzione è inizializzare sempre la slice con make, come abbiamo già fatto nella puntata precedente restituendo make([]Book, 0, ...) invece di una slice nulla.
// Sbagliato: una slice nil viene serializzata come null
var books []Book
// json.Marshal(books) produce: null
// Corretto: una slice vuota ma inizializzata diventa un array vuoto
books := make([]Book, 0)
// json.Marshal(books) produce: []
Gestire JSON di forma variabile
Talvolta dobbiamo trattare JSON la cui struttura non è nota in anticipo. In questi casi possiamo deserializzare in un map[string]any o, per rimandare la decodifica di una porzione, usare il tipo json.RawMessage, che conserva i byte grezzi senza interpretarli. Quest'ultimo è particolarmente utile quando una parte del payload va instradata verso decodificatori diversi a seconda di un campo discriminante.
// Envelope contiene un tipo e un payload la cui forma dipende dal tipo.
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
// Decodifichiamo prima l'involucro, poi il payload in base al tipo.
var env Envelope
json.Unmarshal(data, &env)
switch env.Type {
case "book":
var b Book
json.Unmarshal(env.Payload, &b)
// ... elaborazione del libro
}
Conclusione
Abbiamo esplorato in profondità la serializzazione JSON in Go: i tag delle struct e le loro opzioni, l'insidia di omitempty risolta con i puntatori, la codifica delle date con serializzatori personalizzati, la costruzione di funzioni di supporto per uniformare risposte ed errori, e alcune tecniche per gestire JSON di forma variabile. Con queste basi le nostre risposte sono ora coerenti e ben strutturate. Nella prossima puntata sostituiremo l'archivio in memoria con una vera persistenza, collegando l'API a un database relazionale tramite il pacchetto database/sql e introducendo il pattern repository.