Questo articolo mostra come progettare e implementare un’API GraphQL in Go concentrandosi su un unico model. L’obiettivo è capire lo scheletro essenziale: definizione dello schema, resolver tipizzati, ordinamento, paginazione “page-based”, validazione, autorizzazione e pubblicazione dell’endpoint /graphql
con CORS configurato in modo sicuro. Mantenere il perimetro al solo modello Post
rende il percorso lineare e poi facilmente estendibile.
Stack e organizzazione
- Linguaggio: Go (versione recente, con mod attivi)
- Server GraphQL:
gqlgen
(tipizzato, codegen) sunet/http
- Persistenza: a scelta (driver SQL, GORM, sqlc, o repository in-memory per iniziare)
- Validazione:
go-playground/validator
o regole mirate nei resolver - Autorizzazione: middleware che popola il context con l’utente
- CORS:
github.com/rs/cors
o middleware equivalente
Il flusso tipico è: definire lo schema GraphQL tipizzando Post
e le operazioni, generare i tipi con gqlgen
, implementare i resolver delegando la logica di dati a un repository, esporre il server e applicare CORS e sicurezza.
Schema GraphQL: tipo Post e operazioni
Lo schema espone un tipo essenziale e un set ridotto di operazioni. L’elenco prevede ordinamento per publishedAt
e paginazione “page-based”.
"""Rappresenta un contenuto pubblicato."""
type Post {
id: ID!
title: String!
body: String!
publishedAt: String
createdAt: String!
updatedAt: String!
}
"""Metadati di paginazione."""
type PaginatorInfo {
count: Int!
currentPage: Int!
lastPage: Int!
perPage: Int!
total: Int!
hasMorePages: Boolean!
}
"""Risultato paginato di Post."""
type PostPaginator {
data: [Post!]!
paginatorInfo: PaginatorInfo!
}
type Query {
posts(page: Int = 1, perPage: Int = 10, sort: String = "desc"): PostPaginator!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String!, publishedAt: String): Post!
updatePost(id: ID!, title: String, body: String, publishedAt: String): Post!
deletePost(id: ID!): Boolean!
}
Installazione di gqlgen e bootstrap
Si prepara il progetto, si aggiunge gqlgen
e si generano i file iniziali. La configurazione di gqlgen
crea tipi Go tipizzati, interfacce per i resolver e un server GraphQL pronto da agganciare a net/http
.
go mod init example.com/postapi
go get github.com/99designs/gqlgen@latest
go run github.com/99designs/gqlgen init
# Poi sostituisci lo schema con quello qui sopra e rigenera
go run github.com/99designs/gqlgen generate
Modello dominio Post e repository
Il dominio rimane minimale. Un repository incapsula le operazioni sui dati (in-memory o DB). In produzione sostituiscilo con GORM/sqlc e indicizza il campo di ordinamento.
package domain
import "time"
type Post struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PostRepository interface {
List(page, perPage int, sortAsc bool) (items []Post, total int, err error)
FindByID(id string) (*Post, error)
Create(p Post) (Post, error)
Update(id string, patch map[string]any) (Post, error)
Delete(id string) (bool, error)
}
Resolver con gqlgen
I resolver sono semplici: convalidano input, applicano l’autorizzazione tramite context, orchestrano il repository e costruiscono i metadati di paginazione. L’ordinamento usa publishedAt
e, all’occorrenza, un tie-breaker su ID
.
package graph
import (
"context"
"strings"
"time"
"example.com/postapi/domain"
"example.com/postapi/graph/model"
)
type Resolver struct {
Posts domain.PostRepository
}
func (r *queryResolver) Posts(ctx context.Context, page *int, perPage *int, sort *string) (*model.PostPaginator, error) {
p := 1
if page != nil && *page > 0 { p = *page }
pp := 10
if perPage != nil && *perPage > 0 && *perPage <= 100 { pp = *perPage }
s := "desc"
if sort != nil { s = *sort }
asc := strings.ToLower(s) == "asc"
items, total, err := r.Posts.List(p, pp, asc)
if err != nil { return nil, err }
last := max(1, (total+pp-1)/pp)
data := make([]*model.Post, 0, len(items))
for _, it := range items {
data = append(data, &model.Post{
ID: it.ID,
Title: it.Title,
Body: it.Body,
CreatedAt: it.CreatedAt.Format(time.RFC3339),
UpdatedAt: it.UpdatedAt.Format(time.RFC3339),
PublishedAt: toPtrIfNotNil(it.PublishedAt),
})
}
return &model.PostPaginator{
Data: data,
PaginatorInfo: &model.PaginatorInfo{
Count: len(items),
CurrentPage: p,
LastPage: last,
PerPage: pp,
Total: total,
HasMorePages: p < last,
},
}, nil
}
func (r *queryResolver) Post(ctx context.Context, id string) (*model.Post, error) {
it, err := r.Posts.FindByID(id)
if err != nil || it == nil { return nil, err }
return &model.Post{
ID: it.ID,
Title: it.Title,
Body: it.Body,
CreatedAt: it.CreatedAt.Format(time.RFC3339),
UpdatedAt: it.UpdatedAt.Format(time.RFC3339),
PublishedAt: toPtrIfNotNil(it.PublishedAt),
}, nil
}
func (r *mutationResolver) CreatePost(ctx context.Context, title string, body string, publishedAt *string) (*model.Post, error) {
if !isAuthorized(ctx) { return nil, ErrUnauthorized }
if err := validateCreate(title, body, publishedAt); err != nil { return nil, err }
now := time.Now().UTC()
var pub *time.Time
if publishedAt != nil && *publishedAt != "" {
t, err := time.Parse(time.RFC3339, *publishedAt)
if err != nil { return nil, err }
pub = &t
}
created, err := r.Posts.Create(domain.Post{
Title: title,
Body: body,
PublishedAt: pub,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil { return nil, err }
return &model.Post{
ID: created.ID,
Title: created.Title,
Body: created.Body,
CreatedAt: created.CreatedAt.Format(time.RFC3339),
UpdatedAt: created.UpdatedAt.Format(time.RFC3339),
PublishedAt: toPtrIfNotNil(created.PublishedAt),
}, nil
}
func (r *mutationResolver) UpdatePost(ctx context.Context, id string, title *string, body *string, publishedAt *string) (*model.Post, error) {
if !isAuthorized(ctx) { return nil, ErrUnauthorized }
patch := map[string]any{}
if title != nil { patch["title"] = *title }
if body != nil { patch["body"] = *body }
if publishedAt != nil { patch["publishedAt"] = *publishedAt }
patch["updatedAt"] = time.Now().UTC()
updated, err := r.Posts.Update(id, patch)
if err != nil { return nil, err }
return &model.Post{
ID: updated.ID,
Title: updated.Title,
Body: updated.Body,
CreatedAt: updated.CreatedAt.Format(time.RFC3339),
UpdatedAt: updated.UpdatedAt.Format(time.RFC3339),
PublishedAt: toPtrIfNotNil(updated.PublishedAt),
}, nil
}
func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
if !isAuthorized(ctx) { return false, ErrUnauthorized }
return r.Posts.Delete(id)
}
// Helpers
func max(a, b int) int { if a > b { return a }; return b }
func toPtrIfNotNil(t *time.Time) *string {
if t == nil { return nil }
s := t.UTC().Format(time.RFC3339)
return &s
}
Server HTTP e CORS su /graphql
Si pubblica l’endpoint con net/http
. Il middleware CORS deve consentire il preflight OPTIONS
e includere eventuali header di autorizzazione. Evitare wildcard con credenziali attive e preferire una lista di origin ammessi.
package main
import (
"log"
"net/http"
"os"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/rs/cors"
"example.com/postapi/graph"
"example.com/postapi/graph/generated"
"example.com/postapi/inmemory"
)
func main() {
// Repo di esempio; sostituisci con DB reale
repo := inmemory.NewPostRepo()
srv := handler.NewDefaultServer(generated.NewExecutableSchema(
generated.Config{Resolvers: \&graph.Resolver{Posts: repo}},
))
mux := http.NewServeMux()
mux.Handle("/graphql", srv)
mux.Handle("/", playground.Handler("GraphQL", "/graphql"))
// CORS: elenca gli origin consentiti; abilita credentials se usi cookie
corsMw := cors.New(cors.Options{
AllowedOrigins: []string{"[http://localhost:5173](http://localhost:5173)", "[https:// app.example.com"}](https://app.example.com}),
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"content-type", "authorization"},
AllowCredentials: true,
MaxAge: 600,
})
addr := ":4000"
if fromEnv := os.Getenv("PORT"); fromEnv != "" { addr = ":" + fromEnv }
log.Printf("GraphQL su [http://localhost%s/graphql](http://localhost%s/graphql)", addr)
if err := http.ListenAndServe(addr, corsMw.Handler(mux)); err != nil {
log.Fatal(err)
}
}
Validazione e autorizzazione
La validazione degli input può essere fatta in modo leggero con funzioni dedicate o integrando librerie come validator
. L’autorizzazione, in un progetto reale, si effettua montando un middleware HTTP che risolve l’identità (token, cookie) e inserisce l’utente nel context. I resolver leggeranno dal context per consentire o negare le operazioni di scrittura.
package graph
import (
"context"
"errors"
"time"
)
var ErrUnauthorized = errors.New("unauthorized")
func isAuthorized(ctx context.Context) bool {
// Esempio: recupera un utente dal context (settato dal middleware)
_, ok := ctx.Value(userKey{}).(*User)
return ok
}
func validateCreate(title, body string, publishedAt *string) error {
if len(title) == 0 || len(title) > 255 { return errors.New("invalid title") }
if len(body) == 0 { return errors.New("invalid body") }
if publishedAt != nil && *publishedAt != "" {
if _, err := time.Parse(time.RFC3339, *publishedAt); err != nil {
return errors.New("invalid publishedAt")
}
}
return nil
}
type userKey struct{}
type User struct{ ID string }
Esempi dal punto di vista del client
Le query richiedono solo i campi necessari; le mutation restituiscono l’oggetto aggiornato o un boolean nel caso di eliminazione. Una mutation che ritorna Boolean!
non ammette una subselection.
query {
posts(page: 1, perPage: 5, sort: "desc") {
data { id title publishedAt }
paginatorInfo { currentPage lastPage perPage total hasMorePages count }
}
}
query {
post(id: "123") { id title body publishedAt }
}
mutation {
createPost(title: "Titolo", body: "Testo", publishedAt: "2025-01-01T00:00:00Z") {
id title publishedAt
}
}
mutation {
updatePost(id: "123", title: "Nuovo titolo") { id title updatedAt }
}
mutation {
deletePost(id: "123")
}
Paginazione e ordinamento: note progettuali
La paginazione “page-based” è semplice e adatta a molti casi d’uso; per flussi continui o dataset molto dinamici è possibile adottare una paginazione a cursori, usando un cursore opaco derivato da publishedAt
e id
. È consigliabile creare un indice su publishedAt
(e sull’ID) per mantenere bassa la latenza delle query di elenco.
Performance e affidabilità
- Selettività dei campi: far sì che i resolver mappino le selezioni GraphQL su query dati che leggono solo le colonne necessarie.
- Limiti: imporre soglie su
perPage
e, se serve, usare plugin/estensioni per limitare complessità e profondità delle query. - Error handling: restituire errori prevedibili per validazione e autorizzazione; loggare quelli inattesi lato server.
- Test: coprire i resolver con test che includano casi di ordinamento, paginazione, validazione e accesso negato.
Conclusione
Con gqlgen
e pochi componenti mirati si ottiene rapidamente un’API GraphQL in Go incentrata su Post
: schema chiaro, resolver tipizzati, paginazione e ordinamento prevedibili, validazione e autorizzazione esplicite, CORS configurato per l’uso reale. Questa base, per la sua semplicità e coerenza, è un ottimo punto di partenza per aggiungere nuovi modelli e funzionalità mantenendo un disegno pulito e sostenibile.