Compare commits

...

1 Commits

Author SHA1 Message Date
rdev-worker
0e39598aa6 build: /implement-feature websocket-chat --requirements 'GET /ws upgrades to...
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-06 09:09:52 +00:00
15 changed files with 555 additions and 5 deletions

View File

@ -0,0 +1,4 @@
name: feature/websocket-chat
feature: websocket-chat
base_branch: main
created_at: 2026-02-06T09:05:35.974642783Z

36
.sdlc/config.yaml Normal file
View File

@ -0,0 +1,36 @@
version: 1
project:
name: workspace
branches:
main: main
feature_prefix: feature/
phases:
enabled:
- draft
- specified
- planned
- ready
- implementation
- review
- audit
- qa
- merge
- released
required_artifacts:
audit:
- audit
planned:
- spec
- design
- tasks
- qa_plan
qa:
- qa_results
review:
- review
specified:
- spec
compliance:
require_approvals: true
require_branch: true
require_qa: true

View File

@ -0,0 +1,52 @@
# WebSocket Chat - Technical Design
## Architecture
Leverages the existing `pkg/realtime` package which provides:
- `LocalHub`: In-memory connection registry with room support
- `WSClient`: WebSocket connection with heartbeat (ping/pong)
- `Handler`: HTTP upgrade and connection lifecycle management
- `RedisBroadcaster`: Cross-pod message distribution via Redis Pub/Sub
### Message Flow
```
Client → WebSocket → WSClient.readPump → Handler.OnMessage callback
→ RedisBroadcaster.Publish → Redis Pub/Sub channel
→ RedisBroadcaster.Run (subscriber) → LocalHub.Broadcast
→ WSClient.writePump → All connected WebSocket clients
```
## Changes Required
### 1. Configuration (`internal/config/config.go`)
Add `RedisURL` field to Config struct, loaded from `REDIS_URL` environment variable.
### 2. Main Entry Point (`cmd/server/main.go`)
- Create Redis client from `REDIS_URL`
- Create `LocalHub` and start its event loop
- Create `RedisBroadcaster` and start its subscriber loop
- Pass hub and broadcaster to `RegisterRoutes`
- Register shutdown hooks for hub cancellation and Redis client cleanup
### 3. Route Registration (`internal/api/routes.go`)
- Accept `realtime.Handler` as a parameter
- Mount WebSocket handler routes at `/api/chat-api/ws`
### 4. OpenAPI Spec (`internal/api/spec.go`)
- Add WebSocket tag and endpoint documentation
### 5. Tests
- Unit test for WebSocket handler wiring
- Integration test for WebSocket connection upgrade and message echo
## Dependencies
- `pkg/realtime` (already exists, no changes needed)
- `github.com/redis/go-redis/v9` (already in pkg/go.mod)
- `github.com/gorilla/websocket` (already in pkg/go.mod)

View File

@ -0,0 +1,50 @@
slug: websocket-chat
title: WebSocket Chat
created: 2026-02-06T09:03:57.042968245Z
branch: feature/websocket-chat
phase: implementation
phase_history:
- phase: draft
entered: 2026-02-06T09:03:57.042968245Z
exited: 2026-02-06T09:05:15.971847347Z
- phase: specified
entered: 2026-02-06T09:05:15.971847347Z
exited: 2026-02-06T09:05:32.230363629Z
- phase: planned
entered: 2026-02-06T09:05:32.230363629Z
exited: 2026-02-06T09:05:39.185157676Z
- phase: ready
entered: 2026-02-06T09:05:39.185157676Z
exited: 2026-02-06T09:05:39.189335599Z
- phase: implementation
entered: 2026-02-06T09:05:39.189335599Z
artifacts:
audit:
status: pending
path: audit.md
design:
status: approved
path: design.md
approved_by: user
approved_at: 2026-02-06T09:04:55.897812432Z
qa_plan:
status: approved
path: qa-plan.md
approved_by: user
approved_at: 2026-02-06T09:05:28.161281152Z
qa_results:
status: pending
path: qa-results.md
review:
status: pending
path: review.md
spec:
status: approved
path: spec.md
approved_by: user
approved_at: 2026-02-06T09:04:55.893238485Z
tasks:
status: approved
path: tasks.md
approved_by: user
approved_at: 2026-02-06T09:04:55.902193928Z

View File

