Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ BLUEPRINT_DB_SSLMODE=disable
REDIS_URL=
RATE_LIMIT_RPS=100 # requests/sec per IP (omit or 0 = disabled)
RATE_LIMIT_BURST=500 # burst capacity (defaults to RPS×5)
# CORS allowed origins — comma-separated list of allowed web client URLs
# Defaults to http://localhost:3000 if omitted
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Firebase project ID — e.g. my-app-12345 (omit to disable auth)
FIREBASE_PROJECT_ID=
# Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key
Expand Down
4 changes: 3 additions & 1 deletion backend/docs/environment.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
---
topic: environment
last_verified: 2026-06-23
last_verified: 2026-06-25
sources:
- .env
- .env.example
- internal/bootstrap/bootstrap.go
- internal/infrastructure/database/postgres/db.go
- pkg/firebase/admin.go
Expand Down Expand Up @@ -47,6 +48,7 @@ This runs on package init before any env var is read — no explicit `godotenv.L
| `R2_BUCKET` | `bootstrap.go` | — | R2 bucket name. Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `R2_PUBLIC_URL` | `bootstrap.go` | — | Public base URL for the R2 bucket (custom domain or `r2.dev` subdomain). Required when `R2_ACCOUNT_ID` is set; startup fails if omitted. |
| `IPAPI_KEY` | `bootstrap.go` | — | ipapi.co API key (optional). Free tier works without a key; supplying one enables higher rate limits. |
| `CORS_ALLOWED_ORIGINS` | `bootstrap.go` | `http://localhost:3000` | Comma-separated list of origins allowed by the CORS middleware. Parsed at startup — each entry is whitespace-trimmed. E.g. `https://app.example.com,https://staging.example.com`. |

Variables marked **required** are validated by `bootstrap.validateConfig` at startup — the process exits before attempting a DB connection if any are missing.

Expand Down
43 changes: 36 additions & 7 deletions backend/docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
---
topic: error-handling
last_verified: 2026-06-23
last_verified: 2026-06-25
sources:
- internal/infrastructure/database/postgres/health_repository.go
- internal/transport/handlers/health_handler.go
- internal/transport/handlers/validation.go
- internal/transport/handlers/response.go
- cmd/api/main.go
---

# Error Handling

## Response envelope

All handler responses use helpers defined in `internal/transport/handlers/response.go`. Never call `c.JSON` directly in a handler.

**Success shape:**
```json
{"data": <payload>}
```

**Error shape:**
```json
{"error": {"code": "snake_case_code", "message": "human readable message"}}
```

**Helper signatures:**
```go
// 200 with data wrapped in {"data": ...}
func JSON[T any](c *gin.Context, data T)

// Any status code with data wrapped in {"data": ...}
func JSONStatus[T any](c *gin.Context, status int, data T)

// Error response as {"error": {"code": "...", "message": "..."}}
func JSONError(c *gin.Context, status int, code, message string)
```

Use `JSON` for standard 200 responses, `JSONStatus` when a non-200 success status is needed (e.g., 201 Created), and `JSONError` for all error responses.

