route-test-1770185086/.claude/guides/backend/api-patterns.md
jordan 0f92583dba
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-04 06:04:48 +00:00

3.8 KiB

Backend API Patterns

Handler Pattern (Wrap)

All handlers return error and are wrapped with app.Wrap():

func (h *Handler) Get(w http.ResponseWriter, r *http.Request) error {
    id := chi.URLParam(r, "id")
    item, err := h.svc.Get(r.Context(), id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            return httperror.NotFoundf("item %s not found", id)
        }
        return err // becomes 500
    }
    httpresponse.OK(w, r, item)
    return nil
}

// In routes.go:
r.Get("/items/{id}", app.Wrap(handler.Get))

Request Binding

Use app.Bind or app.BindAndValidate:

func (h *Handler) Create(w http.ResponseWriter, r *http.Request) error {
    var req CreateRequest
    if err := app.BindAndValidate(r, &req); err != nil {
        return err // returns 400 or 422 HTTPError
    }
    // req is decoded and validated
}

Validation uses go-playground/validator struct tags:

  • validate:"required" - field is required
  • validate:"min=1,max=100" - length constraints
  • validate:"email" - email format
  • validate:"uuid" - UUID format

HTTPError Sentinels

Use httperror factories to return typed errors:

Function Status When to use
httperror.BadRequest(msg) 400 Invalid input format
httperror.Unauthorized(msg) 401 Missing/invalid credentials
httperror.Forbidden(msg) 403 No permission
httperror.NotFoundf(fmt, args) 404 Resource doesn't exist
httperror.Conflict(msg) 409 Duplicate resource
httperror.Validation(msg) 422 Struct validation failure
httperror.Internal(msg) 500 Server error (prefer returning raw err)

Add details with httperror.WithDetails(err, details).

Response Envelope

All responses use the standard envelope from httpresponse:

{
  "data": { ... },
  "meta": {
    "request_id": "abc-123",
    "timestamp": "2024-01-15T10:30:00Z"
  }
}

Use httpresponse.OK(w, r, data), httpresponse.Created(w, r, data), httpresponse.NoContent(w).

OpenAPI Documentation

Annotate endpoints in a spec.go file:

spec := openapi.NewOpenAPISpec("Service Name", "1.0.0").
    WithBearerSecurity("bearer", "JWT token")

spec.WithSchema("Item", openapi.Object(map[string]openapi.Schema{
    "id":   openapi.UUID(),
    "name": openapi.String().WithExample("My Item"),
}, "id", "name"))

spec.AddPath("/api/v1/items", "get", map[string]any{
    "summary": "List items",
    "tags":    []string{"Items"},
    "responses": map[string]any{
        "200": openapi.OpResponse("Success", openapi.RefArray("Item")),
    },
})

Mount with application.EnableDocs(spec) to get /docs (Scalar UI) and /openapi.json.

Auth Integration

Auth is opt-in via AUTH_ENABLED=true:

// In routes.go - protected route group
r.Group(func(r app.Router) {
    if cfg.AuthEnabled {
        r.Use(auth.Middleware(auth.MiddlewareConfig{
            Validator: auth.NewJWTValidator(auth.JWTConfig{
                Secret: []byte(cfg.JWTSecret),
            }),
        }))
    }
    r.Post("/items", app.Wrap(handler.Create))
})

Access user in handlers:

user := auth.GetUser(r.Context())
if user != nil {
    logger.Info("created by", "user", user.ID)
}

Health Checks

Basic health is auto-registered at /health and /ready.

For dependency checks:

healthHandler := app.NewHealthHandler(app.HealthConfig{
    Service: "my-service",
    Checks: map[string]app.HealthChecker{
        "database": app.PingChecker(db.PingContext),
        "redis":    app.PingChecker(redis.Ping),
    },
})
r.Get("/health", healthHandler)

Chassis Package

The pkg/chassis package re-exports pkg/app types for convenience:

import "git.threesix.ai/jordan/route-test-1770185086/pkg/chassis"

svc := chassis.New("my-service", chassis.WithDefaultPort(8080))