API REST in Go: validazione, errori e testing

API REST in Go: validazione, errori e testing

Con questa settima puntata chiudiamo la serie portando la nostra API a un livello di qualità adatto alla produzione. Affronteremo tre aspetti che distinguono un servizio robusto da un prototipo: la validazione rigorosa degli input, la restituzione di errori strutturati e coerenti, e la copertura con test automatici. Go offre nella libreria standard tutto ciò che serve per testare un server HTTP senza avviarlo davvero, grazie al pacchetto net/http/httptest, e sfrutteremo questa possibilità per verificare il comportamento degli endpoint costruiti nelle puntate precedenti.

Validare gli input

Non bisogna mai fidarsi dei dati che arrivano dal client. Un libro con titolo vuoto o con un anno assurdo non deve entrare nel sistema. La validazione va eseguita subito dopo la decodifica del JSON, prima di qualsiasi operazione sul database. Definiamo un metodo di validazione sulla struttura di input che raccoglie tutti gli errori riscontrati, così da poterli comunicare al client in un colpo solo anziché uno alla volta.

// ValidationError associa un campo a un messaggio di errore.
type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

// bookInput rappresenta i dati grezzi ricevuti per creare o aggiornare un libro.
type bookInput struct {
    Title  string `json:"title"`
    Author string `json:"author"`
    Year   int    `json:"year"`
}

// Validate controlla i campi e restituisce l'elenco degli errori riscontrati.
func (in bookInput) Validate() []ValidationError {
    var errs []ValidationError

    if strings.TrimSpace(in.Title) == "" {
        errs = append(errs, ValidationError{"title", "il titolo e obbligatorio"})
    }
    if strings.TrimSpace(in.Author) == "" {
        errs = append(errs, ValidationError{"author", "l'autore e obbligatorio"})
    }
    // Controlliamo che l'anno sia in un intervallo plausibile
    currentYear := time.Now().Year()
    if in.Year < 1450 || in.Year > currentYear {
        errs = append(errs, ValidationError{"year", "anno non plausibile"})
    }

    return errs
}

Raccogliere tutti gli errori in una slice, invece di fermarsi al primo, è una cortesia verso chi consuma l'API: un'interfaccia utente può così evidenziare simultaneamente tutti i campi da correggere. Per la validazione di regole complesse esistono librerie dedicate, ma per la maggior parte dei casi un metodo esplicito come questo è chiaro, privo di magia e facilmente testabile.

Errori strutturati e coerenti

Nella terza puntata avevamo introdotto una funzione writeError con un formato uniforme. Affiniamola ora aderendo a uno standard consolidato per gli errori nelle API HTTP, definito dalla specifica Problem Details for HTTP APIs. Questo standard prescrive un corpo JSON con campi ben precisi e un apposito tipo di contenuto, garantendo che i client sappiano sempre come interpretare un errore.

// ProblemDetails rappresenta un errore secondo la specifica RFC 9457.
type ProblemDetails struct {
    Type   string            `json:"type"`
    Title  string            `json:"title"`
    Status int               `json:"status"`
    Detail string            `json:"detail,omitempty"`
    Errors []ValidationError `json:"errors,omitempty"`
}

// writeProblem scrive un errore nel formato standard problem+json.
func writeProblem(w http.ResponseWriter, status int, detail string, validation []ValidationError) {
    // Il tipo di contenuto specifico segnala al client la natura del corpo
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(status)

    problem := ProblemDetails{
        Type:   "about:blank",
        Title:  http.StatusText(status),
        Status: status,
        Detail: detail,
        Errors: validation,
    }
    json.NewEncoder(w).Encode(problem)
}

Ora il gestore di creazione integra validazione ed errori strutturati in un flusso lineare e leggibile.