## General rule
Return errors up the call stack. Callers decide how to handle them.
Never use `log.Fatal` or `os.Exit` inside `internal/`.
Expand Down Expand Up @@ -38,17 +67,17 @@ func (r *HealthRepository) Health(ctx context.Context) (domain.HealthStats, erro
```

## Handler error responses
Handlers call use cases, check errors, and map them to HTTP status codes. The health handler returns 503 when the DB is unreachable:
Handlers call use cases, check errors, and map them to HTTP status codes using the `JSONError` helper. The health handler returns 503 when the DB is unreachable:

```go
func (h *Handler) healthHandler(c *gin.Context) {
stats, err := h.healthUC.GetHealth(c.Request.Context())
if err != nil {
log.Printf("health check failed: %v", err)
c.JSON(http.StatusServiceUnavailable, stats)
JSONStatus(c, http.StatusServiceUnavailable, stats)
return
}
c.JSON(http.StatusOK, stats)
JSON(c, stats)
}
```

Expand All @@ -59,13 +88,13 @@ func (h *Handler) getItemHandler(c *gin.Context) {
item, err := h.itemUC.GetItem(c.Request.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
JSONError(c, http.StatusNotFound, "not_found", "not found")
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
JSONError(c, http.StatusInternalServerError, "internal_error", "internal error")
return
}
c.JSON(http.StatusOK, item)
JSON(c, item)
}
```

Expand Down
41 changes: 34 additions & 7 deletions backend/docs/middleware.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
topic: middleware
last_verified: 2026-06-23
last_verified: 2026-06-25
sources:
- internal/transport/middleware/logger.go
- internal/transport/middleware/ratelimit.go
- internal/transport/middleware/auth.go
- internal/transport/middleware/metrics.go
- internal/transport/middleware/local_network.go
- internal/transport/middleware/geo.go
- internal/transport/middleware/request_id.go
- internal/transport/handlers/routes.go
---

Expand All @@ -18,15 +19,17 @@ All middleware lives in `internal/transport/middleware/` and follows the Gin `Ha
## Registration order

```go
// 1. Sentry error reporting
// 1. Request ID — must be first so every subsequent middleware can read the ID
r.Use(middleware.RequestID())
// 2. Sentry error reporting
r.Use(middleware.SentryMiddleware(sentryDSN))
// 2. Recovery + logger (debug: gin.Logger, release: middleware.Logger)
// 3. Recovery + logger (debug: gin.Logger, release: middleware.Logger)
r.Use(gin.Recovery(), middleware.Logger())
// 3. Prometheus metrics collection
// 4. Prometheus metrics collection
r.Use(middleware.PrometheusMiddleware())
// 4. Rate limiter (no-op when RPS <= 0)
// 5. Rate limiter (no-op when RPS <= 0)
r.Use(middleware.RateLimit(rps, burst))
// 5. CORS
// 6. CORS
r.Use(cors.New(...))

// Global routes (no auth):
Expand All @@ -42,9 +45,33 @@ if h.verifier != nil {
api.GET("/me", h.MeHandler)
```

## RequestID

`RequestID() gin.HandlerFunc` assigns a unique identifier to every request. It is registered as the first middleware in `RegisterRoutes` so all subsequent middleware (including logger and Sentry) have access to the ID.

```go
const RequestIDKey = "request_id"
const RequestIDHeader = "X-Request-ID"

func RequestID() gin.HandlerFunc
```

Behaviour:
- Reads the `X-Request-ID` request header. If present and non-empty, uses that value (allows callers to propagate their own trace IDs).
- If absent or empty, generates a random 16-byte hex string (`crypto/rand`).
- Stores the ID in the Gin context under `RequestIDKey` via `c.Set`.
- Echoes the ID back in the `X-Request-ID` response header.

Reading the ID inside a handler or middleware:
```go
requestID := c.GetString(middleware.RequestIDKey)
```

The structured `Logger()` middleware appends `"request_id"` to every slog record automatically.

## Logger

`Logger() gin.HandlerFunc` emits one structured `slog` record per request after `c.Next()` returns. Fields: `status`, `method`, `path`, `latency`, `ip`, and optionally `query` and `errors`.
`Logger() gin.HandlerFunc` emits one structured `slog` record per request after `c.Next()` returns. Fields: `status`, `method`, `path`, `latency`, `ip`, `request_id`, and optionally `query` and `errors`.

In debug mode (`ENV` not set to `staging`/`production`) Gin's built-in colorful logger is used instead.

Expand Down
24 changes: 17 additions & 7 deletions backend/docs/routing.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
topic: routing
last_verified: 2026-06-23
last_verified: 2026-06-25
sources:
- internal/transport/handlers/handler.go
- internal/transport/handlers/routes.go
Expand All @@ -9,7 +9,9 @@ sources:
- internal/transport/handlers/auth_handler.go
- internal/transport/handlers/me_handler.go
- internal/transport/handlers/validation.go
- internal/transport/handlers/response.go
- internal/transport/middleware/logger.go
- internal/transport/middleware/request_id.go
- internal/server/server.go
- cmd/api/main.go
---
Expand Down Expand Up @@ -78,7 +80,7 @@ prometheus.Register(postgres.NewDBStatsCollector(app.DB))

return &http.Server{
Addr: fmt.Sprintf(":%d", app.Config.Port),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN, app.Config.CORSAllowedOrigins),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
Expand All @@ -88,12 +90,14 @@ return &http.Server{
## Route registration
All routes are registered in `RegisterRoutes()` on `*Handler`, which returns `http.Handler`.
`rps` and `burst` come from `bootstrap.Config` (env vars `RATE_LIMIT_RPS` / `RATE_LIMIT_BURST`); pass `rps=0` to disable.
`allowedOrigins` comes from `bootstrap.Config.CORSAllowedOrigins` (env var `CORS_ALLOWED_ORIGINS`); defaults to `["http://localhost:3000"]` when the env var is not set.
`h.verifier` (set via `NewHandler`) controls Firebase auth — the verifier is read from the struct, not passed to `RegisterRoutes`; a `nil` verifier skips Firebase auth (development only — see [auth](auth.md)).

```go
func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.Handler {
func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string, allowedOrigins []string) http.Handler {
r := gin.New()

r.Use(middleware.RequestID())
r.Use(middleware.SentryMiddleware(sentryDSN))

// Gin's colorful logger locally; structured slog logger in staging/production.
Expand All @@ -106,7 +110,12 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.
r.Use(middleware.PrometheusMiddleware())
r.Use(middleware.RateLimit(rps, burst))

r.Use(cors.New(cors.Config{ ... }))
r.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
}))