@ -0,0 +1,32 @@
# WebSocket Chat - QA Plan
## Test Cases
### TC-1: WebSocket Connection Upgrade
- Connect to `GET /api/chat-api/ws`
- Verify HTTP 101 Switching Protocols
- Verify WebSocket connection is established
### TC-2: Message Send and Broadcast
- Connect two clients to the same room
- Client A sends a chat message
- Verify Client B receives the message via broadcast
### TC-3: Redis Pub/Sub Integration
- Connect a client
- Send a message
- Verify message is published to Redis channel
- Verify subscriber broadcasts to all clients
### TC-4: Room-Based Messaging
- Connect Client A to room "general"
- Connect Client B to room "general"
- Connect Client C to room "other"
- Client A sends message to "general"
- Verify Client B receives it
- Verify Client C does not receive it
### TC-5: Graceful Shutdown
- Connect a client
- Send SIGTERM to server
- Verify connection is closed cleanly

View File

@ -0,0 +1,30 @@
# WebSocket Chat - Feature Specification
## Overview
Add WebSocket support to the chat-api service. `GET /ws` upgrades to a WebSocket connection. Incoming messages are published to a Redis Pub/Sub channel. A Redis subscriber broadcasts received messages to all connected WebSocket clients.
## Requirements
### Functional Requirements
1. **WebSocket Endpoint**: `GET /api/chat-api/ws` upgrades HTTP connections to WebSocket
2. **Message Publishing**: Incoming WebSocket messages are published to Redis Pub/Sub channels
3. **Message Broadcasting**: A Redis subscriber receives messages and broadcasts them to all connected WebSocket clients
4. **Room Support**: Optional room-based messaging via `GET /api/chat-api/ws/{room}` or `?room=` query parameter
### Non-Functional Requirements
1. Uses existing `pkg/realtime` package (Hub, WSClient, RedisBroadcaster)
2. Follows existing chat-api handler and routing patterns
3. Redis connection configured via `REDIS_URL` environment variable
4. Graceful shutdown of hub and Redis broadcaster on SIGTERM
## Acceptance Criteria
- [ ] `GET /api/chat-api/ws` upgrades to WebSocket
- [ ] Messages sent by a client are published to Redis
- [ ] Messages from Redis are broadcast to all connected clients
- [ ] Hub and Redis broadcaster shut down gracefully
- [ ] Configuration via `REDIS_URL` environment variable
- [ ] OpenAPI spec updated with WebSocket endpoint documentation

View File

@ -0,0 +1,29 @@
# WebSocket Chat - Implementation Tasks
## Task 1: Add Redis configuration
- **ID**: ws-1-config
- **Status**: pending
- **Blocked By**: none
- **Files**: `services/chat-api/internal/config/config.go`, `services/chat-api/.env.example`
- **Scope**: Add `RedisURL` field to Config struct. Add `REDIS_URL` to .env.example.
## Task 2: Wire up Hub, Redis broadcaster, and WebSocket handler in main.go
- **ID**: ws-2-main-wiring
- **Status**: pending
- **Blocked By**: ws-1-config
- **Files**: `services/chat-api/cmd/server/main.go`
- **Scope**: Create Redis client, LocalHub, RedisBroadcaster, and realtime.Handler. Start hub and broadcaster goroutines. Pass handler to RegisterRoutes. Register shutdown hooks.
## Task 3: Mount WebSocket routes
- **ID**: ws-3-routes
- **Status**: pending
- **Blocked By**: ws-2-main-wiring
- **Files**: `services/chat-api/internal/api/routes.go`, `services/chat-api/internal/api/spec.go`
- **Scope**: Update RegisterRoutes to accept realtime.Handler. Mount WebSocket handler at `/api/chat-api/ws`. Add WebSocket endpoint to OpenAPI spec.
## Task 4: Add WebSocket handler test
- **ID**: ws-4-tests
- **Status**: pending
- **Blocked By**: ws-3-routes
- **Files**: `services/chat-api/internal/api/handlers/ws_test.go`
- **Scope**: Test WebSocket upgrade, message send/receive, and hub broadcast.

63
.sdlc/state.yaml Normal file
View File

@ -0,0 +1,63 @@
version: 1
project:
name: workspace
active_work:
features:
- slug: websocket-chat
branch: feature/websocket-chat
phase: implementation
blocked: []
last_updated: 2026-02-06T09:05:39.190068428Z
last_action: TRANSITION
last_actor: cli
history:
- timestamp: 2026-02-06T09:03:57.04327906Z
action: CREATE_FEATURE
feature: websocket-chat
actor: cli
result: success
- timestamp: 2026-02-06T09:04:55.893663644Z
action: APPROVE_ARTIFACT
feature: websocket-chat
actor: user
result: success
- timestamp: 2026-02-06T09:04:55.898271126Z
action: APPROVE_ARTIFACT
feature: websocket-chat
actor: user
result: success
- timestamp: 2026-02-06T09:04:55.902590295Z
action: APPROVE_ARTIFACT
feature: websocket-chat
actor: user
result: success
- timestamp: 2026-02-06T09:05:15.972296783Z
action: TRANSITION
feature: websocket-chat
actor: cli
result: success
- timestamp: 2026-02-06T09:05:28.16183808Z
action: APPROVE_ARTIFACT
feature: websocket-chat
actor: user
result: success
- timestamp: 2026-02-06T09:05:32.230960612Z
action: TRANSITION
feature: websocket-chat
actor: cli
result: success
- timestamp: 2026-02-06T09:05:35.978710478Z
action: CREATE_BRANCH
feature: websocket-chat
actor: cli
result: success
- timestamp: 2026-02-06T09:05:39.185695108Z
action: TRANSITION
feature: websocket-chat
actor: cli
result: success
- timestamp: 2026-02-06T09:05:39.190066334Z
action: TRANSITION
feature: websocket-chat
actor: cli
result: success

