3.8 KiB
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 requiredvalidate:"min=1,max=100"- length constraintsvalidate:"email"- email formatvalidate:"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/slack-auth-1770277926/pkg/chassis"
svc := chassis.New("my-service", chassis.WithDefaultPort(8080))