docs(omo-codex): batch 98 (15 files)
This commit is contained in:
@@ -0,0 +1,641 @@
|
||||
# HTTP Backend Stack — gin + slog + validator + pgx
|
||||
|
||||
The canonical production HTTP service skeleton. Distilled from the [CLIProxyAPI](https://github.com/router-for-me/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":"a@b.com","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
|
||||
|
||||
- gin docs: https://gin-gonic.com/docs/
|
||||
- CLIProxyAPI (reference impl): https://github.com/router-for-me/CLIProxyAPI
|
||||
- pgx pool: https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
|
||||
- SSE spec: https://html.spec.whatwg.org/multipage/server-sent-events.html
|
||||
- Go's `http.Server` graceful shutdown: https://pkg.go.dev/net/http#Server.Shutdown
|
||||
Reference in New Issue
Block a user