diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 000000000..75b125e8e --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,89 @@ +openapi: "3.1.0" +info: + title: PicoClaw API + version: "1.0.0" + description: REST API for PicoClaw AI Gateway +servers: + - url: http://localhost:18790 +security: + - ApiKeyAuth: [] +components: + securitySchemes: + ApiKeyAuth: + type: http + scheme: bearer + schemas: + ChatRequest: + type: object + required: [message] + properties: + session_id: { type: string, description: "Session ID (auto-generated if omitted)" } + message: { type: string, description: "User message" } + ChatResponse: + type: object + properties: + session_id: { type: string } + response: { type: string } + SessionList: + type: object + properties: + sessions: { type: array, items: { type: string } } + HealthResponse: + type: object + properties: + status: { type: string, enum: [ok] } + version: { type: string } + Error: + type: object + properties: + error: { type: string } + code: { type: integer } +paths: + /api/chat: + post: + operationId: sendMessage + summary: Send a message and get an AI response + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChatRequest" } + responses: + "200": + description: Successful response + content: + application/json: + schema: { $ref: "#/components/schemas/ChatResponse" } + "401": { description: Unauthorized } + "500": { description: Internal error } + /api/sessions: + get: + operationId: listSessions + summary: List active sessions + responses: + "200": + description: Successful response + content: + application/json: + schema: { $ref: "#/components/schemas/SessionList" } + /api/health: + get: + operationId: healthCheck + summary: Health check + security: [] + responses: + "200": + description: Successful response + content: + application/json: + schema: { $ref: "#/components/schemas/HealthResponse" } + /api/openapi.yaml: + get: + operationId: getSpec + summary: Serve the OpenAPI specification + security: [] + responses: + "200": + description: OpenAPI spec + content: + application/x-yaml: {} diff --git a/cmd/picoclaw/api/openapi.yaml b/cmd/picoclaw/api/openapi.yaml new file mode 100644 index 000000000..75b125e8e --- /dev/null +++ b/cmd/picoclaw/api/openapi.yaml @@ -0,0 +1,89 @@ +openapi: "3.1.0" +info: + title: PicoClaw API + version: "1.0.0" + description: REST API for PicoClaw AI Gateway +servers: + - url: http://localhost:18790 +security: + - ApiKeyAuth: [] +components: + securitySchemes: + ApiKeyAuth: + type: http + scheme: bearer + schemas: + ChatRequest: + type: object + required: [message] + properties: + session_id: { type: string, description: "Session ID (auto-generated if omitted)" } + message: { type: string, description: "User message" } + ChatResponse: + type: object + properties: + session_id: { type: string } + response: { type: string } + SessionList: + type: object + properties: + sessions: { type: array, items: { type: string } } + HealthResponse: + type: object + properties: + status: { type: string, enum: [ok] } + version: { type: string } + Error: + type: object + properties: + error: { type: string } + code: { type: integer } +paths: + /api/chat: + post: + operationId: sendMessage + summary: Send a message and get an AI response + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChatRequest" } + responses: + "200": + description: Successful response + content: + application/json: + schema: { $ref: "#/components/schemas/ChatResponse" } + "401": { description: Unauthorized } + "500": { description: Internal error } + /api/sessions: + get: + operationId: listSessions + summary: List active sessions + responses: + "200": + description: Successful response + content: + application/json: + schema: { $ref: "#/components/schemas/SessionList" } + /api/health: + get: + operationId: healthCheck + summary: Health check + security: [] + responses: + "200": + description: Successful response + content: + application/json: + schema: { $ref: "#/components/schemas/HealthResponse" } + /api/openapi.yaml: + get: + operationId: getSpec + summary: Serve the OpenAPI specification + security: [] + responses: + "200": + description: OpenAPI spec + content: + application/x-yaml: {} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index beb4be790..ce3a031ad 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -29,6 +29,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" + "github.com/sipeed/picoclaw/pkg/gateway" "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" @@ -45,6 +46,10 @@ import ( //go:embed workspace var embeddedFiles embed.FS +//go:generate cp -r ../../api . +//go:embed api/openapi.yaml +var openapiSpec []byte + var ( version = "dev" gitCommit string @@ -693,6 +698,30 @@ func gatewayCmd() { }() fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + // Start REST API if enabled + if cfg.API.Enabled { + gateway.SetVersion(version) + gateway.SetOpenAPISpec(openapiSpec) + apiServer := gateway.NewAPIServer(agentLoop, cfg.API) + apiAddr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) + httpServer := &http.Server{ + Addr: apiAddr, + Handler: apiServer.Handler(), + } + go func() { + fmt.Printf("✓ REST API listening on %s\n", apiAddr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("api", "REST API server error", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + go func() { + <-ctx.Done() + httpServer.Shutdown(context.Background()) + }() + } + go agentLoop.Run(ctx) sigChan := make(chan os.Signal, 1) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 5e7f90f6b..54ea3d1eb 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -209,6 +209,11 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm } +// GetSessionManager returns the session manager for external access (e.g. REST API). +func (al *AgentLoop) GetSessionManager() *session.SessionManager { + return al.sessions +} + // RecordLastChannel records the last active channel for this workspace. // This uses the atomic state save mechanism to prevent data loss on crash. func (al *AgentLoop) RecordLastChannel(channel string) error { diff --git a/pkg/config/config.go b/pkg/config/config.go index 9f25a2e8b..7b47fde3e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -52,6 +52,7 @@ type Config struct { Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` Tracing TracingConfig `json:"tracing"` + API APIConfig `json:"api"` mu sync.RWMutex } @@ -202,6 +203,11 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } +type APIConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_API_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_API_KEY"` +} + type BraveConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` @@ -342,6 +348,10 @@ func DefaultConfig() *Config { Enabled: false, Endpoint: "localhost:4317", }, + API: APIConfig{ + Enabled: false, + APIKey: "", + }, } } diff --git a/pkg/gateway/api.go b/pkg/gateway/api.go new file mode 100644 index 000000000..90e926e93 --- /dev/null +++ b/pkg/gateway/api.go @@ -0,0 +1,166 @@ +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// openapiSpec holds the OpenAPI YAML spec, set via SetOpenAPISpec. +var openapiSpec []byte + +// SetOpenAPISpec sets the embedded OpenAPI spec content. +func SetOpenAPISpec(spec []byte) { + openapiSpec = spec +} + +// version is set by the caller (main.go) via SetVersion. +var apiVersion = "dev" + +// SetVersion sets the version string returned by the health endpoint. +func SetVersion(v string) { + apiVersion = v +} + +// API request/response types matching OpenAPI schemas +type ChatRequest struct { + SessionID string `json:"session_id,omitempty"` + Message string `json:"message"` +} + +type ChatResponse struct { + SessionID string `json:"session_id"` + Response string `json:"response"` +} + +type SessionList struct { + Sessions []string `json:"sessions"` +} + +type HealthResponse struct { + Status string `json:"status"` + Version string `json:"version"` +} + +type ErrorResponse struct { + Error string `json:"error"` + Code int `json:"code"` +} + +// APIServer holds the dependencies for API handlers. +type APIServer struct { + agentLoop *agent.AgentLoop + config config.APIConfig +} + +// NewAPIServer creates a new API server. +func NewAPIServer(agentLoop *agent.AgentLoop, cfg config.APIConfig) *APIServer { + return &APIServer{ + agentLoop: agentLoop, + config: cfg, + } +} + +// Handler returns an http.Handler with all API routes and auth middleware. +func (s *APIServer) Handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/api/chat", s.handleChat) + mux.HandleFunc("/api/sessions", s.handleSessions) + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/openapi.yaml", s.handleSpec) + + publicPaths := []string{"/api/health", "/api/openapi.yaml"} + return AuthMiddleware(s.config.APIKey, publicPaths, mux) +} + +func (s *APIServer) handleChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req ChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Message == "" { + writeError(w, http.StatusBadRequest, "message is required") + return + } + + sessionID := req.SessionID + if sessionID == "" { + sessionID = fmt.Sprintf("api:%s", uuid.New().String()) + } + + logger.InfoCF("api", "Chat request", map[string]interface{}{ + "session_id": sessionID, + "message": req.Message, + }) + + response, err := s.agentLoop.ProcessDirect(context.Background(), req.Message, sessionID) + if err != nil { + logger.ErrorCF("api", "Chat error", map[string]interface{}{ + "error": err.Error(), + }) + writeError(w, http.StatusInternalServerError, "failed to process message") + return + } + + writeJSON(w, http.StatusOK, ChatResponse{ + SessionID: sessionID, + Response: response, + }) +} + +func (s *APIServer) handleSessions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + sessions := s.agentLoop.GetSessionManager().ListSessions() + writeJSON(w, http.StatusOK, SessionList{Sessions: sessions}) +} + +func (s *APIServer) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + writeJSON(w, http.StatusOK, HealthResponse{ + Status: "ok", + Version: apiVersion, + }) +} + +func (s *APIServer) handleSpec(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + w.Header().Set("Content-Type", "application/x-yaml") + w.WriteHeader(http.StatusOK) + w.Write(openapiSpec) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, code int, message string) { + writeJSON(w, code, ErrorResponse{Error: message, Code: code}) +} diff --git a/pkg/gateway/api_test.go b/pkg/gateway/api_test.go new file mode 100644 index 000000000..d8fdb93c0 --- /dev/null +++ b/pkg/gateway/api_test.go @@ -0,0 +1,175 @@ +package gateway + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestAuthMiddleware(t *testing.T) { + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + publicPaths := []string{"/api/health"} + + tests := []struct { + name string + apiKey string + authHeader string + path string + wantStatus int + }{ + { + name: "valid bearer token", + apiKey: "secret123", + authHeader: "Bearer secret123", + path: "/api/chat", + wantStatus: http.StatusOK, + }, + { + name: "invalid bearer token", + apiKey: "secret123", + authHeader: "Bearer wrong", + path: "/api/chat", + wantStatus: http.StatusUnauthorized, + }, + { + name: "missing auth header", + apiKey: "secret123", + authHeader: "", + path: "/api/chat", + wantStatus: http.StatusUnauthorized, + }, + { + name: "public path skips auth", + apiKey: "secret123", + authHeader: "", + path: "/api/health", + wantStatus: http.StatusOK, + }, + { + name: "no api key configured (open mode)", + apiKey: "", + authHeader: "", + path: "/api/chat", + wantStatus: http.StatusOK, + }, + { + name: "wrong auth format", + apiKey: "secret123", + authHeader: "Basic dXNlcjpwYXNz", + path: "/api/chat", + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler := AuthMiddleware(tt.apiKey, publicPaths, inner) + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", w.Code, tt.wantStatus) + } + }) + } +} + +func TestHealthEndpoint(t *testing.T) { + SetVersion("1.2.3") + s := &APIServer{} + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + w := httptest.NewRecorder() + s.handleHealth(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var health HealthResponse + if err := json.NewDecoder(resp.Body).Decode(&health); err != nil { + t.Fatalf("decode: %v", err) + } + if health.Status != "ok" { + t.Errorf("status = %q, want %q", health.Status, "ok") + } + if health.Version != "1.2.3" { + t.Errorf("version = %q, want %q", health.Version, "1.2.3") + } +} + +func TestSpecEndpoint(t *testing.T) { + SetOpenAPISpec([]byte("openapi: '3.1.0'\ninfo:\n title: test")) + s := &APIServer{} + req := httptest.NewRequest(http.MethodGet, "/api/openapi.yaml", nil) + w := httptest.NewRecorder() + s.handleSpec(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + ct := resp.Header.Get("Content-Type") + if ct != "application/x-yaml" { + t.Errorf("content-type = %q, want %q", ct, "application/x-yaml") + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "openapi") { + t.Error("response body should contain 'openapi'") + } +} + +func TestChatEndpointRequiresMessage(t *testing.T) { + s := &APIServer{} + req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + s.handleChat(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + + var errResp ErrorResponse + json.NewDecoder(resp.Body).Decode(&errResp) + if errResp.Error != "message is required" { + t.Errorf("error = %q, want %q", errResp.Error, "message is required") + } +} + +func TestChatEndpointRejectsGet(t *testing.T) { + s := &APIServer{} + req := httptest.NewRequest(http.MethodGet, "/api/chat", nil) + w := httptest.NewRecorder() + s.handleChat(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMethodNotAllowed) + } +} + +func TestChatEndpointInvalidJSON(t *testing.T) { + s := &APIServer{} + req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("not json")) + w := httptest.NewRecorder() + s.handleChat(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } +} diff --git a/pkg/gateway/middleware.go b/pkg/gateway/middleware.go new file mode 100644 index 000000000..a5a0fe7e7 --- /dev/null +++ b/pkg/gateway/middleware.go @@ -0,0 +1,46 @@ +package gateway + +import ( + "net/http" + "strings" +) + +// AuthMiddleware validates the Authorization: Bearer header +// against the configured API key. Public paths bypass auth. +func AuthMiddleware(apiKey string, publicPaths []string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth for public paths + for _, p := range publicPaths { + if r.URL.Path == p { + next.ServeHTTP(w, r) + return + } + } + + // Skip auth if no API key configured (open mode) + if apiKey == "" { + next.ServeHTTP(w, r) + return + } + + auth := r.Header.Get("Authorization") + if auth == "" { + writeError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + writeError(w, http.StatusUnauthorized, "invalid authorization format") + return + } + + token := auth[len(prefix):] + if token != apiKey { + writeError(w, http.StatusUnauthorized, "invalid API key") + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/session/manager.go b/pkg/session/manager.go index cebc5b9a4..6ff586eef 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -86,6 +86,18 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag session.Updated = time.Now() } +// ListSessions returns all session keys. +func (sm *SessionManager) ListSessions() []string { + sm.mu.RLock() + defer sm.mu.RUnlock() + + keys := make([]string, 0, len(sm.sessions)) + for k := range sm.sessions { + keys = append(keys, k) + } + return keys +} + func (sm *SessionManager) GetHistory(key string) []providers.Message { sm.mu.RLock() defer sm.mu.RUnlock()