View File

@ -68,8 +68,8 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
// Extract user from auth context
var userID string
if claims := auth.ClaimsFromContext(r.Context()); claims != nil {
userID = claims.Subject
if user := auth.GetUser(r.Context()); user != nil {
userID = user.ID
}
// Check auth requirement

View File

@ -19,3 +19,6 @@ JWT_SECRET=dev-secret-change-in-production
# Database (if needed)
DATABASE_URL=postgres://dev:dev@localhost:5432/sp3-test-1770368381?sslmode=disable
# Redis (required for WebSocket chat broadcasting)
REDIS_URL=redis://localhost:6379

View File

@ -2,16 +2,23 @@
package main
import (
"context"
"github.com/redis/go-redis/v9"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/app"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/logging"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/realtime"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/adapter/memory"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/api"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/config"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/service"
)
func main() {
// Create logger
logger := logging.Default()
cfg := config.Load()
// Create adapters (repositories)
exampleRepo := memory.NewExampleRepository()
@ -22,8 +29,43 @@ func main() {
// Create application
application := app.New("chat-api", app.WithDefaultPort(8001))
// Set up WebSocket hub and Redis broadcaster
hub := realtime.NewHub(logger)
hubCtx, hubCancel := context.WithCancel(context.Background())
go hub.Run(hubCtx)
var broadcaster realtime.Broadcaster
if cfg.RedisURL != "" {
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
logger.Error("invalid REDIS_URL", "error", err)
} else {
redisClient := redis.NewClient(opts)
redisBroadcaster := realtime.NewRedisBroadcaster(redisClient, hub, logger)
go func() {
if err := redisBroadcaster.Run(hubCtx); err != nil && hubCtx.Err() == nil {
logger.Error("redis broadcaster error", "error", err)
}
}()
broadcaster = redisBroadcaster
application.OnShutdown(func(_ context.Context) error {
return redisClient.Close()
})
}
}
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
Broadcaster: broadcaster,
})
application.OnShutdown(func(_ context.Context) error {
hubCancel()
return nil
})
// Register routes with dependency injection
api.RegisterRoutes(application, exampleService)
api.RegisterRoutes(application, exampleService, wsHandler)
// Start server
application.Run()

View File