r.GET("/", h.HelloWorldHandler)
r.GET("/health", h.HealthHandler)
Expand Down Expand Up @@ -156,21 +165,22 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, sentryDSN string) http.

## Handler pattern
All handlers are methods on `*Handler`. Always use `*gin.Context`.
Use `JSON`, `JSONStatus`, and `JSONError` from `internal/transport/handlers/response.go` — do not call `c.JSON` directly in handlers (see [error-handling](error-handling.md) for the envelope shape).

```go
func (h *Handler) myHandler(c *gin.Context) {
result, err := h.someUC.DoSomething(c.Request.Context(), ...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
JSONError(c, http.StatusInternalServerError, "internal_error", "internal error")
return
}
c.JSON(http.StatusOK, result)
JSON(c, result)
}
```

## CORS configuration
Pre-configured in `RegisterRoutes()` via `github.com/gin-contrib/cors`.
Current allowed origin: `http://localhost:3000`.
Allowed origins come from `allowedOrigins []string` (4th parameter), sourced from `bootstrap.Config.CORSAllowedOrigins` — set via the `CORS_ALLOWED_ORIGINS` env var (comma-separated, defaults to `http://localhost:3000`).
Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH.
`AllowCredentials: true` — cookies and auth headers pass through.

Expand Down
10 changes: 10 additions & 0 deletions backend/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type Config struct {
R2Bucket string
R2PublicURL string
IPAPIKey string
CORSAllowedOrigins []string
}

// ConfigError is returned when required configuration is absent or invalid.
Expand Down Expand Up @@ -230,6 +231,14 @@ func loadConfig() Config {
burst = int(rps) * 5
}

corsOrigins := []string{"http://localhost:3000"}
if raw := os.Getenv("CORS_ALLOWED_ORIGINS"); raw != "" {
corsOrigins = strings.Split(raw, ",")
for i, o := range corsOrigins {
corsOrigins[i] = strings.TrimSpace(o)
}
}

