Creare un’API GraphQL in Go

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) su net/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.

Torna su