build: /implement-feature websocket-chat --requirements 'GET /ws upgrades to...
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
e77bf26608
commit
0e39598aa6
4
.sdlc/branches/feature/websocket-chat.yaml
Normal file
4
.sdlc/branches/feature/websocket-chat.yaml
Normal 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
36
.sdlc/config.yaml
Normal 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
|
||||||
52
.sdlc/features/websocket-chat/design.md
Normal file
52
.sdlc/features/websocket-chat/design.md
Normal 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)
|
||||||
50
.sdlc/features/websocket-chat/manifest.yaml
Normal file
50
.sdlc/features/websocket-chat/manifest.yaml
Normal 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
|
||||||
32
.sdlc/features/websocket-chat/qa-plan.md
Normal file
32
.sdlc/features/websocket-chat/qa-plan.md
Normal 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
|
||||||
30
.sdlc/features/websocket-chat/spec.md
Normal file
30
.sdlc/features/websocket-chat/spec.md
Normal 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
|
||||||
29
.sdlc/features/websocket-chat/tasks.md
Normal file
29
.sdlc/features/websocket-chat/tasks.md
Normal 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
63
.sdlc/state.yaml
Normal 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
|
||||||
@ -68,8 +68,8 @@ func (h *Handler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Extract user from auth context
|
// Extract user from auth context
|
||||||
var userID string
|
var userID string
|
||||||
if claims := auth.ClaimsFromContext(r.Context()); claims != nil {
|
if user := auth.GetUser(r.Context()); user != nil {
|
||||||
userID = claims.Subject
|
userID = user.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auth requirement
|
// Check auth requirement
|
||||||
|
|||||||
@ -19,3 +19,6 @@ JWT_SECRET=dev-secret-change-in-production
|
|||||||
|
|
||||||
# Database (if needed)
|
# Database (if needed)
|
||||||
DATABASE_URL=postgres://dev:dev@localhost:5432/sp3-test-1770368381?sslmode=disable
|
DATABASE_URL=postgres://dev:dev@localhost:5432/sp3-test-1770368381?sslmode=disable
|
||||||
|
|
||||||
|
# Redis (required for WebSocket chat broadcasting)
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|||||||
@ -2,16 +2,23 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
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/app"
|
||||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/logging"
|
"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/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/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"
|
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create logger
|
// Create logger
|
||||||
logger := logging.Default()
|
logger := logging.Default()
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
// Create adapters (repositories)
|
// Create adapters (repositories)
|
||||||
exampleRepo := memory.NewExampleRepository()
|
exampleRepo := memory.NewExampleRepository()
|
||||||
@ -22,8 +29,43 @@ func main() {
|
|||||||
// Create application
|
// Create application
|
||||||
application := app.New("chat-api", app.WithDefaultPort(8001))
|
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
|
// Register routes with dependency injection
|
||||||
api.RegisterRoutes(application, exampleService)
|
api.RegisterRoutes(application, exampleService, wsHandler)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
application.Run()
|
application.Run()
|
||||||
|
|||||||
@ -4,6 +4,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"git.threesix.ai/jordan/sp3-test-1770368381/pkg/app"
|
"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/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/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/config"
|
||||||
"git.threesix.ai/jordan/sp3-test-1770368381/services/chat-api/internal/service"
|
"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:
|
// This allows the monorepo to expose multiple services under a single domain:
|
||||||
// - https://domain/api/chat-api/health
|
// - https://domain/api/chat-api/health
|
||||||
// - https://domain/api/chat-api/examples
|
// - 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()
|
logger := application.Logger()
|
||||||
cfg := config.Load()
|
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", app.Wrap(exampleHandler.List))
|
||||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
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)
|
// Protected routes (auth required when enabled)
|
||||||
r.Group(func(r app.Router) {
|
r.Group(func(r app.Router) {
|
||||||
if cfg.AuthEnabled {
|
if cfg.AuthEnabled {
|
||||||
|
|||||||
@ -8,7 +8,8 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
|||||||
WithDescription("REST API for the chat-api service").
|
WithDescription("REST API for the chat-api service").
|
||||||
WithBearerSecurity("bearer", "JWT authentication token").
|
WithBearerSecurity("bearer", "JWT authentication token").
|
||||||
WithTag("Health", "Service health endpoints").
|
WithTag("Health", "Service health endpoints").
|
||||||
WithTag("Examples", "Example CRUD endpoints")
|
WithTag("Examples", "Example CRUD endpoints").
|
||||||
|
WithTag("WebSocket", "Real-time WebSocket endpoints")
|
||||||
|
|
||||||
// Define reusable schemas
|
// Define reusable schemas
|
||||||
spec.WithSchema("Example", openapi.Object(map[string]openapi.Schema{
|
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
|
return spec
|
||||||
}
|
}
|
||||||
|
|||||||
178
services/chat-api/internal/api/ws_test.go
Normal file
178
services/chat-api/internal/api/ws_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,9 @@ type Config struct {
|
|||||||
// Auth
|
// Auth
|
||||||
AuthEnabled bool
|
AuthEnabled bool
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
|
||||||
|
// Redis
|
||||||
|
RedisURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables.
|
// Load reads configuration from environment variables.
|
||||||
@ -30,5 +33,7 @@ func Load() *Config {
|
|||||||
|
|
||||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
|
||||||
|
RedisURL: os.Getenv("REDIS_URL"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user