@ -4,6 +4,7 @@ package api
import (
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/app"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/auth"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/realtime"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/api/handlers"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/config"
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/service"
@ -14,7 +15,8 @@ import (
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/chat-api/health
// - https://domain/api/chat-api/examples
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
// - https://domain/api/chat-api/ws (WebSocket)
func RegisterRoutes(application *app.App, exampleService *service.ExampleService, wsHandler *realtime.Handler) {
logger := application.Logger()
cfg := config.Load()
@ -35,6 +37,9 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
r.Get("/examples", app.Wrap(exampleHandler.List))
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
// WebSocket endpoint (upgrades to WebSocket connection)
r.Mount("/ws", wsHandler.Routes())
// Protected routes (auth required when enabled)
r.Group(func(r app.Router) {
if cfg.AuthEnabled {

View File

@ -8,7 +8,8 @@ func NewServiceSpec() *openapi.OpenAPISpec {
WithDescription("REST API for the chat-api service").
WithBearerSecurity("bearer", "JWT authentication token").
WithTag("Health", "Service health endpoints").
WithTag("Examples", "Example CRUD endpoints")
WithTag("Examples", "Example CRUD endpoints").
WithTag("WebSocket", "Real-time WebSocket endpoints")
// Define reusable schemas
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
@ -108,5 +109,25 @@ func NewServiceSpec() *openapi.OpenAPISpec {
},
})
// WebSocket
spec.AddPath("/api/chat-api/ws", "get", map[string]any{
"summary": "WebSocket connection",
"description": "Upgrades to WebSocket. Incoming messages are published to Redis and broadcast to all connected clients. Optionally specify a room via /ws/{room} or ?room= query parameter.",
"tags": []string{"WebSocket"},
"parameters": []any{
map[string]any{
"name": "room",
"in": "query",
"description": "Optional room name for scoped messaging",
"schema": openapi.String(),
},
},
"responses": map[string]any{
"101": map[string]any{
"description": "Switching Protocols - WebSocket connection established",
},
},
})
return spec
}

View File

@ -0,0 +1,178 @@
package api
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/logging"
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/realtime"
)
func TestWebSocketUpgrade(t *testing.T) {
logger := logging.Nop()
hub := realtime.NewHub(logger)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
r := chi.NewRouter()
r.Mount("/api/chat-api/ws", wsHandler.Routes())
server := httptest.NewServer(r)
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/chat-api/ws"
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("failed to connect to websocket: %v", err)
}
defer conn.Close()
if resp.StatusCode != 101 {
t.Errorf("expected status 101, got %d", resp.StatusCode)
}
}
func TestWebSocketBroadcast(t *testing.T) {
logger := logging.Nop()
hub := realtime.NewHub(logger)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
r := chi.NewRouter()
r.Mount("/api/chat-api/ws", wsHandler.Routes())
server := httptest.NewServer(r)
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/chat-api/ws"
// Connect two clients
conn1, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("client 1 failed to connect: %v", err)
}
defer conn1.Close()
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("client 2 failed to connect: %v", err)
}
defer conn2.Close()
// Wait for both clients to register with the hub
time.Sleep(100 * time.Millisecond)
// Client 1 sends a message
msg := realtime.Message{
Type: "chat",
Data: json.RawMessage(`"hello from client 1"`),
}
if err := conn1.WriteJSON(msg); err != nil {
t.Fatalf("client 1 failed to send: %v", err)
}
// Client 2 should receive the broadcast
conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
var received realtime.Message
if err := conn2.ReadJSON(&received); err != nil {
t.Fatalf("client 2 failed to receive: %v", err)
}
if received.Type != "chat" {
t.Errorf("expected message type 'chat', got %q", received.Type)
}
var data string
if err := json.Unmarshal(received.Data, &data); err != nil {
t.Fatalf("failed to unmarshal data: %v", err)
}
if data != "hello from client 1" {
t.Errorf("expected 'hello from client 1', got %q", data)
}
}
func TestWebSocketRoomBroadcast(t *testing.T) {
logger := logging.Nop()
hub := realtime.NewHub(logger)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go hub.Run(ctx)
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{})
r := chi.NewRouter()
r.Mount("/api/chat-api/ws", wsHandler.Routes())
server := httptest.NewServer(r)
defer server.Close()
baseURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/chat-api/ws"
// Connect client A to room "general"
connA, _, err := websocket.DefaultDialer.Dial(baseURL+"/general", nil)
if err != nil {
t.Fatalf("client A failed to connect: %v", err)
}
defer connA.Close()
// Connect client B to room "general"
connB, _, err := websocket.DefaultDialer.Dial(baseURL+"/general", nil)
if err != nil {
t.Fatalf("client B failed to connect: %v", err)
}
defer connB.Close()
// Connect client C to room "other"
connC, _, err := websocket.DefaultDialer.Dial(baseURL+"/other", nil)
if err != nil {
t.Fatalf("client C failed to connect: %v", err)
}
defer connC.Close()
// Wait for all clients to register
time.Sleep(100 * time.Millisecond)
// Client A sends a message (room defaults to "general")
msg := realtime.Message{
Type: "chat",
Data: json.RawMessage(`"room message"`),
}
if err := connA.WriteJSON(msg); err != nil {
t.Fatalf("client A failed to send: %v", err)
}
// Client B should receive it (same room)
connB.SetReadDeadline(time.Now().Add(2 * time.Second))
var received realtime.Message
if err := connB.ReadJSON(&received); err != nil {
t.Fatalf("client B failed to receive: %v", err)
}
if received.Type != "chat" {
t.Errorf("expected type 'chat', got %q", received.Type)
}
// Client C should NOT receive it (different room)
connC.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
var unexpected realtime.Message
err = connC.ReadJSON(&unexpected)
if err == nil {
t.Error("client C should not have received message from 'general' room")
}
}

View File

@ -18,6 +18,9 @@ type Config struct {
// Auth
AuthEnabled bool
JWTSecret string
// Redis
RedisURL string
}
// Load reads configuration from environment variables.
@ -30,5 +33,7 @@ func Load() *Config {
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
JWTSecret: os.Getenv("JWT_SECRET"),
RedisURL: os.Getenv("REDIS_URL"),
}
}