API REST in Go: routing e gestione delle richieste

API REST in Go: routing e gestione delle richieste

Nella prima puntata abbiamo costruito lo scheletro di un server HTTP robusto. Ora entriamo nel vivo dell'API: impareremo a instradare le richieste in base al metodo e al percorso, a estrarre parametri dagli URL e a distinguere i diversi endpoint di una risorsa. A partire dalla versione 1.22 di Go, il router della libreria standard è diventato sufficientemente espressivo da coprire la maggior parte delle esigenze senza ricorrere a librerie esterne, e in questa puntata sfrutteremo appieno le sue nuove capacità modellando finalmente la risorsa "libro".

Il ServeMux potenziato

Storicamente il router incluso in net/http, chiamato ServeMux, era piuttosto limitato: non permetteva di distinguere le richieste in base al metodo HTTP né di estrarre segmenti variabili dal percorso. Chi aveva bisogno di queste funzionalità doveva affidarsi a router di terze parti. Con Go 1.22 il ServeMux è stato riscritto e ora supporta pattern che includono sia il metodo sia i parametri di percorso, rendendo la libreria standard adeguata anche per API strutturate.

Un pattern moderno ha la forma METODO /percorso/{parametro}. Vediamo un esempio che registra due gestori distinti sullo stesso percorso, differenziandoli per metodo.

mux := http.NewServeMux()

// Due metodi diversi sullo stesso percorso, gestori distinti
mux.HandleFunc("GET /books", listBooks)
mux.HandleFunc("POST /books", createBook)

// Un parametro di percorso racchiuso tra parentesi graffe
mux.HandleFunc("GET /books/{id}", getBook)
mux.HandleFunc("PUT /books/{id}", updateBook)
mux.HandleFunc("DELETE /books/{id}", deleteBook)

Se un client invia una richiesta con un metodo non previsto per un percorso registrato, il ServeMux risponde automaticamente con il codice 405 Method Not Allowed, valorizzando anche l'intestazione Allow con i metodi ammessi. Questo comportamento, un tempo da implementare a mano, è ora gestito dal router in modo conforme allo standard.

Modellare la risorsa

Definiamo la struttura che rappresenta un libro. Per il momento la teniamo in memoria; la persistenza su database arriverà nella quarta puntata. Introduciamo anche un piccolo archivio protetto da un mutex, dato che le richieste vengono servite da goroutine concorrenti e l'accesso condiviso alla mappa dev'essere sincronizzato.

package main

import "sync"

// Book rappresenta un libro del catalogo.
type Book struct {
    ID     int    `json:"id"`
    Title  string `json:"title"`
    Author string `json:"author"`
    Year   int    `json:"year"`
}

// BookStore e un archivio in memoria protetto da mutex.
type BookStore struct {
    mu     sync.RWMutex
    books  map[int]Book
    nextID int
}

// NewBookStore crea un archivio vuoto pronto all'uso.
func NewBookStore() *BookStore {
    return &BookStore{
        books:  make(map[int]Book),
        nextID: 1,
    }
}

Aggiungiamo i metodi per manipolare l'archivio. Ognuno acquisisce il tipo di lock appropriato: un lock in scrittura per le operazioni che modificano la mappa, uno in lettura per quelle che si limitano a consultarla.

// Add inserisce un nuovo libro assegnandogli un identificatore progressivo.
func (s *BookStore) Add(b Book) Book {
    s.mu.Lock()
    defer s.mu.Unlock()

    b.ID = s.nextID
    s.nextID++
    s.books[b.ID] = b
    return b
}

// Get recupera un libro per identificatore. Il secondo valore
// indica se il libro e stato trovato.
func (s *BookStore) Get(id int) (Book, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    b, ok := s.books[id]
    return b, ok
}

// List restituisce tutti i libri presenti nell'archivio.
func (s *BookStore) List() []Book {
    s.mu.RLock()
    defer s.mu.RUnlock()

    result := make([]Book, 0, len(s.books))
    for _, b := range s.books {
        result = append(result, b)
    }
    return result
}

Estrarre i parametri di percorso

Quando un pattern contiene un segmento variabile come {id}, il valore corrispondente si recupera all'interno del gestore con il metodo PathValue della richiesta. Il valore è sempre una stringa, quindi per un identificatore numerico dobbiamo convertirlo e gestire l'eventuale errore di conversione.

// getBook gestisce GET /books/{id}
func getBook(store *BookStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Estraiamo il parametro "id" dal percorso
        idParam := r.PathValue("id")

        // Convertiamo la stringa in intero, respingendo valori non validi
        id, err := strconv.Atoi(idParam)
        if err != nil {
            http.Error(w, "identificatore non valido", http.StatusBadRequest)
            return
        }

        book, ok := store.Get(id)
        if !ok {
            http.Error(w, "libro non trovato", http.StatusNotFound)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(book)
    }
}

