Compare commits
No commits in common. "feature/websocket-chat" and "main" have entirely different histories.
feature/we
...
main
@ -1,4 +0,0 @@
|
|||||||
name: feature/websocket-chat
|
|
||||||
feature: websocket-chat
|
|
||||||
base_branch: main
|
|
||||||
created_at: 2026-02-06T09:05:35.974642783Z
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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 user := auth.GetUser(r.Context()); user != nil {
|
if claims := auth.ClaimsFromContext(r.Context()); claims != nil {
|
||||||
userID = user.ID
|
userID = claims.Subject
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check auth requirement
|
// Check auth requirement
|
||||||
|
|||||||
@ -19,6 +19,3 @@ 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,23 +2,16 @@
|
|||||||
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()
|
||||||
@ -29,43 +22,8 @@ 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, wsHandler)
|
api.RegisterRoutes(application, exampleService)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
application.Run()
|
application.Run()
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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"
|
||||||
@ -15,8 +14,7 @@ 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
|
||||||
// - https://domain/api/chat-api/ws (WebSocket)
|
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService, wsHandler *realtime.Handler) {
|
|
||||||
logger := application.Logger()
|
logger := application.Logger()
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
@ -37,9 +35,6 @@ 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,8 +8,7 @@ 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{
|
||||||
@ -109,25 +108,5 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,178 +0,0 @@
|
|||||||
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,9 +18,6 @@ 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.
|
||||||
@ -33,7 +30,5 @@ 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