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
5 changes: 4 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ FIREBASE_PROJECT_ID=
# Service account key as a single-line JSON string. Get from: Firebase Console → Project Settings → Service Accounts → Generate new private key
FIREBASE_SERVICE_ACCOUNT_JSON=
# Sentry error tracking DSN (optional; leave empty to disable)
SENTRY_DSN=
SENTRY_DSN=
# WebSocket allowed origin in staging/production — e.g. https://example.com
# Leave empty to deny all cross-origin WebSocket connections in non-debug mode.
BLUEPRINT_WS_ALLOWED_ORIGIN=http://localhost:3000
9 changes: 8 additions & 1 deletion backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"time"

"backend/internal/bootstrap"
"backend/internal/infrastructure/ws"
"backend/internal/server"
)

Expand Down Expand Up @@ -55,7 +56,11 @@ func main() {
os.Exit(1)
}

srv := server.NewServer(app)
hubCtx, hubCancel := context.WithCancel(context.Background())
hub := ws.NewHub()
go hub.Run(hubCtx)

srv := server.NewServer(app, hub)
slog.Info("API docs", "url", fmt.Sprintf("http://localhost%s/swagger/index.html", srv.Addr))

done := make(chan bool, 1)
Expand All @@ -66,6 +71,8 @@ func main() {
}

<-done
hubCancel() // stop hub after all WS connections have been closed by server shutdown