Nota la firma del gestore: non è una semplice http.HandlerFunc, ma una funzione che restituisce una http.HandlerFunc. Questo schema, chiamato closure, ci permette di iniettare la dipendenza dall'archivio senza ricorrere a variabili globali. La funzione esterna riceve lo store e lo cattura nella funzione interna, che è quella effettivamente registrata nel router. È un pattern idiomatico e molto usato in Go per passare dipendenze ai gestori.

Leggere il corpo di una richiesta

Per creare un libro tramite POST dobbiamo leggere il corpo JSON inviato dal client e decodificarlo nella nostra struttura. Il decodificatore di encoding/json legge direttamente dal Body della richiesta, che è uno stream. È buona pratica limitare la dimensione del corpo accettato, per evitare che un client invii payload smisurati, e rifiutare i campi sconosciuti.

// createBook gestisce POST /books
func createBook(store *BookStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Limitiamo il corpo a un megabyte per prudenza
        r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

        var input Book
        decoder := json.NewDecoder(r.Body)

        // Rifiutiamo i campi non previsti dalla struttura Book
        decoder.DisallowUnknownFields()

        if err := decoder.Decode(&input); err != nil {
            http.Error(w, "corpo della richiesta non valido", http.StatusBadRequest)
            return
        }

        created := store.Add(input)

        // Per una risorsa creata rispondiamo con 201 e l'intestazione Location
        w.Header().Set("Content-Type", "application/json")
        w.Header().Set("Location", "/books/"+strconv.Itoa(created.ID))
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(created)
    }
}

Ci sono qui alcune scelte che rispettano fedelmente la semantica REST. Alla creazione di una risorsa rispondiamo con il codice 201 Created anziché con un generico 200, e valorizziamo l'intestazione Location con l'URL della risorsa appena creata, così che il client sappia dove reperirla. La funzione http.MaxBytesReader impone il limite di dimensione, mentre DisallowUnknownFields fa fallire la decodifica se il JSON contiene chiavi non dichiarate nella struttura, aiutando a intercettare errori del client.

I parametri della query string

Oltre ai parametri di percorso, un endpoint può accettare parametri nella query string, tipicamente usati per filtri, ordinamenti e paginazione. Si recuperano dal metodo Query dell'URL della richiesta. Arricchiamo il gestore della lista con un filtro opzionale per autore.

// listBooks gestisce GET /books con filtro opzionale ?author=
func listBooks(store *BookStore) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Leggiamo il parametro "author" dalla query string.
        // Se assente, Get restituisce la stringa vuota.
        authorFilter := r.URL.Query().Get("author")

        all := store.List()
        result := make([]Book, 0, len(all))
        for _, b := range all {
            if authorFilter == "" || b.Author == authorFilter {
                result = append(result, b)
            }
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(result)
    }
}

Una richiesta a /books restituisce l'intero catalogo, mentre /books?author=Calvino restituisce solo i libri il cui autore corrisponde. Il metodo Query restituisce una struttura di tipo url.Values, una mappa da stringhe a liste di stringhe, perché uno stesso parametro può comparire più volte nella query; il metodo Get ne restituisce comodamente il primo valore.

Mettere insieme il router

Assembliamo ora la funzione main che registra tutti gli endpoint, iniettando l'archivio in ciascun gestore tramite le closure viste sopra.

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "sync"
    "time"
)

func main() {
    store := NewBookStore()

    mux := http.NewServeMux()
    mux.HandleFunc("GET /books", listBooks(store))
    mux.HandleFunc("POST /books", createBook(store))
    mux.HandleFunc("GET /books/{id}", getBook(store))

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    log.Println("Server in ascolto su http://localhost:8080")
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

Possiamo provare l'API creando un libro e poi recuperandolo.

# Creiamo un libro
curl -i -X POST http://localhost:8080/books \
  -H "Content-Type: application/json" \
  -d '{"title":"Il barone rampante","author":"Calvino","year":1957}'

# Recuperiamo l'elenco filtrando per autore
curl http://localhost:8080/books?author=Calvino

Conclusione

Abbiamo trasformato lo scheletro della prima puntata in un'API funzionante con più endpoint, capace di distinguere i metodi HTTP, estrarre parametri dal percorso e dalla query string e leggere il corpo delle richieste in modo sicuro. Abbiamo anche introdotto un archivio in memoria concorrente e il pattern delle closure per iniettare le dipendenze nei gestori. Nella prossima puntata approfondiremo la serializzazione JSON, che finora abbiamo usato in modo elementare: vedremo i tag delle struct, la gestione dei valori opzionali, la codifica di tipi complessi come le date e la costruzione di funzioni di supporto per uniformare le risposte.