feat: implement mesh-interop service communication
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Add auth-svc /validate endpoint for token checking Add chat-svc with auth client and Redis task queue Add worker-svc chat handler for task processing Co-Authored-By: Claude Code <claude@anthropic.com>
This commit is contained in:
parent
927537046a
commit
5a877ca1a1
54
services/auth-svc/internal/api/handlers/validate.go
Normal file
54
services/auth-svc/internal/api/handlers/validate.go
Normal file
@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httperror"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
)
|
||||
|
||||
// Validate handles token validation requests from sibling services.
|
||||
type Validate struct {
|
||||
validator *auth.JWTValidator
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewValidate creates a new Validate handler.
|
||||
func NewValidate(validator *auth.JWTValidator, logger *logging.Logger) *Validate {
|
||||
return &Validate{
|
||||
validator: validator,
|
||||
logger: logger.WithComponent("ValidateHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateResponse is returned on successful token validation.
|
||||
type ValidateResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// Check validates the Bearer token from the Authorization header.
|
||||
func (h *Validate) Check(w http.ResponseWriter, r *http.Request) error {
|
||||
token := auth.ExtractBearerToken(r)
|
||||
if token == "" {
|
||||
return httperror.Unauthorized("missing authorization token")
|
||||
}
|
||||
|
||||
user, err := h.validator.Validate(r.Context(), token)
|
||||
if err != nil {
|
||||
h.logger.Debug("token validation failed", "error", err)
|
||||
return httperror.Unauthorized("invalid token")
|
||||
}
|
||||
|
||||
httpresponse.OK(w, r, ValidateResponse{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Roles: user.Roles,
|
||||
Scopes: user.Scopes,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
168
services/auth-svc/internal/api/handlers/validate_test.go
Normal file
168
services/auth-svc/internal/api/handlers/validate_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
)
|
||||
|
||||
func newTestValidateHandler() *Validate {
|
||||
validator := auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte("test-secret"),
|
||||
Issuer: "sp4-debug-1770477266",
|
||||
})
|
||||
return NewValidate(validator, logging.Nop())
|
||||
}
|
||||
|
||||
func generateTestToken(t *testing.T, user *auth.User) string {
|
||||
t.Helper()
|
||||
token, err := auth.GenerateTokenWithIssuer(
|
||||
[]byte("test-secret"),
|
||||
user,
|
||||
time.Hour,
|
||||
"sp4-debug-1770477266",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func TestValidate_Check_ValidToken(t *testing.T) {
|
||||
handler := newTestValidateHandler()
|
||||
|
||||
user := &auth.User{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
Roles: []string{"admin"},
|
||||
Scopes: []string{"read", "write"},
|
||||
}
|
||||
token := generateTestToken(t, user)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-svc/validate", app.Wrap(handler.Check))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-svc/validate", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
|
||||
if data["user_id"] != "user-123" {
|
||||
t.Errorf("expected user_id 'user-123', got %v", data["user_id"])
|
||||
}
|
||||
if data["email"] != "test@example.com" {
|
||||
t.Errorf("expected email 'test@example.com', got %v", data["email"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Check_MissingToken(t *testing.T) {
|
||||
handler := newTestValidateHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-svc/validate", app.Wrap(handler.Check))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-svc/validate", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Check_InvalidToken(t *testing.T) {
|
||||
handler := newTestValidateHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-svc/validate", app.Wrap(handler.Check))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-svc/validate", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Check_ExpiredToken(t *testing.T) {
|
||||
handler := newTestValidateHandler()
|
||||
|
||||
user := &auth.User{ID: "user-123", Email: "test@example.com"}
|
||||
// Generate token that's already expired
|
||||
token, err := auth.GenerateTokenWithIssuer(
|
||||
[]byte("test-secret"),
|
||||
user,
|
||||
-time.Hour, // negative duration = expired
|
||||
"sp4-debug-1770477266",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-svc/validate", app.Wrap(handler.Check))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-svc/validate", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_Check_WrongSecret(t *testing.T) {
|
||||
handler := newTestValidateHandler()
|
||||
|
||||
user := &auth.User{ID: "user-123"}
|
||||
// Generate token with different secret
|
||||
token, err := auth.GenerateTokenWithIssuer(
|
||||
[]byte("wrong-secret"),
|
||||
user,
|
||||
time.Hour,
|
||||
"sp4-debug-1770477266",
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/auth-svc/validate", app.Wrap(handler.Check))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth-svc/validate", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,13 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
|
||||
// Token validation handler for inter-service auth
|
||||
jwtValidator := auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
Issuer: "sp4-debug-1770477266",
|
||||
})
|
||||
validateHandler := handlers.NewValidate(jwtValidator, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
application.EnableDocs(spec)
|
||||
@ -31,6 +38,9 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
application.Route("/api/auth-svc", func(r app.Router) {
|
||||
r.Get("/health", healthHandler.Check)
|
||||
|
||||
// Token validation endpoint for sibling services
|
||||
r.Post("/validate", app.Wrap(validateHandler.Check))
|
||||
|
||||
// Public routes (no auth required)
|
||||
r.Get("/examples", app.Wrap(exampleHandler.List))
|
||||
r.Get("/examples/{id}", app.Wrap(exampleHandler.Get))
|
||||
|
||||
@ -8,6 +8,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
WithDescription("REST API for the auth-svc service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Auth", "Authentication endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
@ -29,6 +30,13 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
spec.WithSchema("ValidateResponse", openapi.Object(map[string]openapi.Schema{
|
||||
"user_id": openapi.String().WithDescription("User identifier"),
|
||||
"email": openapi.String().WithDescription("User email"),
|
||||
"roles": openapi.Array(openapi.String()).WithDescription("User roles"),
|
||||
"scopes": openapi.Array(openapi.String()).WithDescription("User scopes"),
|
||||
}, "user_id"))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/auth-svc/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
@ -41,6 +49,18 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
},
|
||||
})
|
||||
|
||||
// Validate token
|
||||
spec.AddPath("/api/auth-svc/validate", "post", map[string]any{
|
||||
"summary": "Validate token",
|
||||
"description": "Validates a Bearer token and returns the authenticated user info. Used by sibling services for token verification.",
|
||||
"tags": []string{"Auth"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"responses": map[string]any{
|
||||
"200": openapi.OpResponse("Token is valid", openapi.ResponseSchema(openapi.Ref("ValidateResponse"))),
|
||||
"401": openapi.OpResponse("Invalid or missing token", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// List examples
|
||||
spec.AddPath("/api/auth-svc/examples", "get", map[string]any{
|
||||
"summary": "List examples",
|
||||
|
||||
@ -2,28 +2,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/database"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/queue"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/adapter/memory"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/api"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/config"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/service"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/taskqueue"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create logger
|
||||
logger := logging.Default()
|
||||
|
||||
// Load config
|
||||
cfg := config.Load()
|
||||
|
||||
// Create adapters (repositories)
|
||||
exampleRepo := memory.NewExampleRepository()
|
||||
|
||||
// Create services (business logic)
|
||||
exampleService := service.NewExampleService(exampleRepo, logger)
|
||||
|
||||
// Connect to database for queue producer (shared with worker-svc)
|
||||
var producer *taskqueue.Producer
|
||||
if cfg.Database.URL != "" {
|
||||
pool, err := database.Connect(context.Background(), cfg.Database.URL, database.Options{})
|
||||
if err != nil {
|
||||
logger.Error("failed to connect to database for queue", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
jobQueue := queue.NewPostgresQueue(pool.DB, logger)
|
||||
producer = taskqueue.NewProducer(jobQueue, logger)
|
||||
logger.Info("task queue producer initialized")
|
||||
} else {
|
||||
logger.Warn("DATABASE_URL not set, chat task queue disabled")
|
||||
}
|
||||
|
||||
// Create application
|
||||
application := app.New("chat-svc", app.WithDefaultPort(8001))
|
||||
|
||||
// Register routes with dependency injection
|
||||
api.RegisterRoutes(application, exampleService)
|
||||
api.RegisterRoutes(application, exampleService, producer)
|
||||
|
||||
// Start server
|
||||
application.Run()
|
||||
|
||||
@ -2,8 +2,6 @@ name: chat-svc
|
||||
type: service
|
||||
port: 8001
|
||||
path: services/chat-svc
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - other-service
|
||||
dependencies:
|
||||
- auth-svc
|
||||
- postgres
|
||||
|
||||
69
services/chat-svc/internal/api/handlers/chat.go
Normal file
69
services/chat-svc/internal/api/handlers/chat.go
Normal file
@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httperror"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpresponse"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/taskqueue"
|
||||
)
|
||||
|
||||
// Chat handles chat message endpoints.
|
||||
type Chat struct {
|
||||
producer *taskqueue.Producer
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewChat creates a new Chat handler.
|
||||
func NewChat(producer *taskqueue.Producer, logger *logging.Logger) *Chat {
|
||||
return &Chat{
|
||||
producer: producer,
|
||||
logger: logger.WithComponent("ChatHandler"),
|
||||
}
|
||||
}
|
||||
|
||||
// SendRequest is the request body for sending a chat message.
|
||||
type SendRequest struct {
|
||||
Message string `json:"message" validate:"required,min=1,max=5000"`
|
||||
}
|
||||
|
||||
// SendResponse is returned after a message is queued for processing.
|
||||
type SendResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Send accepts a chat message and pushes it to the worker queue for processing.
|
||||
func (h *Chat) Send(w http.ResponseWriter, r *http.Request) error {
|
||||
if h.producer == nil {
|
||||
return httperror.ServiceUnavailable("task queue not configured")
|
||||
}
|
||||
|
||||
var req SendRequest
|
||||
if err := app.BindAndValidate(r, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get authenticated user from context
|
||||
user := auth.GetUser(r.Context())
|
||||
if user == nil {
|
||||
return httperror.Unauthorized("authentication required")
|
||||
}
|
||||
|
||||
jobID, err := h.producer.EnqueueChatProcess(r.Context(), user.ID, req.Message)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to enqueue chat message", "error", err, "user_id", user.ID)
|
||||
return httperror.Internal("failed to queue message for processing")
|
||||
}
|
||||
|
||||
httpresponse.Accepted(w, r, SendResponse{
|
||||
JobID: jobID,
|
||||
Status: "queued",
|
||||
Message: "message queued for processing",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
170
services/chat-svc/internal/api/handlers/chat_test.go
Normal file
170
services/chat-svc/internal/api/handlers/chat_test.go
Normal file
@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/queue"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/taskqueue"
|
||||
)
|
||||
|
||||
// mockQueueProducer implements queue.Producer for testing.
|
||||
type mockQueueProducer struct {
|
||||
mu sync.Mutex
|
||||
jobs []queue.Job
|
||||
}
|
||||
|
||||
func (m *mockQueueProducer) Enqueue(ctx context.Context, jobType string, payload map[string]any) (string, error) {
|
||||
return m.EnqueueWithOptions(ctx, queue.Job{
|
||||
Type: jobType,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockQueueProducer) EnqueueWithOptions(ctx context.Context, job queue.Job) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
job.ID = "mock-job-id"
|
||||
m.jobs = append(m.jobs, job)
|
||||
return job.ID, nil
|
||||
}
|
||||
|
||||
func newTestChatHandler() (*Chat, *mockQueueProducer) {
|
||||
mockQueue := &mockQueueProducer{}
|
||||
producer := taskqueue.NewProducer(mockQueue, logging.Nop())
|
||||
handler := NewChat(producer, logging.Nop())
|
||||
return handler, mockQueue
|
||||
}
|
||||
|
||||
func TestChat_Send_Success(t *testing.T) {
|
||||
handler, mockQueue := newTestChatHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/chat-svc/send", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Inject authenticated user into context
|
||||
ctx := auth.SetUser(r.Context(), &auth.User{ID: "user-123", Email: "test@example.com"})
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
if err := handler.Send(w, r); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(SendRequest{Message: "Hello world"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/chat-svc/send", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Errorf("expected status 202, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected 'data' field in response")
|
||||
}
|
||||
if data["job_id"] != "mock-job-id" {
|
||||
t.Errorf("expected job_id 'mock-job-id', got %v", data["job_id"])
|
||||
}
|
||||
if data["status"] != "queued" {
|
||||
t.Errorf("expected status 'queued', got %v", data["status"])
|
||||
}
|
||||
|
||||
// Verify job was enqueued
|
||||
mockQueue.mu.Lock()
|
||||
defer mockQueue.mu.Unlock()
|
||||
if len(mockQueue.jobs) != 1 {
|
||||
t.Fatalf("expected 1 enqueued job, got %d", len(mockQueue.jobs))
|
||||
}
|
||||
if mockQueue.jobs[0].Payload["user_id"] != "user-123" {
|
||||
t.Errorf("expected user_id 'user-123' in payload, got %v", mockQueue.jobs[0].Payload["user_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_Send_NoAuth(t *testing.T) {
|
||||
handler, _ := newTestChatHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/chat-svc/send", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := handler.Send(w, r); err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(SendRequest{Message: "Hello"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/chat-svc/send", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_Send_NilProducer(t *testing.T) {
|
||||
handler := NewChat(nil, logging.Nop())
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/chat-svc/send", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := auth.SetUser(r.Context(), &auth.User{ID: "user-123"})
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
if err := handler.Send(w, r); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(SendRequest{Message: "Hello"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/chat-svc/send", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected status 503, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_Send_EmptyMessage(t *testing.T) {
|
||||
handler, _ := newTestChatHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/chat-svc/send", func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := auth.SetUser(r.Context(), &auth.User{ID: "user-123"})
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
if err := handler.Send(w, r); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"message": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/chat-svc/send", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// Empty message should fail validation (required,min=1)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@ -4,23 +4,24 @@ package api
|
||||
import (
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/app"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/svc"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/api/handlers"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/authclient"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/config"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/service"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/services/chat-svc/internal/taskqueue"
|
||||
)
|
||||
|
||||
// RegisterRoutes registers all HTTP routes for the service.
|
||||
// Routes are mounted under /api/chat-svc to match the ingress path routing.
|
||||
// This allows the monorepo to expose multiple services under a single domain:
|
||||
// - https://domain/api/chat-svc/health
|
||||
// - https://domain/api/chat-svc/examples
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
|
||||
func RegisterRoutes(application *app.App, exampleService *service.ExampleService, producer *taskqueue.Producer) {
|
||||
logger := application.Logger()
|
||||
cfg := config.Load()
|
||||
|
||||
// Initialize handlers with injected services
|
||||
healthHandler := handlers.NewHealth(logger)
|
||||
exampleHandler := handlers.NewExample(exampleService, logger)
|
||||
chatHandler := handlers.NewChat(producer, logger)
|
||||
|
||||
// Build and mount OpenAPI spec
|
||||
spec := NewServiceSpec()
|
||||
@ -38,6 +39,15 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
// Protected routes (auth required when enabled)
|
||||
r.Group(func(r app.Router) {
|
||||
if cfg.AuthEnabled {
|
||||
// Use remote auth-svc validation if configured, otherwise fall back to local JWT
|
||||
if svc.ServiceConfigured("auth-svc") {
|
||||
ac, err := authclient.New(logger)
|
||||
if err != nil {
|
||||
logger.Error("failed to create auth client", "error", err)
|
||||
} else {
|
||||
r.Use(authclient.Middleware(ac))
|
||||
}
|
||||
} else {
|
||||
r.Use(auth.Middleware(auth.MiddlewareConfig{
|
||||
Validator: auth.NewJWTValidator(auth.JWTConfig{
|
||||
Secret: []byte(cfg.JWTSecret),
|
||||
@ -45,10 +55,14 @@ func RegisterRoutes(application *app.App, exampleService *service.ExampleService
|
||||
}),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
r.Post("/examples", app.Wrap(exampleHandler.Create))
|
||||
r.Put("/examples/{id}", app.Wrap(exampleHandler.Update))
|
||||
r.Delete("/examples/{id}", app.Wrap(exampleHandler.Delete))
|
||||
|
||||
// Chat endpoints (require auth, push to worker queue)
|
||||
r.Post("/send", app.Wrap(chatHandler.Send))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
WithDescription("REST API for the chat-svc service").
|
||||
WithBearerSecurity("bearer", "JWT authentication token").
|
||||
WithTag("Health", "Service health endpoints").
|
||||
WithTag("Chat", "Chat messaging endpoints").
|
||||
WithTag("Examples", "Example CRUD endpoints")
|
||||
|
||||
// Define reusable schemas
|
||||
@ -29,6 +30,16 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
"description": openapi.StringWithMinMax(0, 500).WithDescription("Updated description"),
|
||||
}))
|
||||
|
||||
spec.WithSchema("SendRequest", openapi.Object(map[string]openapi.Schema{
|
||||
"message": openapi.StringWithMinMax(1, 5000).WithDescription("Chat message to process"),
|
||||
}, "message"))
|
||||
|
||||
spec.WithSchema("SendResponse", openapi.Object(map[string]openapi.Schema{
|
||||
"job_id": openapi.String().WithDescription("Queued job identifier"),
|
||||
"status": openapi.String().WithDescription("Job status").WithExample("queued"),
|
||||
"message": openapi.String().WithDescription("Status message"),
|
||||
}, "job_id", "status"))
|
||||
|
||||
// Health
|
||||
spec.AddPath("/api/chat-svc/health", "get", map[string]any{
|
||||
"summary": "Health check",
|
||||
@ -94,6 +105,21 @@ func NewServiceSpec() *openapi.OpenAPISpec {
|
||||
},
|
||||
})
|
||||
|
||||
// Send chat message
|
||||
spec.AddPath("/api/chat-svc/send", "post", map[string]any{
|
||||
"summary": "Send chat message",
|
||||
"description": "Sends a chat message for async processing by the worker. Requires authentication.",
|
||||
"tags": []string{"Chat"},
|
||||
"security": []map[string][]string{{"bearer": {}}},
|
||||
"requestBody": openapi.RequestBody(openapi.Ref("SendRequest"), true),
|
||||
"responses": map[string]any{
|
||||
"202": openapi.OpResponse("Message queued", openapi.ResponseSchema(openapi.Ref("SendResponse"))),
|
||||
"401": openapi.OpResponse("Unauthorized", openapi.ErrorResponseSchema()),
|
||||
"422": openapi.OpResponse("Validation error", openapi.ErrorResponseSchema()),
|
||||
"503": openapi.OpResponse("Queue unavailable", openapi.ErrorResponseSchema()),
|
||||
},
|
||||
})
|
||||
|
||||
// Delete example
|
||||
spec.AddPath("/api/chat-svc/examples/{id}", "delete", map[string]any{
|
||||
"summary": "Delete example",
|
||||
|
||||
77
services/chat-svc/internal/authclient/client.go
Normal file
77
services/chat-svc/internal/authclient/client.go
Normal file
@ -0,0 +1,77 @@
|
||||
// Package authclient provides a client for validating tokens via the auth-svc.
|
||||
package authclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpclient"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/svc"
|
||||
)
|
||||
|
||||
// ValidateResponse is the envelope response from auth-svc /validate endpoint.
|
||||
type ValidateResponse struct {
|
||||
Data ValidateData `json:"data"`
|
||||
}
|
||||
|
||||
// ValidateData is the user info returned by auth-svc.
|
||||
type ValidateData struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// Client validates tokens by calling auth-svc.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *httpclient.Client
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// New creates a new auth client that calls auth-svc/validate.
|
||||
// Requires AUTH_SVC_URL environment variable to be set.
|
||||
func New(logger *logging.Logger) (*Client, error) {
|
||||
baseURL := svc.ServiceURL("auth-svc")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("auth-svc not configured (missing AUTH_SVC_URL env var)")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: httpclient.New(httpclient.Config{}),
|
||||
logger: logger.WithComponent("authclient"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate calls POST /api/auth-svc/validate with the Bearer token.
|
||||
// Returns the authenticated user or an error.
|
||||
func (c *Client) Validate(ctx context.Context, token string) (*auth.User, error) {
|
||||
url := c.baseURL + "/api/auth-svc/validate"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call auth-svc: %w", err)
|
||||
}
|
||||
|
||||
result, err := svc.DecodeResponse[ValidateResponse](resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth-svc validation failed: %w", err)
|
||||
}
|
||||
|
||||
return &auth.User{
|
||||
ID: result.Data.UserID,
|
||||
Email: result.Data.Email,
|
||||
Roles: result.Data.Roles,
|
||||
Scopes: result.Data.Scopes,
|
||||
}, nil
|
||||
}
|
||||
128
services/chat-svc/internal/authclient/client_test.go
Normal file
128
services/chat-svc/internal/authclient/client_test.go
Normal file
@ -0,0 +1,128 @@
|
||||
package authclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpclient"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
)
|
||||
|
||||
func TestClient_Validate_Success(t *testing.T) {
|
||||
// Create a mock auth-svc server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/auth-svc/validate" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected method: %s", r.Method)
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "Bearer valid-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ValidateResponse{
|
||||
Data: ValidateData{
|
||||
UserID: "user-123",
|
||||
Email: "test@example.com",
|
||||
Roles: []string{"admin"},
|
||||
Scopes: []string{"read"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
user, err := client.Validate(context.Background(), "valid-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if user.ID != "user-123" {
|
||||
t.Errorf("expected user ID 'user-123', got '%s'", user.ID)
|
||||
}
|
||||
if user.Email != "test@example.com" {
|
||||
t.Errorf("expected email 'test@example.com', got '%s'", user.Email)
|
||||
}
|
||||
if len(user.Roles) != 1 || user.Roles[0] != "admin" {
|
||||
t.Errorf("expected roles [admin], got %v", user.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Validate_InvalidToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "invalid token"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
_, err := client.Validate(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Validate_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
_, err := client.Validate(context.Background(), "some-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for server error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Validate_BearerTokenPassedCorrectly(t *testing.T) {
|
||||
var receivedAuth string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
receivedAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ValidateResponse{
|
||||
Data: ValidateData{UserID: "user-1"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
_, err := client.Validate(context.Background(), "my-token-123")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if receivedAuth != "Bearer my-token-123" {
|
||||
t.Errorf("expected 'Bearer my-token-123', got '%s'", receivedAuth)
|
||||
}
|
||||
}
|
||||
34
services/chat-svc/internal/authclient/middleware.go
Normal file
34
services/chat-svc/internal/authclient/middleware.go
Normal file
@ -0,0 +1,34 @@
|
||||
package authclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpresponse"
|
||||
)
|
||||
|
||||
// Middleware validates tokens by calling auth-svc.
|
||||
// Extracts the Bearer token from the Authorization header, calls auth-svc/validate,
|
||||
// and stores the authenticated user in the request context.
|
||||
func Middleware(client *Client) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := auth.ExtractBearerToken(r)
|
||||
if token == "" {
|
||||
httpresponse.Unauthorized(w, r, "missing authorization token")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := client.Validate(r.Context(), token)
|
||||
if err != nil {
|
||||
client.logger.Debug("token validation via auth-svc failed", "error", err)
|
||||
httpresponse.Unauthorized(w, r, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := auth.SetUser(r.Context(), user)
|
||||
ctx = auth.SetToken(ctx, token)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
118
services/chat-svc/internal/authclient/middleware_test.go
Normal file
118
services/chat-svc/internal/authclient/middleware_test.go
Normal file
@ -0,0 +1,118 @@
|
||||
package authclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/auth"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/httpclient"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
)
|
||||
|
||||
func newMockAuthServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "Bearer valid-token" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ValidateResponse{
|
||||
Data: ValidateData{
|
||||
UserID: "user-123",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]any{"error": "invalid token"})
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMiddleware_ValidToken(t *testing.T) {
|
||||
server := newMockAuthServer(t)
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
var capturedUserID string
|
||||
r := chi.NewRouter()
|
||||
r.Use(Middleware(client))
|
||||
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
user := auth.GetUser(r.Context())
|
||||
if user != nil {
|
||||
capturedUserID = user.ID
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer valid-token")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if capturedUserID != "user-123" {
|
||||
t.Errorf("expected user ID 'user-123', got '%s'", capturedUserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware_MissingToken(t *testing.T) {
|
||||
server := newMockAuthServer(t)
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(Middleware(client))
|
||||
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware_InvalidToken(t *testing.T) {
|
||||
server := newMockAuthServer(t)
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
httpClient: httpclient.New(httpclient.Config{MaxRetries: 1}),
|
||||
logger: logging.Nop(),
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(Middleware(client))
|
||||
r.Get("/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,9 @@ type Config struct {
|
||||
// Auth
|
||||
AuthEnabled bool
|
||||
JWTSecret string
|
||||
|
||||
// Redis queue URL for pushing tasks to worker-svc
|
||||
RedisURL string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables.
|
||||
@ -30,5 +33,6 @@ func Load() *Config {
|
||||
|
||||
AuthEnabled: strings.EqualFold(os.Getenv("AUTH_ENABLED"), "true"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
RedisURL: os.Getenv("REDIS_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
43
services/chat-svc/internal/taskqueue/producer.go
Normal file
43
services/chat-svc/internal/taskqueue/producer.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Package taskqueue provides a producer for pushing tasks to the worker queue.
|
||||
package taskqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/queue"
|
||||
)
|
||||
|
||||
// Job types that chat-svc produces for the worker.
|
||||
const (
|
||||
JobTypeChatProcess = "chat.process"
|
||||
)
|
||||
|
||||
// Producer enqueues tasks for the worker-svc to process.
|
||||
type Producer struct {
|
||||
queue queue.Producer
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
// NewProducer creates a new task producer.
|
||||
func NewProducer(q queue.Producer, logger *logging.Logger) *Producer {
|
||||
return &Producer{
|
||||
queue: q,
|
||||
logger: logger.WithComponent("taskqueue"),
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueChatProcess enqueues a chat processing task for the worker.
|
||||
func (p *Producer) EnqueueChatProcess(ctx context.Context, userID string, message string) (string, error) {
|
||||
jobID, err := p.queue.Enqueue(ctx, JobTypeChatProcess, map[string]any{
|
||||
"user_id": userID,
|
||||
"message": message,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("enqueue chat.process: %w", err)
|
||||
}
|
||||
|
||||
p.logger.Info("enqueued chat task", "job_id", jobID, "user_id", userID)
|
||||
return jobID, nil
|
||||
}
|
||||
60
services/chat-svc/internal/taskqueue/producer_test.go
Normal file
60
services/chat-svc/internal/taskqueue/producer_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package taskqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/queue"
|
||||
)
|
||||
|
||||
// mockProducer implements queue.Producer for testing.
|
||||
type mockProducer struct {
|
||||
mu sync.Mutex
|
||||
jobs []queue.Job
|
||||
}
|
||||
|
||||
func (m *mockProducer) Enqueue(ctx context.Context, jobType string, payload map[string]any) (string, error) {
|
||||
return m.EnqueueWithOptions(ctx, queue.Job{
|
||||
Type: jobType,
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockProducer) EnqueueWithOptions(ctx context.Context, job queue.Job) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
job.ID = "test-job-id"
|
||||
m.jobs = append(m.jobs, job)
|
||||
return job.ID, nil
|
||||
}
|
||||
|
||||
func TestProducer_EnqueueChatProcess(t *testing.T) {
|
||||
mock := &mockProducer{}
|
||||
producer := NewProducer(mock, logging.Nop())
|
||||
|
||||
jobID, err := producer.EnqueueChatProcess(context.Background(), "user-123", "Hello world")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if jobID != "test-job-id" {
|
||||
t.Errorf("expected job ID 'test-job-id', got '%s'", jobID)
|
||||
}
|
||||
|
||||
if len(mock.jobs) != 1 {
|
||||
t.Fatalf("expected 1 job, got %d", len(mock.jobs))
|
||||
}
|
||||
|
||||
job := mock.jobs[0]
|
||||
if job.Type != JobTypeChatProcess {
|
||||
t.Errorf("expected job type '%s', got '%s'", JobTypeChatProcess, job.Type)
|
||||
}
|
||||
if job.Payload["user_id"] != "user-123" {
|
||||
t.Errorf("expected user_id 'user-123', got '%v'", job.Payload["user_id"])
|
||||
}
|
||||
if job.Payload["message"] != "Hello world" {
|
||||
t.Errorf("expected message 'Hello world', got '%v'", job.Payload["message"])
|
||||
}
|
||||
}
|
||||
@ -75,9 +75,7 @@ func main() {
|
||||
})
|
||||
|
||||
// Register job handlers
|
||||
// TODO: Register your job handlers here
|
||||
// handler.RegisterHandler("send_email", emailHandler)
|
||||
// handler.RegisterHandler("process_image", imageHandler)
|
||||
handler.RegisterHandler(handlers.JobTypeChatProcess, handlers.NewChatProcessHandler(logger))
|
||||
|
||||
// Setup signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
name: worker-svc
|
||||
type: worker
|
||||
path: workers/worker-svc
|
||||
dependencies: []
|
||||
# Add dependencies as needed:
|
||||
# - postgres
|
||||
# - redis
|
||||
# - rabbitmq
|
||||
dependencies:
|
||||
- postgres
|
||||
|
||||
43
workers/worker-svc/internal/handlers/chat.go
Normal file
43
workers/worker-svc/internal/handlers/chat.go
Normal file
@ -0,0 +1,43 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/queue"
|
||||
)
|
||||
|
||||
// JobTypeChatProcess is the job type for chat message processing.
|
||||
const JobTypeChatProcess = "chat.process"
|
||||
|
||||
// NewChatProcessHandler creates a handler for chat.process jobs.
|
||||
func NewChatProcessHandler(logger *logging.Logger) queue.Handler {
|
||||
log := logger.WithComponent("chat-handler")
|
||||
|
||||
return func(ctx context.Context, job *queue.Job) error {
|
||||
userID, _ := job.Payload["user_id"].(string)
|
||||
message, _ := job.Payload["message"].(string)
|
||||
|
||||
if userID == "" {
|
||||
return fmt.Errorf("missing user_id in payload")
|
||||
}
|
||||
if message == "" {
|
||||
return fmt.Errorf("missing message in payload")
|
||||
}
|
||||
|
||||
log.Info("processing chat message",
|
||||
"job_id", job.ID,
|
||||
"user_id", userID,
|
||||
"message_len", len(message),
|
||||
)
|
||||
|
||||
// TODO: Implement actual chat processing logic (e.g., AI response, storage, notifications)
|
||||
|
||||
log.Info("chat message processed",
|
||||
"job_id", job.ID,
|
||||
"user_id", userID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
76
workers/worker-svc/internal/handlers/chat_test.go
Normal file
76
workers/worker-svc/internal/handlers/chat_test.go
Normal file
@ -0,0 +1,76 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/logging"
|
||||
"git.threesix.ai/jordan/sp4-debug-1770477266/pkg/queue"
|
||||
)
|
||||
|
||||
func TestNewChatProcessHandler_Success(t *testing.T) {
|
||||
handler := NewChatProcessHandler(logging.Nop())
|
||||
|
||||
job := &queue.Job{
|
||||
ID: "job-123",
|
||||
Type: JobTypeChatProcess,
|
||||
Payload: map[string]any{
|
||||
"user_id": "user-456",
|
||||
"message": "Hello, world!",
|
||||
},
|
||||
}
|
||||
|
||||
err := handler(context.Background(), job)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChatProcessHandler_MissingUserID(t *testing.T) {
|
||||
handler := NewChatProcessHandler(logging.Nop())
|
||||
|
||||
job := &queue.Job{
|
||||
ID: "job-123",
|
||||
Type: JobTypeChatProcess,
|
||||
Payload: map[string]any{
|
||||
"message": "Hello",
|
||||
},
|
||||
}
|
||||
|
||||
err := handler(context.Background(), job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing user_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChatProcessHandler_MissingMessage(t *testing.T) {
|
||||
handler := NewChatProcessHandler(logging.Nop())
|
||||
|
||||
job := &queue.Job{
|
||||
ID: "job-123",
|
||||
Type: JobTypeChatProcess,
|
||||
Payload: map[string]any{
|
||||
"user_id": "user-456",
|
||||
},
|
||||
}
|
||||
|
||||
err := handler(context.Background(), job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChatProcessHandler_EmptyPayload(t *testing.T) {
|
||||
handler := NewChatProcessHandler(logging.Nop())
|
||||
|
||||
job := &queue.Job{
|
||||
ID: "job-123",
|
||||
Type: JobTypeChatProcess,
|
||||
Payload: map[string]any{},
|
||||
}
|
||||
|
||||
err := handler(context.Background(), job)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty payload")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user