feat(gateway): wire real services into Gateway API handlers

- Router accepts full Dependencies (memory/voice/orchestrator/WShub)
- Memory API: CRUD + search endpoints with PostgreSQL persistence
- Voice API: STT/TTS endpoints
- Admin dashboard: real uptime + service status
- WebSocket chat: routes to AI-Core orchestrator pipeline
- APIAdapter bridges memory domain model to REST interface
- Smoke test: memory CRUD + session creation verified
This commit is contained in:
Aska committed 2026-08-12 12:04:31 +08:00
1 parent 4a71965686
commit 6811480786
5 files changed
+448 -111

No files matched your search

+243 -50
View File
@@ -1,58 +1,115 @@
// Package api provides HTTP routing, middleware, and handlers for the Cyrene Gateway.
// Uses only the standard library net/http to minimize dependencies.
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/services/ai-core/model"
)
// Router handles HTTP routing with middleware support.
type Router struct {
mux *http.ServeMux
authMgr AuthManager
}
// --- Interfaces for injected services ---
// AuthManager is the interface for JWT authentication.
// AuthManager validates JWT tokens.
type AuthManager interface {
ValidateToken(token string) (userID, username, role string, err error)
}
// MemoryEntry is a simplified memory entry for API responses.
type MemoryEntry struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Content string `json:"content"`
Summary string `json:"summary"`
Keywords []string `json:"keywords"`
Category string `json:"category"`
Priority float64 `json:"priority"`
}
// MemorySearcher handles memory search queries.
type MemorySearcher interface {
Search(ctx context.Context, userID, query string, limit int) (any, error)
List(ctx context.Context, userID string, limit int) (any, error)
Add(ctx context.Context, userID, content, category string, priority float64) (any, error)
Get(ctx context.Context, id string) (any, error)
Delete(ctx context.Context, id string) error
}
// VoiceService handles STT and TTS.
type VoiceService interface {
Recognize(ctx context.Context, audioData []byte) (string, error)
Synthesize(ctx context.Context, text string, voice string) ([]byte, error)
}
// OrchestratorPipeline handles the main dialogue flow.
type OrchestratorPipeline interface {
ProcessInput(ctx context.Context, sessionID, userMessage string) (<-chan model.StreamToken, *model.Intent, error)
}
// WebSocketHub manages WS connections.
type WebSocketHub interface {
UpgradeHandler() http.HandlerFunc
}
// Dependencies holds all injectable services.
type Dependencies struct {
AuthMgr AuthManager
MemorySvc MemorySearcher
VoiceSvc VoiceService
Orchestrator OrchestratorPipeline
WSHub WebSocketHub
}
// Router handles HTTP routing with middleware support.
type Router struct {
mux *http.ServeMux
deps Dependencies
}
// New creates a new Router with all routes configured.
func New(authMgr AuthManager) *Router {
r := &Router{
mux: http.NewServeMux(),
authMgr: authMgr,
}
func New(deps Dependencies) *Router {
r := &Router{mux: http.NewServeMux(), deps: deps}
r.setupRoutes()
return r
}
func (r *Router) setupRoutes() {
// Public endpoints
// Public
r.mux.HandleFunc("/api/v1/health", r.healthHandler)
r.mux.HandleFunc("/api/v1/auth/login", r.loginHandler)
r.mux.HandleFunc("/api/v1/auth/register", r.registerHandler)
// Protected endpoints
// Protected
r.mux.HandleFunc("/api/v1/sessions", r.authMW(r.sessionsHandler))
r.mux.HandleFunc("/api/v1/sessions/", r.authMW(r.sessionByIDHandler))
// Memory
r.mux.HandleFunc("/api/v1/memory/search", r.authMW(r.memorySearchHandler))
r.mux.HandleFunc("/api/v1/memory", r.authMW(r.memoryHandler))
r.mux.HandleFunc("/api/v1/memory/", r.authMW(r.memoryHandler))
// Voice
r.mux.HandleFunc("/api/v1/voice/stt", r.authMW(r.voiceSTTHandler))
r.mux.HandleFunc("/api/v1/voice/tts", r.authMW(r.voiceTTSHandler))
// Admin
r.mux.HandleFunc("/api/v1/admin/dashboard", r.authMW(r.adminMW(r.adminDashboardHandler)))
}
// ServeHTTP implements http.Handler.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
if req.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
r.mux.ServeHTTP(w, req)
}
@@ -60,19 +117,18 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
header := req.Header.Get("Authorization")
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing or invalid authorization"})
h := req.Header.Get("Authorization")
if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") {
writeJSON(w, 401, M{"error": "unauthorized"})
return
}
token := header[7:]
userID, username, role, err := r.authMgr.ValidateToken(token)
uid, uname, role, err := r.deps.AuthMgr.ValidateToken(h[7:])
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
writeJSON(w, 401, M{"error": "invalid token"})
return
}
req.Header.Set("X-User-ID", userID)
req.Header.Set("X-Username", username)
req.Header.Set("X-User-ID", uid)
req.Header.Set("X-Username", uname)
req.Header.Set("X-Role", role)
next(w, req)
}
@@ -81,83 +137,209 @@ func (r *Router) authMW(next http.HandlerFunc) http.HandlerFunc {
func (r *Router) adminMW(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
if req.Header.Get("X-Role") != "admin" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin access required"})
writeJSON(w, 403, M{"error": "admin only"})
return
}
next(w, req)
}
}
// --- Public handlers ---
// --- Handlers ---
func (r *Router) healthHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "service": "cyrene-gateway"})
writeJSON(w, 200, M{"status": "ok", "service": "cyrene-gateway", "version": "2.0.0-dev"})
}
func (r *Router) loginHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"})
writeJSON(w, 405, M{"error": "POST required"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
return
}
json.NewDecoder(req.Body).Decode(&body)
mgr, ok := r.authMgr.(*StubAuthManager)
mgr, ok := r.deps.AuthMgr.(*StubAuthManager)
if !ok {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "auth not configured"})
writeJSON(w, 500, M{"error": "auth not configured"})
return
}
token, err := mgr.Login(body.Username, body.Password)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
writeJSON(w, 401, M{"error": "invalid credentials"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"token": token})
writeJSON(w, 200, M{"token": token})
}
func (r *Router) registerHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "registration not implemented"})
writeJSON(w, 200, M{"message": "registration not implemented"})
}
// --- Protected handlers ---
// --- Sessions ---
func (r *Router) sessionsHandler(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case "GET":
writeJSON(w, http.StatusOK, map[string]any{"sessions": []any{}})
writeJSON(w, 200, M{"sessions": []any{}})
case "POST":
writeJSON(w, http.StatusCreated, map[string]string{"session_id": "stub-session"})
writeJSON(w, 201, M{"session_id": randID()})
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
func (r *Router) sessionByIDHandler(w http.ResponseWriter, req *http.Request) {
id := strings.TrimPrefix(req.URL.Path, "/api/v1/sessions/")
if req.Method == "DELETE" {
writeJSON(w, http.StatusNoContent, nil)
return
switch req.Method {
case "DELETE":
writeJSON(w, 204, nil)
case "GET":
writeJSON(w, 200, M{"session_id": id})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
writeJSON(w, http.StatusOK, map[string]string{"session_id": id})
}
// --- Memory ---
func (r *Router) memorySearchHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "GET" {
writeJSON(w, 405, M{"error": "GET required"})
return
}
if r.deps.MemorySvc == nil {
writeJSON(w, 503, M{"error": "memory service unavailable"})
return
}
q := req.URL.Query().Get("q")
uid := req.Header.Get("X-User-ID")
result, err := r.deps.MemorySvc.Search(req.Context(), uid, q, 10)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"data": result})
}
func (r *Router) memoryHandler(w http.ResponseWriter, req *http.Request) {
if r.deps.MemorySvc == nil {
writeJSON(w, 503, M{"error": "memory service unavailable"})
return
}
// Extract ID from path: /api/v1/memory/{id}
path := strings.TrimPrefix(req.URL.Path, "/api/v1/memory")
path = strings.TrimPrefix(path, "/")
userID := req.Header.Get("X-User-ID")
switch {
case path == "" && req.Method == "GET":
result, _ := r.deps.MemorySvc.List(req.Context(), userID, 20)
writeJSON(w, 200, M{"data": result})
case path == "" && req.Method == "POST":
var body struct {
Content string `json:"content"`
Category string `json:"category"`
Priority float64 `json:"priority"`
}
json.NewDecoder(req.Body).Decode(&body)
if body.Category == "" { body.Category = "fact" }
mem, err := r.deps.MemorySvc.Add(req.Context(), userID, body.Content, body.Category, body.Priority)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 201, M{"data": mem})
case path != "" && req.Method == "DELETE":
r.deps.MemorySvc.Delete(req.Context(), path)
writeJSON(w, 204, nil)
case path != "" && req.Method == "GET":
mem, err := r.deps.MemorySvc.Get(req.Context(), path)
if err != nil {
writeJSON(w, 404, M{"error": "memory not found"})
return
}
writeJSON(w, 200, M{"data": mem})
default:
writeJSON(w, 405, M{"error": "method not allowed"})
}
}
// --- Voice ---
func (r *Router) voiceSTTHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, 405, M{"error": "POST required"})
return
}
if r.deps.VoiceSvc == nil {
writeJSON(w, 503, M{"error": "voice service unavailable"})
return
}
var body struct{ AudioData []byte `json:"audio_data"` }
json.NewDecoder(req.Body).Decode(&body)
text, err := r.deps.VoiceSvc.Recognize(req.Context(), body.AudioData)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"text": text})
}
func (r *Router) voiceTTSHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
writeJSON(w, 405, M{"error": "POST required"})
return
}
if r.deps.VoiceSvc == nil {
writeJSON(w, 503, M{"error": "voice service unavailable"})
return
}
var body struct {
Text string `json:"text"`
Voice string `json:"voice"`
}
json.NewDecoder(req.Body).Decode(&body)
if body.Voice == "" { body.Voice = "zh-CN-XiaoxiaoNeural" }
audio, err := r.deps.VoiceSvc.Synthesize(req.Context(), body.Text, body.Voice)
if err != nil {
writeJSON(w, 500, M{"error": err.Error()})
return
}
writeJSON(w, 200, M{"audio_size": len(audio)})
}
// --- Admin ---
var startTime = time.Now()
func (r *Router) adminDashboardHandler(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"dashboard": map[string]any{
"status": "running",
"version": "2.0.0-dev",
writeJSON(w, 200, M{
"dashboard": M{
"status": "running",
"version": "2.0.0-dev",
"uptime_sec": int(time.Since(startTime).Seconds()),
"services": M{
"memory": r.deps.MemorySvc != nil,
"voice": r.deps.VoiceSvc != nil,
"ai_core": r.deps.Orchestrator != nil,
},
},
})
}
// --- Helpers ---
type M = map[string]any
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@@ -165,3 +347,14 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
json.NewEncoder(w).Encode(data)
}
}
func randID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func (r *Router) userID(req *http.Request) string { return req.Header.Get("X-User-ID") }
// Ensure unused imports don't cause issues
+5 -5
View File
@@ -8,7 +8,7 @@ import (
)
func TestHealth(t *testing.T) {
r := New(NewStubAuthManager())
r := New(Dependencies{AuthMgr: NewStubAuthManager()})
srv := httptest.NewServer(r)
defer srv.Close()
@@ -25,7 +25,7 @@ func TestHealth(t *testing.T) {
func TestLoginAndProtectedEndpoint(t *testing.T) {
auth := NewStubAuthManager()
r := New(auth)
r := New(Dependencies{AuthMgr: auth})
srv := httptest.NewServer(r)
defer srv.Close()
@@ -57,7 +57,7 @@ func TestLoginAndProtectedEndpoint(t *testing.T) {
func TestLoginWrongPassword(t *testing.T) {
auth := NewStubAuthManager()
r := New(auth)
r := New(Dependencies{AuthMgr: auth})
srv := httptest.NewServer(r)
defer srv.Close()
@@ -75,7 +75,7 @@ func TestLoginWrongPassword(t *testing.T) {
func TestProtectedWithoutToken(t *testing.T) {
auth := NewStubAuthManager()
r := New(auth)
r := New(Dependencies{AuthMgr: auth})
srv := httptest.NewServer(r)
defer srv.Close()
@@ -115,7 +115,7 @@ func TestStubAuthManager(t *testing.T) {
}
func TestCORSHeaders(t *testing.T) {
r := New(NewStubAuthManager())
r := New(Dependencies{AuthMgr: NewStubAuthManager()})
srv := httptest.NewServer(r)
defer srv.Close()