305 lines
7.8 KiB
Markdown
305 lines
7.8 KiB
Markdown
# Implementation Tasks: WebSocket Chat
|
|
|
|
## Task Overview
|
|
|
|
| ID | Task | Status | Blocked By |
|
|
|----|------|--------|------------|
|
|
| WS-1 | Extend config with Redis URL | pending | - |
|
|
| WS-2 | Wire realtime components in main.go | pending | WS-1 |
|
|
| WS-3 | Mount WebSocket handler in routes.go | pending | WS-2 |
|
|
| WS-4 | Add WebSocket documentation to spec.go | pending | WS-3 |
|
|
| WS-5 | Add integration tests for WebSocket chat | pending | WS-3 |
|
|
|
|
---
|
|
|
|
## WS-1: Extend config with Redis URL
|
|
|
|
**Status:** pending
|
|
|
|
### Description
|
|
Add Redis URL configuration to the chat-api service config.
|
|
|
|
### Files to Modify
|
|
- `services/chat-api/internal/config/config.go`
|
|
|
|
### Implementation Details
|
|
|
|
Add to the `Config` struct:
|
|
```go
|
|
// Redis configuration for realtime pub/sub
|
|
RedisURL string
|
|
```
|
|
|
|
Add to the `Load()` function:
|
|
```go
|
|
RedisURL: os.Getenv("REDIS_URL"),
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] `Config` struct has `RedisURL` field
|
|
- [ ] `Load()` reads from `REDIS_URL` environment variable
|
|
- [ ] Empty string is valid (disables Redis, uses local-only broadcast)
|
|
|
|
---
|
|
|
|
## WS-2: Wire realtime components in main.go
|
|
|
|
**Status:** pending
|
|
**Blocked By:** WS-1
|
|
|
|
### Description
|
|
Initialize the realtime hub and Redis broadcaster in the service entry point.
|
|
|
|
### Files to Modify
|
|
- `services/chat-api/cmd/server/main.go`
|
|
|
|
### Implementation Details
|
|
|
|
1. Import required packages:
|
|
```go
|
|
import (
|
|
"context"
|
|
"github.com/redis/go-redis/v9"
|
|
"git.threesix.ai/jordan/sp3-verify-1770325830/pkg/realtime"
|
|
"git.threesix.ai/jordan/sp3-verify-1770325830/services/chat-api/internal/config"
|
|
)
|
|
```
|
|
|
|
2. Load config and create context:
|
|
```go
|
|
cfg := config.Load()
|
|
ctx := context.Background()
|
|
```
|
|
|
|
3. Create hub:
|
|
```go
|
|
hub := realtime.NewHub(logger)
|
|
go hub.Run(ctx)
|
|
```
|
|
|
|
4. Create Redis broadcaster (optional):
|
|
```go
|
|
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)
|
|
broadcaster = realtime.NewRedisBroadcaster(redisClient, hub, logger)
|
|
go broadcaster.Run(ctx)
|
|
}
|
|
}
|
|
```
|
|
|
|
5. Pass hub and broadcaster to route registration:
|
|
```go
|
|
api.RegisterRoutes(application, exampleService, hub, broadcaster)
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Hub is created and running in a goroutine
|
|
- [ ] Redis broadcaster is created when `REDIS_URL` is set
|
|
- [ ] Redis broadcaster is nil when `REDIS_URL` is empty
|
|
- [ ] Hub and broadcaster are passed to route registration
|
|
|
|
---
|
|
|
|
## WS-3: Mount WebSocket handler in routes.go
|
|
|
|
**Status:** pending
|
|
**Blocked By:** WS-2
|
|
|
|
### Description
|
|
Update the route registration to accept realtime components and mount the WebSocket handler.
|
|
|
|
### Files to Modify
|
|
- `services/chat-api/internal/api/routes.go`
|
|
|
|
### Implementation Details
|
|
|
|
1. Update function signature:
|
|
```go
|
|
func RegisterRoutes(
|
|
application *app.App,
|
|
exampleService *service.ExampleService,
|
|
hub realtime.Hub,
|
|
broadcaster realtime.Broadcaster,
|
|
) {
|
|
```
|
|
|
|
2. Import realtime package:
|
|
```go
|
|
import "git.threesix.ai/jordan/sp3-verify-1770325830/pkg/realtime"
|
|
```
|
|
|
|
3. Create and configure WebSocket handler:
|
|
```go
|
|
wsHandler := realtime.NewHandler(hub, logger, realtime.HandlerConfig{
|
|
Broadcaster: broadcaster,
|
|
OnConnect: func(conn realtime.Connection) {
|
|
logger.Info("websocket client connected",
|
|
"client_id", conn.ID(),
|
|
"user_id", conn.UserID(),
|
|
)
|
|
},
|
|
OnDisconnect: func(conn realtime.Connection) {
|
|
logger.Info("websocket client disconnected",
|
|
"client_id", conn.ID(),
|
|
)
|
|
},
|
|
OnMessage: func(conn realtime.Connection, msg *realtime.Message) *realtime.Message {
|
|
if msg.Type == "" {
|
|
msg.Type = realtime.MessageTypeChat
|
|
}
|
|
return msg
|
|
},
|
|
AuthRequired: cfg.AuthEnabled,
|
|
})
|
|
```
|
|
|
|
4. Mount WebSocket routes:
|
|
```go
|
|
application.Mount("/api/chat-api/ws", wsHandler.Routes())
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Function signature accepts `hub` and `broadcaster` parameters
|
|
- [ ] WebSocket handler is created with appropriate callbacks
|
|
- [ ] Handler is mounted at `/api/chat-api/ws`
|
|
- [ ] Auth requirement respects `AUTH_ENABLED` config
|
|
|
|
---
|
|
|
|
## WS-4: Add WebSocket documentation to spec.go
|
|
|
|
**Status:** pending
|
|
**Blocked By:** WS-3
|
|
|
|
### Description
|
|
Document the WebSocket endpoint in the OpenAPI specification.
|
|
|
|
### Files to Modify
|
|
- `services/chat-api/internal/api/spec.go`
|
|
|
|
### Implementation Details
|
|
|
|
Add WebSocket tag:
|
|
```go
|
|
spec.WithTag("WebSocket", "Real-time WebSocket endpoints")
|
|
```
|
|
|
|
Add WebSocket path documentation:
|
|
```go
|
|
spec.AddPath("/api/chat-api/ws", "get", map[string]any{
|
|
"summary": "WebSocket connection",
|
|
"description": "Upgrades to WebSocket for real-time chat. Messages are broadcast to all connected clients via Redis pub/sub.",
|
|
"tags": []string{"WebSocket"},
|
|
"responses": map[string]any{
|
|
"101": map[string]any{
|
|
"description": "Switching Protocols - WebSocket upgrade successful",
|
|
},
|
|
"401": openapi.OpResponse("Unauthorized - authentication required", nil),
|
|
},
|
|
})
|
|
|
|
spec.AddPath("/api/chat-api/ws/{room}", "get", map[string]any{
|
|
"summary": "WebSocket connection to room",
|
|
"description": "Upgrades to WebSocket and joins the specified room. Messages are broadcast only to clients in the same room.",
|
|
"tags": []string{"WebSocket"},
|
|
"parameters": []map[string]any{
|
|
{
|
|
"name": "room",
|
|
"in": "path",
|
|
"required": true,
|
|
"description": "Room identifier to join",
|
|
"schema": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"responses": map[string]any{
|
|
"101": map[string]any{
|
|
"description": "Switching Protocols - WebSocket upgrade successful",
|
|
},
|
|
"401": openapi.OpResponse("Unauthorized - authentication required", nil),
|
|
},
|
|
})
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] WebSocket tag added to spec
|
|
- [ ] `/api/chat-api/ws` endpoint documented
|
|
- [ ] `/api/chat-api/ws/{room}` endpoint documented with room parameter
|
|
- [ ] Response codes documented (101, 401)
|
|
|
|
---
|
|
|
|
## WS-5: Add integration tests for WebSocket chat
|
|
|
|
**Status:** pending
|
|
**Blocked By:** WS-3
|
|
|
|
### Description
|
|
Add tests to verify WebSocket functionality.
|
|
|
|
### Files to Create
|
|
- `services/chat-api/internal/api/handlers/websocket_test.go`
|
|
|
|
### Implementation Details
|
|
|
|
Test cases:
|
|
1. **Connection upgrade** - Verify WebSocket upgrade succeeds
|
|
2. **Message broadcast** - Verify messages sent by one client reach others
|
|
3. **Room isolation** - Verify room messages don't leak to other rooms
|
|
4. **Ping/pong** - Verify heartbeat mechanism
|
|
|
|
Example test structure:
|
|
```go
|
|
package handlers_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"git.threesix.ai/jordan/sp3-verify-1770325830/pkg/logging"
|
|
"git.threesix.ai/jordan/sp3-verify-1770325830/pkg/realtime"
|
|
)
|
|
|
|
func TestWebSocket_Connection(t *testing.T) {
|
|
// Create hub
|
|
hub := realtime.NewHub(logging.Nop())
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go hub.Run(ctx)
|
|
|
|
// Create handler
|
|
handler := realtime.NewHandler(hub, logging.Nop(), realtime.HandlerConfig{})
|
|
|
|
// Create test server
|
|
server := httptest.NewServer(handler.Routes())
|
|
defer server.Close()
|
|
|
|
// Connect WebSocket
|
|
wsURL := "ws" + server.URL[4:] + "/"
|
|
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
|
if err != nil {
|
|
t.Fatalf("dial failed: %v", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Verify connection count
|
|
time.Sleep(50 * time.Millisecond)
|
|
if hub.ConnectionCount() != 1 {
|
|
t.Errorf("expected 1 connection, got %d", hub.ConnectionCount())
|
|
}
|
|
}
|
|
```
|
|
|
|
### Acceptance Criteria
|
|
- [ ] Test WebSocket connection upgrade works
|
|
- [ ] Test message broadcast to multiple clients
|
|
- [ ] Test room-based message isolation
|
|
- [ ] All tests pass
|