API REST in Go: serializzazione JSON

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.