func createBook(repo *BookRepository) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        var input bookInput
        if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
            writeProblem(w, http.StatusBadRequest, "corpo JSON non valido", nil)
            return
        }

        // Validiamo prima di toccare il database
        if errs := input.Validate(); len(errs) > 0 {
            writeProblem(w, http.StatusUnprocessableEntity, "dati non validi", errs)
            return
        }

        book := Book{Title: input.Title, Author: input.Author, Year: input.Year}
        created, err := repo.Insert(r.Context(), book)
        if err != nil {
            log.Printf("errore nell'inserimento: %v", err)
            writeProblem(w, http.StatusInternalServerError, "errore interno", nil)
            return
        }

        w.Header().Set("Location", "/books/"+strconv.FormatInt(created.ID, 10))
        writeJSON(w, http.StatusCreated, created)
    }
}

Il codice 422 Unprocessable Entity è quello corretto quando il JSON è sintatticamente valido ma viola le regole di dominio, mentre il 400 Bad Request resta riservato ai casi in cui il corpo non è nemmeno decodificabile. Questa distinzione, apparentemente sottile, aiuta i client a reagire in modo appropriato.

Testare un gestore HTTP

Il pacchetto net/http/httptest ci permette di invocare un gestore con una richiesta finta e di ispezionare la risposta, senza aprire alcuna porta di rete. Gli strumenti principali sono httptest.NewRequest, che costruisce una richiesta, e httptest.NewRecorder, un ResponseWriter che registra ciò che il gestore scrive. Scriviamo un primo test per la validazione, il caso in cui inviamo un libro privo di titolo.

func TestCreateBook_ValidationFails(t *testing.T) {
    // Un corpo con titolo vuoto deve essere respinto
    body := strings.NewReader(`{"title":"","author":"Calvino","year":1979}`)
    req := httptest.NewRequest(http.MethodPost, "/books", body)
    rec := httptest.NewRecorder()

    // Invochiamo il gestore con un repository fittizio, qui non raggiunto
    handler := createBook(newFakeRepo())
    handler.ServeHTTP(rec, req)

    // Verifichiamo il codice di stato
    if rec.Code != http.StatusUnprocessableEntity {
        t.Fatalf("atteso 422, ottenuto %d", rec.Code)
    }

    // Verifichiamo che il tipo di contenuto sia quello degli errori
    ct := rec.Header().Get("Content-Type")
    if ct != "application/problem+json" {
        t.Errorf("Content-Type inatteso: %s", ct)
    }
}

Il test è diretto e veloce: nessuna rete, nessun database, solo la logica del gestore. Il Recorder espone il codice di stato in Code, le intestazioni con Header e il corpo con Body, che possiamo decodificare per ispezionarne la struttura.

Isolare le dipendenze con un'interfaccia

Nel test precedente abbiamo usato un newFakeRepo. Perché sia possibile sostituire il repository reale con uno fittizio, il gestore non deve dipendere dal tipo concreto *BookRepository, ma da un'interfaccia che ne descrive il comportamento. Questo è uno dei motivi per cui in Go si definiscono le interfacce dal lato di chi le consuma.

// BookInserter e l'unica capacita che createBook richiede al repository.
type BookInserter interface {
    Insert(ctx context.Context, b Book) (Book, error)
}

// Il gestore ora dipende dall'interfaccia, non dal tipo concreto.
func createBook(repo BookInserter) http.HandlerFunc {
    // ... corpo invariato
}

Nel test possiamo allora fornire un'implementazione minimale che restituisce valori controllati, senza toccare un database reale.

// fakeRepo e un'implementazione di BookInserter per i test.
type fakeRepo struct {
    insertFunc func(ctx context.Context, b Book) (Book, error)
}

func (f *fakeRepo) Insert(ctx context.Context, b Book) (Book, error) {
    return f.insertFunc(ctx, b)
}