return Config{
Port: port,
Env: os.Getenv("ENV"),
Expand All @@ -249,6 +258,7 @@ func loadConfig() Config {
R2Bucket: os.Getenv("R2_BUCKET"),
R2PublicURL: os.Getenv("R2_PUBLIC_URL"),
IPAPIKey: os.Getenv("IPAPI_KEY"),
CORSAllowedOrigins: corsOrigins,
DB: postgres.DBConfig{
Host: os.Getenv("BLUEPRINT_DB_HOST"),
Port: os.Getenv("BLUEPRINT_DB_PORT"),
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func NewServer(app *bootstrap.App, hub *ws.Hub) (*http.Server, error) {

return &http.Server{
Addr: fmt.Sprintf(":%d", app.Config.Port),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN, app.Config.CORSAllowedOrigins),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/transport/handlers/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ func (h *Handler) MeHandler(c *gin.Context) {
val, _ := c.Get(middleware.FirebaseClaimsKey)
token, ok := val.(*usecase.FirebaseToken)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token")
return
}
c.JSON(http.StatusOK, token)
JSON(c, token)
}
7 changes: 5 additions & 2 deletions backend/internal/transport/handlers/auth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ func TestMeHandler_WithClaims(t *testing.T) {
t.Fatalf("expected 200, got %d", w.Code)
}

var got usecase.FirebaseToken
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
var resp struct {
Data usecase.FirebaseToken `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal body: %v", err)
}
got := resp.Data
if got.UID != want.UID || got.Email != want.Email || got.Name != want.Name {
t.Errorf("response mismatch: got %+v, want %+v", got, *want)
}
Expand Down
12 changes: 6 additions & 6 deletions backend/internal/transport/handlers/fcm_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (h *Handler) RegisterFCMToken(c *gin.Context) {
val, _ := c.Get(middleware.FirebaseClaimsKey)
claims, ok := val.(*usecase.FirebaseToken)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token")
return
}

Expand All @@ -48,7 +48,7 @@ func (h *Handler) RegisterFCMToken(c *gin.Context) {
}

if err := h.fcmTokenRepo.SaveToken(c.Request.Context(), claims.UID, req.Token, req.Platform); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
JSONError(c, http.StatusInternalServerError, "internal_error", "failed to save token")
return
}

Expand All @@ -62,7 +62,7 @@ func (h *Handler) RegisterFCMToken(c *gin.Context) {
}
}

c.JSON(http.StatusOK, gin.H{"message": "token registered"})
JSON(c, gin.H{"message": "token registered"})
}

// UnregisterFCMToken removes an FCM device token, typically called on logout.
Expand All @@ -83,7 +83,7 @@ func (h *Handler) UnregisterFCMToken(c *gin.Context) {
val, _ := c.Get(middleware.FirebaseClaimsKey)
claims, ok := val.(*usecase.FirebaseToken)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
JSONError(c, http.StatusUnauthorized, "unauthorized", "missing or invalid token")
return
}

Expand All @@ -93,9 +93,9 @@ func (h *Handler) UnregisterFCMToken(c *gin.Context) {
}

if err := h.fcmTokenRepo.DeleteToken(c.Request.Context(), claims.UID, req.Token); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove token"})
JSONError(c, http.StatusInternalServerError, "internal_error", "failed to remove token")
return
}

c.JSON(http.StatusOK, gin.H{"message": "token unregistered"})
JSON(c, gin.H{"message": "token unregistered"})
}
4 changes: 2 additions & 2 deletions backend/internal/transport/handlers/health_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ func (h *Handler) HealthHandler(c *gin.Context) {
stats, err := h.healthUC.GetHealth(c.Request.Context())
if err != nil {
slog.Warn("health check failed", "error", err)
c.JSON(http.StatusServiceUnavailable, stats)
JSONStatus(c, http.StatusServiceUnavailable, stats)
return
}
c.JSON(http.StatusOK, stats)
JSONStatus(c, http.StatusOK, stats)
}
Loading
Loading