Back to Oh My Openagent

HTTP Backend Stack — gin + slog + validator + pgx

packages/omo-codex/plugin/skills/programming/references/go/backend-stack.md

4.5.1216.6 KB
Original Source

HTTP Backend Stack — gin + slog + validator + pgx

The canonical production HTTP service skeleton. Distilled from the CLIProxyAPI codebase — a real proxy serving OpenAI / Gemini / Claude / Codex APIs in production, with SSE streaming, WebSocket upgrades, request logging, and hot-reload config.

If you are tempted to pick echo or chi instead, see libraries.md — gin wins on ecosystem, not technical merit, and the win is large enough to matter.


go.mod

go
module github.com/your-org/myservice

go 1.23

require (
    github.com/gin-gonic/gin v1.10.1
    github.com/go-playground/validator/v10 v10.22.1
    github.com/caarlos0/env/v11 v11.2.2
    github.com/google/uuid v1.6.0
    github.com/jackc/pgx/v5 v5.7.6
    golang.org/x/sync v0.18.0
)

Project structure

cmd/server/main.go          # ≤ 50 LOC; flags → run.Execute(ctx)
internal/
  cmd/run.go                # ~150 LOC; signal handling, config load, server.Run
  config/config.go          # env-driven Config struct
  api/
    server.go               # gin.Engine setup, route mounting, http.Server
    middleware/
      request_id.go
      request_logging.go
      auth.go
      recovery.go
      cors.go
    handlers/
      users.go              # one file per resource
      streams.go            # SSE / WebSocket endpoints
  domain/                   # smart-constructor types (Email, UserID, ...)
  service/                  # business logic
  store/                    # pgx + sqlc
  obs/
    logger.go               # slog setup

cmd/server/main.go

go
package main

import (
    "context"
    "log/slog"
    "os"
    "os/signal"
    "syscall"

    "github.com/your-org/myservice/internal/cmd"
)

func main() {
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    if err := cmd.Execute(ctx); err != nil {
        slog.Error("fatal", slog.Any("err", err))
        os.Exit(1)
    }
}

That is the entire main. Anything more is a smell.


internal/config/config.go

go
package config

import (
    "time"
    "github.com/caarlos0/env/v11"
)

type Config struct {
    Host            string        `env:"HOST"             envDefault:"0.0.0.0"`
    Port            int           `env:"PORT"             envDefault:"8080"`
    DatabaseURL     string        `env:"DATABASE_URL,required"`
    ReadTimeout     time.Duration `env:"READ_TIMEOUT"     envDefault:"15s"`
    WriteTimeout    time.Duration `env:"WRITE_TIMEOUT"    envDefault:"30s"`
    ShutdownTimeout time.Duration `env:"SHUTDOWN_TIMEOUT" envDefault:"20s"`
    LogLevel        string        `env:"LOG_LEVEL"        envDefault:"info"`
    LogFormat       string        `env:"LOG_FORMAT"       envDefault:"json"`
    Env             string        `env:"ENV"              envDefault:"development"`
}

func Load() (Config, error) {
    var cfg Config
    if err := env.Parse(&cfg); err != nil {
        return Config{}, err
    }
    return cfg, nil
}

internal/obs/logger.go

go
package obs

import (
    "context"
    "log/slog"
    "os"
)

type ctxKey struct{ name string }
var requestIDKey = ctxKey{"request_id"}

func NewLogger(level, format string) *slog.Logger {
    var lvl slog.Level
    _ = lvl.UnmarshalText([]byte(level))

    opts := &slog.HandlerOptions{Level: lvl, AddSource: true}

    var h slog.Handler
    switch format {
    case "text":
        h = slog.NewTextHandler(os.Stdout, opts)
    default:
        h = slog.NewJSONHandler(os.Stdout, opts)
    }
    return slog.New(&ctxHandler{Handler: h})
}

// ctxHandler pulls request_id from ctx into every log line.
type ctxHandler struct{ slog.Handler }

