feat-dev-e2e2/.claude/guides/backend/api-patterns.md
jordan 74e0e28cac
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-03 02:20:35 +00:00

152 lines
3.8 KiB
Markdown

# 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-e2e2/pkg/chassis"
svc := chassis.New("my-service", chassis.WithDefaultPort(8080))
```