func TestCreateBook_Success(t *testing.T) {
    repo := &fakeRepo{
        insertFunc: func(ctx context.Context, b Book) (Book, error) {
            // Simuliamo l'inserimento assegnando un identificatore
            b.ID = 42
            return b, nil
        },
    }

    body := strings.NewReader(`{"title":"Palomar","author":"Calvino","year":1983}`)
    req := httptest.NewRequest(http.MethodPost, "/books", body)
    rec := httptest.NewRecorder()

    createBook(repo).ServeHTTP(rec, req)

    if rec.Code != http.StatusCreated {
        t.Fatalf("atteso 201, ottenuto %d", rec.Code)
    }

    // Decodifichiamo il corpo per verificare l'identificatore assegnato
    var created Book
    json.NewDecoder(rec.Body).Decode(&created)
    if created.ID != 42 {
        t.Errorf("atteso id 42, ottenuto %d", created.ID)
    }
}

Testare l'intera catena con i middleware

Finora abbiamo testato i gestori isolati. Talvolta però vogliamo verificare il comportamento dell'intero router, middleware compresi: per esempio che un endpoint protetto respinga le richieste prive di token. In questo caso costruiamo il router completo e lo invochiamo, ancora con httptest.

func TestProtectedEndpoint_RequiresToken(t *testing.T) {
    handler := buildRouter(testDeps()) // costruisce il router come in main

    // Richiesta senza intestazione Authorization
    req := httptest.NewRequest(http.MethodPost, "/books", strings.NewReader("{}"))
    rec := httptest.NewRecorder()

    handler.ServeHTTP(rec, req)

    if rec.Code != http.StatusUnauthorized {
        t.Fatalf("atteso 401 senza token, ottenuto %d", rec.Code)
    }
}

Estrarre la costruzione del router in una funzione buildRouter, separata da main, è ciò che rende possibile questo tipo di test. È una buona regola generale: la funzione main dovrebbe limitarsi a leggere la configurazione e ad avviare il server, delegando l'assemblaggio dell'applicazione a funzioni testabili.

Le tabelle di test

Quando dobbiamo verificare la stessa logica su molti casi, come la validazione, l'idioma di Go è il table-driven test: un'unica funzione che itera su una tabella di casi, ciascuno con i propri input e risultati attesi. Il codice risulta compatto e aggiungere un caso significa aggiungere una riga.

func TestBookInputValidate(t *testing.T) {
    cases := []struct {
        name      string
        input     bookInput
        wantCount int
    }{
        {"tutto valido", bookInput{"Titolo", "Autore", 2000}, 0},
        {"titolo vuoto", bookInput{"", "Autore", 2000}, 1},
        {"anno futuro", bookInput{"Titolo", "Autore", 3000}, 1},
        {"tutto errato", bookInput{"", "", 100}, 3},
    }

    for _, tc := range cases {
        // t.Run crea un sottotest con nome proprio per ciascun caso
        t.Run(tc.name, func(t *testing.T) {
            got := len(tc.input.Validate())
            if got != tc.wantCount {
                t.Errorf("attesi %d errori, ottenuti %d", tc.wantCount, got)
            }
        })
    }
}

Eseguiamo l'intera suite con il comando abituale, eventualmente misurando la copertura del codice.

# Eseguiamo tutti i test con output dettagliato
go test -v ./...

# Misuriamo la percentuale di codice coperta dai test
go test -cover ./...

Conclusione della serie

Con questa puntata la nostra API REST in Go è completa e solida. Nel corso dei sette articoli siamo partiti da un server minimo basato sulla sola libreria standard e abbiamo aggiunto, un livello alla volta, il routing moderno del ServeMux di Go 1.22, una serializzazione JSON curata, la persistenza su database relazionale con il pattern repository, i middleware per le funzionalità trasversali, l'autenticazione stateless con JWT e infine la validazione, gli errori strutturati secondo lo standard e una copertura di test automatici. Il risultato è un servizio idiomatico, testabile e pronto per la produzione, costruito quasi interamente con gli strumenti che Go offre di serie. Da qui si può proseguire in molte direzioni, come la documentazione OpenAPI, la paginazione, il rate limiting o l'osservabilità, ma le fondamenta su cui costruire sono ormai saldamente al loro posto.