func (h *ctxHandler) Handle(ctx context.Context, r slog.Record) error {
    if id, ok := ctx.Value(requestIDKey).(string); ok && id != "" {
        r.AddAttrs(slog.String("request_id", id))
    }
    return h.Handler.Handle(ctx, r)
}

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

internal/api/server.go

go
package api

import (
    "context"
    "fmt"
    "log/slog"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/your-org/myservice/internal/api/handlers"
    "github.com/your-org/myservice/internal/api/middleware"
    "github.com/your-org/myservice/internal/config"
)

type Server struct {
    cfg    config.Config
    srv    *http.Server
    logger *slog.Logger
}

func New(cfg config.Config, logger *slog.Logger, h *handlers.Handler) *Server {
    gin.SetMode(gin.ReleaseMode)
    r := gin.New()

    // Middleware order matters — see "Middleware ordering" below.
    r.Use(
        middleware.RequestID(),     // 1. assign request_id first
        middleware.Recovery(logger), // 2. recovery wraps everything
        middleware.RequestLogger(logger),
        middleware.CORS(),
    )

    h.Mount(r)

    return &Server{
        cfg:    cfg,
        logger: logger,
        srv: &http.Server{
            Addr:         fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
            Handler:      r,
            ReadTimeout:  cfg.ReadTimeout,
            WriteTimeout: cfg.WriteTimeout,
        },
    }
}

func (s *Server) Run(ctx context.Context) error {
    errCh := make(chan error, 1)
    go func() {
        s.logger.InfoContext(ctx, "server starting",
            slog.String("addr", s.srv.Addr))
        if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            errCh <- err
        }
        close(errCh)
    }()

    select {
    case <-ctx.Done():
        s.logger.InfoContext(ctx, "shutdown signal received")
        shutdownCtx, cancel := context.WithTimeout(
            context.Background(), s.cfg.ShutdownTimeout)
        defer cancel()
        return s.srv.Shutdown(shutdownCtx)
    case err := <-errCh:
        return err
    }
}

Notes:

  • gin.New() not gin.Default()Default() adds Logger() (text format, not slog) and Recovery() (no logger injection). We replace both.
  • gin.SetMode(gin.ReleaseMode) silences debug output. Production assumed.
  • http.Server with explicit timeouts. The default nil timeouts are a DoS waiting to happen.
  • Graceful shutdown: SIGINT/SIGTERM cancels the ctx → Shutdown(shutdownCtx) gives in-flight requests up to ShutdownTimeout to finish.

Middleware ordering — the rule that actually matters

RequestID    →   Recovery    →   Logger    →   CORS    →   Auth    →   Handler
   (1)            (2)              (3)            (4)         (5)
  1. RequestID is first so every subsequent middleware sees it.
  2. Recovery wraps everything after it. Order: a panic in CORS still gets caught.
  3. Logger sees the request_id and the recovered panic.
  4. CORS before Auth — OPTIONS preflight must return without auth.
  5. Auth is the last cross-cutting middleware. Per-route auth (admin-only) is mounted on a sub-router with extra middleware.
go
// Public routes — no auth
api := r.Group("/api/v1")
{
    api.POST("/auth/login", h.Login)
    api.GET("/healthz", h.Healthz)
}

// Authenticated routes
authed := r.Group("/api/v1", middleware.Auth(authSvc))
{
    authed.GET("/users/:id", h.GetUser)
    authed.POST("/users", h.CreateUser)
}

// Admin-only routes
admin := r.Group("/api/v1/admin",
    middleware.Auth(authSvc),
    middleware.RequireRole("admin"))
{
    admin.GET("/users", h.ListAllUsers)
}

Middleware examples

middleware/request_id.go

go
package middleware

import (
    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
    "github.com/your-org/myservice/internal/obs"
)

func RequestID() gin.HandlerFunc {
    return func(c *gin.Context) {
        id := c.GetHeader("X-Request-ID")
        if id == "" {
            id = uuid.Must(uuid.NewV7()).String()
        }
        c.Request = c.Request.WithContext(obs.WithRequestID(c.Request.Context(), id))
        c.Header("X-Request-ID", id)
        c.Next()
    }
}

