# Backend API Patterns ## Handler Pattern (Wrap) All handlers return `error` and are wrapped with `app.Wrap()`: ```go 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`: ```go 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`: ```json { "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: ```go 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`: ```go // 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: ```go 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: ```go 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: ```go import "git.threesix.ai/jordan/feat-dev-e2e/pkg/chassis" svc := chassis.New("my-service", chassis.WithDefaultPort(8080)) ```