if app.Cache != nil {
if err := app.Cache.Close(); err != nil {
slog.Error("cache close error", "error", err)
Expand Down
1 change: 1 addition & 0 deletions backend/docs/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ The `docs` agent reads this index first to locate the right file before diving i
| Middleware (logger, rate limiter) | [middleware.md](middleware.md) | `internal/transport/middleware/logger.go`, `internal/transport/middleware/ratelimit.go`, `internal/transport/handlers/routes.go` |
| Firebase Auth (token verification, middleware, MeHandler) | [auth.md](auth.md) | `internal/usecase/auth_usecase.go`, `internal/transport/middleware/auth.go`, `internal/transport/handlers/auth_handler.go`, `pkg/firebase/admin.go`, `internal/bootstrap/bootstrap.go` |
| Observability (Sentry error tracking) | [observability.md](observability.md) | `internal/transport/middleware/sentry.go`, `internal/bootstrap/bootstrap.go`, `internal/transport/handlers/routes.go` |
| WebSocket (Hub, client, GET /ws, auth, wiring) | [websocket.md](websocket.md) | `internal/infrastructure/ws/`, `internal/transport/handlers/ws_handler.go`, `internal/transport/handlers/routes.go`, `internal/server/server.go`, `cmd/api/main.go` |
25 changes: 15 additions & 10 deletions backend/docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,18 @@ sources:

## Handler struct
```go
// internal/handler/handler.go
// internal/transport/handlers/handler.go
type Handler struct {
healthUC usecase.HealthUseCase
verifier usecase.FirebaseTokenVerifier // nil disables auth (dev only)
hub *ws.Hub
}

func NewHandler(healthUC usecase.HealthUseCase) *Handler {
return &Handler{healthUC: healthUC}
func NewHandler(healthUC usecase.HealthUseCase, verifier usecase.FirebaseTokenVerifier, hub *ws.Hub) *Handler {
return &Handler{healthUC: healthUC, verifier: verifier, hub: hub}
}
```
The `Handler` struct holds use case interfaces — not `*sql.DB` directly. Add new use case fields here as features are added.
The `Handler` struct holds use case interfaces and infrastructure dependencies — not `*sql.DB` directly. `verifier` is stored on the struct (not passed to `RegisterRoutes`) so the WebSocket handler can read it inline for query-param auth.

## Wiring (server.go)
`internal/server/server.go` contains `NewServer(app *bootstrap.App) *http.Server` — wiring only, no logic.
Expand All @@ -34,11 +36,11 @@ It receives the already-validated `*bootstrap.App` (which holds `*sql.DB`, `Cach
```go
healthRepo := postgres.NewHealthRepository(app.DB)
healthUC := usecase.NewHealthUseCase(healthRepo)
h := handlers.NewHandler(healthUC)
h := handlers.NewHandler(healthUC, app.Firebase, hub)

return &http.Server{
Addr: fmt.Sprintf(":%d", app.Config.Port),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Firebase, app.Config.SentryDSN),
Handler: h.RegisterRoutes(app.Config.RateLimitRPS, app.Config.RateLimitBurst, app.Config.SentryDSN),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
Expand All @@ -51,7 +53,7 @@ All routes registered in `RegisterRoutes()` on `*Handler`, which returns `http.H
`verifier` is a `usecase.FirebaseTokenVerifier`; pass `nil` to skip Firebase auth (development only — see [auth](auth.md)).

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

// Gin's colorful logger locally; structured slog logger in staging/production.
Expand All @@ -67,11 +69,13 @@ func (h *Handler) RegisterRoutes(rps float64, burst int, verifier usecase.Fireba

r.GET("/", h.HelloWorldHandler)
r.GET("/health", h.HealthHandler)
r.GET("/ws", h.WsHandler) // WebSocket upgrade — auth via ?token= query param

r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

api := r.Group("/api/v1")
if verifier != nil {
api.Use(middleware.FirebaseAuth(verifier))
if h.verifier != nil {
api.Use(middleware.FirebaseAuth(h.verifier))
}
api.GET("/me", h.MeHandler)

Expand Down Expand Up @@ -104,7 +108,8 @@ Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH.
|---|---|---|---|---|
| GET | `/` | none | `HelloWorldHandler` — returns `{"message": "Hello World"}` | `hello_handler.go` |
| GET | `/health` | none | `HealthHandler` — returns `HealthStats`; 503 when DB is down | `health_handler.go` |
| GET | `/api/v1/me` | FirebaseAuth | `MeHandler` — returns verified `FirebaseToken` claims | `auth_handler.go` |
| GET | `/ws` | `?token=` query param | `WsHandler` — upgrades to WebSocket; 401 when token missing/invalid | `ws_handler.go` |
| GET | `/api/v1/me` | FirebaseAuth header | `MeHandler` — returns verified `FirebaseToken` claims | `auth_handler.go` |

## Graceful shutdown
Wired in `cmd/api/main.go` via `signal.NotifyContext` for SIGINT/SIGTERM.
Expand Down
38 changes: 38 additions & 0 deletions backend/docs/swagger/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,44 @@ const docTemplate = `{
}
}
}
},
"/ws": {
"get": {
"description": "Upgrades HTTP to WebSocket. Pass a Firebase ID token as ` + "`" + `?token=\u003ctoken\u003e` + "`" + `. Returns 401 when the token is missing or invalid.",
"produces": [
"application/json"
],
"tags": [
"websocket"
],
"summary": "Open a WebSocket connection",
"parameters": [
{
"type": "string",
"description": "Firebase ID token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"101": {
"description": "Switching Protocols",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
Expand Down
38 changes: 38 additions & 0 deletions backend/docs/swagger/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,44 @@
}
}
}
},
"/ws": {
"get": {
"description": "Upgrades HTTP to WebSocket. Pass a Firebase ID token as `?token=\u003ctoken\u003e`. Returns 401 when the token is missing or invalid.",
"produces": [
"application/json"
],
"tags": [
"websocket"
],
"summary": "Open a WebSocket connection",
"parameters": [
{
"type": "string",
"description": "Firebase ID token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"101": {
"description": "Switching Protocols",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
},
"definitions": {
Expand Down
26 changes: 26 additions & 0 deletions backend/docs/swagger/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ paths:
summary: Health check
tags:
- ops
/ws:
get:
description: Upgrades HTTP to WebSocket. Pass a Firebase ID token as `?token=<token>`.
Returns 401 when the token is missing or invalid.
parameters:
- description: Firebase ID token
in: query
name: token
required: true
type: string
produces:
- application/json
responses:
"101":
description: Switching Protocols
schema:
type: string
"401":
description: Unauthorized
schema:
additionalProperties:
type: string
type: object
summary: Open a WebSocket connection
tags:
- websocket
securityDefinitions:
BearerAuth:
description: Firebase ID token — prefix with "Bearer "
Expand Down
Loading
Loading