middleware/recovery.go

go
package middleware

import (
    "log/slog"
    "net/http"
    "runtime/debug"

    "github.com/gin-gonic/gin"
)

func Recovery(logger *slog.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        defer func() {
            if r := recover(); r != nil {
                logger.ErrorContext(c.Request.Context(), "panic recovered",
                    slog.Any("panic", r),
                    slog.String("stack", string(debug.Stack())),
                )
                if !c.Writer.Written() {
                    c.JSON(http.StatusInternalServerError,
                        gin.H{"error": "internal_error"})
                }
                c.Abort()
            }
        }()
        c.Next()
    }
}

middleware/request_logging.go

go
func RequestLogger(logger *slog.Logger) gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        logger.InfoContext(c.Request.Context(), "http request",
            slog.String("method",  c.Request.Method),
            slog.String("path",    c.Request.URL.Path),
            slog.Int("status",     c.Writer.Status()),
            slog.Int("bytes",      c.Writer.Size()),
            slog.Duration("elapsed", time.Since(start)),
            slog.String("ip",      c.ClientIP()),
        )
    }
}

The sloglint linter enforces typed attrs (slog.String(...)) over slog.Any("path", ...). Keep the form.

middleware/cors.go

go
func CORS() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Header("Access-Control-Allow-Origin",  "*")
        c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
        c.Header("Access-Control-Allow-Headers", "*")
        if c.Request.Method == http.MethodOptions {
            c.AbortWithStatus(http.StatusNoContent)
            return
        }
        c.Next()
    }
}

Note the explicit OPTIONS short-circuit — preflight must NOT go through Auth.


Handlers — the canonical shape

go
package handlers

import (
    "errors"
    "net/http"

    "github.com/gin-gonic/gin"
    "github.com/go-playground/validator/v10"
    "github.com/your-org/myservice/internal/domain"
    "github.com/your-org/myservice/internal/httperr"
    "github.com/your-org/myservice/internal/service"
)

type Handler struct {
    Users *service.UserService
}

func (h *Handler) Mount(r gin.IRouter) {
    api := r.Group("/api/v1")
    api.POST("/users", h.CreateUser)
    api.GET("/users/:id", h.GetUser)
}

type createUserReq struct {
    Email    string `json:"email"    binding:"required,email"`
    Username string `json:"username" binding:"required,alphanum,min=3,max=32"`
}

func (h *Handler) CreateUser(c *gin.Context) {
    var req createUserReq
    if err := c.ShouldBindJSON(&req); err != nil {
        writeBindingError(c, err)
        return
    }

    email, err := domain.NewEmail(req.Email)
    if err != nil {
        httperr.Write(c, err)
        return
    }
    username, err := domain.NewUsername(req.Username)
    if err != nil {
        httperr.Write(c, err)
        return
    }

    user, err := h.Users.Create(c.Request.Context(), email, username)
    if err != nil {
        httperr.Write(c, err)
        return
    }
    c.JSON(http.StatusCreated, user)
}

func writeBindingError(c *gin.Context, err error) {
    var vErr validator.ValidationErrors
    if errors.As(err, &vErr) {
        out := make(map[string]string, len(vErr))
        for _, fe := range vErr {
            out[fe.Field()] = fe.Tag()
        }
        c.JSON(http.StatusBadRequest, gin.H{"errors": out})
        return
    }
    c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
}

See data-modeling.md for the validator tag reference; see error-handling.md for the httperr.Write funnel.


SSE streaming — the production pattern

CLIProxyAPI streams OpenAI-compatible SSE for hundreds of concurrent clients. The pattern:

go
func (h *Handler) StreamChat(c *gin.Context) {
    ctx, cancel := context.WithCancel(c.Request.Context())
    defer cancel()

    // 1. Set SSE headers BEFORE writing any body
    c.Header("Content-Type",  "text/event-stream")
    c.Header("Cache-Control", "no-cache")
    c.Header("Connection",    "keep-alive")
    c.Header("X-Accel-Buffering", "no") // disable nginx buffering

    // 2. Obtain the flusher — REQUIRED for streaming
    flusher, ok := c.Writer.(http.Flusher)
    if !ok {
        httperr.Write(c, errors.New("streaming unsupported"))
        return
    }

    // 3. Pull chunks from upstream
    chunks, errs := h.svc.StreamCompletions(ctx, req)

    for {
        select {
        case <-ctx.Done():
            return  // client disconnected, ctx cancelled
        case chunk, ok := <-chunks:
            if !ok {
                fmt.Fprint(c.Writer, "data: [DONE]\n\n")
                flusher.Flush()
                return
            }
            fmt.Fprintf(c.Writer, "data: %s\n\n", chunk)
            flusher.Flush()
        case err := <-errs:
            // Error mid-stream — emit as SSE event and bail
            fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", err.Error())
            flusher.Flush()
            return
        }
    }
}

Key facts:

  • Headers MUST be set before the first Write. Otherwise gin auto-sets Content-Type: text/plain.
  • c.Writer.(http.Flusher) is the streaming primitive. Without flusher.Flush(), the response is buffered and arrives as one blob at the end.
  • Always respond to <-ctx.Done(). A disconnected client must stop upstream work — otherwise you generate tokens for nothing.
  • The trailing \n\n per event is wire-mandatory for SSE parsing. Missing it = the client never sees the event.

WebSocket upgrade

go
import "github.com/gorilla/websocket"  // still the canonical WS lib in 2026

var upgrader = websocket.Upgrader{
    ReadBufferSize:  4096,
    WriteBufferSize: 4096,
    CheckOrigin: func(r *http.Request) bool {
        // tighten in production
        return true
    },
}

func (h *Handler) WebSocketEcho(c *gin.Context) {
    conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
    if err != nil {
        slog.ErrorContext(c.Request.Context(), "ws upgrade failed", slog.Any("err", err))
        return
    }
    defer conn.Close()

    for {
        mt, msg, err := conn.ReadMessage()
        if err != nil { return }
        if err := conn.WriteMessage(mt, msg); err != nil { return }
    }
}

For long-lived connections, use conn.SetReadDeadline + SetPongHandler for keepalive. CLIProxyAPI's wsrelay package is a reference implementation.


Database wiring — pgx pool, injected, never global

go
package store

import (
    "context"
    "fmt"
    "github.com/jackc/pgx/v5/pgxpool"
)

func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
    cfg, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, fmt.Errorf("parse dsn: %w", err)
    }
    cfg.MaxConns = 25
    cfg.MinConns = 5
    cfg.MaxConnLifetime = time.Hour
    cfg.MaxConnIdleTime = 30 * time.Minute

    pool, err := pgxpool.NewWithConfig(ctx, cfg)
    if err != nil {
        return nil, fmt.Errorf("connect: %w", err)
    }
    if err := pool.Ping(ctx); err != nil {
        pool.Close()
        return nil, fmt.Errorf("ping: %w", err)
    }
    return pool, nil
}

See sqlc-pgx.md for queries.


Healthcheck

go
func (h *Handler) Healthz(c *gin.Context) {
    if err := h.pool.Ping(c.Request.Context()); err != nil {
        c.JSON(503, gin.H{"db": "down", "error": err.Error()})
        return
    }
    c.JSON(200, gin.H{"ok": true})
}

Mount BEFORE auth. Health checks must be unauthenticated.


Testing the server

go
func TestCreateUser_returns_201_for_valid_input(t *testing.T) {
    // Given
    h := newTestHandler(t)
    r := gin.New()
    h.Mount(r)

    body := `{"email":"[email protected]","username":"alice"}`
    req := httptest.NewRequest("POST", "/api/v1/users", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()

    // When
    r.ServeHTTP(rec, req)

    // Then
    require.Equal(t, http.StatusCreated, rec.Code)
    var got struct{ ID string `json:"id"` }
    require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
    require.NotEmpty(t, got.ID)
}

See testing.md for full patterns (testcontainers integration, table-driven, goleak).


Sources