API REST in Go: i middleware
Molte funzionalità di un'API non appartengono a un singolo endpoint ma vanno applicate in modo trasversale a tutte le richieste: registrare gli accessi, misurare i tempi di risposta, recuperare i panici, impostare le intestazioni CORS, limitare la frequenza delle richieste. In Go questo si ottiene con i middleware, funzioni che avvolgono un gestore aggiungendo comportamento prima e dopo la sua esecuzione. In questa puntata capiremo il pattern che li rende possibili, ne scriveremo alcuni essenziali per il nostro catalogo e vedremo come comporli in una catena ordinata.
Il pattern del middleware
Un middleware, nella sua forma più diffusa, è una funzione che riceve un http.Handler e ne restituisce un altro. Il gestore restituito avvolge quello originale: esegue del codice, chiama il gestore interno e magari esegue altro codice al ritorno. Questa struttura è possibile perché http.Handler è un'interfaccia, e possiamo comporne quante ne vogliamo mantenendo la stessa firma.
// Middleware e il tipo comune a tutti i nostri middleware.
type Middleware func(http.Handler) http.Handler
Il più semplice middleware immaginabile è quello che non fa nulla se non passare la richiesta al gestore successivo. Serve a fissare lo schema mentale prima di scrivere middleware utili.
func passthrough(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Qui potremmo agire prima della gestione della richiesta
next.ServeHTTP(w, r) // deleghiamo al gestore avvolto
// Qui potremmo agire dopo la gestione della richiesta
})
}
Un middleware di logging
Il primo middleware davvero utile registra ogni richiesta con il suo metodo, percorso, codice di stato e durata. Sorge però un problema tecnico: l'interfaccia http.ResponseWriter non espone il codice di stato che il gestore ha scritto. Per catturarlo dobbiamo avvolgere il ResponseWriter in un tipo che intercetti la chiamata a WriteHeader.
// statusRecorder avvolge ResponseWriter per catturare il codice di stato.
type statusRecorder struct {
http.ResponseWriter
status int
}
// WriteHeader memorizza il codice prima di inoltrarlo al writer originale.
func (rec *statusRecorder) WriteHeader(code int) {
rec.status = code
rec.ResponseWriter.WriteHeader(code)
}
// LoggingMiddleware registra i dettagli di ogni richiesta.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Inizializziamo lo stato a 200, valore predefinito se il gestore
// scrive il corpo senza chiamare esplicitamente WriteHeader.
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s -> %d (%s)",
r.Method, r.URL.Path, rec.status, time.Since(start))
})
}
La tecnica dell'incorporamento (embedding) è centrale: incorporando http.ResponseWriter nella nostra struttura, ereditiamo automaticamente tutti i suoi metodi, e ne ridefiniamo soltanto uno, WriteHeader, per catturare il codice. Il gestore avvolto riceve il nostro statusRecorder credendolo un normale ResponseWriter, e noi otteniamo l'informazione che ci serve.
Un middleware di recovery
Se un gestore va in panico, per esempio a causa di un accesso a un puntatore nullo, per impostazione predefinita Go interrompe soltanto la goroutine di quella richiesta e il server resta in piedi, ma il client riceve una connessione interrotta senza alcuna risposta. Un middleware di recovery intercetta il panico, lo registra e restituisce una risposta di errore pulita.
// RecoverMiddleware intercetta i panici e restituisce un errore 500.
func RecoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
// recover cattura il valore del panico, se presente
if err := recover(); err != nil {
log.Printf("panico recuperato: %v", err)
writeError(w, http.StatusInternalServerError, "errore interno")
}
}()
next.ServeHTTP(w, r)
})
}
Il funzionamento si basa sulla funzione differita defer, che viene eseguita anche quando la goroutine sta risalendo lo stack a causa di un panico. All'interno, recover arresta la propagazione del panico e ci restituisce il valore che lo ha causato. È importante che questo sia uno dei primi middleware della catena, così da avvolgere tutto ciò che potrebbe fallire.
Un middleware per il CORS
Quando l'API viene consumata da una pagina web servita da un'origine diversa, il browser impone i controlli CORS. Un middleware dedicato imposta le intestazioni che autorizzano le origini consentite e gestisce le richieste di preflight, quelle con metodo OPTIONS che il browser invia per verificare i permessi prima della richiesta effettiva.
// CORSMiddleware imposta le intestazioni CORS per l'origine consentita.
func CORSMiddleware(allowedOrigin string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// La richiesta di preflight va chiusa subito con 204 No Content
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
Qui il middleware è parametrizzato: la funzione esterna CORSMiddleware riceve l'origine consentita e restituisce il middleware vero e proprio. È lo stesso schema di closure che abbiamo usato per iniettare le dipendenze nei gestori, applicato ora alla configurazione dei middleware. In produzione l'origine non dovrebbe mai essere l'asterisco quando l'API richiede autenticazione.
Comporre la catena
Applicare un solo middleware è semplice: basta avvolgere il router. Ma quando i middleware sono molti, annidare le chiamate a mano diventa illeggibile. Scriviamo una piccola funzione di supporto che compone una sequenza di middleware in un unico gestore, applicandoli nell'ordine desiderato.
// Chain applica una sequenza di middleware a un gestore.
// I middleware vengono eseguiti nell'ordine in cui sono elencati.
func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
// Applichiamo i middleware in ordine inverso, cosi che il primo
// dell'elenco sia il piu esterno e quindi il primo a essere eseguito.
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
L'ordine di applicazione conta. Poiché ogni middleware avvolge il successivo, per far sì che il primo elemento dell'elenco sia il più esterno, e dunque il primo a ricevere la richiesta, dobbiamo applicarli dall'ultimo al primo. Con questa funzione la composizione diventa dichiarativa e leggibile.
func main() {
// ... apertura del database e creazione del repository
mux := http.NewServeMux()
mux.HandleFunc("GET /books", listBooks(repo))
mux.HandleFunc("POST /books", createBook(repo))
mux.HandleFunc("GET /books/{id}", getBook(repo))
mux.HandleFunc("DELETE /books/{id}", deleteBook(repo))
// Il recovery e il piu esterno, cosi cattura i panici di tutti gli altri.
handler := Chain(mux,
RecoverMiddleware,
LoggingMiddleware,
CORSMiddleware("https://catalogo.example.com"),
)
server := &http.Server{
Addr: ":8080",
Handler: handler,
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)
}
}
Passare valori attraverso il context
Spesso un middleware calcola un'informazione che i gestori a valle devono poter leggere: un identificatore di richiesta per la tracciabilità, o l'utente autenticato. Il canale corretto per trasportarla è il context della richiesta, non variabili globali. Per evitare collisioni tra chiavi, si usa un tipo privato come chiave del context.
// Definiamo un tipo privato per le chiavi del context, evitando collisioni.
type contextKey string
const requestIDKey contextKey = "requestID"
// RequestIDMiddleware genera un identificatore univoco per ogni richiesta.
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := generateID() // funzione che produce un identificatore univoco
// Deriviamo un nuovo context con il valore e lo associamo alla richiesta
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequestIDFromContext estrae l'identificatore dal context, se presente.
func RequestIDFromContext(ctx context.Context) string {
// L'asserzione di tipo restituisce anche un booleano di esito
id, _ := ctx.Value(requestIDKey).(string)
return id
}
La chiave di tipo contextKey, non esportata, garantisce che nessun altro pacchetto possa accidentalmente sovrascrivere o leggere il nostro valore, perché non ha modo di costruire una chiave identica. La funzione RequestIDFromContext fornisce un accesso tipizzato e sicuro al valore, incapsulando l'asserzione di tipo. Questo è l'approccio idiomatico per condividere dati request-scoped in Go, e lo useremo nella prossima puntata per trasportare l'utente autenticato.
Conclusione
I middleware ci hanno permesso di aggiungere comportamento trasversale a tutta l'API senza duplicare codice nei singoli gestori. Abbiamo scritto middleware per il logging, con la tecnica dell'incorporamento del ResponseWriter, per il recupero dai panici, per il CORS, e ne abbiamo composti diversi con una funzione di catena che ne rispetta l'ordine. Infine abbiamo visto come propagare valori tramite il context in modo sicuro. Nella prossima puntata sfrutteremo proprio questi strumenti per costruire l'autenticazione: genereremo token JWT al login e proteggeremo gli endpoint con un middleware che verifica il token e inserisce l'utente nel context.